The aim of this project is to demonstrate Unit, Mutation and Integration tests in Spring reactive web.
Following utility classes were implemented:
MockWebServerKitto easily mock and testWebClientin unit test.HttpClientKitto easily send requests to our controller in integration test.WireMockKitto easily stub our 3rd party dependencies in integration test.
You could use these classes in your projects.
A demo project,customer-service, were implemented to show usage of these utility classes and testing a Spring reactive application.
customer-service gets customer information user-service, and address information from address-service.
customer-service endpoints
| Operation | Endpoint | Description |
|---|---|---|
| POST | /customers |
Add a new customer |
| POST | /customers/{customerId}/address |
Add a new address to a given customer |
| GET | /customers/{customerId}/address |
Get address list of a given customer |
| GET | /customers/{customerId}/address/{addressId} |
Get an address of a given customer |
| DELETE | /customers/{customerId}/address/{addressId} |
Delete a given address |
user-service endpoints
| Operation | Endpoint | Description |
|---|---|---|
| POST | /users |
Add a new user |
| GET | /users/{userId} |
Get user information |
address-service endpoints
| Operation | Endpoint | Description |
|---|---|---|
| POST | /address/{customerId} |
Add a new address |
| GET | /address/{customerId} |
Get address list of a given customer |
| GET | /customers/{customerId}/{addressId} |
Get an address of a given customer |
| DELETE | /customers/{customerId}/{addressId} |
Delete agiven address |
Testing Spring controller, services and clients (WebClient) components were explained in this section.
WebTestClient were used to send request to the controller.
@ExtendWith(SpringExtension.class) annotations should be added to use WebTest client.
Here is a sample to test a GET endpoint:
webTestClient.get()
.uri(CUSTOMER_PATH, customerId)
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus()
.isOk()
.expectBody()
.jsonPath("$.id").isEqualTo(customerId);StepVerifier were used to test reactive service implementations. Dependencies were mocked using Mockito.
For each test a addressWebClient created as a mock class.
@BeforeEach
void setUp() {
addressWebClient = Mockito.mock(AddressWebClient.class);
addressService = new AddressService(addressWebClient);
}createAddress method were mocked using when statement. The method were consumed using StepVerifier.
Response was verified expectNext and verifyComplete.
@Test
void shouldAddAddress() {
Address addressRequest = ClientDataProvider.addressRequest();
Address addressResponse = ClientDataProvider.addressResponse();
when(addressWebClient.createAddress(addressRequest)).thenReturn(Mono.just(addressResponse));
StepVerifier.create(addressService.addAddress(addressRequest))
.expectNext(addressResponse)
.verifyComplete();
}Error cases and exception were verified using expectErrorMatches statement.
...
StepVerifier.create(addressService.addAddress(addressRequest))
.expectErrorMatches(new ApplicationException(ErrorCode.ADDRESS_INVALID_REQUEST)::equals)
.verify();MockWebServerKit class were implemented as a utility class to test spring WebClient in an easy way.
This class uses MockWebServer from okhttp3 project in order to mock the third party server responses.
.prepareMockResponseWith(HttpStatus.OK, addressResponse, headers): is used to set up a dummy response when an endpoint called from a 3rd party client..call(() -> addressWebClient.getAddress(addressId)):ClientDelegateis FunctionalInterface that has acallmethod. You can delegate this call to endpoint method.- the response could be
successfulorerror. You can check success body withexpectResponse, error cases byexpectClientErrorandexpectServerError. For expectResponse, give the expected object to able to check response body. .takeRequest(): takes the request. After this call you can only check the request. You can checkpath,headerparameters. Or forPOSTandPUToperations you can checkbody.
-
Create a mockWebTestClient using
mockWebTestClient = MockWebServerKit.create();to start a mock server before each test.@BeforeEach public void setup() { mockWebTestClient = MockWebServerKit.create(); AddressProperties addressProperties = AddressProperties.builder() .url(mockWebTestClient.getMockServerUrl()) .pathAddresses(ADDRESSES_PATH) .pathAddress(ADDRESS_PATH) .build(); addressWebClient = new AddressWebClient(WebClient.builder(), addressProperties); }
Here, mockWebTestClient.getMockServerUrl() is base-url.
- Prepare a mock response for a given request and check actual webClient implementation calls endpoint using correct path, header, body fields and get expected response.
-
Example: testing a GET endpoint call
mockWebTestClient .prepareMockResponseWith(HttpStatus.OK, addressResponse, headers) .call(() -> addressWebClient.getAddress(addressId)) .expectResponse(addressResponse) .takeRequest() .expectHeader(HttpHeaders.ACCEPT, MediaTypes.APPLICATION_JSON) .expectMethod(HttpMethod.GET.name()) .expectPath(expectedPath);
-
Example: testing a POST endpoint call
mockWebTestClient .prepareMockResponseWith(HttpStatus.CREATED, addressResponse, headers) .call(() -> addressWebClient.createAddress(addressRequest)) .expectResponse(addressResponse) .takeRequest() .expectHeader(HttpHeaders.ACCEPT, MediaTypes.APPLICATION_JSON) .expectHeader(HttpHeaders.CONTENT_TYPE, MediaTypes.APPLICATION_JSON) .expectMethod(HttpMethod.POST.name()) .expectPath(ADDRESSES_PATH) .expectBody(addressRequest, Address.class);
-
Example: testing a DELETE endpoint call
mockWebTestClient .prepareMockResponseWith(HttpStatus.NO_CONTENT) .call(() -> addressWebClient.deleteAddress(addressId)) .expectNoContent() .takeRequest() .expectMethod(HttpMethod.DELETE.name()) .expectPath(ADDRESS_PATH.replace("{addressId}", addressId))
-
Example: testing client errors when endpoint return 4xx error
mockWebTestClient.prepareMockResponseWith(HttpStatus.BAD_REQUEST) .call(() -> addressWebClient.deleteAddress("address-2")) .expectClientError();
-
Example: testing server errors when endpoint return 5xx error
mockWebTestClient.prepareMockResponseWith(HttpStatus.INTERNAL_SERVER_ERROR) .call(() -> addressWebClient.deleteAddress("address-3")) .expectServerError();
- Close mock server using
mockWebTestClient.dispose();after each test.
@AfterEach
public void tearDown() throws IOException {
mockWebTestClient.dispose();
}Test coverage could be measured code and mutation coverage.
Code coverage measures percentage of execution paths that exercised during tests.
On the other hand mutation test dynamically change the code, cause the tests fail and measures tests coverage.
In this project, pitest was used for the mutation testing.
Each change in the code called as mutant. You can check all mutators from here for the pitest framework.
The customer-service project is a gradle project, and pitest integrated to the project using info.solidsoft.pitest gradle plugin.
You can check settings from build.gradle file.
pitest {
threads.set(4)
outputFormats.set(['XML', 'HTML'])
timestampedReports.set(false)
mutators.set(['CONDITIONALS_BOUNDARY', 'VOID_METHOD_CALLS', 'NEGATE_CONDITIONALS',
'INVERT_NEGS', 'MATH', 'INCREMENTS',
'TRUE_RETURNS', 'FALSE_RETURNS', 'PRIMITIVE_RETURNS', 'EMPTY_RETURNS', 'NULL_RETURNS']
)
timeoutConstInMillis.set(10000)
junit5PluginVersion.set('0.12')
}- threads: Number of threads to run pitest
- outputFormats: To generate pitest reports in XML and HTML formats
- timestampedReports: Generate reports and named report directory with timestamp. Setted false.
- mutators: List of mutators. Default mutators will be used w/o setting this field.
- timeoutConstInMillis: Test timeouts
- junit5PluginVersion: to run JUnit5 tests with pitest. Check pitest-junit5-plugin
To run the pitest on your local run the following command in the project root directory.
./gradlew pitestTest reports will be under ./customer-service/build/reports/pitests directory. You can open index.html in a browser to see the mutation coverage and details.
WireMock, OkHttpClient were used in integration tests.
- WireMockKit utility class added to manage 3rd party dependencies
- Client{NAME}Service uses WireMockKit to stub 3rd party client
- HttpClientKit utility class added to send request to the customer-service controller
- Each endpoint implemented as a separated class
WireMockKit uses stubFor methods of WireMock to stub 3rd party dependencies.
WireMockKit has helper methods to stub the third party client.
Operation list:
- setupGetStub(String requestUrl, int responseStatus, T responseBody)
- setupPostStub(String requestUrl, int responseStatus, T responseBody)
- setPutStub(String requestUrl, int responseStatus, T responseBody)
- setupPatchStub(String requestUrl, int responseStatus, T responseBody)
- setupPatchStub(String requestUrl, int responseStatus) (if patch operation returns NO_CONTENT)
- setupDeleteStub(String requestUrl, int responseStatus)
A helper method implementation.
public static <T> void setupGetStub(String requestUrl, int responseStatus, T responseBody) {
stubFor(get(urlEqualTo(requestUrl))
.withHeader(HttpHeaders.ACCEPT, containing(MediaType.APPLICATION_JSON_VALUE))
.willReturn(aResponse()
.withFixedDelay(0)
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withStatus(responseStatus)
.withBody(ObjectMapperUtils.toJsonString(responseBody))
)
);
}WireMockKit uses url matching to match a request to client endpoint.
- Stub a GET endpoint
//...
List<Address> addressList;
//...
WireMockKit.setupGetStub(MessageFormat.format("/address/{0}", customerId),
HttpStatus.SC_OK,
addressList);- Stub a POST endpoint
//...
Address address;
//...
WireMockKit.setupPostStub(MessageFormat.format("/address/{0}", customerId),
HttpStatus.SC_CREATED,
address);- Stub a DELETE endpoint
WireMockKit.setupDeleteStub(MessageFormat.format(/address/{0}/{1}, customerId, addressId),
HttpStatus.SC_NO_CONTENT);HttpClientKit uses OkHttpClient and creates a static instance of it.
private static final OkHttpClient client = getOkHttpClient();
private static OkHttpClient getOkHttpClient() {
OkHttpClient client = new OkHttpClient.Builder()
.callTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.build();
return client;
}HttpClientKit support following operations
- Response performPatch(String targetUrl, T requestBody)
- Response performPut(String targetUrl, T requestBody)
- Response performPost(String targetUrl, T requestBody)
- Response performGet(String targetUrl)
- Response performDelete(String targetUrl)
And, following helper methods to get response body string as an actual dto object.
- T getResponseBody(Response response, Class type)
- T getResponseBody(Response response, TypeReference valueTypeRef)
A helper method implementation:
public static <T> Response performPatch(String targetUrl, T requestBody)
throws IOException {
Request request = new Request.Builder()
.patch(RequestBody.create(ObjectMapperUtils.toJsonString(requestBody),
okhttp3.MediaType.parse("application/json")))
.url(targetUrl)
.build();
return client.newCall(request).execute();
}In order to use HttpClientKit.
- Call the service endpoint using HttpClientKit helper methods.
//...
Customer customerRequest;
//...
Response response = HttpClientKit.performPost("http://localhost:8091/customers", customerRequest);- Convert response body to your DTO classes.
Customer actualResponse = HttpClientKit.getResponseBody(response, Customer.class);Then check status of response and compare response object with the expected result.
Add customer address end point test.
@Test
public void shouldAddCustomer() throws IOException {
//given
final User userResponse = ClientDataProvider.userResponse();
final Address addressResponse = ClientDataProvider.addressResponse();
ClientUserService.stubWith(userResponse)
.postUserReturnCREATED();
ClientAddressService.stubWith(userResponse.getId())
.addAddress(addressResponse)
.createAddressReturnCREATED();
//when
final Customer customerRequest = DataProvider.customerRequest();
final String url = MessageFormat.format(CUSTOMER_URL, getBaseUrl());
Response response = HttpClientKit.performPost(url, customerRequest);
//then
Customer actualResponse = HttpClientKit.getResponseBody(response, Customer.class);
Customer expectedResponse = DataProvider.customerResponse();
assertThat(response.code(), is(HttpStatus.SC_CREATED));
assertThat(actualResponse, is(expectedResponse));
}