Java Testing in 2024: Elevate your Spring Tests with these Patterns

Java Testing in 2024: Elevate your Spring Tests with these Patterns

Over the years, I have seen many different ways of writing Tests with Spring in Java. In this opinionated post I want to share some of the insights I had working with many different approaches in various projects. To start things of I would like to share some of my opinions about testing in general, so you can see if you agree and if the following advice is useful to you. As in all things, there exist trade offs. So it's important to keep in mind what you want to optimise for.


Over the years, I have seen many different ways of writing Tests with Spring in Java. In this opinionated post I want to share some of the insights I had working with many different approaches in various projects. To start things of I would like to share some of my opinions about testing in general, so you can see if you agree and if the following advice is useful to you. As in all things, there exist trade offs. So it's important to keep in mind what you want to optimise for. These are some things I try to optimise for when writing tests

  • Tests are simple, short and easily understandable
  • The friction of writing new tests should be kept to a minimum
  • Tests serve as Documentation
  • Tests are isolated from each other
  • Test close to production
  • KISS is way more important than DRY in Tests

Everything that follows tries to adhere to these principles in the context of bigger and more complex enterprise projects. So let's get started with some general advice

Structure and Setup

Since your tests should be easy to recognise and read, let's start with some structure.

Arrange, Act, Assert

Let's start with the basics. It's important that all the tests follow more or less the same structure so we can easily identify the different stages when a test starts to fail.

@Test
void testCreateUserDto() {
	// arrange
	UserDto john = new UserDto("John", 30);

	// act
	insertIntoDatabase(john);

	// assert
	Assertions.assertThat("John").isEqualTo(john.name());
	Assertions.assertThat(30).isEqualTo(john.age());
}

Basic stuff, you can also use "Given, when, then" if you prefer.

Prefer fixed over randomized Data

It's tempting to just use methods like

// Bad
UUID uuid = UUID.randomUUID();
LocalDate.now()

In your test classes, but I would advise against it. Over the long run, you will just lose time with patterns like this. Error messages are not very helpful and debugging can become a pain, so prefer using fixed data:

// Good
UUID uuid = UUID.fromString("38400000-8cf0-11bd-b23e-10b96e4ef00d");
LocalDate localDate = LocalDate.of(2024, 1, 1);

If you want to mix the convenience of the first example with the deterministic behaviour of the second, you can also create a Utility class which returns data that is unique to your testMethod, for example by using the stack trace like this

    public static UUID nonRandomUuid() {
        StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
        StackTraceElement testMethodElement = stackTrace[2];
        String className = testMethodElement.getClassName();
        String methodName = testMethodElement.getMethodName();
        String uniqueString = className + "." + methodName;
        return UUID.nameUUIDFromBytes(uniqueString.getBytes(StandardCharsets.UTF_8));
    }

Extensively use Builders

With the advance of Java Records, It can sometimes become tedious to change parameters in test code. It goes without saying however, that you should never weaken encapsulation or convert a record to a regular class just so you can modify it more easily in your tests. Also, Builders can help you easily identify important parameters. In the example above, I create an object UserDto with a name and age. In real projects, you will have many more parameters, and only 2-3 or three of them will be relevant to the test.

Specific Builders for Testclasses can help you out here and make your code easily changeable for new test cases. Let's create a simple Builder:


public class UserDtoBuilder {
    private String name;
    private int age;

    public UserDtoBuilder setName(String name) {
        this.name = name;
        return this;
    }

    public UserDtoBuilder setAge(int age) {
        this.age = age;
        return this;
    }

    public static UserDtoBuilder defaultUserBuilder() {
        return new UserDtoBuilder()
                .setAge(20)
                .setName("John Doe");
    }

    public static UserDto defaultUserDto() {
        return new UserDtoBuilder().build();
    }

    public UserDto build() {
        return new UserDto(name, age);
    }
}

Here I've add two methods with a default prefix that allow you to easily overwrite default parameters in tests and one that returns the default object directly. So now when only the age parameter is relevant for the test you can easily modify it like this

@Test
void testCreateUserDto() {
	// arrange
	UserDto john = defaultUserBuilder()
	.setAge(50)
	.build();
  ....
}

Or just use the defaultUserDto() if you need the object directly

insertIntoDatabase(defaultUserDto());

You might also look into using Libraries like RecordBuilder or Lombok for this.

Use var judiciously

Java 10 introduced the var keyword and it certainly has it's place. However It's important to not overshoot with the usage of type inference. It might be tempting to replace every single occurence of a Type with the var keyword, but always keep in mind if this will help readability. There is no clear answer when to use var or avoid it, but following the https://openjdk.org/projects/amber/guides/lvti-style-guide LVTI style guide helped me alot.

Isolated Tests

Keeping your Tests isolated from each other is important, here is why.

The Test is responsible for setting up it's data

Often times I see a pattern like this applied in test classes

UserDto john;

@BeforeEach
void setUp() {
	this.john = defaultUserBuilder()
			.setAge(50)
			.build();
}

@Test
void testCreateUserDto() {
	// arrange

	// act
	insertIntoDatabase(this.john);

	// assert
	Assertions.assertThat("John").isEqualTo(this.john.name());
	Assertions.assertThat(30).isEqualTo(this.john.age());
}

@Test
void testCreateUserDto2() {
	// arrange

	// act
	insertIntoDatabase(this.john);

	// assert
}

And I get it, after all everyone of us learned that repeating yourself is bad, and why would I create the same object twice in each method when I can just elegantly pull it up, right?

There are a couple of reasons why to avoid this:

  • It forces the reader of your test to jump around to understand a test
  • Tests are no longer isolated
  • Changing a parameter might cause other tests to fail

So what typically happens is that tests are written with the field until one test needs to have different parameters, and from there on out, chaos ensues and it's no longer clear who is responsible for what. An even worse offender of this are generic "TestData" classes spanning multiple test classes. Longterm, your code will become much harder to maintain if you try to be fancy in these matters. And trust me, there is nothing more annoying than wanting to write a quick Integrationtest for your new feature and spending a whole morning fixing unrelated tests because you needed to change a single parameter.

Extensively use Helper Functions and expose parameters

The above example was the first step to make your testing code short and concise, now follows the second. We should use helper methods that explain what we are about to do and expose relevant parameters for the test. Calling your default repository methods can get repetitive and creating every Object from scratch hides what is relevant for the test. So instead of:

// Bad
this.userRepository.save(new UserDto("John", 30));
this.userRepository.save(new UserDto("John", 40));
this.userRepository.save(new UserDto("John", 50));

prefer this:

// Good
insertIntoDatabase(userDtoWithAge(30), userDtoWithAge(40), userDtoWithAge(50));

But try to strike a balance here. We don't want to hide too much information in the helper functions! Everything that influences the test (in the above example the age variable) should be clearly visible in the test. Everything that is not relevant (like the name) can be hidden away.

Careful with the inheritance

Personally, I try to avoid inheritance in my test classes. Creating good abstractions is hard, and I've seen too many hierarchies like this

class BasicTest {}
class IntermediateTest extends BasicTest {}
class AdvancedTest extends IntermediateTest {}

to be very wary of such hierarchies. I'm a big advocate of using Annotations & Composition to make your tests easily understandable and maintanable. I've written about this topic at length in my test containers articles. You can check it out here.

Avoid overusing Constants and Variables

// Bad
private static final String JOHN = "John";
private static final int AGE = 30;


@Test
void testCreateUserDto() {
// assert
Assertions.assertThat(JOHN).isEqualTo(john.name());
Assertions.assertThat(AGE).isEqualTo(john.age());
}

You get the idea. This breaks isolation and makes it harder to read. The more tests there are in the class, the sooner you will run into problems you don't want to deal with.

Test close to production

As you are probably aware, Unit tests alone just don't cut it. This has only become more obvious in the current era of software development where projects often have multiple different artifacts and systems they need to interact with. Investing in good and reliable Integration tests is invaluable to the quality of your software.

Try to avoid In-Memory solutions

In-Memory solutions like H2 are great if you wan't to quickly spin something up and get quick feedback. However, a green test doesn't give you 100% confidence that your code works with a real database in production. In the context of big enterprise projects, there exist better alternatives now, like the Testcontainers library. Use a Docker container to spin up the same version of the database that runs in your Production and test against that. You also don't have the hassle of learning H2 specific syntax and dealing with that in your tests.

Try to avoid Mocks

Again, I don't want to be dogmatic about this, but Mocks were mostly used in a time where it was really complicated / expensive to test your code against "real" hardware in the pre-docker era. There is no harm in using a Mock here and there, but most of your integration tests should test a complete vertical slice of your relevant usecases. So instead of Mocking out your Database and Message Broker calls, try using Testcontainers.

Use Annotations to abstract away setups

The lack of tests in your Junior Devs code often doesn't come from lacking intention to write tests. When starting out, it can just be pretty damn complicated getting to run an Integration Test when you are just starting out and don't know all the in and outs of setting up Tests. Try to make it as easy as possible for them by using Annotations that abstract away the setup. In my current Project, all that is needed to start an Integration Test with a Database container is a simple Annotation like this: (Kotlin example from my )

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@EnableTestcontainers
@SpringBootTest
@ActiveProfiles("test")
annotation class IntegrationTest(
    vararg val properties: String = []
)
@IntegrationTest(properties = ["spring.profiles.active=test"])

You can check out this Github Repository for an example:

Recommended Libraries for Testing

JUnit 5

Try to move on from Junit 4 and don't mix it in your test classes. It's the state of the art. Time to move on.

Parameterized Tests

Often times you can avoid repeating yourself using parameterized Tests like so:

enum SampleEnum {ONE, TWO, THREE}

@ParameterizedTest
@MethodSource("values")
void parameterizedTest(SampleEnum sampleEnum){
	System.out.println(sampleEnum);
}

private static Stream<Arguments> values() {
	return Stream.of(
		Arguments.of(SampleEnum.ONE),
		Arguments.of(SampleEnum.TWO),
		Arguments.of(SampleEnum.THREEE)
		);
}

JUnit Extensions

You can often times avoid setUp code in your @BeforeEach by using a Junit Extension like so.

import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;

public class LoggingExtension implements BeforeEachCallback, AfterEachCallback {

    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        System.out.println("Starting test: " + context.getDisplayName());
    }

    @Override
    public void afterEach(ExtensionContext context) throws Exception {
        System.out.println("Finished test: " + context.getDisplayName());
    }
}

Another valid use case for this is to clean databases before your tests.

Use Tags and DisplayNames

Nice add-on to have readable test descriptions

@Test
@Tag("unit")
@DisplayName("Create UserDto")
void testCreateUserDto() {

AssertJ

AssertJ is my favorite library for handling assertions. It's fluent, easily readable hand has some nice assertion abilities for Collections as well. I especially like the assertAll Method:

    @Test
    void testCreateUserDto() {
        // assert
        var userDto = defaultUserDto();

        assertAll("Assert UserDto",
                () -> Assertions.assertThat("John").isEqualTo(userDto.name()),
                () -> Assertions.assertThat(10).isEqualTo(userDto.age())
        );
    }

Awaitility

If you are dealing with async code, there is no need to use Thread.sleep() everywhere. Awaitility has you covered and can evaluate your code after meeting a certain condition, so you don't need to unnecesarily slow down your CI/CD Pipeline, have a look at this nice example from their homepage.

@Test public void updatesCustomerStatus() { messageBroker.publishMessage(updateCustomerStatusMessage);
await().atMost(5, SECONDS).until(customerStatusIsUpdated()); ... }

Testcontainers

As you already know, I'm a big advocate of this library and have written multiple articles on it.

ArchUnit

Last but not least we have ArchUnit. This allows you the verify that your team follows the guidelines of this blog post ;) By allowing to "metatest" your tests or project structure like so: (example from their homepage)

@Test
public void Services_should_only_be_accessed_by_Controllers() {
    JavaClasses importedClasses = new ClassFileImporter().importPackages("com.mycompany.myapp");

    ArchRule myRule = classes()
        .that().resideInAPackage("..service..")
        .should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");

    myRule.check(importedClasses);
}

I can highly recommend this library as well. Since it greatly helps preventing unwanted changes to your module / package hierarchy

My Take on TDD

There are multiple definitions of what TDD means, but if we go by the "Write Tests first, make it fail and write your Code until the Test passed" Definition then It's not for me.

I've never experienced significant improvements in productivity, design or speed when writing my tests first. The way I see it, productivity is a very personal topic and what works for me doesn't necessarily work for you. So I'm not really a fan of dogmatic approaches.

Conclusion

So that's it! This post should cover some of the most important Lessons if learned from writing hundreds of different tests in various projects. I hope there are some mistakes you can avoid using these patterns and I'll probably add some additional points over time as well. I also want to point out that I learned a lot from this article back in the days and I can highly recommend it as well.