Spring Boot provides great support for testing controllers via WebMvcTest which allows calling controllers directly via the MockMvc utility. The developer can mock corresponding service and repository calls and verify the service orchestration within the controller as well as the appropriate setup of JSON responses. However, this kind of test does not run the complete chain of registered HTTP filters, resolvers etc.
In order to test an incoming REST call in all layers of the Spring Boot application, the integration test should target the application the same way as any external client would run a call. The schema depicted below shows a simple REST service orchestration using an external authentication service via spring security to secure the API routes. The application under test allows certain API routes for all authenticated users but specific routes just for admin authorities.
The integration test is executing a REST call toward the controller under test. The RESTcall includes a Bearer token which authenticates the user, issuing the call. Using anauthentication filter, spring security runs a validation call toward an Auth API in order tovalidate the given token. The Auth API returns the authorities (roles) of the authenticateduser. Based on the returned authorities spring security is going to allow or deny the specificAPI route access.
Challenges
Integration tests should run for each pull request in order to verify that changes do not break the system. The build infrastructure is going to run PR builds parallel in order to provide timely feedback for each developer. Therefore, resources as persistence stores need to be tenant aware to avoid that tests interfere in test data setup and verification procedures.
Note: Another approach would be using embedded databases per test, which brings isolation out of the box. The issue with embedded databases is, that migrations might not be transferrable 1:1 due to different sql syntax. Thus, one would need separate tests for the migrations and might not utilise database constraint checks etc.
Single Test Run
Each integration test will run along the following chain
setup of user, who shall execute the API call
setup of interceptors to catch REST call toward the auth service, which shall not be tested
setup of database, including all migrations
setup of database objects necessary to test the business logic
verify the response
clean up the database objects created for the test
tear down the database
Mock of authentication users
Spring Boot provides the RestTemplateFactory which allows to enhance calls done via the RestTemplate transparently. The sample method below enhances each request with the HTTP Authorization header and adds the Bearer token used in common JWT token APIs.
public static RestTemplateBuilder bearerAuthTemplate(String token,
Optional<Integer> port) {
return baseTemplate(port).additionalInterceptors(
(httpRequest, bytes, clientHttpRequestExecution) -> {
httpRequest.getHeaders().add(HttpHeaders.AUTHORIZATION,
"Bearer " + token);
return clientHttpRequestExecution.execute(httpRequest, bytes);
});
}
Targeting the controller under test by using this rest template will always include the Bearer Token. All integration tests are using this TestRestTemplate which is configured to use a static token in each call. This allows easy mocking of all Auth API calls because the validate call will always use the static token.
@Configuration
public class TestRestTemplateConfig {
@Value("${auth:bearer}")
private String auth;
@Bean
public RestTemplateBuilder restTemplateBuilder() {
if ("basic".equals(auth)) {
return RestTemplateFactory.basicAuthTemplate("user", "password", Optional.empty());
}
if ("bearer".equals(auth)) {
return RestTemplateFactory.bearerAuthTemplate(
AbstractIntegrationTestBase.BEARER_TOKEN, Optional.empty());
}
// no auth
return RestTemplateFactory.noAuth(Optional.empty());
}
}
The authentication call toward the Auth API is caught by using the MockRestServiceServer. This server allows intecepting calls triggered by the TestRestTemplate. The listing below shows how to setup a user with expected authorities. Please be aware of the Bearer token, which is part of the validate request according to the applications authentication logic.
// bind the MockRestServiceServer to listen to restTemplate used to call controller under test
private void initMockRestServiceServer() {
mockRestServiceServer = MockRestServiceServer.bindTo(restTemplate).ignoreExpectOrder(true).build();
}
// mock the auth service call an return the given authorities
private void mockAuthService(final String userId, final UserRole... roles) {
mockSsoServer(MockRestResponseCreators
.withSuccess("{\"userId\": \"" + userId + "\", \"roles\": ["
+ Arrays.stream(roles).map(r -> String.format("\"%s\"", r.name())).collect(
Collectors.joining(","))
+ "]}",
MediaType.APPLICATION_JSON_UTF8));
}
private void mockSsoServer(DefaultResponseCreator responseCreator) {
mockRestServiceServer.reset();
mockRestServiceServer.expect(manyTimes(),
MockRestRequestMatchers.requestTo(
"http://mock-sso:8080/validate?token=" + AbstractIntegrationTestBase.BEARER_TOKEN))
.andExpect(MockRestRequestMatchers.method(HttpMethod.GET))
.andRespond(responseCreator);
}
Setup of Database
Each integration test runs isolated in its own database schema. In order to allow automated set up as well as tear down of the test database the integration test is using two different database users. The provisioning database allows the creation of database schemas as well as the creation of users. The application database user is created by the provisioning user during the start of the spring boot application. Spring Boot provides an event mechanism which calls registered listeners during startup and shutdown. The ApplicationPreparedEvent is used to setup the database and store update the Spring datasource configuration with the application user.
Please find below the setup of database. The sql script for creating the database includes placeholders for the application user which is simply build by using the current system time in millis.
@Component
public class ApplicationEnvironmentPreparedListener implements ApplicationListener<ApplicationPreparedEvent> {
private static final Logger LOGGER = LoggerFactory.getLogger(ApplicationEnvironmentPreparedListener.class);
@Override
public void onApplicationEvent(ApplicationPreparedEvent applicationPreparedEvent) {
applicationPreparedEvent.getApplicationContext().registerShutdownHook();
setupDatabase(applicationPreparedEvent);
}
private void setupDatabase(ApplicationPreparedEvent applicationPreparedEvent) {
ConfigurableEnvironment environment = applicationPreparedEvent.getApplicationContext().getEnvironment();
String provisioningDbUrl = environment.getProperty("spring.provisioning.datasource.url");
String provisioningDbUser = environment.getProperty("spring.provisioning.datasource.username");
String provisioningDbPassword = environment.getProperty("spring.provisioning.datasource.password");
String appDatabaseUrl = environment.getProperty("spring.datasource.url");
String dbUser = getDatabaseUser();
// this event is called twice for some reasons => avoid creating two schemas
if (!hasDatabaseBeenCreatedInBuild()) {
dbUser = runSql(getDataSource(provisioningDbUser, provisioningDbPassword, provisioningDbUrl));
}
//add user to spring boot environment
Properties props = new Properties();
props.put("spring.datasource.username", dbUser);
props.put("spring.datasource.url", appDatabaseUrl + dbUser);
environment.getPropertySources().addFirst(new PropertiesPropertySource("integrationTestProperties", props));
LOGGER.info("integration tests run on database schema {}", dbUser);
}
private String getDatabaseUser() {
if (hasDatabaseBeenCreatedInBuild()) {
return generatedDbSchemaName();
} else {
return "db_integration_" + String.valueOf(System.currentTimeMillis());
}
}
private String runSql(DataSource datasource) {
String generatedUser = getDatabaseUser();
Resource resource = new ClassPathResource("databaseInit.sql");
// file needs to be executed as one script in order to allow the "use database" directives
// loads the file, replaces the placeholder, writes into temp file for execution which is
// deleted on exit
try {
String sqlCommands = ScriptUtils.readScript(new LineNumberReader(new InputStreamReader(resource.getInputStream())), "--",";");
sqlCommands = sqlCommands.replaceAll("@userName@", generatedUser);
File foo = File.createTempFile(generatedUser, "sql");
foo.deleteOnExit();
FileWriter writer = new FileWriter(foo);
writer.write(sqlCommands);
writer.flush();
FileSystemResource tempResource = new FileSystemResource(foo);
ScriptUtils.executeSqlScript(datasource.getConnection(), tempResource);
} catch (Exception e) {
LOGGER.error("cannot initialize database for some reasong", e);
throw new RuntimeException(e);
}
System.setProperty("generated.db", generatedUser);
return generatedUser;
}
private DataSource getDataSource(String user, String password, String url) {
DataSourceBuilder dataSourceBuilder = DataSourceBuilder.create();
dataSourceBuilder.url(url);
dataSourceBuilder.username(user);
dataSourceBuilder.password(password);
return dataSourceBuilder.build();
}
private boolean hasDatabaseBeenCreatedInBuild() {
return generatedDbSchemaName() != null;
}
private String generatedDbSchemaName() {
return System.getProperty("generated.db");
}
CREATE DATABASE [@userName@]
CONTAINMENT = NONE;
USE [@userName@]
;
IF NOT EXISTS (SELECT name FROM sys.filegroups WHERE is_default=1 AND name = N'PRIMARY') ALTER DATABASE [@userName@] MODIFY FILEGROUP [PRIMARY] DEFAULT
;
CREATE LOGIN [@userName@] WITH PASSWORD=N'password', DEFAULT_DATABASE=[@userName@], CHECK_EXPIRATION=OFF, CHECK_POLICY=OFF;
CREATE USER [@userName@] FOR LOGIN [@userName@];
ALTER ROLE [db_owner] ADD MEMBER [@userName@];
Run Test
At this stage, the integration test setup provides an empty database. Database migrations in order to setup the DDL structure as well as to seed data can be done by using Flyway. In addition, entities necessary to test business logic can be inserted via JDBC templates using plain sql commands or FactoryDuke.
The test runs by simply calling the REST controller under test via the pre-configured rest template.
@Test
public void getResource_ok() throws Exception {
final ResponseEntity<JsonNode> response = restTemplate.getForEntity("/resources/4711"), JsonNode.class, userId);
assertEquals(HttpStatus.OK, response.getStatusCode());
....
}
Tear Down Database
The ContextClosedEvent is used to drop the application user as well as to drop the test database. This is done similar to the ApplicationPreparedEvent. The provisioning user is utilitzed to stop all running sessions of the application user, drop the application user as well as the integration test database.