Spring boot based bookstore application. The primary objective to develope bookstore is to learn practical test implementation in a spring app based on the test pyramid concepts.
Bookstore is a Rest Application which exposes few endpoints as below:
- /books : Get all the books from repository.
- /book/{isbn} : Get a book by given isbn path parameter.
- /book/price/{isbn} : Get a book price by given isbn path parameter.
Kafka Use Case
- Consumer Side - The bookstore listens on a Kafka topic (
newbooks
) and adds the consumed event in bookRepository. - Producer Side - The bookstore listens on a Kafka topic (
removeentry
) and deletes the book from bookRepository. It also produces an event for consumers of bookstore to update them about deletion a book from database. The producer produces event onconsumer-one
topic.
Integration With Other Service
In order to replicate the microservice usecase, the bookstore talks to price service to fetch the price of a book. Its REST API based communication.
gradle test
runs all the unit tests.
gradle integrationTest
runs integration tests.
gradle componentTest
runs integration tests.
The principle behind test automation is to get fast and accurate feedback for any changes in the code. Test pyramid talks about writing lots of small and fast unit tests. Write some more coarse-grained tests and very few high-level tests that test your application from end to end.
Some useful resources on test pyramid:
Smallest piece of code tested in isolation.
- One test class per production class.
- A unit test class should at least test public interface of the class.
- Make sure all the happy paths and edge cases are tested.
- Unit tests should not be tied to implementation too closely. Unit test should test for If I enter value x and y, will the result be z? Not if I enter value x, will the method A gets called and then return some result of class A plus the result of class B?
- Private methods are considered as implementation details. No need to test them.
Arrange -> Act -> Assert
Given -> When -> Then
Setup test data -> Test -> Assert
Controller Test
@Test
void shouldReturnAllBooks() throws Exception {
Book book = new Book("121212", "A Book");
ArrayList<Book> books = new ArrayList<>();
books.add(book);
given(bookRepository.findAll()).willReturn(books);
mockMvc.perform(get("/books"))
.andDo(print())
.andExpect(content().json("[{\"id\":" + null + ", \"isbn\":\"121212\",\"title\":\"A Book\"}]"))
.andExpect(status().is2xxSuccessful());
}
- Prevention of bugs
- Supports refactoring
- Leads to better design
- Prevents breaking changes
- More confidence on your code
Integration tests are added to codebase with an objective that modules developed separately work as expected when integrated.
- When testing a submodule with other module, the integration test should verify that the submodule is able to communicate sufficiently with other module. It should have basic coverage of success and error scenarios.
- The integration tests should not be testing the state of communication, example if module A gives input X then output of module B should be Y. Additional coverage can be tested in other layers of pyramid.
- There should be very few integration tests which give faster feedback.
- Test Beds (mocks, stubs) can be used to test the integration points.
@Test
public void shouldCallWeatherService() throws Exception {
wireMockServer.stubFor(get(urlEqualTo("/price"))
.willReturn(aResponse()
.withBody(read("classpath:price_response.json"))
.withHeader(CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withStatus(200)));
var weatherResponse = subject.fetchPrice();
assertThat(weatherResponse.getPrice(), is(10.00));
}
In above test, subject.fetchPrice()
reads the priceService url from configuration file. Having above
test insures that our service is able to read the configuration properly and talks to the external service.
There is a downside of using wireMock stub, as it returns a prefixed response and does not know anything if actual price service changes the response schema. To ensure contract change by external service does not break our system in production, we can have contract tests. One can refer to https://martinfowler.com/bliki/ContractTest.html to read more about contract testing.
- Tests the communication between different sub-modules
- Faster feedback
Testing the whole component isolating the third-party code and services.
Goal of component testing is to test that different parts of the microservice work together as expected at the same time isolating third-party code and services. The isolation of dependencies can be achieved by test beds (mocks, in-memory database etc).
- end to end journey of microservice within the boundry of service in context.
- unhappy responses from external systems.
- The component testing can achieved by running the microservice in memory along with in memory test doubles. This will make the tests faster but will not touch the network. This also needs a separate application config to run the microservice in memory.
@Test
public void shouldReturnBookResponseWithPrice() throws Exception {
wireMockServer.stubFor(get(urlEqualTo("/price"))
.willReturn(aResponse()
.withBody(read("classpath:price_response.json"))
.withHeader(CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withStatus(200)));
when()
.get(String.format("http://localhost:%s/book/price/123", port))
.then()
.log().all()
.statusCode(is(200))
.body(containsString("{\"id\":1,\"isbn\":\"123\",\"title\":\"Clean Code\",\"price\":10.0}"));
}
@Test
void shouldAddBookWhenEventReceivedForNewEntry() throws ExecutionException, InterruptedException, IOException {
produceEvent("books", "156:Java Book");
produceEvent("books", "157:Clean Code");
await()
.atMost(60, TimeUnit.SECONDS)
.untilAsserted(
() -> {
Assertions.assertEquals(bookRepository.count(), 3);
});
when()
.get(String.format("http://localhost:%s/book/price/156", port))
.then()
.log().all()
.statusCode(is(200))
.body(containsString("\"isbn\":\"156\",\"title\":\"Java Book\",\"price\":10.0}"));
}
@Test
void shouldDeleteBookWhenEventReceivedToDeleteBook() throws ExecutionException, InterruptedException {
produceEvent("remove.entry", "123");
List<String> eventsReceived = consumeEvents("consumer-one");
Assertions.assertEquals(1, eventsReceived.size(), "Events Received Count");
Assertions.assertEquals("123", eventsReceived.get(0), "Event Content");
}
Read more about Component Testing here.