1. Introduction
Test classes often contain member variables referring to the system under test, mocks, or data resources used in the test. By default, both JUnit 4 and 5 create a new instance of the test class before running each test method. This provides a clean separation of state between tests.
In this tutorial, we are going to learn how JUnit 5 allows us to modify the lifecycle of the test class using the @TestInstance annotation. We’ll also see how this can help us with managing large resources or more complex relationships between tests.
2. Default Test Lifecycle
Let’s start by looking at the default test class lifecycle, common to JUnit 4 and 5:
class AdditionTest {
private int sum = 1;
@Test
void addingTwoReturnsThree() {
sum += 2;
assertEquals(3, sum);
}
@Test
void addingThreeReturnsFour() {
sum += 3;
assertEquals(4, sum);
}
}
This code could easily be JUnit 4 or 5 test code, apart from the missing public keyword that JUnit 5 does not require.
These tests pass because a new instance of AdditionTest is created before each test method is called. This means that the value of the variable sum is always set to 1 before the execution of each test.
If there were only one shared instance of the test object, the variable sum would retain its state after every test. As a result, the second test would fail.
3. The @BeforeClass and @BeforeAll Annotations
There are times when we need an object to exist across multiple tests. Let’s imagine we would like to read a large file to use as test data. Since it might be time-consuming to repeat that before every test, we might prefer to read it once and keep it for the whole test fixture.
JUnit 4 addresses this with its @BeforeClass annotation:
private static String largeContent;
@BeforeClass
public static void setUpFixture() {
// read the file and store in 'largeContent'
}
We should note that we have to make the variables and the methods annotated with JUnit 4’s @BeforeClass static.
JUnit 5 provides a different approach. It provides the @BeforeAll annotation which is used on a static function, to work with static members of the class.
However, @BeforeAll can also be used with an instance function and instance members if the test instance lifecycle is changed to per-class.
4. The @TestInstance Annotation
The @TestInstance annotation lets us configure the lifecycle of JUnit 5 tests.
@TestInstance has two modes. One is LifeCycle.PER_METHOD (the default). The other is Lifecycle.PER_CLASS. The latter enables us to ask JUnit to create only one instance of the test class and reuse it between tests.
Let’s annotate our test class with the @TestInstance annotation and use the Lifecycle.PER_CLASS mode:
@TestInstance(Lifecycle.PER_CLASS)
class TweetSerializerUnitTest {
private String largeContent;
@BeforeAll
void setUpFixture() {
// read the file
}
}
As we can see, none of the variables or functions are static. We are allowed to use an instance method for @BeforeAll when we use the PER_CLASS lifecycle.
We should also note that the changes made to the state of the instance variables by one test will now be visible to the others.
5. Uses of @TestInstance(PER_CLASS)
5.1. Expensive Resources
This annotation is useful when instantiation of a class before every test is quite expensive. An example could be establishing a database connection, or loading a large file.
Solving this previously led to a complex mix of static and instance variables, which is now cleaner with a shared test class instance.
5.2. Deliberately Sharing State
Sharing state is usually an anti-pattern in unit tests, but can be useful in integration tests. The per-class lifecycle supports sequential tests that intentionally share state. This may be necessary to avoid later tests having to repeat steps from earlier tests, especially if getting the system under test to the right state is slow.
When sharing state, to execute all the tests in sequence, JUnit 5 provides us with the type-level @TestMethodOrder annotation. Then we can use the @Order annotation on the test methods to execute them in the order of our choice.
@TestMethodOrder(OrderAnnotation.class)
class OrderUnitTest {
@Test
@Order(1)
void firstTest() {
// ...
}
@Test
@Order(2)
void secondTest() {
// ...
}
}
5.3. Sharing Some State
The challenge with sharing the same instance of the test class is that some members may need to be cleaned between tests, and some may need to be maintained for the duration of the whole test.
We can reset variables that need to be cleaned between tests with methods annotated with @BeforeEach or @AfterEach.
6. Conclusion
In this tutorial, we learned about the @TestInstance annotation and how it can be used to configure the lifecycle of JUnit 5 tests.
We also looked at why it might be useful to share a single instance of the test class, in terms of handling shared resources or deliberately writing sequential tests.
As always, the code for this tutorial can be found on GitHub.