How to test the Services of your Spring Boot Application


In this Blogpost, I’ll show you how to test the business logic inside your service classes. In the example, we will implement some plain Unit Tests. I will also discuss the downsides of this approach and why I prefer it over Integration Tests nonetheless.

The Example

The example of this blog post is the PackageService. It is a class that provides the logic for accepting a package in a post office. It will perform some checks on the package’s measurements and persists its data. Here is the code:

PackageService.java
@Service
class PackageService {

    static final double MAX_WEIGHT = 31.5;
    static final double MAX_SUM_OF_SIDE_LENGTHS = 300.0;

    private final PackageRepository packageRepository;

    PackageService(PackageRepository packageRepository) {
        this.packageRepository = packageRepository;
    }

    Package acceptPackage(Package p) throws MaxPackageWeightExceededException, InvalidPackageDimensionsException {

        if (p.getWeight() > MAX_WEIGHT) {
            throw new MaxPackageWeightExceededException(p.getWeight());
        }

        List<Double> dimensions = Stream.of(p.getWidth(), p.getLength(), p.getHeight())
            .sorted(Double::compareTo).collect(Collectors.toList());

        if (dimensions.get(0) < 1.0 || dimensions.get(1) < 11.0 || dimensions.get(2) < 15.0) {
            throw InvalidPackageDimensionsException.toSmall(dimensions.get(0), dimensions.get(1), dimensions.get(2));
        }

        if (p.getLength() + p.getWidth() + p.getHeight() > MAX_SUM_OF_SIDE_LENGTHS) {
            throw InvalidPackageDimensionsException.toBig(dimensions.get(0), dimensions.get(1), dimensions.get(2));
        }

        return packageRepository.save(p);
    }
}

Writing the Test

Before we start writing the test, let’s sum up PackageServices behavior we need to test:

  • throw an MaxPackageWeightExceededException if the weight exceeds 31,5kg
  • throw an InvalidPackageDimensionsException if the measurements of the package are below 1,0cm x 11,0cm x15,0cm
  • throw an InvalidPackageDimensionsException if the sum of the length, width, and height exceeds 300cm
  • persist the package into the datastore if the validation was successful

Test the Validation Logic

The code to test the first behavior may look like this:

PackageServiceTest.java
class PackageServiceTest {

    @Test
    void weightIsExceededTest() {
        Package invalidPackage = new Package(30.0, 30.0, 30.0, 32.0);
        var packageRepository = mock(PackageRepository.class);

        var cut = new PackageService(packageRepository);

        assertThrows(MaxPackageWeightExceededException.class,
                     () -> cut.acceptPackage(invalidPackage));
    }
}

As I said in the intro, I generally try to use Unit Tests as often as possible and use Integration Tests only if they are really necessary. Therefore, this test is a plain Unit Test. We create an instance of a Package with valid data, mock dependencies with Mockito, and use them to instantiate a PackageService. After this setup phase, we wrap the execution of the acceptPackage method into JUnits assertThrows method, to test, whether a MaxPackageWeightExceededException is thrown or not. I omitted the tests regarding the InvalidPackageDimensionsException in this post. If you want to see them, have a look at the GitHub repository.

Test the persistence Logic

The next thing we want to test is the actual persistence of the package.

PackageServiceTest.java
@Test
void packageWasPersisted() throws MaxPackageWeightExceededException, InvalidPackageDimensionsException {

    var p = new Package(30.0, 30.0, 30.0, 30.0);
    var packageRepository = mock(PackageRepository.class);

    when(packageRepository.save(any(Package.class))).then(returnsFirstArg());

    var cut = new PackageService(packageRepository);
    var persistedPackage = cut.acceptPackage(p);

    verify(packageRepository, times(1)).save(any(Package.class));
    assertEquals(30, persistedPackage.getWeight());
    assertEquals(30, persistedPackage.getHeight());
    assertEquals(30, persistedPackage.getWidth());
    assertEquals(30, persistedPackage.getLength());
}

This test is a plain unit test, too. Like in the one before we prepare the test by instantiating objects and mocking the dependencies. After executing the method we want to test, we make our assertions. This time we use Mockitos verify to make sure that the save method of our repository was called exactly once.

What are the Downsides of this Approach?

As always, there is no silver bullet. Therefore, it shouldn’t surprise you that this way of testing comes with its downsides.
If the thing we want to test depends on something other, we try to mock it away. The problem with this approach is, that we must know exactly how those components interact with one another. In the example code from the section “Test the persistence Logic” we had to know how our PackageService uses the PackageRepository to mock the dependency and to verify the interaction.

PackageServiceTest.java
@Test
void packageWasPersisted() throws MaxPackageWeightExceededException, InvalidPackageDimensionsException {
    // ...
    when(packageRepository.save(any(Package.class))).then(returnsFirstArg());
    // ...
    verify(packageRepository, times(1)).save(any(Package.class));
    // ...
}

By relying on those kinds of implementation details we make our tests brittle and harder to maintain. Every time the interface of the PackageRepository changes we may break the tests and need to modify them. The good thing is, that this kind of problem pops up quite early in the development cycle. If the interface changes in an incompatible way, the compiler will throw an error. The problems may also be reduced by using the refactoring tools of your IDE.

Why I avoid Integration Tests

Although integration tests are a great tool, I try to avoid them when possible. In comparison to Unit Tests, they have a few downsides that I want to discuss in this section.

Setup Cost of Infrastructure Dependencies

To run integration tests for our service classes, we need to set up some infrastructure. Do you want to persist data? Let’s start a relational database. Do you communicate with a 3rd party Service over HTTP? Better configure a MockServer. Want to publish messages? RabbitMq! All of those dependencies must be present, configured, and ready whenever your integration tests run.

Testing of multiple Components

The purpose of Integration Tests is – well – testing the integration of multiple components. While this is a critical aspect of nearly all systems it also bears a problem. The area for potential bugs increases with every new component. When you test the behavior of a class with an integration test, you can never be sure where a problem originates. Is the error in your class the root cause or just a symptom? You can not know unless you debugged the test.

They are slow

Last but not least, integration tests are much slower than unit tests. They have an increased startup time due to the starting of infrastructure and initialization of the Spring Context. At runtime, they need extra time for communication with external systems like the database or REST services. This doesn’t seem to be much time wasted, but in my experience, it is quite annoying to wait for the tests while developing.

Resources