Introduction
In this article, I will show and explain how you can write more tests with relatively small changes in your code.
Recently, one of my colleagues wrote a massive test with repetitive code. The difference between test cases was only data that was submitted to the service. I will use JUnit 5 in this article, but feel free to check the library docs you use for writing tests.
Problem
Let’s imagine the following situation: we have a service that accepts the following data and returns the following response.
{
"operationType": "+",
"operands": [
1,
2,
3,
4,
5
]
}
{
"result": 15
}
Of course, we can write many tests to cover each aspect, but there is a better solution. Let me introduce to you TestFactory from JUnit 5. You may argue that artificial cases use a test factory, and I agree with you, but the article is designed to give you an idea of how you can achieve more tests without writing more code.
Let’s implement our data transfer object first.
package io.vrnsky.testfactorydemo.dto;
import java.util.List;
public record Request(
String operationType,
List<Integer> operands
) {
}
package io.vrnsky.testfactorydemo.dto;
public record Response(
Integer result
) {
}
The next step is to implement service and controller layers.
package io.vrnsky.testfactorydemo.service;
import io.vrnsky.testfactorydemo.dto.Request;
import io.vrnsky.testfactorydemo.dto.Response;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CalculationService {
public Response calculate(Request request) {
return switch (request.operationType()) {
case "+" -> new Response(sum(request.operands()));
case "*" -> new Response(multiply(request.operands()));
default -> throw new IllegalArgumentException("Invalid operation: " + request.operationType());
};
}
private int sum(List<Integer> operands) {
if (operands == null || operands.isEmpty()) {
return 0;
}
return operands.stream().mapToInt(i -> i).sum();
}
private int multiply(List<Integer> operands) {
if (operands == null || operands.isEmpty()) {
return 0;
}
int result = 1;
for (Integer operand : operands) {
result *= operand;
}
return result;
}
}
package io.vrnsky.testfactorydemo.controller;
import io.vrnsky.testfactorydemo.dto.Request;
import io.vrnsky.testfactorydemo.dto.Response;
import io.vrnsky.testfactorydemo.service.CalculationService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CalculateController {
private final CalculationService calculationService;
public CalculateController(CalculationService calculationService) {
this.calculationService = calculationService;
}
@PostMapping("/calculate")
public Response calculate(@RequestBody Request request) {
return calculationService.calculate(request);
}
}
The controller is not required; we can test only the service layer. I have implemented a controller to test manually that the application is working. Manual testing is a bad thing because it cannot be automated.
curl -X POST "http://localhost:8080/calculate" -H "Content-type:application/json" -d '{"operationType": "+", "operands": [1, 2, 3]}'
{"result":6}%
curl -X POST "http://localhost:8080/calculate" -H "Content-type:application/json" -d '{"operationType": "+", "operands": []}'
{"result":0}
Okay, now it is what you have been waiting for — let’s write a unit test with TestFactory. But before implementing, let’s create two folders inside the test resource directory.
In the files, I have a request and response as follows.
{"operationType": "+", "operands": [1, 2, 3]}
{"result":6}
The next step is to implement a test factory within the unit test.
package io.vrnsky.testfactorydemo.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.vrnsky.testfactorydemo.dto.Request;
import io.vrnsky.testfactorydemo.dto.Response;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.List;
import java.util.stream.Stream;
class CalculationServiceTest {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final CalculationService CALCULATION_SERVICE = new CalculationService();
@TestFactory
Stream<DynamicTest> testCalculateService() {
int counter = 0;
List<Stream<DynamicTest>> test = Collections.singletonList(generateTest("(+)", counter++, "request/1.json", "response/1.json"));
return test
.stream()
.flatMap(stream -> stream);
}
private Stream<DynamicTest> generateTest(String tag, int counter, String requestPath, String responsePath) {
return Stream.of(createTest(tag, counter, requestPath, responsePath));
}
private DynamicTest createTest(String tag, int counter, String requestPath, String responsePath) {
return DynamicTest.dynamicTest(tag + " | Calculation test case #" + counter, () -> {
var request = this.getRequest(requestPath);
var expected = this.getResponse(responsePath);
var actual = CALCULATION_SERVICE.calculate(request);
Assertions.assertEquals(expected.result(), actual.result());
});
}
private Request getRequest(String path) {
try {
byte[] content = Files.readAllBytes(Paths.get(CalculationServiceTest.class.getClassLoader().getResource(path).getPath()));
return OBJECT_MAPPER.readValue(new String(content, StandardCharsets.UTF_8), Request.class);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private Response getResponse(String path) {
try {
byte[] content = Files.readAllBytes(Paths.get(CalculationServiceTest.class.getClassLoader().getResource(path).getPath()));
return OBJECT_MAPPER.readValue(new String(content, StandardCharsets.UTF_8), Response.class);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
Let me explain what is going on inside this class.
First, we have a public method annotated with TestFactory annotation, which the testing framework will run. The other parts of the helper method achieve the following things:
Read requests from the test resource folder
Read the expected response from the test resource folder
Creation of dynamic test
For now, I have created files for only one unit test. You can add your own if you would like.
I have added two more files to test other inputs.
The article’s title tells you that you can write less code and achieve more test coverage. It is true, but you still need to change something. You need to add a new request-response pair.
Here is an updated version of the method annotated by the TestFactory annotation.
@TestFactory
Stream<DynamicTest> testCalculateService() {
int counter = 0;
List<Stream<DynamicTest>> test = Arrays.asList(
generateTest("(+)", counter++, "request/1.json", "response/1.json"),
generateTest("(+)", counter++, "request/2.json", "response/2.json"),
generateTest("(+)", counter++, "request/3.json", "response/3.json")
);
return test
.stream()
.flatMap(stream -> stream);
}
Now, you can run your tests and will see something similar.
The code could be better. There is always a place to improve something. For example, one thing you do is to read all files in the directory instead of hard-coding it. By doing this, you can achieve a minimal change in code only by adding files.
Conclusion
You may disagree with my point of view, but less code means fewer bugs in the code. Sometimes, using TestFactory is not a good choice, but it is a good choice when you have something to test, and the logic of the service is complex. The other reason behind choosing TestFactory is a lot of parameters that impact the results of something. Recently, I faced a request with a lot of parameters. The response is hard depending on the request data, so I have stuck with the test factory choice.
Top comments (0)