Efficient enterprise testing — unit & use case tests (2/6)
In the first part of the series we saw some generally applicable principles and constraints that effective tests should fulfill. In this part, we will have a closer look at code-level unit tests and component or use case tests.
Unit tests
Unit tests verify the behavior of a single unit, usually a class, while all concerns that are external to the unit are ignored or simulated. Unit tests should test the business logic of the individual units, without verifying further integration or configuration thereof.
From my experience, most enterprise developers have a pretty good understanding, how unit tests are constructed. You can have a look at this example in my coffee-testing project to get an idea. Most projects are using JUnit in combination with Mockito to mock dependencies, and ideally AssertJ to effectively define readable assertions. What I always pick upon is that we can execute the unit tests without special extensions or runners, i.e. running them with plain JUnit only. The reason for that is simple: execution time; we should be able to run hundreds of tests within a few milliseconds.
Unit tests generally perform very fast and they easily support crafting complex test suites or special development workflows, since they’re easy to execute and don’t impose constraints on the test suite life cycle.
However, one shortcoming of having many unit tests that mock the dependencies of the tested class is that they will be tightly coupled to the implementation, especially the class structures and methods, which make it hard to refactor our code. In other words, for every refactoring action in the production code, the test code needs to change as well. In the worst case, this leads developers to do less refactorings, simply because they become too cumbersome, which quickly results in declining quality of the project’s code. Ideally, developers should be able to refactor code and move things around, as long as they don’t alter the behavior of the application, as it is perceived from it’s users. Unit tests don’t always make it easy to refactor production code.
From experience in projects, unit tests are very effective in testing code that has a high density of concise logic or functionality, like the implementation of a specific algorithm, and at the same time doesn’t interact too much with other components. The less dense or complex the code in a specific class, the lower the cyclomatic complexity, or the higher the interaction with other components, the less effective unit tests are in testing that class. Especially in microservices with a comparable small amount of specialized business logic and a high amount of integration to external systems, there’s arguably less need for having many unit tests. The individual units of these systems usually contain little specialized logic, apart from a few exceptions. This needs to be taken into account when choosing the trade-off where to spend our time and effort on.
Use case tests
In order to tackle the issue of tightly coupling the tests to the implementation, we can use a slightly different approach to widen the scope of tests. In my book, I described the concepts of component tests, for lack of a better term, which we might also call use case tests.
Use case tests are code-level integration tests that don’t make use of embedded containers nor reflection scanning, just yet, for reasons of test startup time. They verify the business logic behavior of coherent components that usually participate in a single use case, from the business method of the boundary down to all involved components. Integration to external systems such as databases are mocked away.
Building up such scenarios without using a more advanced technology that automatically wires up the components sounds like a lot of effort. However, we define reusable test components, or test doubles, that extend the components with mocking, wiring, and test configuration, in order to minimize the overall effort of refactoring changes. The goal is to craft single responsibilities that limit the impact of change to a single or few classes in the test scope. Doing this in a reusable way limits the overall required effort and pays off once the project grows larger, since we only pay the plumbing costs once per component, which amortizes quickly.
To get a better idea, imagine we’re testing the use case of ordering a coffee, which includes two classes, CoffeeShop
, and OrderProcessor
.
The test double classes CoffeeShopTestDouble
and OrderProcessorTestDouble
, or *TD
, reside in the test scope of the project while they’re extending the CoffeeShop
and OrderProcessor
components which reside in the main scope. The test doubles may setup the required mocking and wiring logic and potentially extend the public interface of the class with use case-related mocking or verification methods.
The following shows the test double class for the CoffeeShop
component:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 | 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) { // setup entity manager mock behavior } } |
The test double class can access the fields and constructors of the CoffeeShop
base class to setup the dependencies. It uses other components in their test double form, for example OrderProcessorTestDouble
, to be able to invoke additional mocking or verification methods that are part of the use case.
The test double classes are reusable components that are written once per project scope and are used in multiple use case tests:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | 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); } } |
The use case test verifies the processing of an individual business use case which is invoked on the entry point, here CoffeeShop
. These tests become brief and very readable, since the wiring and mocking happens in the individual test doubles, and they can furthermore utilize use case-specific verification methods, such as verifyProcessOrders()
.
As you can see, the test double extends the production scope class for setting up the mocks and for methods to verify the behavior. While this seems like some effort to setup, the costs amortizes quickly if we have multiple use cases that can reuse the components within the whole project. The more our project grows, the bigger the benefits of this approach, especially if we look at the test execution time. All our test cases still run using JUnit, which executes hundreds of them in no time.
This is the main benefit of this approach: that use case tests will run just as quickly as plain unit tests, yet facilitate it to refactor production code, since changes have to be made in a single or few components only. Additionally, enhancing the test doubles with expressive setup and verification methods that are specific to our domain makes our test code more readable, facilitates the usage, and avoids boilerplate code in the test cases.
Code-level tests that don’t include any advanced test context runner can be executed very quickly and don’t add too much time to the overall build, even in very complex projects. The next part of the series will show code-level as well as system-level integration tests.
Published on Java Code Geeks with permission by Sebastian Daschner, partner at our JCG program. See the original article here: Efficient enterprise testing — unit & use case tests (2/6) Opinions expressed by Java Code Geeks contributors are their own. |