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:
@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 PackageService
s 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:
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.
@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.
@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
- You can find the code of this blog post in this GitHub repository.
- If you are interested in testing the web layer of your application, have a look at my post “How to test the Web Layer of your Spring Boot Application with @WebMvcTest”
- Do you want to learn how to test your JPA-based Data Layer? Have a look at my post “How to test the Data Layer of your Spring Boot Application with @DataJpaTest”