Part 2 of our guide to unit testing with JUnit covers advanced techniques for staying efficient when applying JUnit 5 unit tests in your projects.
💡 A series on JUnit testing
- Part 1: How to write JUnit test cases? A step-by-step guide with examples covers 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 and key best practices.
Table of contents:
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 ValueSource
s 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 likeassumeTrue()
(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:
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, most developers rely on boilerplate code that can be reused across multiple test cases. Using Symflower to generate boilerplate code saves you time and effort, enabling you to focus on more exciting software development challenges.
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 can also generate complete test suties and provide real-time, test-backed diagnostics. The tool is available as a plugin for the most popular IDEs (Visual Studio Code, IntelliJ IDEA, 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.
🤓 Check out these Java resources
- The best IntelliJ IDEA productivity plugins for Java developers
- What are the top Java unit testing frameworks & tools in 2024?
- What are Java modules and how to use them?
- The best static analysis tools and linters for Java
- How to write reusable code? Guide & best practices for reusability in Java
- Mocking frameworks for Java: Mockito vs EasyMock vs JMockit
- Java Unit Testing Frameworks Compared: JUnit vs TestNG differences and similarities