Reflections on Effective Enterprise Testing

Hello, Habr!

Recently, we have returned to a thorough study of the testing topic, and in the foreseeable plans we even have an excellent book on Unit Testing. At the same time, we believe that context is important in this topic as nowhere else, therefore today we offer a translation of two publications (combined into one) published on the blog of a prominent Java EE specialist Sebastian Dashner - namely, 1/6 and 2/6 from the series “ Thoughts on efficient enterprise testing. "

Enterprise testing is a topic that has not yet been examined in as much detail as we would like. It takes a lot of time and effort to write and especially to support tests, however, trying to save time by abandoning the tests is not an option. What volumes of tasks, approaches and testing technologies are worth exploring in order to increase the effectiveness of testing?

Introduction


Regardless of the different types of tests and their scope, the point of preparing a set of tests is to make sure on this material that in production our application will work exactly as expected. Such motivation should be the main one when checking whether the system fulfills the task, if we consider this system from the user's point of view.

Since attention span and context switching are things to be reckoned with, we must ensure that our tests are run and tested in a short time frame and that test results are predictable. When writing code, fast verification of the code (feasible within one second) is crucial - this ensures high productivity and focus during work.
On the other hand, we must ensure test support. Software changes very often, and with a substantial coverage of the code with functional tests, each functional change in the production code will require a change at the test level. Ideally, the test code should change only when the functionality, i.e., business logic, changes, and not when cleaning up unnecessary code and refactoring. In general, test scenarios should include the possibility of non-functional, structural changes.

When we consider the different areas of application of tests, the question arises: which of these areas are worth the time and effort? For example, in microservice applications, as well as in any system that provides significant work on the distribution and integration of code, integration tests are especially important, helping to grope the boundaries of the system. Therefore, we need an effective way to test the entire application as a whole during local development, while maintaining the environment and structure of this application in the form that is as close to production as possible.

Principles and Limitations


Regardless of the solutions that will be selected, let's define the following principles and limitations for our test suite:

  • , . , . , , .
  • , , . , , , . , , .
  • - . , , .
  • , , . : « HTTP- gRPC, JSON - enterprise-, ..?”.
  • , , -. API, DSL .
  • « », , , , , , “dev” debug () , dev Quarkus', Telepresence, watch-and-deploy (« ») .
  • . , , , , . .
  • , , -, , , . , , , .


The unit test checks the behavior of a single module, usually a class, while all external factors that are not related to the structure of the module are ignored or simulated. Unit tests should verify the business logic of individual modules, without checking their further integration or configuration.

In my experience, most enterprise developers have a pretty good idea of ​​how unit tests are compiled. To make an impression of this, you can see this example in my coffee-testing project.. In most projects, JUnit is used in combination with Mockito to simulate dependencies, and ideally with AssertJ to efficiently define readable statements. I always emphasize that unit tests can be performed without special extensions or starters, that is, to do this with the usual JUnit. The explanation is simple: it’s all about runtime, because we need the ability to run hundreds of tests in a matter of milliseconds.

As a rule, unit tests run very quickly, and it is easy to assemble complex test suites or special workflows from them, since they are simple to execute and do not impose any restrictions on the life cycle of the test.

However, when you have a lot of unit tests simulating the dependencies of the tested class, there is one drawback: they are closely connected with the implementation (this especially applies to classes and methods), which is why our code is difficult to refactor. In other words, for each refactoring act in the production code, it also requires changes to the test code. In the worst case, developers even begin to partially refuse refactoring, simply because it is too burdensome, and the quality of the code in the project is rapidly declining. Ideally, the developer should be able to refactor and rearrange the elements, provided that because of this there are no changes in the application that are noticeable to the user. Unit tests by no means always simplify refactoring production code.

But experience suggests that unit tests are very effective at checking code that is densely filled with concise logic or describes the implementation of a specific function, for example, an algorithm, and, at the same time, does not interact very actively with other components. The less complex or dense the code in a particular class, the less its cyclomatic complexity, or the more actively it interacts with other components, the less effective unit tests will be when testing this class. Especially in cases with microservices, in which relatively little business logic is contained, but extensive integration with external systems is provided, there is probably little need to use unit tests in many. In such systems, individual modules (with rare exceptions) usually contain little specialized logic. This should be considered when decidingwhat is more appropriate to spend time and effort.

Testing application situations


To cope with the problem of tightly linking tests with the implementation, you can try a slightly different approach to expand the scope of the tests. In my book, I wrote about component tests, because I could not find a better term; but, in essence, in this case we are talking about testing applied situations.

Application situation tests are integration tests that operate at the code level, which do not use built-in containers - they are abandoned for the sake of speeding up the launch. They test the business logic of well-coordinated components, which are usually used within a specific practical case, from the boundary method - and then down to all the components associated with them. Integration with external systems, for example, with databases, is imitated using mocks.

Building such scenarios without the use of more advanced technologies that would automatically connect components seems like a big piece of work. However, we define reusable test components, they are also test counterparts that extend components by simulating, connecting, and also adding test configurations; all this is done to minimize the total amount of effort required for refactoring. The goal is to create the only responsibilities limiting the degree of influence of changes to a single class (or several classes) in the field of testing. Carrying out such work with a view to reuse, we reduce the total amount of necessary work, and such a strategy is justified when the project grows, but each component requires only minor repairs, and this work is quickly amortized.

To better imagine all this, suppose that we are testing a class that describes the order of coffee. This class includes two other classes: CoffeeShopand OrderProcessor.



Classes of test doubles, CoffeeShopTestDoubleand OrderProcessorTestDoublethey *TDare located in the test area of ​​the project, where they inherit the components CoffeeShopand OrderProcessorlocated in the main area of ​​the program. Test counterparts can set the necessary simulation and connection logic and potentially expand the class’s public interface using the simulation methods needed in this application or by verification methods.

The following shows the test double class for the component CoffeeShop:

public class CoffeeShopTestDouble extends CoffeeShop {

    public CoffeeShopTestDouble(OrderProcessorTestDouble orderProcessorTestDouble) {
        entityManager = mock(EntityManager.class);
        orderProcessor = orderProcessorTestDouble;
    }

    public void verifyCreateOrder(Order order) {
        verify(entityManager).merge(order);
    }

    public void verifyProcessUnfinishedOrders() {
        verify(entityManager).createNamedQuery(Order.FIND_UNFINISHED, Order.class);
    }

    public void answerForUnfinishedOrders(List<Order> orders) {
        //     
    }
}

The test double class can access the fields and constructors of the CoffeeShop base class to establish dependencies. Here, in the form of test twins, variants of other components are also used, in particular, OrderProcessorTestDoublethey are needed to call additional simulation or verification methods, which are part of the practical case.

Classes of test doubles are reusable components, each of which is written once per scope of each project, and then is used in many practical cases:

class CoffeeShopTest {

    private CoffeeShopTestDouble coffeeShop;
    private OrderProcessorTestDouble orderProcessor;

    @BeforeEach
    void setUp() {
        orderProcessor = new OrderProcessorTestDouble();
        coffeeShop = new CoffeeShopTestDouble(orderProcessor);
    }

    @Test
    void testCreateOrder() {
        Order order = new Order();
        coffeeShop.createOrder(order);
        coffeeShop.verifyCreateOrder(order);
    }

    @Test
    void testProcessUnfinishedOrders() {
        List<Order> orders = Arrays.asList(...);
        coffeeShop.answerForUnfinishedOrders(orders);

        coffeeShop.processUnfinishedOrders();

        coffeeShop.verifyProcessUnfinishedOrders();
        orderProcessor.verifyProcessOrders(orders);
    }

}

Component test verifies the specific case of the business logic that is invoked at the entry point, in this case CoffeeShop. Such tests are obtained concise and readable, since all connection and simulation are performed in separate test twins, and later they can use highly specialized screening techniques, such as verifyProcessOrders().

As you can see, the test class expands the scope of the production class, allowing you to install mokee and use methods that verify the behavior. Despite the fact that it seems that setting up such a system takes a lot of effort, these costs are quickly amortized if, within the framework of the entire project, we have many practical cases where components can be reused. The more our project grows, the more useful this approach becomes - especially if you pay attention to the time taken to complete the tests. All our test cases are still run using JUnit, and in the shortest possible time, they are executed in hundreds.

This is the main benefit of this approach: component tests run as fast as regular unit tests, however, they stimulate production code refactoring, since changes need to be made to a single component or to just a few components. In addition, improving test counterparts with expressive tuning and verification methods specific to our subject area, we increase the readability of our code, facilitate its use, and get rid of stereotyped code in test scripts.

All Articles