How to write good code and how to write good unit tests?

Table of contents

Preface

What is unit testing

The value of unit testing

Characteristics of unit testing

What do unit tests measure?

How to write unit tests

Use Mock

Are there a lot of mocks?


This article aims to give you some guidance on how to write unit tests. It would be great if it can help you.

Preface

I have been writing code for several years. Many people may only know that there is such a thing as unit testing, but they have never written a unit test themselves. Unit testing seems to have always been an option, not a requirement, because even if there is no unit testing, every company at least has dedicated testers. We write the code and then put it in the test environment and hand it over to the testers for verification. Can. This seems to work without unit testing.

However, after going through many detours, we found that even if we cannot achieve 100% unit test coverage, just writing unit tests for some complex functions can still save us a lot of time. The main reasons are as follows:

  • In the process of writing unit tests, you can find some bugs in the code you wrote. We have verified it before deploying it to the test environment. Fewer bugs reach testers.
  • When there are unit tests, when fixing bugs or adding or modifying functions later, you can immediately verify whether there is any impact on the old module after writing the code.
  • Unit test drive can drive us to write better code, because if you write the code casually, you will find out how to test it. I can't understand the code I wrote.
  • Using unit tests can make our verification more time-saving. We don't have to run the application and then use the existing system to create data or something to verify the code we modified. We only need to mock out the code that has nothing to do with the current situation so that we can only verify the code we are currently writing.

What is unit testing

There may be some people who can write tests, but they may not write well. For example, the granularity of the test is too large, such as writing tests directly for the http interface. But we also know that there may be a lot of logic behind an interface. In this way, The tests we write will also contain a lot of uncertainty, because any modification of the logic behind this large interface may cause our tests to fail. Such "unit tests" are undoubtedly very fragile. As shown in the figure below, abnormalities in the RPC server, database server, file system, and HTTP server will cause our test to fail. If we are writing such a test now, then we have to take a closer look at the next content.

Essentially, this kind of test is not a unit test, but an integration test. Unit tests do not include interaction with other components, and our http interface may call the database and has a strong dependence on the database. This dependency is also a source of test vulnerability. In a good unit test, all dependencies are mocked (simulated). That is to say, in our code, there will still be database access code, but in When running the test, no actual database access operations will occur. In a complex system, it may also include rpc calls, http calls, etc., and we need to mock out these strongly dependent things in unit tests.

To understand unit testing, you must first understand what a "unit" is. The so-called "unit" refers to the smallest unit of code call, which actually refers to a function block (Function) or method (Method). **Unit testing refers to the testing of these code call units. **Unit testing is a kind of white-box testing, which is a test that must be very clear about the code details of the unit. So, if we don't write the code well, we can't write the tests. Writing unit tests can drive us to write better code.

Unit tests are written and executed by software engineers. Compared to unit testing, there are also integration tests. Integration testing is basically a black box test, which is mainly carried out by testers according to the function manual of the software and requires the cooperation of a special testing environment.

The value of unit testing

From a testing perspective alone, unit testing is the lowest cost and fastest. Because unit tests do not have any external dependencies and can be executed directly after writing, we do not need to prepare a complete environment for unit tests, such as installing various components of the server first, running them, and then starting the application.

When we finish writing the code, we can click to run the test and we will immediately know whether there is a problem with our code. Because unit testing does not need to rely on these external things. So even if we don't even have the server ready yet, we can still unit test our code to verify the correctness of the code.

In addition, for software engineers, if there is no way to quickly verify their code when writing code, there will be no feedback, and there will often be a strong sense of insecurity. The more code you write, the more insecurity you will accumulate, and you will eventually realize that you are completely unsure of the code you have written. Even with the rapid iteration method, it takes at least a week to get feedback from the test. And it is very likely that the feedback results from the test will cause you to write a week of code in vain, and you will have to overthrow everything. So when testers are testing, software engineers are very anxious. If the iteration time is longer, the psychological pressure will be greater. When testing is in progress, software engineers are often busy trying to fix problems, and they are also prone to conflicts with the testing team, resulting in communication problems.

Of course, this problem will get better after a period of time, because bugs will always be fixed one by one over time. Then we can develop some new features on a more stable system. But it is still unavoidable that newly developed functions may break the old logic in some very secret places, and then it will be discovered after a period of time. This may not be the result we want to see.

In addition, once unit tests are written, they can be used for a long time, especially during regression, which can help save a lot of testing time. We can easily know whether new functions or modifications to old code have damaged the original functions. Unit testing can help find many hidden problems.

In general, unit testing can bring us the following value:

  • Lower cost and faster verification. (Does not depend on any actual environment)
  • Reduce regression testing time. (Unit testing can ensure that old functionality is not affected)
  • It drives us to write better code. Well-designed code makes it easier to write unit tests.
  • Unit testing is essentially a document that describes the intent behind the code we write.
  • Make subsequent refactoring safer. (Unit testing can verify whether the refactoring has bugs)
  • Shorten the feedback cycle and reduce defect repair costs. (Feedback can be obtained during the development stage, when the cost of repair is the lowest)
  • Improve the speed of software delivery while ensuring quality. (Fewer bugs, faster iteration)

Characteristics of unit testing

  • Fast: It should take very little time to run unit tests.

If we have pulled some excellent open source projects, we can run the unit tests in them, and we may find that the unit tests of all codes have been completed in a few seconds.

  • Standalone: ​​Unit tests are standalone and can be run independently. Does not depend on other tests.

One benefit of independence is that you can independently verify whether a certain logic is correct. If you need to rely on other tests, it means that our code still has some design flaws. Because this seems to some extent, there is a strong dependency between our different logics.

  • Repeatable: The results of running unit tests should be consistent (idempotent)

If the results we run are different every time, then we cannot make assertions about the results of the program, and we cannot judge whether the results of the operation are correct.

  • Self-checking: The test should be able to automatically detect whether the test passed or failed without any human interaction.

For example, we cannot say to run a test and then check whether the database is written successfully and whether the file is written successfully. Because there is nothing easy to test for this kind of thing. As long as the database can run correctly, it can definitely be written. If the database cannot be written under certain abnormal circumstances, it is not a bug in our code, so we will mock the database access. The same is true for file reading and writing, RPC calls, etc.

What do unit tests measure?

As we said above, unit testing is a test of a function block (Function) or method (Method). But not all "units" require unit testing. Since we want to do unit testing, we need to know what to test. For example, does the following code need to be tested?

public static Response get(String url) throws IOException {
    okhttp3.Request request = new okhttp3.Request.Builder()
            .url(url)
            .build();

    return client.newCall(request).execute();
}

This is a very common http call code, which just calls the okhttp library to initiate a GET request based on the passed url. If you want to unit test, what exactly is it testing? Test whether the server behind that url is functioning properly? Test whether my local network is normal?

In fact, this type of dependence on external systems does not require testing. As long as it can be compiled and passed, the operating system will ensure its normal execution. If it cannot execute normally, it is not a problem with our code. It may be that the server where the URL is located is down, or the local network is abnormal. But this has nothing to do with whether our code can handle logic correctly.

The business logic we write cannot handle errors when the external server is down. For example, our code calculates 1+1, and we assert that it is equal to 2. Then we initiate an HTTP request, but the HTTP request is abnormal. Well, I can’t say that 1+1 is not equal to 2 at this time. Because our 1+1=2 logic has nothing to do with the external system.

**Unit testing tests the business logic code we wrote. **All interactions with external systems do not need to be tested.

How to write unit tests

After clarifying what we want to test, we need to learn how to write unit tests: by providing expected inputs and expected results, and comparing them with the actual running results of the unit, we can know whether the unit is working as expected. consistent.

So, there are three steps to writing unit tests:

  • Construct input parameters and predict the output produced by that input.
  • Call the target method to be tested and get the output.
  • Check whether the output of the target method is consistent with the expected output (Assert assertion).

For the same target method, by constructing various inputs, repeat the above steps to detect whether various normal and boundary conditions are consistent with expectations, ensuring that all possibilities of the target method are covered.

Here is a simple example (PHP):

// 单元测试的目标方法
function add(int $a, int $b): int
{
    return $a + $b;
}

// 单元测试
// 测试 add 方法
public function testAdd()
{
    // 构建输入
    $a = 1;
    $b = 1;

    // 调用目标方法
    $sum = $this->add($a, $b);

    // 比对输出与期望的值是否一致。
    // 如果不一致的话,单元测试不通过,说明我们的目标方法有错误或者我们的期望值有错误。
    $this->assertEquals(2, $sum);
}

We found that writing unit tests doesn't seem that difficult, right? Of course, most of the requirements in actual work are much more complicated than this, but the steps of unit testing are actually the three mentioned above: constructing input, calling the method under test, and verifying the output.

Use Mock

Unit testing is actually not complicated. What is complicated is actually our code. If we want to write unit tests better, we must also understand mocks in unit tests.

Mock is a technology that helps us simulate class methods in unit testing. We know that unit tests should not have dependencies on external components such as databases, so how can we implement it so that unit tests have no external dependencies? The answer is mock. When our code needs to depend on a certain class, we can use the mock library to generate a simulated object. When our code needs to call certain methods of this object, it will not actually Produce the actual call. This is a bit abstract, but here is a very typical example:

class Adder
{
    public function add($a, $b)
    {
        return $a + $b;
    }
}

class Calculator
{
    private $adder;

    /**
     * @param Adder $adder 代表一个对外部的依赖
     */
    public function __construct(Adder $adder)
    {
        $this->adder = $adder;
    }

    public function add($a, $b)
    {
        // 这里只使用了外部依赖,实际中可能包含非常多的逻辑
        return $this->adder->add($a, $b);
    }
}

// 单元测试
public function testCalculator()
{
    // 创建一个模拟的 Adder 对象
    $adder = Mockery::mock(Adder::class)->makePartial();
    // shouldReceive 表明这个 mock 对象的 add 方法会被调用
    // once 表明这个方法只会被调用一次(没有 once 调用表示可以被调用任意次数)
    // with 如果调用 mock 对象的时候传递了 1 和 2 两个参数,就会返回 andReturn 中的参数
    $adder->shouldReceive('add')->once()->with(1, 2)->andReturn(3);

    $c = new Calculator($adder);
    $this->assertEquals(3, $c->add(1, 2));

    $adder = Mockery::mock(Adder::class)->makePartial();
    // 没有指定 with,传递任意参数都会返回 3
    $adder->shouldReceive('add')->andReturn(3);
    $c = new Calculator($adder);
    $this->assertEquals(3, $c->add(2, 3));
}

In all common programming languages, there will be a relatively mature mock library, such as:

  • Mockery in PHP (the above example uses Mockery)
  • Mockito in Java
  • testify in go also provides mock function

With Mock, we can achieve the goal of isolating external dependencies. Whether it is RPC, database, or reading and writing files, we can use a simulated object to simulate the actual operation. This means that no matter how the external system changes, if our unit test passes, it means that the code we wrote is logically correct. This will make our unit tests more robust.

 During unit testing, we usually inject external dependencies into our code in the form of mocks. There will be large differences in the implementation of various languages, and sometimes it is related to the framework used:

  • PHP's Laravel can mock an object, bind it to the container, and then use app() to use the dependency injection function provided by the framework, or mock it yourself and directly create a new instance for testing.
  • Java's Spring Boot's dependency injection is more advanced. Injection can be achieved directly by adding the @Mock/@InjectMocks annotation to the class field.

Are there a lot of mocks?

After reading the above description, we may be excited to write unit tests. After we start writing unit tests, we may feel very frustrated. After writing one test for a day with a cup of tea and a cigarette, we will find out why we need to mock so many things. At this time, we may start to think about whether this mocking approach is right or not, and why is it so laborious to write?

When this happens, it often reflects problems in the design behind our code. If a class needs to rely on many other things, it means that the class itself is too complex. What to do at this time? Of course, as long as you can run! As long as one of the code and the person can run, it will be fine.

We may not be able to do anything about the code of the legacy system, but for our new code, we still have the opportunity to improve it. In the process of writing new code and writing unit tests at the same time, we can think about how to write code that can be written Unit tested. We can take a look at some things about software design, such as "The Beauty of Software Design" by Zheng Ye. Personally, I feel it is quite down-to-earth. Continuously writing unit tests enables us to write reusable, generalizable code and improve our software design.

Guess you like

Origin blog.csdn.net/GDYY3721/article/details/132305622