How to save money on a psychotherapist using test-driven development

    Have you ever had this condition?

    image

    I want to show you how TDD can improve the quality of code using a specific example.
    Because everything that I met while studying the issue was quite theoretical.
    It so happened that I happened to write two almost identical applications: one was written in the classical style, since I did not know TDD then, and the second - just using TDD.

    Below I will show where the biggest differences were.

    Personally, this was important to me, because every time someone found a bug in my code, I caught a heavy minus for self-esteem. Yes, I understood that bugs are normal, everyone writes them, but the feeling of inferiority did not go away. Also, in the process of evolution of the service, I sometimes understood that I myself wrote one such that itching my hands to throw everything out and rewrite it again. And how it happened is incomprehensible. Somehow everything was fine at the beginning, but after a couple of features and after a while you can’t look at architecture without tears. Although it seems like every step of the change was logical. The feeling that I did not like the product of my own work flowed smoothly into the feeling that the programmer was from me, I'm sorry, like a bullet from shit.

    It turned out that I am not the only one and many of my colleagues have similar sensations. And then I decided that either I would learn to write normally, or it was time to change my profession. I tried test-driven development in an attempt to change something in my programming approach.

    Looking ahead, based on the results of several projects, I can say that TDD provides a cleaner architecture, but it slows down development. And it is not always suitable and not for everyone.

    What is TDD again


    image


    TDD - development through testing. Wiki article here .
    The classic approach is to first write an application, then cover it with tests.

    TDD approach - first we write tests for the class, then implementation. We move through the levels of abstraction - from the highest to the applied, at the same time breaking the application into class layers, from which we order the behavior we need, being free from a specific implementation.

    And if I were to read this for the first time, I would not understand anything either.
    Too many abstract words: let's look at an example.
    We will write a real springing application in Java, we will write it in TDD, and I will try to show my thinking process during the development process and in the end draw conclusions whether it makes sense to spend time on TDD or not.

    Practical task


    Suppose we are so lucky that we have the ToR of what we need to develop. Typically, analysts do not bother with it, and it looks something like this:

    It is necessary to develop a microservice that will calculate the possibility of selling goods with subsequent delivery to the client at home. Information about this feature should be sent to a third-party DATA system.

    Business logic is as follows: the product is available for sale with delivery if:

    • Product is in stock
    • The contractor (for example, the company DostavchenKO) has the opportunity to take it to the client
    • Product color - not blue (we do not like blue)

    Our microservice will be notified about a change in the quantity of goods on the store shelf via an http-request.

    This notification is a trigger for calculating availability.

    Plus, so that life does not seem to be honey:

    • The user should be able to manually disable certain products.
    • In order not to spam DATA, you only need to send availability data for those products that have changed.

    We read a couple of times TK - and go.



    Integration test


    In TDD, one of the main questions that you have to ask to everything that you write is: “What do I want from ...?”

    And the first question we ask is just for the whole application.
    So the question is:

    What do I want from my microservice?

    Answer:

    Actually a lot of things. Even such simple logic gives a lot of options, an attempt to write down which, and even more so to create tests for all of them, can be an impossible task. Therefore, to answer the question at the application level, we will choose only the main test cases.

    That is, we assume that all the input data is valid format, third-party systems respond normally, and previously there was no information on the product.

    So, I want to:

    • An event has arrived that there is no product on the shelf. Notify that delivery is unavailable.
    • The event came that the yellow product is in stock, DostavchenKO is ready to take it. Notify about the availability of goods.
    • Two messages came in a row - both with a positive amount of goods in the store. Sent only one message.
    • Two messages arrived: in the first one there is a product in the store, in the second - no longer. We send two messages: first - available, then - no.
    • I can disable the product manually, and notifications are no longer sent.
    • ...

    The main thing here is to stop in time: as I already wrote, there are too many options, and it makes no sense to describe all of them here - only the most basic ones. In the future, when we write tests for business logic, their combination is likely to cover everything that we come up with here. The main motivation here is to be sure that if these tests pass, then the application works as we need.

    We will now distill all these Wishlist into tests. Moreover, since this is Wishlist at the application level, we will have tests with raising the spring context, that is, quite heavy.
    And this, unfortunately, for many TDD ends, because to write such an integration test, you need quite a lot of effort that people are not always willing to spend. And yes, this is the most difficult step, but, believe me, after you go through it, the code will almost write itself, and you will be sure that your application will work just the way you want.


    In the process of answering the question, you can already start writing code in the generated spring initializr class. The names of the tests are just our Wishlist. For now, just create empty methods:

    @Test
    public void notifyNotAvailableIfProductQuantityIsZero() {}
    @Test
    public void notifyAvailableYellowProductIfPositiveQuantityAndDostavchenkoApproved() {}
    @Test
    public void notifyOnceOnSeveralEqualProductMessages() {}
    @Test
    public void notifyFirstAvailableThenNotIfProductQuantityMovedFromPositiveToZero() {}
    @Test
    public void noNotificationOnDisabledProduct() {}
    

    Regarding the naming of methods: I strongly advise you to make them informative, not test1 (), test2 (), because later on, when you forget what class you wrote and what it is responsible for, you will have the opportunity instead of try to parse directly the code, just open the test and read the contract method that the class satisfies.

    Start filling out the tests


    The main idea is to emulate everything external in order to check what is happening inside.

    “External” in relation to our service is all that is NOT the microservice itself, but that directly communicates with it.

    In this case, the external is:

    • The system that our service will notify about changes in the quantity of goods
    • Customer who will disconnect goods manually
    • Third-party DostavchenKO system

    To emulate the requests of the first two, we use springing MockMvc.
    To emulate DostavchenKO we use wiremock or MockRestServiceServer.

    As a result, our integration test looks like this:

    Integration test
    
    @RunWith(SpringRunner.class)
    @SpringBootTest
    @AutoConfigureMockMvc
    @AutoConfigureWireMock(port = 8090)
    public class TddExampleApplicationTests {
        @Autowired
        private MockMvc mockMvc;
        @Before
        public void init() {
            WireMock.reset();
        }
        @Test
        public void notifyNotAvailableIfProductQuantityIsZero() throws Exception {
            stubNotification(
                    // language=JSON
                    "{\n" +
                            "  \"productId\": 111,\n" +
                            "  \"available\": false\n" +
                            "}");
            performQuantityUpdateRequest(
                    // language=JSON
                    "{\n" +
                            "  \"productId\": 111,\n" +
                            "  \"color\" : \"red\",  \n" +
                            "  \"productQuantity\": 0\n" +
                            "}");
            verify(1, postRequestedFor(urlEqualTo("/notify")));
        }
        @Test
        public void notifyAvailableYellowProductIfPositiveQuantityAndDostavchenkoApproved() throws Exception {
            stubDostavchenko("112");
            stubNotification(
                    // language=JSON
                    "{\n" +
                            "  \"productId\": 112,\n" +
                            "  \"available\": true\n" +
                            "}");
            performQuantityUpdateRequest(
                    // language=JSON
                    "{\n" +
                            "  \"productId\": 112,\n" +
                            "  \"color\" : \"Yellow\",  \n" +
                            "  \"productQuantity\": 10\n" +
                            "}");
            verify(1, postRequestedFor(urlEqualTo("/notify")));
        }
        @Test
        public void notifyOnceOnSeveralEqualProductMessages() throws Exception {
            stubDostavchenko("113");
            stubNotification(
                    // language=JSON
                    "{\n" +
                            "  \"productId\": 113,\n" +
                            "  \"available\": true\n" +
                            "}");
            for (int i = 0; i < 5; i++) {
                performQuantityUpdateRequest(
                        // language=JSON
                        "{\n" +
                                "  \"productId\": 113,\n" +
                                "  \"color\" : \"Yellow\",  \n" +
                                "  \"productQuantity\": 10\n" +
                                "}");
            }
            verify(1, postRequestedFor(urlEqualTo("/notify")));
        }
        @Test
        public void notifyFirstAvailableThenNotIfProductQuantityMovedFromPositiveToZero() throws Exception {
            stubDostavchenko("114");
            stubNotification(
                    // language=JSON
                    "{\n" +
                            "  \"productId\": 114,\n" +
                            "  \"available\": true\n" +
                            "}");
            performQuantityUpdateRequest(
                    // language=JSON
                    "{\n" +
                            "  \"productId\": 114,\n" +
                            "  \"color\" : \"Yellow\",\n" +
                            "  \"productQuantity\": 10\n" +
                            "}");
            stubNotification(
                    // language=JSON
                    "{\n" +
                            "  \"productId\": 114,\n" +
                            "  \"available\": false\n" +
                            "}");
            performQuantityUpdateRequest(
                    // language=JSON
                    "{\n" +
                            "  \"productId\": 114,\n" +
                            "  \"color\" : \"Yellow\",\n" +
                            "  \"productQuantity\": 0\n" +
                            "}");
            verify(2, postRequestedFor(urlEqualTo("/notify")));
        }
        @Test
        public void noNotificationOnDisabledProduct() throws Exception {
            stubNotification(
                    // language=JSON
                    "{\n" +
                            "  \"productId\": 115,\n" +
                            "  \"available\": false\n" +
                            "}");
            disableProduct(115);
            for (int i = 0; i < 5; i++) {
                performQuantityUpdateRequest(
                        // language=JSON
                        "{\n" +
                                "  \"productId\": 115,\n" +
                                "  \"color\" : \"Yellow\",\n" +
                                "  \"productQuantity\": " + i + "\n" +
                                "}");
            }
            verify(1, postRequestedFor(urlEqualTo("/notify")));
        }
        private void disableProduct(int productId) throws Exception {
            mockMvc.perform(
                    post("/disableProduct?productId=" + productId)
            ).andDo(
                    print()
            ).andExpect(
                    status().isOk()
            );
        }
        private void performQuantityUpdateRequest(String content) throws Exception {
            mockMvc.perform(
                    post("/product-quantity-update")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(content)
            ).andDo(
                    print()
            ).andExpect(
                    status().isOk()
            );
        }
        private void stubNotification(String content) {
            stubFor(WireMock.post(urlEqualTo("/notify"))
                    .withHeader("Content-Type", equalTo(MediaType.APPLICATION_JSON_UTF8_VALUE))
                    .withRequestBody(equalToJson(content))
                    .willReturn(aResponse().withStatus(HttpStatus.OK_200)));
        }
        private void stubDostavchenko(final String productId) {
            stubFor(get(urlEqualTo("/isDeliveryAvailable?productId=" + productId))
                    .willReturn(aResponse().withStatus(HttpStatus.OK_200).withBody("true")));
        }
    }

    What just happened?


    Мы написали интеграционный тест, прохождение которого нам гарантирует работоспособность системы по основным юзер стори. И мы сделали это ДО того как начать реализовывать сервис.

    Одно из преимуществ такого подхода — это то, что в процессе написания пришлось сходить в реальный DostavchenKO и получить оттуда реальный ответ на реальный запрос, который мы внесли в наш стаб. Очень хорошо, что мы этим озаботились в самом начале разработки, а не после того, как весь код написан. И тут оказывается, что формат не тот, который указан в ТЗ, или сервис вообще недоступен, или ещё что-нибудь.

    Также хотелось бы отметить, что мы пока ещё не только не написали ни однойthere are lines of code that will later go to the prod, but have not even made a single assumption about how our microservice will be arranged inside: what layers there will be, whether we will use the base, if so, which, etc. At the time of writing the test, we are abstracted from the implementation, and, as we will see later, this can give a number of architectural advantages.

    Unlike canonical TDD, where implementation is written immediately after the test, the integration test will not take a very long time. In fact, it will not turn green until the very end of development, until absolutely everything is written, including the files.
    We are going further.

    Controller


    After we wrote the integration test and now we are sure that after we finish the task, we can sleep peacefully at night, it's time to start programming the layers. And the first layer that we will implement is the controller. Why exactly him? Because this is the entry point to the program. We need to move from top to bottom, from the very first layer with which the user will interact, to the last.
    It is important.

    And again, it all starts with the same question:

    What do I want from the controller?

    Answer:

    We know that the controller is engaged in communication with the user, validation and conversion of input data and does not contain business logic. So the answer to this question might be something like this:

    I want:

    • BAD_REQUEST returned to the user when trying to disconnect a product with an invalid id
    • BAD_REQUEST when trying to notify about a change of goods with invalid id
    • BAD_REQUEST when trying to notify of a negative quantity
    • INTERNAL_SERVER_ERROR if DostavchenKO is unavailable
    • INTERNAL_SERVER_ERROR, if unable to send to DATA

    Since we want to be a user friend, for all the items above, in addition to the http code, you must display a custom message describing the problem so that the user understands what the problem is.

    • 200 if processing was successful
    • INTERNAL_SERVER_ERROR with a default message in all other cases, so as not to shine stackrace

    Until I started writing on TDD, the last thing I was thinking about was what my system would bring out for the user in some special and, at first glance, unlikely case. I didn’t think for one simple reason - it’s so difficult to write an implementation, in order to take into account absolutely all edge cases, sometimes there is not enough RAM in the brain. And after the written implementation, analyzing the code for something that you might not have considered in advance is still a pleasure: we all think that we are writing the perfect code right away). While there is no implementation, there is no need to think about it, and there is no pain to change it, if that. Having written the test first, you don’t have to wait until the stars converge, and after the withdrawal to the prod, a certain number of systems will fail, and the customer will come running to you with a request to fix something. And this applies not only to the controller.

    Start writing tests


    Everything is clear with the first three: we use spring validation, if an invalid request arrives, the application will throw an execution, which we will catch in an exception handler. Here, as they say, everything works by itself, but how does the controller know that some third-party system is unavailable?

    It is clear that the controller itself should not know anything about third-party systems, because what system to ask and what is the business logic, that is, there must be some kind of intermediary. This intermediary is the service. And we will write tests on the controller using the mock of this service, emulating its behavior in certain cases. So, the service must somehow inform the controller that the system is unavailable. You can do this in different ways, but the easiest way to throw custom execution. We will write a test for this controller behavior.

    Test for communication error with a third-party DATA system
    
    @RunWith(SpringRunner.class)
    @WebMvcTest
    @AutoConfigureMockMvc
    public class ControllerTest {
        @MockBean
        private UpdateProcessorService updateProcessorService;
        @Test
        public void returnServerErrorOnDataCommunicationError() throws Exception {
            doThrow(new DataCommunicationException()).when(updateProcessorService).processUpdate(any(Update.class));
            performUpdate(
                    //language=JSON
                    "{\n" +
                            "  \"productId\": 1,\n" +
                            "  \"color\": \"red\",\n" +
                            "  \"productQuantity\": 10\n" +
                            "}"
            ).andDo(
                    print()
            ).andExpect(
                    status().isInternalServerError()
            ).andExpect(
                    content().json("{\n" +
                            "  \"errors\": [\n" +
                            "    {\n" +
                            "      \"message\": \"Can't communicate with Data system\"\n" +
                            "    }\n" +
                            "  ]\n" +
                            "}")
            );
        }
    }
    


    At this stage, several things appeared by themselves:

    • A service that will be injected into the controller and which will be delegated the processing of an incoming message for a new quantity of goods.
    • The method of this service, and accordingly its signature, which will conduct this processing.
    • The realization that the method should throw custom execution when the system is unavailable.
    • This custom execution itself.

    Why by themselves? Because, as you remember, we have not written an implementation yet. And all these entities appeared in the process of how we program tests. So that the compiler does not swear, in real code, we will have to create everything described above. Fortunately, almost any IDE will help us generate the necessary entities. Thus, we kind of write a test - and the application is filled with classes and methods.

    In total, the tests for the controller are as follows:

    Tests
    
    @RunWith(SpringRunner.class)
    @WebMvcTest
    @AutoConfigureMockMvc
    public class ControllerTest {
        @InjectMocks
        private Controller controller;
        @MockBean
        private UpdateProcessorService updateProcessorService;
        @Autowired
        private MockMvc mvc;
        @Test
        public void returnBadRequestOnDisableWithInvalidProductId() throws Exception {
            mvc.perform(
                    post("/disableProduct?productId=-443")
            ).andDo(
                print()
            ).andExpect(
                    status().isBadRequest()
            ).andExpect(
                    content().json(getInvalidProductIdJsonContent())
            );
        }
        @Test
        public void returnBadRequestOnNotifyWithInvalidProductId() throws Exception {
            performUpdate(
                    //language=JSON
                    "{\n" +
                            "  \"productId\": -1,\n" +
                            "  \"color\": \"red\",\n" +
                            "  \"productQuantity\": 0\n" +
                            "}"
            ).andDo(
                    print()
            ).andExpect(
                    status().isBadRequest()
            ).andExpect(
                    content().json(getInvalidProductIdJsonContent())
            );
        }
        @Test
        public void returnBadRequestOnNotifyWithNegativeProductQuantity() throws Exception {
            performUpdate(
                    //language=JSON
                    "{\n" +
                            "  \"productId\": 1,\n" +
                            "  \"color\": \"red\",\n" +
                            "  \"productQuantity\": -10\n" +
                            "}"
            ).andDo(
                    print()
            ).andExpect(
                    status().isBadRequest()
            ).andExpect(
                    content().json("{\n" +
                            "  \"errors\": [\n" +
                            "    {\n" +
                            "      \"message\": \"productQuantity is invalid\"\n" +
                            "    }\n" +
                            "  ]\n" +
                            "}")
            );
        }
        @Test
        public void returnServerErrorOnDostavchenkoCommunicationError() throws Exception {
            doThrow(new DostavchenkoException()).when(updateProcessorService).processUpdate(any(Update.class));
            performUpdate(
                    //language=JSON
                    "{\n" +
                            "  \"productId\": 1,\n" +
                            "  \"color\": \"red\",\n" +
                            "  \"productQuantity\": 10\n" +
                            "}"
            ).andDo(
                    print()
            ).andExpect(
                    status().isInternalServerError()
            ).andExpect(
                    content().json("{\n" +
                            "  \"errors\": [\n" +
                            "    {\n" +
                            "      \"message\": \"DostavchenKO communication exception\"\n" +
                            "    }\n" +
                            "  ]\n" +
                            "}")
            );
        }
        @Test
        public void returnServerErrorOnDataCommunicationError() throws Exception {
            doThrow(new DataCommunicationException()).when(updateProcessorService).processUpdate(any(Update.class));
            performUpdate(
                    //language=JSON
                    "{\n" +
                            "  \"productId\": 1,\n" +
                            "  \"color\": \"red\",\n" +
                            "  \"productQuantity\": 10\n" +
                            "}"
            ).andDo(
                    print()
            ).andExpect(
                    status().isInternalServerError()
            ).andExpect(
                    content().json("{\n" +
                            "  \"errors\": [\n" +
                            "    {\n" +
                            "      \"message\": \"Can't communicate with Data system\"\n" +
                            "    }\n" +
                            "  ]\n" +
                            "}")
            );
        }
        @Test
        public void return200OnSuccess() throws Exception {
            performUpdate(
                    //language=JSON
                    "{\n" +
                            "  \"productId\": 1,\n" +
                            "  \"color\": \"red\",\n" +
                            "  \"productQuantity\": 10\n" +
                            "}"
            ).andDo(
                    print()
            ).andExpect(
                    status().isOk()
            );
        }
        @Test
        public void returnServerErrorOnUnexpectedException() throws Exception {
            doThrow(new RuntimeException()).when(updateProcessorService).processUpdate(any(Update.class));
            performUpdate(
                    //language=JSON
                    "{\n" +
                            "  \"productId\": 1,\n" +
                            "  \"color\": \"red\",\n" +
                            "  \"productQuantity\": 10\n" +
                            "}"
            ).andDo(
                    print()
            ).andExpect(
                    status().isInternalServerError()
            ).andExpect(
                    content().json("{\n" +
                            "  \"errors\": [\n" +
                            "    {\n" +
                            "      \"message\": \"Internal Server Error\"\n" +
                            "    }\n" +
                            "  ]\n" +
                            "}")
            );
        }
        @Test
        public void returnTwoErrorMessagesOnInvalidProductIdAndNegativeQuantity() throws Exception {
            performUpdate(
                    //language=JSON
                    "{\n" +
                            "  \"productId\": -1,\n" +
                            "  \"color\": \"red\",\n" +
                            "  \"productQuantity\": -10\n" +
                            "}"
            ).andDo(
                    print()
            ).andExpect(
                    status().isBadRequest()
            ).andExpect(
                    content().json("{\n" +
                            "  \"errors\": [\n" +
                            "    { \"message\": \"productQuantity is invalid\" },\n" +
                            "    { \"message\": \"productId is invalid\" }\n" +
                            "  ]\n" +
                            "}")
            );
        }
        private ResultActions performUpdate(String jsonContent) throws Exception {
            return mvc.perform(
                    post("/product-quantity-update")
                            .contentType(MediaType.APPLICATION_JSON_UTF8_VALUE)
                            .content(jsonContent)
            );
        }
        private String getInvalidProductIdJsonContent() {
            return
                    //language=JSON
                    "{\n" +
                            "  \"errors\": [\n" +
                            "    {\n" +
                            "      \"message\": \"productId is invalid\"\n" +
                            "    }\n" +
                            "  ]\n" +
                            "}";
        }
    }

    Now we can write the implementation and ensure that all tests pass successfully:
    Implementation
    
    @RestController
    @AllArgsConstructor
    @Validated
    @Slf4j
    public class Controller {
        private final UpdateProcessorService updateProcessorService;
        @PostMapping("/product-quantity-update")
        public void updateQuantity(@RequestBody @Valid Update update) {
            updateProcessorService.processUpdate(update);
        }
        @PostMapping("/disableProduct")
        public void disableProduct(@RequestParam("productId") @Min(0) Long productId) {
            updateProcessorService.disableProduct(Long.valueOf(productId));
        }
    }
    


    Exception handler
    
    @ControllerAdvice
    @Slf4j
    public class ApplicationExceptionHandler {
        @ExceptionHandler(ConstraintViolationException.class)
        @ResponseBody
        @ResponseStatus(HttpStatus.BAD_REQUEST)
        public ErrorResponse onConstraintViolationException(ConstraintViolationException exception) {
            log.info("Constraint Violation", exception);
            return new ErrorResponse(exception.getConstraintViolations().stream()
                    .map(constraintViolation -> new ErrorResponse.Message(
                            ((PathImpl) constraintViolation.getPropertyPath()).getLeafNode().toString() +
                                    " is invalid"))
                    .collect(Collectors.toList()));
        }
        @ExceptionHandler(value = MethodArgumentNotValidException.class)
        @ResponseBody
        @ResponseStatus(value = HttpStatus.BAD_REQUEST)
        public ErrorResponse onMethodArgumentNotValidException(MethodArgumentNotValidException exception) {
            log.info(exception.getMessage());
            List fieldErrors = exception.getBindingResult().getFieldErrors().stream()
                    .map(fieldError -> new ErrorResponse.Message(fieldError.getField() + " is invalid"))
                    .collect(Collectors.toList());
            return new ErrorResponse(fieldErrors);
        }
        @ExceptionHandler(DostavchenkoException.class)
        @ResponseBody
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        public ErrorResponse onDostavchenkoCommunicationException(DostavchenkoException exception) {
            log.error("DostavchenKO communication exception", exception);
            return new ErrorResponse(Collections.singletonList(
                    new ErrorResponse.Message("DostavchenKO communication exception")));
        }
        @ExceptionHandler(DataCommunicationException.class)
        @ResponseBody
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        public ErrorResponse onDataCommunicationException(DataCommunicationException exception) {
            log.error("DostavchenKO communication exception", exception);
            return new ErrorResponse(Collections.singletonList(
                    new ErrorResponse.Message("Can't communicate with Data system")));
        }
        @ExceptionHandler(Exception.class)
        @ResponseBody
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        public ErrorResponse onException(Exception exception) {
            log.error("Error while processing", exception);
            return new ErrorResponse(Collections.singletonList(
                    new ErrorResponse.Message(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase())));
        }
    }


    What just happened?


    In TDD, you don’t have to keep all the code in your head.

    Let's again: do not keep the entire architecture in RAM. Just look at one layer. He is simple.

    In the usual process, the brain is not enough, because there are a lot of implementations. If you are a superhero who can take into account all the nuances of a large project in your head, then TDD is not necessary. I can not do that. The larger the project, the more I am mistaken.

    After realizing that you need to understand only what the next layer needs, enlightenment in life comes. The fact is that this approach allows you not to do unnecessary things. Here you are talking with a girl. She says something about a problem at work. And you think how to solve it, you rack your brains. And she does not need to solve it, she just needs to tell. And that’s it. She just wanted to share something. Learning about this at the very first stage of listen () is priceless. For everything else ... well, you know.


    Service


    Next we implement the service.

    What do we want from the service?

    We want him to deal with business logic, i.e.:

    1. He knew how to disconnect goods, and also notified about :
    2. Availability, if the item is not disconnected, is in stock, the color of the item is yellow, and DostavchenKO is ready to make delivery.
    3. Inaccessibility, if the goods are not available regardless of anything.
    4. Unavailability if the product is blue.
    5. Inaccessibility if DostavchenKO refuses to carry it.
    6. Inaccessibility if the goods are disconnected manually.
    7. Next, we want the service to throw execution if any of the systems is unavailable.
    8. And also, in order not to spam DATA, you need to organize lazy sending messages, namely:
    9. If we used to send available products for goods and have now calculated what is available, then we are not sending anything.
    10. And if previously unavailable, but now available - we send.
    11. And still it is necessary to write down it somewhere ...

    STOP!


    Don't you think that our service is starting to do too much?

    Judging by our Wishlist, he knows how to turn off goods, and considers accessibility, and makes sure not to send previously sent messages. This is not high cohesion. It is necessary to move heterogeneous functionalities into different classes, and therefore there should be already three services: one will deal with the disconnection of goods, the other will calculate the possibility of delivery and pass it on to the service, which will decide whether to send it or not. By the way, in this way, the business logic service will not know anything about the DATA system, which is also a definite plus.

    In my experience, quite often, having gone headlong into implementation, it is easy to lose sight of architectural moments. If we wrote the service right away, without thinking about what it should do, and, more importantly, than it should NOT, the likelihood of overlapping areas of responsibility would increase. I would like to add on my own behalf that this particular example that happened to me in real practice and the qualitative difference between the results of TDD and sequential programming approaches inspired me to write this post.

    Business logic


    Thinking about the business logic service for the same reasons as high cohesion, we understand that we need another level of abstraction between it and the real DostavchenKO. And, since we design the service first , we can demand from the DostavchenKO client such an internal contract that we want. In the process of writing a test for business logic, we will understand what we want from the client with the following signature:

    
    public boolean isAvailableForTransportation(Long productId) {...}
    

    At the service level, it doesn’t matter to us how the real DostavchenKO answers: in the future, the client’s task will somehow get this information out of him. Once it may be simple, but sometime it will be necessary to make several requests: at the moment we are abstracted from this.

    We want a similar signature from a service that will deal with disconnected goods:

    
    public boolean isProductEnabled(Long productId) {...}
    

    So, the questions “What do I want from the business logic service?” Recorded in the tests look as follows:

    Service Tests
    
    @RunWith(MockitoJUnitRunner.class)
    public class UpdateProcessorServiceTest {
        @InjectMocks
        private UpdateProcessorService updateProcessorService;
        @Mock
        private ManualExclusionService manualExclusionService;
        @Mock
        private DostavchenkoClient dostavchenkoClient;
        @Mock
        private AvailabilityNotifier availabilityNotifier;
        @Test
        public void notifyAvailableIfYellowProductIsEnabledAndReadyForTransportation() {
            final Update testProduct = new Update(1L, 10L, "Yellow");
            when(dostavchenkoClient.isAvailableForTransportation(testProduct.getProductId())).thenReturn(true);
            when(manualExclusionService.isProductEnabled(testProduct.getProductId())).thenReturn(true);
            updateProcessorService.processUpdate(testProduct);
            verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), true)));
        }
        @Test
        public void notifyNotAvailableIfProductIsAbsent() {
            final Update testProduct = new Update(1L, 0L, "Yellow");
            updateProcessorService.processUpdate(testProduct);
            verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), false)));
            verifyNoMoreInteractions(manualExclusionService);
            verifyNoMoreInteractions(dostavchenkoClient);
        }
        @Test
        public void notifyNotAvailableIfProductIsBlue() {
            final Update testProduct = new Update(1L, 10L, "Blue");
            updateProcessorService.processUpdate(testProduct);
            verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), false)));
            verifyNoMoreInteractions(manualExclusionService);
            verifyNoMoreInteractions(dostavchenkoClient);
        }
        @Test
        public void notifyNotAvailableIfProductIsDisabled() {
            final Update testProduct = new Update(1L, 10L, "Yellow");
            when(manualExclusionService.isProductEnabled(testProduct.getProductId())).thenReturn(false);
            updateProcessorService.processUpdate(testProduct);
            verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), false)));
            verifyNoMoreInteractions(dostavchenkoClient);
        }
        @Test
        public void notifyNotAvailableIfProductIsNotReadyForTransportation() {
            final Update testProduct = new Update(1L, 10L, "Yellow");
            when(dostavchenkoClient.isAvailableForTransportation(testProduct.getProductId())).thenReturn(false);
            when(manualExclusionService.isProductEnabled(testProduct.getProductId())).thenReturn(true);
            updateProcessorService.processUpdate(testProduct);
            verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), false)));
        }
        @Test(expected = DostavchenkoException.class)
        public void throwCustomExceptionIfDostavchenkoCommunicationFailed() {
            final Update testProduct = new Update(1L, 10L, "Yellow");
            when(dostavchenkoClient.isAvailableForTransportation(testProduct.getProductId()))
                    .thenThrow(new RestClientException("Something's wrong"));
            when(manualExclusionService.isProductEnabled(testProduct.getProductId())).thenReturn(true);
            updateProcessorService.processUpdate(testProduct);
        }
    }
    


    At this stage, they were born by themselves:

    • DostavchenKO client with service-friendly singatura
    • A service in which it will be necessary to implement the logic of lazy sending, to whom the designed service will transmit the results of its work
    • Service of disconnected goods and its signature

    Implementation:

    Implementation
    
    @RequiredArgsConstructor
    @Service
    @Slf4j
    public class UpdateProcessorService {
        private final AvailabilityNotifier availabilityNotifier;
        private final DostavchenkoClient dostavchenkoClient;
        private final ManualExclusionService manualExclusionService;
        public void processUpdate(Update update) {
            if (update.getProductQuantity() <= 0) {
                availabilityNotifier.notify(getNotAvailableProduct(update.getProductId()));
                return;
            }
            if ("Blue".equals(update.getColor())) {
                availabilityNotifier.notify(getNotAvailableProduct(update.getProductId()));
                return;
            }
            if (!manualExclusionService.isProductEnabled(update.getProductId())) {
                availabilityNotifier.notify(getNotAvailableProduct(update.getProductId()));
                return;
            }
            try {
                final boolean availableForTransportation = dostavchenkoClient.isAvailableForTransportation(update.getProductId());
                availabilityNotifier.notify(new ProductAvailability(update.getProductId(), availableForTransportation));
            } catch (Exception exception) {
                log.warn("Problems communicating with DostavchenKO", exception);
                throw new DostavchenkoException();
            }
        }
        private ProductAvailability getNotAvailableProduct(Long productId) {
            return new ProductAvailability(productId, false);
        }
    }
    


    Disabling Products


    The time has come for one of the inevitable TDD phases - refactoring. If you remember, after the implementation of the controller, the service contract looked like this:

    public void disableProduct(long productId)

    And now we decided to move the disconnection logic into a separate service.

    From this service at this stage we want the following:

    • The ability to turn off goods.
    • We want him to return that the goods are disconnected if he was disconnected earlier.
    • We want him to return that the product is available if there was no disconnection before.

    Looking at Wishlist, which are a direct consequence of the contract between the business logic service and the projected one, I would like to note the following:

    1. Во-первых, сразу видно, что у приложения могут быть проблемы, если кто-то захочет отключённый товар включить обратно, т. к. на данный момент этот сервис этого делать попросту не умеет. А это значит, что, возможно, стоит обсудить этот вопрос с аналитиком, который ставил задачу на разработку. Я понимаю, что в данном случае этот вопрос должен был возникнуть сразу после первого прочтения ТЗ, но мы проектируем довольно простую систему, в более масштабных проектах это могло бы быть не так очевидно. Тем более что мы не знали, что у нас будет сущность, отвечающая только за функционал отключения товаров: напомню, что у нас она родилась только в процессе разработки.
    2. Во-вторых, сигнатура методов сервиса содержит только идентификатор товара. И сохранять в коллекцию отключённых товаров мы будем только идентификатор — как минимум потому, что у нас на вход просто больше ничего нет. Забегая вперёд, могу сказать, что, когда мы будем проектировать сервис ленивой отправки, нам там тоже придётся сохранять то, что нам передают за неимением лучшего, т. е. ProductAvailability. Как видно из вышесказанного, мы нигде не сохраняем сам товар. Т. е., вместо того, чтобы иметь god object, товар с флагами отключён, доступен для доставки и ещё бог весть какими, как у нас могло бы получиться, если бы не использовали TDD, у нас в каждом сервисе есть своя коллекция своих сущностей, которая выполняет только одну работу. И это получилось, что называется, «само» — мы просто задавали один вопрос: «Чего я хочу от ...» И это второй пример того, как, используя TDD, мы получаем более правильную архитектуру.

    Tests and implementation are very simple:

    Tests
    @SpringBootTest
    @RunWith(SpringRunner.class)
    public class ManualExclusionServiceTest {
        @Autowired
        private ManualExclusionService service;
        @Autowired
        private ManualExclusionRepository manualExclusionRepository;
        @Before
        public void clearDb() {
            manualExclusionRepository.deleteAll();
        }
        @Test
        public void disableItem() {
            Long productId = 100L;
            service.disableProduct(productId);
            assertThat(service.isProductEnabled(productId), is(false));
        }
        @Test
        public void returnEnabledIfProductWasNotDisabled() {
            assertThat(service.isProductEnabled(100L), is(true));
            assertThat(service.isProductEnabled(200L), is(true));
        }
    }


    Implementation
    
    @Service
    @AllArgsConstructor
    public class ManualExclusionService {
        private final ManualExclusionRepository manualExclusionRepository;
        public boolean isProductEnabled(Long productId) {
            return !manualExclusionRepository.exists(productId);
        }
        public void disableProduct(long productId) {
            manualExclusionRepository.save(new ManualExclusion(productId));
        }
    }
    


    Lazy Submission Service


    So, we got to the last service, which will ensure that the DATA system is not spammed with the same messages.

    Let me remind you that the result of the work of the business logic service, that is, the ProductAvailability object, in which there are only two fields: productId and isAvailable, is already transferred to it.

    According to the good old tradition, we begin to think about what we want from this service:

    • Sending a notification for the first time in any case.
    • Sending a notification if the availability of the product has changed.
    • We do not send anything if not.
    • If sending to a third-party system ended with an exception, then the notification that caused the exception should not be included in the database of sent notifications.
    • Also, when executing from the DATA side, the service needs to throw its DataCommunicationException.

    Everything is relatively simple here, but I would like to note one point:

    We need information about what we sent earlier, which means we will have a repository where we will save past calculations on the availability of goods.

    The ProductAvailability object is not suitable for saving, because at least there is no identifier, which means it is logical to create another one. The main thing here is not to freak out and not to add this identifier together with @Document (we will use MongoDb as the base) and indexes in ProductAvailability itself.

    You need to understand that a ProductAvailability object with all the few fields was created at the stage of designing classes that are higher in the call hierarchy than the one we are designing now. These classes do not need to know anything about database-specific fields, since this information was not required when designing.

    But this is all talk.

    Interestingly, due to the fact that we have already written a bunch of tests with the ProductAvailability that we are transferring to the service now, adding new fields to it will mean that these tests will also need to be refactored, which may require some effort. This means that there will be much fewer people who want to make a god object out of ProductAvailability than if they wrote the implementation right away: there, on the contrary, adding a field to an existing object would be easier than creating another class.

    Tests
    @RunWith(SpringRunner.class)
    @SpringBootTest
    public class LazyAvailabilityNotifierTest {
        @Autowired
        private LazyAvailabilityNotifier lazyAvailabilityNotifier;
        @MockBean
        @Qualifier("dataClient")
        private AvailabilityNotifier availabilityNotifier;
        @Autowired
        private AvailabilityRepository availabilityRepository;
        @Before
        public void clearDb() {
            availabilityRepository.deleteAll();
        }
        @Test
        public void notifyIfFirstTime() {
            sendNotificationAndVerifyDataBase(new ProductAvailability(1L, false));
        }
        @Test
        public void notifyIfAvailabilityChanged() {
            final ProductAvailability oldProductAvailability = new ProductAvailability(1L, false);
            sendNotificationAndVerifyDataBase(oldProductAvailability);
            final ProductAvailability newProductAvailability = new ProductAvailability(1L, true);
            sendNotificationAndVerifyDataBase(newProductAvailability);
        }
        @Test
        public void doNotNotifyIfAvailabilityDoesNotChanged() {
            final ProductAvailability productAvailability = new ProductAvailability(1L, false);
            sendNotificationAndVerifyDataBase(productAvailability);
            sendNotificationAndVerifyDataBase(productAvailability);
            sendNotificationAndVerifyDataBase(productAvailability);
            sendNotificationAndVerifyDataBase(productAvailability);
            verify(availabilityNotifier, only()).notify(eq(productAvailability));
        }
        @Test
        public void doNotSaveIfSentWithException() {
            doThrow(new RuntimeException()).when(availabilityNotifier).notify(anyObject());
            boolean exceptionThrown = false;
            try {
                availabilityNotifier.notify(new ProductAvailability(1L, false));
            } catch (RuntimeException exception) {
                exceptionThrown = true;
            }
            assertTrue("Exception was not thrown", exceptionThrown);
            assertThat(availabilityRepository.findAll(), hasSize(0));
        }
        @Test(expected = DataCommunicationException.class)
        public void wrapDataException() {
            doThrow(new RestClientException("Something wrong")).when(availabilityNotifier).notify(anyObject());
            lazyAvailabilityNotifier.notify(new ProductAvailability(1L, false));
        }
        private void sendNotificationAndVerifyDataBase(ProductAvailability productAvailability) {
            lazyAvailabilityNotifier.notify(productAvailability);
            verify(availabilityNotifier).notify(eq(productAvailability));
            assertThat(availabilityRepository.findAll(), hasSize(1));
            assertThat(availabilityRepository.findAll().get(0),
                    hasProperty("productId", is(productAvailability.getProductId())));
            assertThat(availabilityRepository.findAll().get(0),
                    hasProperty("availability", is(productAvailability.isAvailable())));
        }
    }


    Implementation
    @Component
    @AllArgsConstructor
    @Slf4j
    public class LazyAvailabilityNotifier implements AvailabilityNotifier {
        private final AvailabilityRepository availabilityRepository;
        private final AvailabilityNotifier availabilityNotifier;
        @Override
        public void notify(ProductAvailability productAvailability) {
            final AvailabilityPersistenceObject persistedProductAvailability = availabilityRepository
                    .findByProductId(productAvailability.getProductId());
            if (persistedProductAvailability == null) {
                notifyWith(productAvailability);
                availabilityRepository.save(createObjectFromProductAvailability(productAvailability));
            } else if (persistedProductAvailability.isAvailability() != productAvailability.isAvailable()) {
                notifyWith(productAvailability);
                persistedProductAvailability.setAvailability(productAvailability.isAvailable());
                availabilityRepository.save(persistedProductAvailability);
            }
        }
        private void notifyWith(ProductAvailability productAvailability) {
            try {
                availabilityNotifier.notify(productAvailability);
            } catch (RestClientException exception) {
                log.error("Couldn't notify", exception);
                throw new DataCommunicationException();
            }
        }
        private AvailabilityPersistenceObject createObjectFromProductAvailability(ProductAvailability productAvailability) {
            return new AvailabilityPersistenceObject(productAvailability.getProductId(), productAvailability.isAvailable());
        }
    }


    Conclusion



    A similar application had to be written in practice. And it turned out that at first it was written without TDD, then the business said that it was not needed, and after six months the requirements changed, and it was decided to rewrite it again from scratch (the benefit is microservice architecture, and it was not so scary to throw something out) .

    By writing the same application using different techniques, I can appreciate their differences. In my practice, I saw how TDD helps to build the architecture, it seems to me, more correctly.

    I can assume that the reason for this is not the creation of tests before implementation, but that, having written the tests at the beginning, we first think about what the created class will do. Also, while there is no implementation, we can really “order” in the called objects the exact contract that the object that calls them needs, without the temptation to quickly add something somewhere and get an entity that will deal with many tasks at the same time.

    In addition, one of the main advantages of TDD for myself, I can highlight that I really became more confident in the product that I produce. This may be due to the fact that the average code written on TDD is probably better covered by tests, but it was after I started writing on TDD that my number of edits to the code was reduced after I gave its testing almost to zero.

    And in general, there was a feeling that as a developer I became better.

    Application code can be found here . For those who want to understand how it was created in steps, I recommend paying attention to the history of commits, after analyzing which, I hope, the process of creating a typical TDD application will be more understandable.

    Here is a very usefula video that I highly recommend watching to anyone who wants to plunge into the world of TDD.

    The application code reuses a formatted string like json. This is necessary in order to check how the application will parse json on POJO objects. If you use IDEA, then quickly and without pain the necessary formatting can be achieved using JSON language injections.

    What are the disadvantages of the approach?


    It is a long time to develop. Programming in the standard paradigm, my colleague could afford to put the service to the testers for testing without tests at all, adding them along the way. It was very fast. On TDD this will not work. If you have tight deadlines, then your managers will be unhappy. Here the trade off between doing well right away, but for a long time and not very good, but fast. I choose the first for myself, because the second as a result is longer. And with big nerves.

    According to my feelings, TDD is not suitable if you need to do a lot of refactoring: because unlike an application created from scratch, it is not obvious which way to approach and what to start doing first. It may turn out that you are working on a class test, which you will delete as a result.

    TDD is not a silver bullet. This is a story about clear, readable code that can create performance problems. For example, you created N classes, which, as in Fowler, each do their own thing. And then it turns out that in order to do their job, they need everyone to go to the base. And you will have N queries in the database. Instead of making, for example, 1 god object and going 1 time. If you fight for milliseconds, then using TDD you need to take this into account: the readable code is not always the fastest.

    And finally, it’s quite difficult to switch to this methodology - you need to teach yourself to think differently. Most of the pain is in the first stage. The first integration test I wrote 1.5 days.

    Well, the last. If you use TDD and your code is still not very, then the matter may not be in the methodology. But it helped me.


    Also popular now: