Skip to content

Instantly share code, notes, and snippets.

@adamcroissant
Created October 9, 2018 16:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save adamcroissant/582388ec38e30b04c6d80e9ef907869e to your computer and use it in GitHub Desktop.
Save adamcroissant/582388ec38e30b04c6d80e9ef907869e to your computer and use it in GitHub Desktop.
Integration Testing a Spring Boot RESTful Service with an Embedded Test Database

Spring Framework Integration Testing

Testing a RESTful web service with a mocked in-memory database

Please note that this article assumes a moderate level of familiarity with Java, Spring, and Integration Testing. For help with those topics, see the Further Reading section at the bottom of this article.

Introduction

Integration testing is an extremely useful way to verify your application code. Fitting between unit tests and end to end tests, integration tests can verify entire paths through your code base. Integration tests, unlike end to end tests, allow you to avoid considering the current status of external systems that you depend on and avoid setting up test data before each run because your external dependencies are mocked. Unfortunately, this testing approach can be difficult to use in a Spring Boot application.

Many enterprise Java application are written using Spring Boot. Spring offers some amazing tools to speed up development through abstraction, allowing you as the developer to ignore many details you would otherwise have to configure manually. However, it can be difficult to mock dependencies when the details of how you run your application and connect to dependencies are hidden from you. By giving up control over the mechanisms used to enable data access, it can limit the ways in which you are able to inject logic in order to verify application behavior.

Further increasing the difficulty is that Spring's documentation has some unfortunate gaps. While there are great tutorials on how to do certain types of testing, there are certain approaches that are not covered at all. One such approach is how to tackle integration testing of a REST service when the service owner cannot safely read/write to the database while testing. This could be due to other applications depending on the data in that database, performance, or repeatability concerns. Regardless of the reason, it is a valuable testing approach that will be demonstrated in this article.

We will be using the example of a simple REST service with a single SQL database dependency that we connect to using spring-boot-starter-data-jpa, a predefined bundle of Spring Components that enables easy SQL data access via JPA, and h2, a free SQL database written in Java. The code for the application is provided in full at the bottom*, but it consists of the following components:

@SpringBootApplication
class SlalomiteApplication

@RestController
class SlalomiteController // depends on SlalomiteService

@Service
class SlalomiteService // depends on SlalomiteRepository

interface SlalomiteRepository extends CrudRepository<Slalomite, Long>

*all example code is written using Java 10

The Problem

See the below repository class. Though the application code is extremely simple thanks to Spring's CrudRepository, it's not obvious how to mock the components provided by Spring. This makes it hard to verify the application logic that relies on this repository class being correct.

import org.springframework.data.repository.CrudRepository;

public interface SlalomiteRepository extends CrudRepository<Slalomite, Long> {
}

If you were to look at the Spring testing tutorial I linked above it may lead to the conclusion that you should use Spring Boot's @DataJpaTest and @SpringBootTest(webEnvironment = ...) annotations together. Doing so should enable you to mock the database and write an integration test for this application. That integration test would look something like this:

package com.slalom.articles.springintegration.sqldb;

import static org.junit.Assert.assertTrue;

import java.time.Instant;
import java.util.Date;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@SpringBootTest(classes = SlalomiteApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DataJpaTest
public class SlalomiteIntegrationTestBroken {
    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    public void getSlalomites_ShouldReturnAdam() {
        var slalomite = new Slalomite("Adam", Date.from(Instant.now()));
        this.entityManager.persist(slalomite);

        var response = restTemplate.getForEntity("/api/v1/slalomites", String.class);

        assertTrue(response.getBody().contains("Adam"));
    }
}

*if unfamiliar with using @SpringBootTest to integration test Spring REST services, I recommend reading this article

This may look correct by combining the ideas in the Spring tutorials, Spring guides, and many blogs, but you would unfortunately run into the following exception upon running it:

java.lang.IllegalStateException: Failed to load ApplicationContext... Caused by: org.springframework.context.ApplicationContextException: Unable to start web server; nested exception is org.springframework.context.ApplicationContextException: Unable to start ServletWebServerApplicationContext due to missing ServletWebServerFactory bean.

The issue here is with @DataJpaTest. From the javadoc:

Using this annotation will disable full auto-configuration and instead apply only configuration relevant to JPA tests.

The problem is that @DataJpaTest enables only the components necessary to run the persistence layer and disables all other components. This includes the components Spring uses to start the servlet container. Since @SpringBootTest tries to start the servlet container, the above exception is produced when it is unable to find the beans needed to launch the servlet.

The Solution

The solution to our problem is surprisingly simple, and is actually hinted at in the same @DataJpaTest javadoc even though it is not mentioned in the Spring documentation or in many places online:

If you are looking to load your full application configuration, but use an embedded database, you should consider @SpringBootTest combined with @AutoConfigureTestDatabase rather than this annotation.

Using @AutoConfigureTestDatabase, we are not provided with many of the conveniences that @DataJpaTest provides. For one, the TestEntityManager used in the prior example is not available. We also do not get the benefit of transactional tests, which means we will have to be more careful to clean up after ourselves. However, the key benefit is still preserved - while running our integration test, the application will connect to an in-memory database created by Spring rather than our actual database instance. This enables us to read and write from the database without concern for pre-existing data or worrying about creating data that will impact other applications or users, making the test safer to run as part of a CI/CD pipeline and more repeatable.

Since we do not have access to TestEntityManager here, we instead need to either directly write SQL to set up and clean up the database, or we need to leverage our SlalomiteRepository bean to do the writes before we actually invoke the controller methods. For this demonstration, we will go with the second option. Though it is not as much of a "black box" approach as writing the SQL directly, it is simpler and less brittle. The code for the new, passing test is below:

package com.slalom.articles.springintegration.sqldb;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

import java.time.Instant;
import java.util.Date;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@SpringBootTest(classes = SlalomiteApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase
public class SlalomiteIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private SlalomiteRepository repo;

    @After
    public void cleanup() {
    	repo.deleteAll();
    }

    @Test
    public void getSlalomites_ShouldReturnAdam() {
        var slalomite = new Slalomite("Adam", Date.from(Instant.now()));
        repo.save(slalomite);

        var response = restTemplate.getForEntity("/api/v1/slalomites", String.class);

        assertTrue(response.getBody().contains("Adam"));
    }
}

Note the addition of the cleanup method. If the changes to the data are not cleaned up after each test, you can end up with unexpected failures as the preconditions for a given test are not met. In practice, those failures could end up random due to how your test runner chooses to order your unit tests.

Conclusion

We have now created an integration test that allows us to fully test a code path through our application for a given user scenario, without having to worry about connection statuses to a database or being careful about our impact on other clients of the database. This approach can give you relatively fast running tests with a high degree of repeatability. This is an invaluable asset when trying to verify code quickly for a frequently deploying pipeline. Even if you are not in a continuous deployment environment, the test style used here can be powerful in its ability to help developers refactor with confidence. Unlike unit tests, integration tests enable entire classes to be moved, deleted, or added and can still protect against regression by proving the correctness of the user functionality after the changes.


Further Reading


Source Code

Available on Gitlab here: http://gitlab.slalomatl.codes/adam.croissant/article-spring-integration-test-jpa

package com.slalom.articles.springintegration.sqldb;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SlalomiteApplication {

    public static void main(String[] args) {
        SpringApplication.run(SlalomiteApplication.class, args);
    }
}
package com.slalom.articles.springintegration.sqldb;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;

@RestController
@RequestMapping("/api/v1")
public class SlalomiteController {
    private SlalomiteService service;

    @Autowired
    public SlalomiteController(SlalomiteService service) {
        this.service = service;
    }

    @GetMapping("slalomites")
    public @ResponseBody List<Slalomite> getSlalomites() {
        return service.getSlalomites();
    }

    @GetMapping("slalomites/{id}")
    public @ResponseBody Slalomite getSlalomite(@PathVariable Long id) {
        return service.getSlalomite(id);
    }

    @PostMapping("slalomites")
    public ResponseEntity<?> createSlalomite(@RequestBody Slalomite s) throws URISyntaxException {
        var newSlalomite = service.saveSlalomite(s);

        return ResponseEntity.created(
                new URI("localhost:8080/api/v1/slalomites/" + newSlalomite.getId()))
                .body(newSlalomite);
    }
}
package com.slalom.articles.springintegration.sqldb;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

@Service
public class SlalomiteService {

    @Autowired
    private SlalomiteRepository repo;

    public List<Slalomite> getSlalomites() {
        var slalomites = new ArrayList<Slalomite>();
        for (var slalomite : repo.findAll()) {
            slalomites.add(slalomite);
        }
        return slalomites;
    }

    public Slalomite getSlalomite(Long id) {
        return repo.findById(id).get();
    }

    public Slalomite saveSlalomite(Slalomite s) {
        return repo.save(s);
    }
}
package com.slalom.articles.springintegration.sqldb;

import org.springframework.data.repository.CrudRepository;

public interface SlalomiteRepository extends CrudRepository<Slalomite, Long> {
}
package com.slalom.articles.springintegration.sqldb;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import java.util.Date;

@Entity
public class Slalomite {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String name;
    private Date startDate;

    protected Slalomite() {}

    public Slalomite(String name, Date startDate) {
        this.name = name;
        this.startDate = startDate;
    }

    public Long getId() {
        return id;
    }

    protected void setId(long id) { this.id = id; }

    public String getName() {
        return name;
    }

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

    public Date getStartDate() {
        return startDate;
    }

    public void setStartDate(Date startDate) {
        this.startDate = startDate;
    }

    @Override
    public String toString() {
        return String.format(
                "Customer[id=%d, name='%s', startDate='%s']",
                id, name, startDate.toString());
    }
}
package com.slalom.articles.springintegration.sqldb;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

import java.time.Instant;
import java.util.Date;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@SpringBootTest(classes = SlalomiteApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase
public class SlalomiteIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private SlalomiteRepository repo;

    @After
    public void cleanup() {
    	repo.deleteAll();
    }

    @Test
    public void getSlalomites_ShouldReturnAdam() {
        var slalomite = new Slalomite("Adam", Date.from(Instant.now()));
        repo.save(slalomite);

        var response = restTemplate.getForEntity("/api/v1/slalomites", String.class);

        assertTrue(response.getBody().contains("Adam"));
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment