Cleaner Spring Boot IT Rest tests

Anton Tkachenko
Duda
Published in
9 min readApr 23, 2023

It’s pretty common for developers to hear that test code is something that doesn’t require much attention. You usually write it once and read it only when tests fail. As a result, copying and pasting test code seem like a quick and easy solution. While I agree with this approach for unit tests of a compact class, “dirty” test code is usually compensated by the small amount of context covered, so it’s relatively easy to keep the full picture in mind.

However, when we move from class-level unit tests to the application or system-level end-to-end (e2e) feature testing, the amount of code that is executed in a scenario is usually much larger. The typical flow goes through web, service validation, and persistency layers, and in-application or even cross-application messages can be published. If on top of this, you also add dirty copy-pasted test code, you’ll get yourself in trouble in the not-so-distant future.

In this article, I want to target a narrow topic related to integrational testing: how to make test code compact, readable, and reusable. We’ll take a look at a small demo application that allows the user to manage cooking recipes and create integrational tests to verify that CRUD (create-read-update-delete) operations work as expected.

The source code can be found on github, and in the article, I’ll go over main points and comment them. This article is split into two parts for readability — and part 2 is here

Application Overview

Simple application with web layer that goes directly to in-memory h2 database.
Main build.gradle dependencies:

implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'

And “standard” h2 configuration in application.properties file

spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect

Here’s the jpa RecipeEntity that also defines the recipe model with 3 simple fields (ingredients are de-facto a list, but for simplicity, we’ll save it in DB as a comma-separated string)

@Entity
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RecipeEntity {
@Id
@GeneratedValue(generator = "uuid")
@GenericGenerator(name = "uuid", strategy = "uuid2")
private String id;
private String name;
private String instructions;
private String ingredients;

@Transient
public List<String> getIngredientsList() {
return asList(ingredients.split(","));
}

@Transient
public void setIngredientsList(List<String> ingredients) {
this.ingredients = String.join(",", ingredients);
}
}

And here is the rest API for CRUD operations

@RequestMapping("/recipes")
public interface RecipesRestApi {

@GetMapping
Page<RecipeResponse> getRecipesPage();

@PostMapping
RecipeResponse createRecipe(
@RequestBody RecipeRequest request
);

@GetMapping("/{id}")
RecipeResponse getRecipe(@PathVariable("id") String id);

@PatchMapping("/{id}")
RecipeResponse updateRecipe(
@PathVariable("id") String id,
@RequestBody RecipeRequest request
);

@DeleteMapping("/{id}")
void deleteRecipe(@PathVariable("id") String id);

}

Request & response objects: similar to the entity. Classes are package-private so that NOT to accidentally use them in tests

class RecipeRequest {
private String name;
private String instructions;
private List<String> ingredients;
}
class RecipeResponse extends RecipeRequest {
private String id;
}

Controller only contains web-to-entity mapping, so I won’t even specify its code here. If “recipe-by-id” doesn’t exist, it throws an exception that is mapped into 404 status in trivial exception handler

@ControllerAdvice
public class ControllerExceptionsHandler {

@ExceptionHandler(ResourceNotExistException.class)
public final ResponseEntity handleException(Exception ex) {
return new ResponseEntity<>(
Map.of("message", ex.getMessage()),
HttpStatus.NOT_FOUND
);
}

}

Test flow that verifies main use-cases

We’ll take a look at tests that cover the following flow:

  • creating a recipe
  • retrieving existing recipes / single recipe by id
  • updating recipe
  • deleting recipe
  • (and verifying that it was deleted)

I will show 5 different variations of a test class that verifies this flow, and with every “version increase”, there will be less and less code to read.

Test Configuration

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@SpringBootTest(webEnvironment = DEFINED_PORT)
@AutoConfigureMockMvc
@DirtiesContext(classMode = BEFORE_CLASS)
@ActiveProfiles("test")
public class BaseFullContextTest {
// will be used to save nerves while jsonifying requests and responses
public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

}
  • @TestInstance(PER_CLASS) — tests will “stateful”, and next steps will depend on the outcome of the previous (test will be @Ordered). This will save “setup” code, and instead of having “test per single case”, we’ll have “TEST CLASS per scenario”
  • @DirtiesContext(BEFORE_CLASS) — recreating context is the simplest way to clean h2 db between test classes for such a tiny app.
  • @SpringBootTest(DEFINED_PORT) & @AutoConfigureMockMvc —some tests will use mock-web-environment, others will make real HTTP calls to embedded tomcat hosting our web-application
  • @ActiveProfiles(“test”) — for spring-boot to pick up configs from application-test.properties

Version 0: “MockMvc boilerplate”

Corresponds to test class CrudFlowTest_v0_MockMvcBoilerplate. This test variation is the longest and the most verbose of all.

Creating recipe and making all assertions:

@Test
@Order(0)
void createNewRecipe() throws Exception {
mockMvc.perform(
post("/recipes")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name" : "Man sushi",
"instructions" : "Call sushi bar",
"ingredients" : [ "Phone", "Sushi bar", "Money" ]
}
"""
)
).andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(
jsonPath("name").value("Man sushi")
)
.andExpect(
jsonPath("instructions").value("Call sushi bar")
)
.andExpect(
jsonPath("ingredients[0]").value("Phone")
)
.andExpect(
jsonPath("ingredients[1]").value("Sushi bar")
)
.andExpect(
jsonPath("ingredients[2]").value("Money")
)
.andReturn();
}

MockMvc has a pretty rich, readable and flexible DSL that allows to make calls and assertions about all attributes of response — from status and headers to specific paths. But there is a flaw — this code is very verbose. Also, it relies on lots of static imports from various RequestBuilders and ResultMathchers classes that you need to know and remember

import static org.springframework.test.---.MockMvcRequestBuilders.get;
import static org.springframework.test.---.MockMvcRequestBuilders.post;
import static org.springframework.test.---.MockMvcResultMatchers.content;
import static org.springframework.test.---.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.---.MockMvcResultMatchers.status;

Also sometimes we need to directly access properties of response to make some manipulations in test — and this is when ObjectMapper can help us.

String recipeId;

@Test
@Order(1)
void createdRecipe_shouldBeAvailable_viaGetPage_andViaGetById() throws Exception {
String recipesPageResponseString = mockMvc.perform(
get("/recipes")
).andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andReturn().getResponse().getContentAsString();

Map responseAsMap = OBJECT_MAPPER.readValue(recipesPageResponseString, Map.class);

List<Map<String, Object>> content = (List) responseAsMap.get("content");
assertThat(content).hasSize(1);
var recipe = content.get(0);
assertThat(recipe.get("name")).isEqualTo("Man sushi");
assertThat(recipe.get("instructions")).isEqualTo("Call sushi bar");
assertThat((List) recipe.get("ingredients"))
.hasSize(3)
.containsExactly("Phone", "Sushi bar", "Money");

recipeId = (String) recipe.get("id");

String byIdRecipeJson = mockMvc.perform(
get("/recipes/" + recipeId)
).andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andReturn().getResponse().getContentAsString();

Map byIdRecipe = OBJECT_MAPPER.readValue(byIdRecipeJson, Map.class);
assertThat(byIdRecipe).isEqualTo(recipe);
}

Unfortunately, Java is a strongly typed language —and in order to make similar json-assumptions, we need to write lots and lots of code.

Repository source code also contains three other steps — but clearly, using “raw” MockMvc apis results in writing (and reading) lots of code.

Version 1 -> test-utils and mvc-client

As you might have noticed, there is a lot of repeated code that can be reused instead of being copied

  • andExpect(status().isOk()) / content().contentType(APPLICATION_JSON) — 95% of test cases expect the server to respond with “ok” — and most applications use json format — so it’s not worth mentioning every time (and should be encapsulated somewhere)
  • integrational testing is mostly about making sure that your application performs for the main declared behavior, not for all the billion possible non-happy-path-scenarios
  • there are lots of libraries for making http calls with simple API for frequent cases, so why not have some “wrapper” around MockMvc that can reduce the amount of code and be reused for testing all APIs in the project?
/**
* Simplified test-version of org.springframework.web.client.RestOperations
*/
public interface TestRestOperations {

/**
* Accepts "raw" json content, expects 200 status and returns mockmvc result
* actions for response assertions
*/
ResultActions postActions(String path, String content);

/**
* Performs mockmvc-get, expects 200 status and reads response into Map / List object
*/
<T> T doGet(String path);

/**
* Performs mockmvc-get, expects given status and parses response into object
*/
<T> T doGet(HttpStatus expectedStatus, String path);

... any other helper operations that you may need
}

link to repo: TestRestOperations

And sample implementation that contains all the boilerplate:
(yes, some private methods can be extracted to reuse assertions)…

@Component
@RequiredArgsConstructor
class TestRestOperationsImpl implements TestRestOperations {

private final MockMvc mockMvc;

@Override
@SneakyThrows
public ResultActions postActions(String path, String content) {
MockHttpServletRequestBuilder requestBuilder = post(path)
.contentType(MediaType.APPLICATION_JSON)
.content(content);
return mockMvc.perform(requestBuilder)
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON));
}

@Override
@SneakyThrows
public <T> T doGet(String path) {
String responseJson = mockMvc.perform(get(path))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andReturn().getResponse().getContentAsString();
return readResponse(responseJson);
}

@Override
@SneakyThrows
public <T> T doGet(HttpStatus expectedStatus, String path) {
String responseJson = mockMvc.perform(get(path))
.andExpect(status().is(expectedStatus.value()))
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andReturn().getResponse().getContentAsString();
return readResponse(responseJson);
}

...

@SneakyThrows
private static <T> T readResponse(String response) {
return (T) OBJECT_MAPPER.readValue(response, Object.class);
}
}
  • repeating/chaining of “andExpect( jsonPath…)” can be extracted. Btw, adding @SneakyThrows helps us get rid of checked exceptions
    link to repo: MockMvcOperationsUtils
/**
* Contains various helper methods that help reduce amount of copy-paste
*/
public class MockMvcOperationsUtils {
@SneakyThrows
public static void asserJsonPaths(
ResultActions resultActions,
Map<String, String> pathValues
) {
for (Map.Entry<String, String> entry : pathValues.entrySet()) {
resultActions.andExpect(
jsonPath(entry.getKey()).value(entry.getValue())
);
}
}
}

General idea is — to prefer composition and reusable classes/components over private methods in test classes that are not related to flow. Now, the test code will be shorter:

CrudFlowTest_v1_WithUtilsAndTestRestOperations

    @Autowired
private TestRestOperations testRestOperations;

@Test
@Order(0)slo
void createNewRecipe() {
ResultActions postActions = testRestOperations.postActions(
"/recipes",
"""
{
"name" : "Man sushi",
"instructions" : "Call sushi bar",
"ingredients" : [ "Phone", "Sushi bar", "Money" ]
}
"""
);

asserJsonPaths(postActions, Map.of(
"name", "Man sushi",
"instructions", "Call sushi bar",
"ingredients[0]", "Phone",
"ingredients[1]", "Sushi bar",
"ingredients[2]", "Money"
));
}

Also, if your scenario requires some authentication, then you can apply it per class on TestRestOperations in any convenient way

By reusing code in tests, we make them easier to read. Also, we can use “java-lang-assertions” over Assertj’s assertThat to have even less code to read.

@Test
@Order(2)
void shouldUpdateRecipeById_inPatchManner() {
Map<String, Object> patchResponse = testRestOperations.doPatch(
"/recipes/" + recipeId,
Map.of("name", "Lazy sushi")
);

assertThat(patchResponse.get("name")).isEqualTo("Lazy sushi");
assertThat(patchResponse.get("instructions")).isEqualTo("Call sushi bar");
}

@Test
@Order(3)
void reloadById_verifyNameUpdate() {
Map byIdRecipe = testRestOperations.doGet("/recipes/" + recipeId);

assert byIdRecipe.get("name").equals("Lazy sushi");
assert byIdRecipe.get("instructions").equals("Call sushi bar");
}

However, Java’s assert is not very helpful when it fails:

Version 2 — wrapping operations into “test client” interfaces

Corresponds to CrudFlowTest_v2_WithTestClient

If your application has lots of cross-api scenarios, at some point you will get tired of rememberhing / specifying all the endpoints and request/response models — and this is another area to reuse code.
To simplify it, we can “mirror” our server-side api to encapsulate endpoints and arguments, json-mapping. Also we’ll leave some javadoc how request/response body looks like.

Implementation will rely on existing “mock-mvc client”

@Component
@RequiredArgsConstructor
class RecipesRestApiTestClientImpl implements RecipesRestApiTestClient {

private final TestRestOperations restOperations;

@Override
public Map getRecipesPage() {
return restOperations.doGet("/recipes");
}

@Override
public Map createRecipe(Map request) {
return restOperations.doPost("/recipes", request);
}
...
}

In this iteration, test code will be even easier to read than in previous, but it still requires some strong-typing

@Test
@Order(3)
void reloadById_verifyNameUpdate() {
Map byIdRecipe = apiClient.getRecipe(recipeId);

assert byIdRecipe.get("name").equals("Lazy sushi");
assert byIdRecipe.get("instructions").equals("Call sushi bar");
}

@Test
@Order(4)
void deleteById_shouldRemoveFrom_getPage() {
apiClient.deleteRecipe(recipeId);

Map pageResponse = apiClient.getRecipesPage();
List<Map<String, Object>> content = (List) pageResponse.get("content");
assert content.isEmpty();
}

@Test
@Order(5)
void getMissingRecipeById_shouldReturn404() {
Map responseAsMap = apiClient.getRecipe(NOT_FOUND, recipeId);
assert responseAsMap.get("message").equals("No recipe for id " + recipeId);
}

Version 3 — using better syntax for testing

Java is not the only programming language that works on JVM platform. We also have Groovy — a dynamically-typed language that has syntax similar to JavaScript. Groovy also is fully compatible with all your existing Java codebase. If you’re writing gradle buildscripts from time to time, then you are already familiar with Groovy. In order to add it to your project, you’ll need the following setup:

plugins { id 'groovy' }
dependencies { testImplementation 'org.codehaus.groovy:groovy-all:3.0.13' }

And then to create .groovy classes under groovy namespace

If you already know Java, learning groovy won’t be a big issue — 99% of Java code is accepted by Groovy compiler. There are some minor differences that you will pick up easily ( for instance, def is identical to Java’s var)

This is how test code will look now:
CrudFlowTest_v3_WithTestClientInGroovy

@Test
@Order(0)
void createNewRecipe() {
def response = apiClient.createRecipe([
"name" : "Man sushi",
"instructions": "Call sushi bar",
"ingredients" : ["Phone", "Sushi bar", "Money"]
])

assert response["name"] == "Man sushi"
assert response["instructions"] == "Call sushi bar"
assert response["ingredients"] == ["Phone", "Sushi bar", "Money"]
assert response.ingredients[0] == 'Phone'
}

Note that [ ‘key’ : ‘value’ ] defines a Map, but [‘first’, ‘second’] is a List.

Single-quotes define regular ‘string’, and double-quotes define parametrized string “recipe id $recipeId” — like in most modern languages

Groovy supports dynamic field accessors like javascript. If you use them, IDE will not help you with compilation & hints (and code will fail in runtime), but your tests will be short and readable

Also, Groovy’s assert is much better than Java’s and gives a lot of information about failure (nearly as much as the AssertJ library):

@Test
@Order(2)
void shouldUpdateRecipeById_inPatchManner() {
def patchResponse = apiClient.updateRecipe(
recipeId, ["name": "Lazy sushi"]
)

assert patchResponse.name == "Lazy sushi"
assert patchResponse.instructions == "Call sushi bar"
}
@Test
@Order(5)
void getMissingRecipeById_shouldReturn404() {
def response = apiClient.getRecipe(NOT_FOUND, recipeId)
assert response.message == "No recipe for id $recipeId"
}

One more trick that reduces amount of test code is covered in Part 2

--

--