In week 3, you’ve learned more about unit tests in Java. Using those, you test the smallest units of software in isolation to demonstrate they work. However, software never consists of just a few small units. Many units interact with each other, and when additional layers like databases and external webservers are added to the mix, the complexity grows a lot.
To deal with this complexity, there are more types of tests that are more elaborate than unit tests. The take more time to write, but also are closer to how an application behaves in the real world. Even more elaborate would be end-to-end tests, where actual users use your application, but this is of course the most time consuming. These three types of tests can be thought of as a pyramid:

For more information, see https://martinfowler.com/articles/practical-test-pyramid.html
In this chapter, we’ll introduce a more elaborate method of testing: integration tests (referred to as “Service Tests” in the image above).
Integration tests are different. Rather than testing one piece in isolation, an integration test verifies that multiple pieces of your application work correctly together. In a Spring Boot backend, that typically means testing the full path from an incoming HTTP request through your controller, service, and repository layers, all the way to the database and back.
The trade-off is speed and complexity. Unit tests can run in milliseconds and need no infrastructure. Integration tests start a Spring application context, connect to a database, and execute real HTTP requests. They are slower to run and more involved to set up. A healthy test suite has both: unit tests for business logic that can be tested in isolation, and integration tests for the boundaries where your application meets the outside world.
@SpringBootTestIn the context of Postify, consider an endpoint that returns a user's stream history. An integration test for that endpoint would verify the full chain: the HTTP request is routed to the right controller method, the controller calls the service, the service queries the database via the repository, the result is serialised to JSON, and the response arrives with the correct status code and body. A unit test of the service method alone could not catch a bug in the SQL query, a misconfigured URL mapping, or a missing JSON field. Only a test that exercises the full stack can do that.
In order to test all these integrations, a lot of things need to be set up: we need a database layer, and a webserver running, and a client to send HTTP requests with. Let’s start with the webserver.
Using @SpringBootTest tells Spring to bootstrap the entire application context before the tests run, exactly as it would when the application starts normally. All your beans are created, all your configuration is loaded, and your datasource is connected. The test runs inside a fully initialized Spring application. Here’s an example:
@SpringBootTest
class StreamIntegrationTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldCountStreamsForUser() {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM streams WHERE user_id = ?",
Integer.class,
1
);
assertThat(count).isGreaterThan(0);
}
}
<aside> 💡
Note that in an actual app, we’d probably call a method like Integer count = Streams.countStreamsForUser(1) instead of defining the query inside the test, but it’s shown here for clarity regardless.
</aside>
As you can see, the same Arrange-Act-Assert pattern is used here. The database connection is set up using the autowired jdbcTemplate, next a query is performed, and finally the result is verified. We are testing the full round trip from app to database to app here: an actual integration.
Because the full context is loaded by @SpringBootTest, you can @Autowire any bean directly into your test: JdbcTemplate, services, repositories, anything. This makes @SpringBootTest powerful but also slower than a plain unit test. Starting the application context typically takes a few seconds, which is why you want to reuse it across tests rather than restarting it for every test class. Spring helps you with this by caching the application context and reusing it across test classes.
With all these services to test, there’s a lot to configure to have them run properly. By default @SpringBootTest loads application.properties. For tests however you usually want different settings: a test database, SQL logging enabled, different credentials, and so on. You can create a separate src/test/resources/application.properties (Spring Boot finds this file automatically if it exists, and only uses it for tests) that overrides the values you need:
spring.datasource.url=jdbc:postgresql://localhost:5432/postify_test
spring.datasource.username=postgres
spring.datasource.password=yourpassword
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
When testing services (like a webserver of database), you can choose to either fully run them, or you can mock them. When mocking something, you are implementing a “fake” that tries to behave like to original as much as possible. As before, the tradeoff between mocking or not is usually speed and complexity. We’ll go over mocked services here, as they are quicker to set up, and very mature, so mock very well.
MockMvc simulates HTTP requests inside the Spring MVC layer without starting a real server. Requests never go over a network socket; they are processed entirely in memory. This makes it fast and precise, and it gives you access to Spring MVC internals like handler mappings and filters. Here’s an example for some StreamController in the context of Postify streams:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
class StreamControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldReturnStreamsForUser() throws Exception {
mockMvc.perform(get("/users/1/streams"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].trackId").exists());
}
}
Here, we configure a @SpringBootTest with a mocked web environment (needed for MockMvc), autowire a jdbcTemplate and a REST client (MockMvc), next perform a GET request to the /users/1/streams URI, and verify the results (both status and JSON body).
Note that the assertions here are tested using the injected mockMvc instance. The perform method executes the HTTP request, the andExpect test assertions. The actual returned JSON is tested via a jsonPath method, which can be used to browse through a JSON object to see if the results are as expected. See https://mkyong.com/spring-boot/testing-json-in-spring-boot/#testing-json-simple-structure for a more elaborate example of how to use mockMv with JSON responses.
Let's say the Postify application has an endpoint that returns a single artist by ID:
@RestController
@RequestMapping("/artists")
public class ArtistController {
private final JdbcTemplate jdbcTemplate;
public ArtistController(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@GetMapping("/{id}")
public ResponseEntity<Artist> getArtist(@PathVariable int id) {
try {
Artist artist = jdbcTemplate.queryForObject(
"SELECT artist_id, artist_name, artist_country FROM artists WHERE artist_id = ?",
(rs, rowNum) -> {
Artist a = new Artist();
a.setArtistId(rs.getInt("artist_id"));
a.setArtistName(rs.getString("artist_name"));
a.setArtistCountry(rs.getString("artist_country"));
return a;
},
id
);
return ResponseEntity.ok(artist);
} catch (EmptyResultDataAccessException e) {
return ResponseEntity.notFound().build();
}
}
}
The integration test for this endpoint:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
class ArtistControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldReturnArtistById() throws Exception {
mockMvc.perform(get("/artists/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.artistName").value("Roxy Dekker"))
.andExpect(jsonPath("$.artistCountry").value("NL"));
}
@Test
void shouldReturn404WhenArtistNotFound() throws Exception {
mockMvc.perform(get("/artists/99999"))
.andExpect(status().isNotFound());
}
}
A few things to notice:
@Test method tests one specific behaviour; the happy path and the not-found case are separate tests. This makes failures easy to diagnose."Roxy Dekker" and "NL" ) come directly from the seed data. Consistent seed data matters: your tests can rely on specific values being present, and things like this makes integration tests more slow to set up.Now consider an endpoint that records a new stream event:
@RestController
@RequestMapping("/streams")
public class StreamController {
private final JdbcTemplate jdbcTemplate;
public StreamController(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@PostMapping
public ResponseEntity<Void> recordStream(@RequestBody StreamRequest request) {
jdbcTemplate.update(
"INSERT INTO streams (user_id, track_id) VALUES (?, ?)",
request.getUserId(), request.getTrackId()
);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
Where StreamRequest is a simple class:
public class StreamRequest {
private int userId;
private int trackId;
// getters and setters
}
The integration test:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
class StreamControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldRecordStream() throws Exception {
mockMvc.perform(post("/streams")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"userId": 1,
"trackId": 10
}
"""))
.andExpect(status().isCreated());
// verify the stream was actually written to the database
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM streams WHERE user_id = 1 AND track_id = 10",
Integer.class
);
assertThat(count).isGreaterThan(0);
}
@Test
void shouldReturn400WhenRequestBodyIsMissing() throws Exception {
mockMvc.perform(post("/streams")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest());
}
}
In this test, we’ve verified not just the response, but also the end result in the database (which here is an actual database - we’ll get to mocking that one in a minute). Again, we’ve not just tested the happy flow, but also an error path.
Running integration tests against your development database is a bad idea. Tests insert, update, and delete data — if they run against the same database your application is connected to, they will corrupt your development data, and tests may interfere with each other depending on what data happens to be present. You need a separate database that exists purely for testing. Spring Boot supports two common approaches: an H2 in-memory database for lightweight testing, and Testcontainers for tests that need a real PostgreSQL instance.
H2 is a Java-based database that runs entirely in memory. It starts in milliseconds, requires no installation, and is wiped clean when the JVM exits. For many integration tests it is the fastest and simplest option. Although historically commonly used, it is losing some ground to TestContainers (which we’ll discuss next). That’s because H2 has its own SQL dialect, making tests more removed from the actual situation. However, it is simple to set up and does not require any networking infra. Set it up by adding the dependency:
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
Configure it in your test application.properties:
spring.datasource.url=jdbc:h2:mem:postify_test;DB_CLOSE_DELAY=-1
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
This is enough for SpringBoot to understand an h2 database should be spun up in memory! No further configuration is needed.
TestContainers is a library that spins up real Docker containers (and so requires Docker to be installed!) during your test run. Instead of an H2 approximation, your tests run against an actual PostgreSQL instance. Add the dependencies:
<!--Add a dependency for the test containers framework... -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!--... and one specifically for the postgresql implementation. -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
Preparing the connection to this database can be done by defining an (abstract) super class for all integration tests:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
@Testcontainers
public abstract class AbstractIntegrationTest {
// Important! This container is defined as `static` so that is is reused across ALL tests
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("postify_test")
.withUsername("postgres")
.withPassword("postgres");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
Every test class that needs the database extends this base class:
class ArtistControllerTest extends AbstractIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldReturnArtist() throws Exception {
mockMvc.perform(get("/artists/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.artistName").value("Roxy Dekker"));
}
}
Integration tests often require some data to test on. Tests also insert/update/delete data, and this might conflict with other tests. You can seed the data manually per test, using @BeforeEach and AfterEach annotations:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
class ArtistControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbcTemplate;
private int testArtistId;
@BeforeEach
void insertTestData() {
jdbcTemplate.update(
"INSERT INTO artists (artist_id, artist_name, artist_country) " +
"VALUES (999, 'Test Artist', 'NL')"
);
testArtistId = 999;
}
@AfterEach
void cleanUpTestData() {
jdbcTemplate.update("DELETE FROM artists WHERE artist_id = 999");
}
@Test
void shouldReturnTestArtist() throws Exception {
mockMvc.perform(get("/artists/" + testArtistId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.artistName").value("Test Artist"));
}
}
This requires lots of boilerplate code for setting up the test, but does isolate the test a little. You can also use the seed script to add a generic base line of test data. By adding this to the abstract super class, it runs only ones for ALL tests:
@BeforeAll
void initDatabase() throws Exception {
try (Connection connection = dataSource.getConnection()) {
ScriptUtils.executeSqlScript(connection,
new ClassPathResource("setup-postify.sql"));
ScriptUtils.executeSqlScript(connection,
new ClassPathResource("seed-postify.sql"));
}
}
Make sure to add your SQL scripts to the test class path (src/test/java/resources).
Finally, an alternative method for managing test data per test case, is rolling back the transaction used by a test. This is simple! Just annotate your class with @Transactional , and the whole transaction will be rolled back, whether the tests failed or succeeded. Note that this ONLY works with webEnvironment = SpringBootTest.WebEnvironment.MOCK ; if we weren’t mocking the webserver, but using an actual Tomcat webserver, the request would be handled in its own thread, and use a transaction outside of the test scope. Results would be committed instead of rolled back
Finally, we also want to mock external webservers. When writing integration tests for endpoints that call external services, you generally do not want your tests to make real HTTP requests to the outside world. A live API may be slow, unreliable, or unavailable in a CI environment, and test results should never depend on an external system you do not control. @MockBean solves this by replacing a Spring bean in the application context with a Mockito mock for the duration of the test. Any call to the mocked bean returns whatever you tell it to return; no real code executes and no network request is made. This makes your tests fast, deterministic, and self-contained.
The key difference between @MockBean and a plain Mockito @Mock is that @MockBean integrates with the Spring application context: the mock is injected everywhere the real bean would have been, so the rest of the application behaves exactly as it would in production, just with the one external dependency swapped out for something you control.
As a concrete example, imagine Postify fetches a public user profile from https://jsonplaceholder.typicode.com/users/{id} to enrich its own user data. The service responsible for that call might look like this:
@Service
public class ExternalUserService {
private final RestTemplate restTemplate;
public ExternalUserService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public ExternalUserProfile fetchProfile(int userId) {
String url = "<https://jsonplaceholder.typicode.com/users/>" + userId;
return restTemplate.getForObject(url, ExternalUserProfile.class);
}
}
In a test, rather than letting that call go out over the network, you replace the entire bean with a mock:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ExternalUserService externalUserService;
@Test
void shouldReturnEnrichedUserProfile() throws Exception {
// instruct the mock to return a fixed value when called
ExternalUserProfile fakeProfile = new ExternalUserProfile();
fakeProfile.setEmail("[email protected]");
fakeProfile.setWebsite("lena.nl");
when(externalUserService.fetchProfile(1))
.thenReturn(fakeProfile);
mockMvc.perform(get("/users/1/profile"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.userName").value("lena_v"))
.andExpect(jsonPath("$.externalEmail").value("[email protected]"))
.andExpect(jsonPath("$.website").value("lena.nl"));
}
@Test
void shouldReturn404WhenExternalProfileNotFound() throws Exception {
// instruct the mock to throw an exception instead
when(externalUserService.fetchProfile(99999))
.thenThrow(new ExternalProfileNotFoundException("No profile found"));
mockMvc.perform(get("/users/99999/profile"))
.andExpect(status().isNotFound());
}
}
when(...).thenReturn(...) and when(...).thenThrow(...) come from Mockito, which is included automatically with spring-boot-starter-test. The first tells the mock what to return on the happy path; the second simulates the case where the external API returns no result, letting you verify that your controller handles that failure correctly and returns the right status code. Between these two tests you cover both sides of the external API interaction without a single real network call being made.
The HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with ❤️ by the HackYourFuture community · Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.