Cleaner Spring Boot IT Rest tests with client generation

Anton Tkachenko
Duda
Published in
8 min readApr 23, 2023

This is part 2 of the article; it uses the same application code and test setup as Part 1. You can find the source code on GitHub.

OpenAPI documentation and its usage

The OpenAPI specification covers a standard way of documenting REST endpoints, including the request/response body, query parameters, headers, and more.

The typical flow of using OpenAPI is as follows:

  1. You create a JSON/YAML documentation of your application’s REST APIs using one of the doc-generation tools. For instance, Stripe’s API schema is documented using OpenAPI.
  2. The API consumer needs a user interface to browse the API. Several solutions provide user interfaces based on YAML/JSON schema, such as Stripe API Docs. The default one is https://editor.swagger.io/.
  3. To invoke application endpoints from the consumer app, you can generate an SDK using one of the tools, such as https://github.com/OpenAPITools/openapi-generator.

Code generation can save you a lot of time. You won’t need to specify all the endpoints and request/response objects, as everything can be generated for you. If your application maintains API docs in order, you can benefit a lot from it when writing tests.

How to generate open API schema for endpoints of your application

Springdoc can be used to create REST API docs in a few clicks. Here’s the Gradle script:

plugins {
id 'org.springdoc.openapi-gradle-plugin' version '1.6.0'
}
dependencies {
implementation 'org.springdoc:springdoc-openapi-ui:1.6.14'
}
// https://github.com/springdoc/springdoc-openapi-gradle-plugin
openApi {
apiDocsUrl.set("http://localhost:8080/v3/api-docs.yaml")
outputDir.set(file("./"))
outputFileName.set("api-schema.yaml")
}

Now you can use this Gradle task to produce an OpenAPI schema of your controllers. It will take some time because it actually starts your application. The schema should be regenerated after any change in the controller so that in a pull request, you would see how API consumers are affected.

Schema should be regenerated after any change in controller — so that in pull request you would see how api consumers are affected.

Generating client sdk from openapi-schema

The next step is to turn API docs schema into generated code:

plugins { id "org.openapi.generator" version "6.3.0" }
task generateClient(
type: org.openapitools.generator.gradle.plugin.tasks.GenerateTask,
group: 'openapi tools'
){
generatorName = "java"
inputSpec = "$rootDir/api-schema.yaml"
outputDir = "$buildDir/generated"
apiPackage = "com.example.apiclient.api"
library = 'resttemplate'
invokerPackage = "com.example.apiclient.invoker"
modelPackage = "com.example.apiclient.model"
configOptions = [
dateLibrary: "java8"
]
}
// this code is needed for generated code to be available in 
// test-namespace of our application
sourceSets.test.java.srcDirs += ['build/generated/src/main/java']
compileTestJava.dependsOn generateClient

The client will be regenerated before compiling main code, and it will also be added to the test-classpath, so our tests can use it, and we won’t need to commit it to the repo.

Using generated client-sdk in IT tests

In order to use generated API accessors as Spring beans in our tests, we need to write a small config and define a bean of com.example.apiclient.invoker.ApiClient, which is the main component used by generated code to invoke endpoints.

@Configuration
@ComponentScan("com.example.apiclient.api")
public class GeneratedApiClientConfiguration {
@Bean
public ApiClient apiClient(@Value("${server.port}") Integer serverPort) {
RestTemplate restTemplate = new RestTemplate();
// to support PATCH requests
restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory());
ApiClient apiClient = new ApiClient(restTemplate);
apiClient.setBasePath("http://localhost:" + serverPort);
return apiClient;
}
}

Now we can enjoy strong typing of generated request & response classes.

CrudFlowTest_v4_WithGeneratedTestClientInGroovy

import com.example.apiclient.api.RecipesControllerApi
import com.example.apiclient.model.RecipeRequest

@Autowired
private RecipesControllerApi apiClient
@Test
@Order(0)
void createNewRecipe() {
def response = apiClient.createRecipe(new RecipeRequest()
.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"]
}

Although it may seem like we are injecting the controller and calling it like a service, that’s not true. The only difference between this and the production environment is that here we are calling the server directly by localhost-ip, not via load-balancer and all other infrastructure. However, the requests go through all layers of Tomcat, Spring web, serialize and deserialize.

Furthermore, if we change any server code (for example, rename a request field), our tests will fail (as they should) because the test code doesn’t reuse any “production” classes — it relies only on the generated API schema.

Using Groovy as the test language is preferred over Java because it’s more compact. However, pay attention that in this example, response.name and response.instructions are actual compiler-checked and IDE-suggested properties (.name is identical to .getName() in Groovy).

In order to make non-200 requests, we’ll need a small helper utility method to read RestTemplate’s exceptions into a strong-typed record class.

@Test
@Order(5)
void getMissingRecipeById_shouldReturn404() {
def failureResponse = expectHttpFailure(NOT_FOUND, () -> apiClient.getRecipe(recipeId))
assert failureResponse.message() == "No recipe for id $recipeId"
}
public class ApiClientUtils {

record FailureResponse(String message) {
}

@SneakyThrows
public static FailureResponse expectHttpFailure(
HttpStatus status, Runnable operation
) {
try {
operation.run();
throw new RuntimeException("Code is expected to throw HttpStatusCodeException with status " + status);
} catch (HttpStatusCodeException e) {
assert e.getStatusCode() == status;
return OBJECT_MAPPER.readValue(e.getResponseBodyAsString(), FailureResponse.class);
}
}
}

Using generated clients for “shallow” web layer tests (@WebMvcTest)

Spring Boot provides capabilities for convenient testing only of selected application layer (only db/web/ … ) with set of special annotations.

The benefit of such tests is that the context starts much faster than with @SpringBootTest, and it’s beneficial if we want to target some security rules or servlet-filters. As in the previous example, we can save ourselves lots of effort by using generated code to invoke our APIs:

Source code: ShallowControllerTest_WithGeneratedClient

Key configuration points:

  • @WebMvcTest — starts mock-web environment, filters, http-conterters, security (if applicable) and all components that normally are provided by spring-boot-web-starter
  • @ContextConfiguration(classes = MockMvcShallowTestContext) + @ComponentScan([“com.example.cleaner_int_tests.web”]) + @MockBean RecipeEntityRepository mockRepository

This is context of our application’s components that we want to have in our “shallow” test context. E.g. — controllers & request/response-converter, as well as Mockito’s mockbeans to imitate our db/service layer.

  • Finally, we need @ComponentScan for “com.example.apiclient.api” with generated clients — and also to define ApiClient bean with RestTemplate that is based on MockMvcClientHttpRequestFactory (and MockMvc will be provided by WebMvcTest)
@Bean
ApiClient apiClient(MockMvc mockMvc) {
RestTemplate restTemplate = new RestTemplate(
new MockMvcClientHttpRequestFactory(mockMvc)
);
return new ApiClient(restTemplate)
}

This test verifies the following points in integration:

  • The request will reach the controller through all web layers.
  • The request will be converted to an entity.
  • The mock-repository will be called with the correct parameters.
  • The entity returned by the mock-repository will be correctly transformed into a response. And since we fully control the values received and returned by mocks, this gives us great freedom in making assertions that target specific details of our shallow context.
@Autowired
RecipesControllerApi apiClient

@Autowired
RecipeEntityRepository mockRepository

@Test
void createNewRecipe_willCallRepoAndMakeAllConversions() {
when(mockRepository.save(argThat({ RecipeEntity it ->
assert it.name == "Man sushi"
assert it.instructions == "Call sushi bar"
assert it.getIngredientsList() == ["Phone", "Sushi bar", "Money"]
return true
}))).thenReturn(new RecipeEntity(
"123", "Man sushi", "Call sushi bar",
"Phone,Sushi bar,Money"
))

def response = apiClient.createRecipe(new RecipeRequest()
.name("Man sushi").instructions("Call sushi bar")
.ingredients(["Phone", "Sushi bar", "Money"])
)

assert response.id == "123"
assert response.name == "Man sushi"
assert response.instructions == "Call sushi bar"
assert response.ingredients == ["Phone", "Sushi bar", "Money"]

verify(mockRepository).save(any())
}

Final step — using clean generated groovy models

If you look into the build/generated/src/main/java/com/example/apiclient/model folder and open any class, you will see lots of annotations, public-static-constants, getters and setters, and even with the project structure on the left, it’s not easy to guess what the model fields are.

In contrast, if we change generation language from java to groovy, this is what we’ll see:

Groovy sources are very compact, and the compiler does lots of work for you out of the box, such as multiple constructors, toString & hashCode, getter-setters, and many other things. In order to make our generated classes more readable, we can use Groovy models instead of Java models. But we need to keep Java invokers (e.g., ApiClient) because we can’t use Groovy with RestTemplate that we configured. To achieve this, we will:

  • generate two clients — for Java and Groovy, with identical parameters.
[generateGroovyModels, generateJavaInvoker].each {
it.inputSpec = "$rootDir/api-schema.yaml"
it.outputDir = "$buildDir/generated"
it.apiPackage = "com.example.apiclient.api"
it.invokerPackage = "com.example.apiclient.invoker"
it.modelPackage = "com.example.apiclient.model"
}
  • in groovy client, we will delete “…apiclient/api” package
task generateGroovyModels(
type: org.openapitools.generator.gradle.plugin.tasks.GenerateTask,
group: 'openapi tools'
){
generatorName = "groovy"
doLast {
delete "build/generated/src/main/groovy/com/example/apiclient/api"
}
}
  • in Java client, we will delete verbose “…apiclient/model” package and copy the rest into groovy namespace.
task generateJavaInvoker(
type: org.openapitools.generator.gradle.plugin.tasks.GenerateTask,
group: 'openapi tools'
){
generatorName = "java"
library = 'resttemplate'
doLast {
delete "build/generated/src/main/java/com/example/apiclient/model"
copy {
from 'build/generated/src/main/java'
into "build/generated/src/main/groovy"
}
}
}
  • we will put all our test code (java and groovy classes) under /groovy namespace. Groovy compiler knows how to compile both groovy and java classes
  • last, we’ll need to make a small compilation change in tests when creating request object
// before - with Java's generated "chained-setters"
def response = apiClient.createRecipe(new RecipeRequest()
.name("Man sushi").instructions("Call sushi bar")
.ingredients(["Phone", "Sushi bar", "Money"])
)
// after - with Groovy's all-args-constructor with named params
def response = apiClient.createRecipe(new RecipeRequest(
name: "Man sushi",
instructions: "Call sushi bar",
ingredients: ["Phone", "Sushi bar", "Money"]
))

def patchResponse = apiClient.updateRecipe(
recipeId, new RecipeRequest(name: "Lazy sushi")
)

This change is not merged into main branch because some people can think that this is an overkill — but you can see the diff here: https://github.com/tkachenkoas/cleaner-int-tests/pull/6/files

Summary and general thoughts

In conclusion, the article highlights the importance of maintaining a clean and efficient test suite for web applications. The use of generated client code and Groovy as a test language can greatly simplify the testing process and ensure stronger type checking of requests and responses. The article emphasizes the benefits of testing only selected layers of the application, which can speed up the testing process while still providing thorough coverage. The use of Groovy models can also make generated code more readable and easier to work with. Overall while test code may not be running in a production environment, it is still important to maintain it in good shape and apply best practices for clean and efficient code.

--

--