Location>code7788 >text

Deeper Understanding of Unit Testing: Tips and Best Practices

Popularity:643 ℃/2024-08-15 16:30:43

I've shared how to quickly get started with open source projects and how to do integration testing in open source projects, but I haven't talked about specific hands-on exercises.

Today to talk in detail about how to write unit tests.

🤔 When do you need unit tests?

This should be a consensus, for some single function, the core logic, while changes are infrequent public function is necessary to do unit testing.

It is often recommended to do e2e testing for functions that are complex in terms of business, cumbersome in terms of links, but also core processes, so as to ensure the consistency of the final test results.

💀 Specific cases

We all know that the main purpose of a single test is to simulate the execution of every line of code you've ever written, with the goal of covering the major branches and doing so with every line of your own code in mind.

summarized belowApache HertzBeat of some single tests as an example of how to write a unit test.


Let's start with one of the simplest#preCheck function test as an example.
Here.preCheck The function is simply a test to do parameter checksums.
The test is done as long as we manually set themetrics set tonull You can enter this if condition.

@ExtendWith()
class UdpCollectImplTest {

    @InjectMocks
    private UdpCollectImpl udpCollect;

    @Test
    void testPreCheck() {
        List<String> aliasField = new ArrayList<>();
        ("responseTime");
        Metrics metrics = new Metrics();
        (aliasField);
        assertThrows(, () -> (metrics));
    }
}    

Coming to the specific single test code, let's look at it line by line:

@ExtendWith() beJunit5 An annotation provided in which the passed-in This is a common framework we use for single-test mocking.

Simply put, it tellsJunit5 The current test class will run with mockito as an extension, which allows you tomock Some of our runtime objects.


@InjectMocks  
private UdpCollectImpl udpCollect;

@InjectMocks alsomockito This library provides annotations that are typically used to declare classes that need to be tested.

@InjectMocks  
private AbstractCollect udpCollect;

Note that this annotation must be a concrete class, not an abstract class or interface.

In fact, when we understand his principle we can know exactly why:

When we debug the run we will findudpCollect object has a value, and if we remove this annotation@InjectMocks Running it again throws a null pointer exception.

Because it doesn't initialize udpCollect.

rather than using@InjectMocksAfter annotation, themockito The framework automatically gives theudpCollect injects a proxy object; if it's an interface or an abstract class, the mockito framework has no way of knowing which object to create.

Of course in this simple scenario, we directlyudpCollect = new UdpCollectImpl() It's okay to conduct tests.

🔥 Work with jacoco to export single-measurement coverage


In IDEA we can use theCoverage The way in which the operation of theIDEA Just show our single test coverage in the source code, the green part represents where it actually executes to at runtime.

We can also be found in themaven Integration in projectsjacoco, just add a root directory of Add aplugin That's all.

<plugin>  
    <groupId></groupId>  
    <artifactId>jacoco-maven-plugin</artifactId>  
    <version>${}</version>  
    <executions>  
        <execution>  
            <goals>  
                <goal>prepare-agent</goal>  
            </goals>  
        </execution>  
        <execution>  
            <id>report</id>  
            <phase>test</phase>  
            <goals>  
                <goal>report</goal>  
            </goals>  
        </execution>  
    </executions>  
</plugin>

afterwardsmvn test A test report will be generated in the target directory.

We can also integrate in GitHub's CICodecovHe will read the test data directly from jacoco and add a test report in the comments section of PR.

require a license fromCodecov Just add your project's token to the repo's environment variable.

You can refer to this PR for details:/apache/hertzbeat/pull/1985

☀️ more complex single test

Just shown is a very simple scenario, here's a slightly more complex one.

Let's take this single test as an example:

@ExtendWith()
public class RedisClusterCollectImplTest {
    
    @InjectMocks
    private RedisCommonCollectImpl redisClusterCollect;


    @Mock
    private StatefulRedisClusterConnection<String, String> connection;

    @Mock
    private RedisAdvancedClusterCommands<String, String> cmd;

    @Mock
    private RedisClusterClient client;
}

This single test adds one more to the earlier@Mock The notes.

This is because we need to test theRedisCommonCollectImpl Classes that require a dependency on theStatefulRedisClusterConnection/RedisAdvancedClusterCommands/RedisClusterClient The services provided by these classes.

The single test requires the use of themockito Create an object of them and inject them into theRedisCommonCollectImplIn the category.

Otherwise we need to prepare the resources needed for the single test, such as Redis, MySQL, etc. that can be used.

🚤 Simulation behavior

It's not enough to just inject it in, we need to simulate its behavior:

  • For example, calling a function can simulate returning data
  • Simulating a function call that throws an exception
  • Simulating Function Call Time Consumption

Here is an example of the most common simulated function return:

String clusterNodes = ().clusterInfo();

In the source code, you can see that the connectionclusterInfo() function returns cluster information.

        String clusterKnownNodes = "2";
        String clusterInfoTemp = """
                cluster_slots_fail:0
                cluster_known_nodes:%s
                """;
        String clusterInfo = (clusterInfoTemp, clusterKnownNodes);
        (()).thenReturn(clusterInfo);        

At this point we can use the().thenReturn() to simulate the return data of this function.

And one of thecmd Naturally, simulated returns are also required:

        ().when(()->((),
                ())).thenReturn(client);
        (()).thenReturn(connection);
        
        (()).thenReturn(cmd);
        ((())).thenReturn(info);
        (()).thenReturn(clusterInfo);

cmd attributable(()).thenReturn(cmd);returned, and theconnection beginning of() Returned.

It ends up being like a condom.client It is created in the source code by a static function.

⚡ Simulate static functions

I vaguely remember when I was first introduced tomockito In the 16-17 year period, simulating calls to static functions was not yet supported, but it is now:

@Mock  
private RedisClusterClient client;


().when(()->((),  
        ())).thenReturn(client);

This allows you to simulate the return value of a static function, but only if the returnedclient require the use of@Mock Annotation.

💥 Analog Constructors


Sometimes we also need to simulate the constructor so that we can simulate the subsequent behavior of this object.

        MockedConstruction<FTPClient> mocked = (,
                (ftpClient, context) -> {
                    ().when(ftpClient).connect((),
                            (()));

                    (invocationOnMock -> true).when(ftpClient)
                            .login((), ());
                    ((())).thenReturn(isActive);
                    ().when(ftpClient).disconnect();
                });

It is possible to use to perform the simulation, and some of the object's behavior is written directly within this simulation function.

It is important to note that the return of themocked Objects need to remember to close.

Mock not required

Of course not all scenarios requiremock

For example, in the first scenario, there is no need for themock

Something like this.PR If you're relying on a basic memory cache component, there's no need to mock it, but if you're relying on theRedis The caching component still needs to be mocked.
/apache/hertzbeat/pull/2021

⚙️ modify source code

If some test scenarios require access to internal variables for subsequent testing, but the test class doesn't provide a function to access the variables, we'll have to modify the source code to match the test.

Like this one.PR

Of course if it's just for functions or variables that are used in a test environment, we can add the@VisibleForTestingThe annotation is labeled as such, and this annotation serves no other purpose and makes it clearer to subsequent maintainers what this is for.

📈 Integration Testing

Unit testing can only test some of the functions of a single function, to ensure the quality of the entire software only rely on a single test is not enough, we also need integration testing.

Integration testing is usually required for open source projects that need to provide services to the public:

  • Pulsar
  • Kafka
  • Dubbo, etc.

The service-oriented applications I've come across fall into two main categories: Java applications and Golang applications.

🐳Golang

Golang Because the toolchain is not as robust as Java, most of the integration testing functionality is achieved by writing Makefiles and shell scripts.

I'm still familiar with Pulsar'sgo-client For example, its GitHub integration test is triggered by a GitHub action, defined as follows:

The final call is to the test command in the Makefile, passing in the version of Golang you want to test.

Dockerfile

This image is simply a way to run the Pulsar image as a base image (which contains the Pulsar server), and then copy the code for pulsar-client-go into it and compile it.

Then run:

cd /pulsar/pulsar-client-go && ./scripts/

Also known as a test script.

The logic of the test script is also simple:

  • Start the pulsar server
  • Run the test code
    This is because the address of the connection to the server in all the test code islocalhost, so it can be directly connected.

Through hereaction Logs keep track of all runs.

☕Java

Java, because of its powerful toolchain, requires little in the way of Makefiles and scripts to execute integration tests.

Or take Pulsar as an example, its integration tests need to simulate starting a server locally (because Pulsar's server source code and test code are written in Java, it is more convenient to do the test), and then run the test code.

The advantage of this is that any single test can be run directly locally, whereas Go code still needs to start a server locally first, which is more cumbersome to test.

To see how it's done, I take one of theBrokerClientIntegrationTestAs an example:


Will start the server side first when the single test starts.

will eventually call thePulsarTestContext (used form a nominal expression)build function launchbroker(server-side), and performing a single test also requires only the use of themvn test It is possible to trigger these unit tests automatically.

It's just that each single test requires starting and stopping the server, so it usually takes 1 to 2 hours to run all the single tests for Pulsar.

These are the scenarios that you may come across in the day-to-day writing of a single test, and I hope they are helpful.