The aim of this project is to demonstrate Unit, Mutation and Integration tests in Spring reactive web.
Following utility classes were implemented:
MockWebServerKit
to easily mock and testWebClient
in unit test.HttpClientKit
to easily send requests to our controller in integration test.WireMockKit
to 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))
:ClientDelegate
is FunctionalInterface that has acall
method. You can delegate this call to endpoint method.- the response could be
successful
orerror
. You can check success body withexpectResponse
, error cases byexpectClientError
andexpectServerError
. 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
,header
parameters. Or forPOST
andPUT
operations 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 pitest
Test 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));
}