Icon for gfsd IntelliJ IDEA

How to write JUnit test cases: advanced techniques

An illustration for part 2 of Symflower's series on writing JUnit test cases which dives into some advanced unit testing techniques with examples.

In Part 2 of our unit testing guide with JUnit, we’ll dig deeper and present some advanced techniques that will be useful once you start applying unit tests in your projects with JUnit 5.

In case you missed it, in Part 1 of our guide to writing JUnit test cases, we covered the basics: why and how to write Java unit test cases with JUnit 5, as well as a step-by-step Java unit test tutorial to help you get started. We also included a few best practices to help streamline your unit testing efforts.

Advanced techniques for unit testing

Technique #1: Checking for exceptions

Sometimes you need to make sure that a method throws an exception, e.g. when it receives unsupported inputs. In our running example (check the previous post in this series for context) we assume there exists a manager for triangles: TriangleManager that throws an InvalidTriangleException in case it gets an invalid triangle to work with. To perform this check, you can use the following code snippet:

public class TriangleManagerTest {
    static Database InMemoryDB;

    @Test
    public void updateTriangleThrowsNPE() {
        TriangleManager triangleManager = new TriangleManager(InMemoryDB);
        Throwable exception = assertThrows(InvalidTriangleException.class, ()->{
            triangleManager.storeTriangle(new Triangle(-1, 1, 1));
        });

        assertEquals("Triangle Sides need to be positive.", exception.getMessage());
    }
}

Note that assertThrows expects an executable as its second parameter. For that, we use a lambda expression whose body performs the method call which should throw the exception.

assertThrows checks that the thrown exception is of the supplied type, meaning that inheriting exception classes will also pass the assertion. For example, a more precise InvalidTriangleNegativeSizeException that inherits from InvalidTriangleException would also be accepted. In case you want to check that only an instance of the specified class and no child class is thrown, use the assertThrowsExactly assertion.

Another attribute of assertThrows is, that it returns the thrown exception. This makes it possible to validate the returned exception even deeper, e.g. we are checking its messages in the above example.

Technique #2: Parameterized testing

Parameterized tests let you execute a single specific test method multiple times, each time with a different parameter (value). Parameterized testing was for long missing from JUnit, but version 4 of the framework enabled the use of parameters in your tests without any additional libraries. Version 5 of JUnit made their usage even easier by streamlining the API.

Using parameterized tests, you can save time writing tests that differ only in their inputs and expected results but are otherwise the same. For instance, when writing a piece of code performing a calculation, you’ll want to test that code with a wide range of input values to make sure it returns known-correct output values. In such cases, parameterized tests are very useful and can save you time: both in writing and maintaining tests.

In order to set up parameterized tests for JUnit 5, you’ll need to add the following dependency to your POM file’s dependencies section:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>5.9.1</version>
    <scope>test</scope>
</dependency>

In order to declare a parameterized test, you’ll just use the @ParameterizedTest instead of the @Test annotation. In addition, you’ll also have to specify the source that is used within the parameterized test. The simplest kind of sources are ValueSources which define a list of primitives that should be used within the test.

Take a look at the following code snippet which uses a list of integers as inputs:

package com.symflower.demo.triangle;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.*;

public class TriangleTest {
   @ParameterizedTest
   @ValueSource(ints = { 5, 10, 15 })
   void getTypeEquilaterals(int length) {
       Triangle t = new Triangle(length, length, length);

       assertEquals(TriangleType.equilateral, t.getType());
   }
}

This snippet specifies three tests, once instantiating the triangle under test with “5”, “10”, and “15” respectively. Note that the interfaces ParameterizedTest and ValueSource need to be imported in order for the test to be executed!

In addition, JUnit’s parameterized tests let you define @EnumSource, @MethodSource, as well as other annotations as sources for your parameters. Take a look at Junit 5’s user guide to learn how these can be applied.

Technique #3: Using assumptions

The Assumptions class in JUnit 5 enables the use of static methods that let you set conditions for test execution. These conditions will be based on your assumptions – if that assumption fails, the test will be aborted (rather than failed, as would be the case with assertions). Essentially, they let you programmatically decide whether to continue executing tests, saving quite a bit of time and, potentially, computing bandwidth during testing.

Another major reason to use them, is if you want to make sure that the setup and initialization code of your test is valid, i.e. there is no reason to do further testing if all assertions would fail anyway because of an invalid setup. It is smart to use assumptions in cases where it would not make sense to continue executing your test method if certain conditions were not given, i.e. if your tests rely on something that just simply isn’t there in your current runtime environment.

Assumptions can take three forms in JUnit 5: assumeTrue(), assumeFalse(), and assumingThat():

  • assumeTrue() only runs the test if the condition is true. Otherwise, the test will be aborted.
  • assumeFalse() lets the test proceed if the condition is false. Otherwise, the test will be aborted.
  • assumingThat() allows a bit more flexibility: it works like assumeTrue() (e.g. the test is only executed if the condition is true), but if a condition is false, rather than aborting the test altogether, it lets you just skip the affected block and proceed with executing the rest of the method.

Let’s assume the test method updateTriangleThrowsNPE should only be run in the CI (and you are using a GitLab CI). In this case, adding the following line to your test method will do the trick:

...
import static org.junit.jupiter.api.Assumptions.*;
...

@Test
public void updateTriangleThrowsNPE() {
    assumeTrue(System.getenv("GITLAB_CI") != null, "Skipped test: not in CI environment");

    ...
}

In case the above assumption is false, Maven will mark the test as “skipped” as can be seen in the following example CLI output:

[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.symflower.demo.AppTest
[WARNING] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.03 s - in com.symflower.demo.AppTest
[INFO]
[INFO] Results:
[INFO]
[WARNING] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

Technique #4: Disabling Tests

Sometimes it comes in handy to temporarily ignore certain test cases. To stop those tests (individual test methods or entire test classes) from executing, you can disable tests using the @Disabled annotation. Any @Disabled test method will be marked disabled in the test report. As a parameter, @Disabled also lets you provide a reason for disabling the test.

Here’s what disabling a test would look like using our previous example:

...
import org.junit.jupiter.api.Disabled;
...

@Disabled
@Test
public void updateTriangleThrowsNPE() {
    assumeTrue(System.getenv("GITLAB_CI") != null, "Skipped test: not in CI environment");

    ...
}

Just like with a false assumption, disabled tests are marked as “skipped” as can be seen in the following example CLI output:

[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.symflower.demo.AppTest
[WARNING] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.015 s - in com.symflower.demo.AppTest
[INFO]
[INFO] Results:
[INFO]
[WARNING] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

Technique #5: Nested Test Classes

Nested test classes can be used to group tests that logically belong together. Coming back to our running example, let’s assume we want to add several test cases for the individual triangle types. In this case, we could group together all tests for invalid triangles as follows:

...
import org.junit.jupiter.api.Nested;
...

public class TriangleTest {
    @Nested
    class GetTypeInvalid {
        @Test
        public void getTypeZeroSides() {
            Triangle t = new Triangle(0, 0, 0);
            TriangleType expected = TriangleType.invalid;
            TriangleType actual = t.getType();

            assertEquals(expected, actual);
        }

        @Test
        public void getTypeNegativeSide() {
            Triangle t = new Triangle(1, -1, 0);
            TriangleType expected = TriangleType.invalid;
            TriangleType actual = t.getType();

            assertEquals(expected, actual);
        }

        @Test
        public void getTypeIncunstructableTriangle() {
            Triangle t = new Triangle(1, 1, 10);
            TriangleType expected = TriangleType.invalid;
            TriangleType actual = t.getType();

            assertEquals(expected, actual);
        }
    }
}

A nested class with the name GetTypeInvalid is added that has to be annotated using @Nested in order to let the JUnit framework execute the tests within this class. Next, we can add all tests concerning invalid triangles to the class GetTypeInvalid.

The following screenshot shows JetBrains' IntelliJ IDEA when such nested has been detected:

Shows JetBrains' IntelliJ IDEA testing functionality for the above test code.

Technique #6: Timeout

Using the timeout annotation helps make sure that your test method doesn’t exceed a defined amount of time upon execution. If the test takes longer to execute than the duration you specified (defined in seconds by default), @timeout will simply fail the test.

Note that @Timeout may also be used as a global annotation at the class level.

To more granularly control the timeout condition, use the assertTimeout assertion. This assertion lets you specify a timeout for certain parts of the test method that should not take longer than the defined time. assertTimeoutPreemptively is similar to assertTimeout, but instead of just failing the test, it will also abort the execution altogether in case of a timeout.

Let’s see how you would use @timeout in your code. Add the following annotation to have a timeout in 5 seconds: @Timeout(5)

...
import org.junit.jupiter.api.Timeout;
...

public class TriangleTest {
    @Test
    @Timeout(5)
    public void someLongRunningTest() {
        ...
    }
}

This annotation can be added either at the method or the class level. If you want to use another unit of time for your timeouts, that is possible too, with the following annotation: @Timeout(value = 200, unit = TimeUnit.MILLISECONDS)

Keep it in mind that TimeUnit requires an import: import java.util.concurrent.TimeUnit;

+1: Automatically write the boilerplate of your tests

All the advanced techniques detailed above are very useful and can help boost efficiency when writing unit test cases. Yet you’ll still have to write all those test cases manually. To speed up the process, it’s smart to rely on boilerplate code that you can reuse across multiple test cases. It’s even smarter to have Symflower write your boilerplate code, saving you time and effort to further streamline your testing workflow.

Symflower can automatically generate smart unit test templates based on your implementation. When creating these tests, your cursor will automatically jump to the right location, so all you’ll have to do is fill in test name and values by hand, and your tests are ready to run. Symflower is available as a plugin for the most popular IDEs (IntelliJ IDEA, Visual Studio Code, GoLand and Android Studio), as well as your CLI. Try Symflower for yourself, and make sure you sign up for our newsletter to receive updates on software development and testing topics!

| 2023-02-18