12 minutes read

You already know how to write simple tests using a variety of assertion methods and @Test annotations. Now, we are going to learn about the test class lifecycle and ways of controlling it with the help of JUnit5 annotations.

Getting started

We will use the following User class in our example:

private const val MIN_PASSWORD_LENGTH = 8

class User(private val username: String?, private val password: String?) {
    fun hasStrongPassword(): Boolean {
        return password != null && password.length >= MIN_PASSWORD_LENGTH
    }

    fun hasValidUsername(): Boolean {
        return !username.isNullOrBlank()
    }

    val isValid: Boolean
        get() = hasValidUsername() && hasStrongPassword()

}

It has two private String fields: username and password, a constructor that takes two String arguments, and three public methods to check if an instance of the class User has a valid username, a strong password (which is considered strong if it is at least 8 characters long), and represents a valid User, which means that it has a valid username and a strong password.

Also, we have the following test suite to test the correctness of the implementation of the User class methods:

import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test

internal class UserTest {
    @Test
    fun hasStrongPassword() {
        val username = "Alice"
        val password = "12345678"
        val user = User(username, password)
        assertTrue(user.hasStrongPassword())
    }

    @Test
    fun hasValidUsername() {
        val username = "Alice"
        val password = "12345678"
        val user = User(username, password)
        assertTrue(user.hasValidUsername())
    }

    @Test
    fun isValid() {
        val username = "Alice"
        val password = "12345678"
        val user = User(username, password)
        assertTrue(user.isValid)
    }
}

Look at the implementation of these tests. Each of them is completely independent of the others. We are not calling any test in another test, and each test uses a new instance of the User class. That may not make much sense in this particular case, but if we are testing more complex classes, we will want to start each time with a clean state of the object being tested.

This means that we should not create a single instance of a class and share it among all tests, since a certain test may leave some state in the object that might affect the results of other tests. That is why we should execute each test using a new instance of the tested class.

However, in our case, we initialized a new object of the User class in each test method repeating the same code in multiple places, which generally is a bad idea.

In our example, we wrote just a few tests and the initialization requires only a few lines of code (we even could have done it as a one-liner), but in other projects, we could have classes that require more lines of code to initialize their instances. Also, notice that we tested just the happy path of execution of the User class methods, but in a real project, we will have to check every execution path of every method, and this will require us to write many tests. Fortunately, JUnit5 provides us tools using which we can better organize code according to our needs.

Test class instance lifecycle

First, let's talk about how JUnit5 executes our tests. It creates a new instance of the test class before executing each test method. This way, it ensures execution of individual test methods in isolation to avoid possible side effects produced by any changes of state of the test class instance.

@TestInstance annotation controls the test class instance lifecycle. It is set to TestInstance.Lifecycle.PER_METHOD by default but can be changed if necessary.

Also, JUnit5 has special annotations to designate any methods as lifecycle methods, such as @BeforeAll, @AfterAll, @BeforeEach, or @AfterEach. They instruct the framework to execute the designated methods before or after executing actual test methods.

The annotations @BeforeEach and @AfterEach indicate, respectively, that the annotated method will be executed before and after each method of the test class annotated with @Test, while @BeforeAll and @AfterAll methods will be executed before or after all the @Test methods in the test class.

In the following example, we create a new test class to see how lifecycle annotations actually work:

import org.junit.jupiter.api.*

class LifeCycleTest {

    constructor(){
        println("Test Class Constructor")
    }

    @BeforeEach
    fun beforeEach() {
        println("Before each test")
    }

    @AfterEach
    fun afterEach() {
        println("After each test")
    }

    @Test
    fun test1() {
        println("Test 1")
    }

    @Test
    fun test2() {
        println("Test 2")
    }
}

A test fixture is a fixed state of objects intended to provide a known and fixed environment for running tests.

Running the tests gives the following output:

Test Class Constructor
Before each test
Test 1
After each test
Test Class Constructor
Before each test
Test 2
After each test

Note that the methods annotated with @BeforeAll and @AfterAll are static because this way, they can be shared among new test class instances created for each test method. If your test class has ten test methods, the methods annotated with @BeforeEach and @AfterEach will execute ten times each, while the methods annotated with @BeforeAll and @AfterAll will execute only once.

Let's see how it works:

import org.junit.jupiter.api.*

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class LifeCycleTest {

    constructor(){
        println("Test Class Constructor")
    }

    @BeforeAll
    fun beforeAll() {
        println("Before all tests")
    }

    @AfterAll
    fun afterAll() {
        println("After all tests")
    }

    @BeforeEach
    fun beforeEach() {
        println("Before each test")
    }

    @AfterEach
    fun afterEach() {
        println("After each test")
    }

    @Test
    fun test1() {
        println("Test 1")
    }

    @Test
    fun test2() {
        println("Test 2")
    }
}

The result of the test class will be as follows:

Test Class Constructor
Before all tests
Before each test
Test 1
After each test
Before each test
Test 2
After each test
After all tests

The following diagram illustrates this order to help you better understand the test class lifecycle:

test class life cycle

Test instance per class

In the previous example, we used the @TestInstance annotation (Lifecycle.PER_CLASS), let's talk about it. If for any reason you would like to execute all test methods on the same instance of the test class, JUnit5 allows you to do so by annotating the test class with @TestInstance(Lifecycle.PER_CLASS). In this mode, a new instance of the test class will be created only once, therefore if your test methods rely on a state stored in its non-static variables, you may need to reset that state in @BeforeEach or @AfterEach methods.

We will see how PER_CLASS works in another example. First, let's add this annotation to our LifeCycleTest class:

import org.junit.jupiter.api.*;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class LifeCycleTest {

    constructor(){
        println("Test Class Constructor")
    }

    @BeforeAll
    fun beforeAll() {
        println("Before the test fixture")
    }

    @AfterAll
    fun afterAll() {
        println("After the test fixture")
    }

    @BeforeEach
    fun beforeEach() {
        println("Before each test")
    }

    @AfterEach
    fun afterEach() {
        println("After each test")
    }

    @Test
    fun test1() {
        println("Test 1")
    }

    @Test
    fun test2() {
        println("Test 2")
    }
}

Note that since the test class instance is shared among all test methods, there is no need for the @BeforeAll and @AfterAll methods to be static. Now, let's run it and see what has changed compared to the new instance per test method:

Test Class Constructor
Before the test fixture
Before each test
Test 1
After each test
Before each test
Test 2
After each test
After the test fixture

For the per test method test instance lifecycle, remove the @TestInstance annotation from your test class or explicitly use @TestInstance(TestInstance.Lifecycle.PER_METHOD).

The following diagram illustrates the method call sequence:

method call sequence

If you are using this mode and your test methods rely on state stored in instance variables, you may need to reset that state in @BeforeEach or @AfterEach methods to avoid unexpected side effects.

Using lifecycle annotations

Now, we may rewrite our UserTest class and get rid of initialization of User instances in each test method:

import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

internal class UserTest {
    var user: User? = null

    @BeforeEach
    fun createUser() {
        val username = "Alice"
        val password = "12345678"
        user = User(username, password)
    }

    @Test
    fun hasStrongPassword() {
        assertTrue(user!!.hasStrongPassword())
    }

    @Test
    fun hasValidUsername() {
        assertTrue(user!!.hasValidUsername())
    }

    @Test
    fun isValid() {
        assertTrue(user!!.isValid)
    }
}

In what other cases may lifecycle annotation help? @BeforeEach , as you have seen, may be used to set up new instances of the classes being tested. @AfterEach is handy to clean up any side effects of the execution of the tests or to provide detailed information about their execution and results. @BeforeAll and @AfterAll are great for setting up and tearing down the entire test fixture. You may use methods annotated by @BeforeAll to create and initialize big data structures, establish connections to data sources, fetch data from databases, remote repositories, or hard drives, and after that close resources and clean everything up in @AfterAll methods.

Putting it all together, we can write the following implementation of our test class with a pre-defined set of input data:

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

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class UserTest {
    lateinit var names: Array<String?>
    lateinit var passwords: Array<String?>
    lateinit var expectedOutcomes: BooleanArray

    var index = 0

    var user: User? = null
    var expected = false

    @BeforeAll
    fun setUp() {
        names = arrayOf("Alice", "Alice", "Alice", "", null, "    ")
        passwords = arrayOf("12345678", "123", null, "12345678", "12345678", "12345678")
        expectedOutcomes = booleanArrayOf(true, false, false, false, false, false)
    }

    @BeforeEach
    fun createUser() {
        user = User(names[index], passwords[index])
        expected = expectedOutcomes[index]
    }

    @AfterEach
    fun incrementIndex() {
        index++
    }

    @RepeatedTest(value = 6, name = "user.isValid() test {currentRepetition}/{totalRepetitions}")
    fun isValid() {
        assertEquals(expected, user!!.isValid)
    }
}

Here we used @RepeatedTest to run the annotated test 6 times (value = 6) and defined a custom name for displaying test results. {currentRepetition} and {totalRepetitions} are placeholders for displaying the current run and the total number of test runs. Here is the output:

user.isValid() test 1/6
user.isValid() test 2/6
user.isValid() test 3/6
user.isValid() test 4/6
user.isValid() test 5/6
user.isValid() test 6/6

Even in this simple example lifecycle annotations help us create multiple test cases with ease.

Conclusion

When we write test methods, we do the following: set up, initialize, and assert. If we have many test methods, we have to repeat these lines of code multiple times. We can use lifecycle method annotations @BeforeAll, @AfterAll, @BeforeEach, and @AfterEach to better organize code, separate test fixture initialization logic from test case assertions, and control the state of our test class. JUnit uses a new test instance per each test method by default. In the case we need a single test instance for our test class, we can use @TestInstance(TestInstance.Lifecycle.PER_CLASS) annotation.

58 learners liked this piece of theory. 10 didn't like it. What about you?
Report a typo