Before we will learn how to write integration tests for Spring Boot web applications, it's a good idea to take a quick look at the tools which help us to write better tests. This blog post highlights six testing tools which we should use when we are writing integration tests for Spring Boot web applications.
1. JUnit 5
This is an obvious choice. JUnit 5 is the default testing framework of Spring Boot, but it's also an extremely popular testing framework that’s used to write tests for JVM applications. In other words, if we have to write tests for JVM applications in 2023, it's very likely that we must use JUnit 5. This means that if we ran into a problem, it's relatively easy to get the help we need in order to solve our problem.
If we want to write clean integration tests with JUnit 5, we should:
1. Separate different scenarios by writing nested test classes and configure a custom display name for every test class (the root class and every nested test class) and test method. This helps us to write a runnable documentation which describes the true behavior of our code. Also, if we follow this rule and an integration test fails, we should have a pretty good idea what's wrong.
2. Write tests which assert one logical concept. If we truly want to create an executable specification, we should write tests which help us to: pinpoint possible problems as fast as possible and see which requirements are fulfilled by the system under test. That's why we should write tests which are as small as possible, but not too small. For example:
- If we cannot write a short display name for a test method, the test method is most likely too big and we should split it into multiple smaller test methods.
- If we have to check the outcome of multiple test methods before we know if the system under test fulfills the requirement X, the test methods are most likely too small and we should combine them into one test method.
3. Be curious and experiment with parameterized tests. JUnit 5 has a versatile support for parameterized tests, but for some reason, developers don't write them even though they help us to reduce duplicate code when we have to write tests for scenarios which have similar but slightly different "response". For example, we should at least investigate if we can leverage parameterized tests when we are writing integration tests for our error handling code or finder methods.
4. Learn how to use the JUnit 5 extension API. If we need something that isn't supported out of the box, we can simply write our own JUnit 5 extension by using this API. If we have multiple test classes which require custom behavior, we should write a custom JUnit 5 extension instead of using inheritance because using a base test class can cause unwanted side effects when we run the tests found from the derived classes.
2. Spring Profiles
Spring profiles allow us to use different configuration in different environments. To be more specific, when we are using profiles with Spring Boot, we can use different property values in different environments and select which beans are loaded to the application context when a specific profile is active. At first, this feature doesn't sound like a testing tool, but it's extremely useful when we are writing integration tests for code that either deals with the current date and/or time or uses "random" values.
For example, if we are using Spring profiles, we can write integration tests which:
- Use a constant date and time as the "current date and time". This help us to write deterministic integration tests for code which uses the current date and/or time in its business logic.
- Use a different password generator (either no operation or hashing without salt). This allows us to write more comprehensive integration tests for code which either inserts new passwords into the database or updates existing passwords because we can write assertions for the generated passwords.
Testcontainers is a game changer. Back in the day when we couldn't use it (it wasn't released yet), we had to write integration tests against an in-memory database, start the database manually before we could run our integration tests, or write a command line script which starts the database and run the script with Maven before our integration were run. In other words, we had to:
- Write tests which are easy to run and don't help us to ensure that our application is working as expected when it uses the real database.
- Write tests which ensure that our application is working as expected when it uses the real database and are cumbersome to run.
- Write tests which ensure that our application is working as expected when it uses the real database, are easy to run, and can fail if the database cannot be started for some reason.
Testcontainers is a tool which allows us to start Docker containers before our integration tests are run and stop them after our tests have been run. Because it's easy to integrate Testcontainers with both JUnit 5 and Spring Test Framework, we can finally write integration tests which are easy to run (just clone the repo and run the tests) and help us to ensure that our application is working as expected when it uses the real database. However, I didn't call Testcontainers a game changer because of just this feature.
Testcontainers is game changer because it can basically start any Docker container before our tests are run and stop the container after our tests have been run. Because we can finally use the real dependencies, we can write more comprehensive integration, api, and end-to-end tests. This is extremely useful if our application uses the microservices architecture or has internal dependencies (dependencies which are managed by us) like message brokers, internal APIs, and so on.
Because we want to write deterministic integration tests, we must initialize our database into a known state before we run our integration tests. If we are writing integration tests for a Spring Boot web application which uses a relational database, we should initialize our database into a known state by using SQL scripts instead of DbUnit (or some other similar tool) because:
- Because we already know SQL, we don't have to learn a new DSL that's used to write the dataset files. This means that our dataset files (aka SQL scripts) are both easy to read and write.
- If we use SQL, there are no surprises when we initialize our database into a known state because we don't have to use a tool that generates the invoked SQL from our dataset files.
- Because the Spring TestContext framework has an excellent support for SQL scripts, we don't have to write any custom code. We can just write the required SQL scripts and we are good to go.
5. Good Assertion Libraries
Even though JUnit 5 has its own assertion API, we can make our tests easier to read, write, and maintain by using these assertion libraries:
AssertJ. AssertJ is my go-to assertion library because of these four reasons:
- It has an API that helps us to write assertions which make sense when we translate them to English (or any other language that’s used by humans). Example: Ensure that the return value of operation Y is equal to X.
- AssertJ protects its users from programming errors. Because the
assertThat()method of the
org.assertj.core.api.Assertionsclass takes only one method parameter and it returns an assertion object that allows us write assertions with AssertJ, it’s nearly impossible to make a programming error. For example, we cannot pass method parameters in the wrong order or invoke the wrong assertion method.
- We can write custom assertions which emphasize business rules and help us to remove duplicate assertion code from our test suite.
- AssertJ has a built-in support for soft assertions. If we have to add multiple assertions to one test method, we should replace normal assertions with soft assertions. Because AssertJ runs all soft assertions, collects possible assertion failures, and reports all assertion failures after all soft assertions have been run, we have to run our test only once. In other words, if we use soft assertions, our feedback loop will be as fast as possible.
Hamcrest. If I am writing integration tests for Spring Boot web applications with Spring MVC Test framework (aka MockMvc), I write my assertions with Hamcrest. Even though Hamcrest isn't the most sexy assertion library out there, it works very well in this situation because the Spring MVC Test framework has a built-in support for Hamcrest and most of our assertions are very simple. Also, because we can create Hamcrest matchers by using static factory methods which are named properly (for example:
equalTo()), I think that Hamcrest helps us write assertions which are easy to read.
AssertJ-DB. If I have to write assertions for the data that's found from a relational database, I will use AssertJ-DB because of these three reasons:
1. We don't have to use dataset files. Instead, we can write our assertions by using Java programming language. This solves two problems:
- It's quite common that we have to write multiple different assertions for the data that's found from the same database table. If we write these assertions by using dataset files, these dataset files contain duplicate configuration (table and column names) and duplicate test data. That's why tests which use dataset files are hard to maintain. It's true that we can make this problem a bit less painful by splitting large dataset files into smaller files, but we cannot totally eliminate this problem. On the other hand, if we write our assertions with AssertJ-DB, we can eliminate duplicate assertion code by putting it to one "place".
- If we write our assertions by using dataset files, the only way to read them is to open the dataset file. This makes our tests hard to read. On the other hand, if we write our assertions with AssertJ-DB, we can read them without leaving our test class. This makes our tests more pleasant to read.
2. We can write custom assertions which emphasize business rules and help us to remove duplicate assertion code from our test suite. Even though AssertJ-DB has a clean API, the fact is that when we want to write an assertion for a column value, we have to specify the name of the database table, the name of the column, the index of the table row, and the expected value. This becomes repetitive quite fast and we end up with a lot of duplicate code. Luckily, we can get rid of duplicate code by putting it to one place.
Also, there are situations when we have to write assertions which enforce a business rule. For example, if we are writing tests for code which creates new payments, we have to make sure that our code creates a pending transaction. AssertJ-DB helps us to replace the traditional data-centric assertions with custom assertions which ensure that our tests are easy to read, write, and maintain.
3. AssertJ-DB has a built-in support for soft assertions. If we have to write assertions for multiple column values and one of our assertion fails, we might not have all the information we need because it’s possible that some of our assertions weren’t run. We can solve this problem and shorten our feedback loop by writing soft assertions. When we run a test method which has multiple soft assertions, AssertJ-DB runs all assertions, collects possible assertion failures, and reports all assertion failures after all assertions have been run. In other words, if we use soft assertions, we have to run our test only once.
If we are writing tests for code that invokes an HTTP API, we might not want to use real thing because:
- We cannot run it in our test environment because the API is provided by a 3rd party. Also, we don't necessarily want to use the test version provided by the API vendor because initializing its data into a known state before our tests are run might be hard, impossible, or simply impractical.
- We don't want to run the real thing because it's too slow and would cause performance problems to our test suite.
- We cannot run the API in our test environment because it's not done (yet).
If we are in this situation, we should use WireMock which allows us to replace an HTTP API with a test double. We can stub HTTP APIs and write assertions which ensure that the expected HTTP requests were send to the HTTP API.
Also, if we don't want to start and stop WireMock manually (it requires custom code and is a bit cumbersome), we can start and stop WireMock server by using Testcontainers. This means that our tests are easier and more fun to write because we can concentrate on writing test code instead of writing code that's required to configure the runtime environment of our tests.
The next parts of my Spring MVC Test tutorial describe how we can write clean and fast integration tests for Spring Boot web applications by using the testing tools highlighted on this blog post. We will start by finding out how we can write integration tests for Spring Boot repositories.