Though, there was a problem...
Some time ago, while working on one of the tasks in the project, I got to the stage where I had to write a test. I am aware that someone now could say that I should start with tests at the beginning. Why didn't I? Because some parts of the code already existed (and were not tested), and I wanted to be sure that my changes would not mess with what I found. This decision was ultimately aimed at overall improvement of the quality and testability of this part of the application.
The application remained uncooperative and did not want to perform, despite the fact that in my subjective opinion the quality of the found code was not so bad. Of course, I would find a few things that I would like to refactor, but despite the lack of testing, the code seemed testable. And I was not mistaken. After about 40 minutes I had a very decent test suite written, that covered a few existing and new cases. As an engineer, I was satisfied, but still something was bothering me. So I took two steps back and looked at my work again. I was troubled by a few questions. Why was the code so reluctant to be tested, despite its apparent testability? Did I spend enough time on these tests? What's more, I started to wonder if what I tested actually gives me enough feedback in the case of subsequent changes to the system? It was definitely a topic worth exploring a bit more!
Application Architecture On Her Testesty's (Secret) Service
For the sake of simplicity, let's assume that the code architecture is primarily the way a code is organized. Of course, this does not only apply to the folders structure, but above all, what responsibilities are met by its individual parts. In the case with my project, we talk about striving to meet the demands of Clean Architecture, also known as Hexagonal, Ports and Adapters or even as Onion, although the latter name has not really caught on. Within this architecture, we distinguish three layers:
-
01
Infrastructure level - where we communicate with the outside world and use external dependencies (e.g. attached libraries and packages),
-
02
Application level - where the operational part of our application should be happening,
-
03
Domain level - where key decisions should be made, e.g. about changing the state of the application.
An interesting property that we should look for in such architecture is the range of visibility of each of these layers. The domain only sees itself and cannot access other layers. The application level has knowledge of the domain and itself. Infrastructure, in turn, sees everything - both from above and below. This mindset forces the developer to always include dependencies from the infrastructure layer to the application layer. We can say that we insert the dependencies from the bottom or, as in the case of the visualization characteristic of this architecture - from the outside to the inside.
Graph 1. Visualization of Clean Architecture, own diagram (Domain, App/application, Infrastructure)
We also aim for such architecture when we try to design our application according to DDD (Domain Driven Design) methodology. The system I was working with was definitely heading in DDD direction, although there were still a lot of things to do. That is why ideas derived from DDD appeared in the code, such as entities, value objects, or repositories. This blog-post is not intended to explain what DDD is and what concepts it consists of, but for a further understanding of the whole context, let's adopt such simplified definitions for a few of the terms that I used:
-
Entity - it is an object with an identity that represents some model. For example, a user that we can uniquely identify. This model, however, unlike the typical approach containing mainly anemic entities, apart from data, also has methods that allow you to perform some action on this object.
-
Value object - this is an object that has no identity. A perfect example is the representation of money. However, unlike a simple data structure, it may also have some logic, e.g. related to validation, different representation or comparison.
-
Repository - it is an object that allows us to perform database operations. However, it should provide a high-level API to not reveal implementation details.
It seems Uncle Bob would have accepted that. However, how could such architecture help us in any way in testing? What layer should we test the most? What should such tests look like? Unfortunately, it seems that the very concept of this architecture does not answer those questions. So let's try to ask a slightly different one. What makes a test easy to write, and what makes it difficult or even impossible?
White or black?
One popular pattern for writing unit tests is the 3xA rule:
-
Arrange - prepare the application so that it can be tested,
-
Act - perform an action,
-
Assert - check and test if the result of the action is consistent with your predictions.
After analyzing the tests I wrote for the project I noticed that the Arrange stage, i.e. test preparation, was the most time consuming part. The very implementation of the action and test usually came down to one or two lines of code, but preparation took even a dozen or so. Besides, I had a strong feeling that I needed to go too deep into the implementation to know how to test a given piece of code.
Let's look at the following code snippet:
Code 1: Implementation of the Calculator class
interface CalculatorInterface
{
public function add(float $x, float $y): float;
public function divide(float $x, float $y): float;
}
final class Calculator implements CalculatorInterface
{
public function add(float $x, float $y): float
{
return $x + $y;
}
public function divide(float $x, float $y): float
{
if ($y == 0) {
throw new \DivisionByZeroError();
}
return $x / $y;
}
}
Now let's try to test this code:
Code 2: Tests for the Calculator class
class CalculatorTest extends TestCase
{
public function testDivideTwoIntegers()
{
// Arrange
$calculator = new Calculator();
// Act
$result = $calculator->divide(6, 3);
// Assert
$this->assertEquals(2, $result);
}
public function testDivideByZero()
{
// Assert
$this->expectException(\DivisionByZeroError::class);
// Arrange
$calculator = new Calculator();
// Act
$calculator->divide(6, 0);
}
public function testAddTwoIntegers()
{
// Arrange
$calculator = new Calculator();
// Act
$result = $calculator->add(7, 3);
// Assert
$this->assertEquals(10, $result);
}
}
The code shows a very simple implementation of a calculator with two methods for dividing and adding. Very often, such samples are used to show an example of testing an application.
In each test case, I started with a comment of the part of the Arrange, Act and Assert sections. You will notice that these sections are very short and concise. Each test consists of 3 lines, one for each section. However, I would like to highlight one important thing that seems to be overlooked or not sufficiently resonant in such examples. These tests are very simple and quick to write. You could say that we are testing a very simple sample class, but actually such cases rarely occur in the real world. One could even say that these tests are detached from reality. But let's consider what, apart from this uncomplicated example, makes these tests so simple:
-
explicitly defined contract in the form of the CalculatorInterface interface,
-
the Calculator class has no dependencies,
-
methods have all the information to be able to perform the requested action,
-
there is a constant relationship between the input parameters and the return value.
It can be clearly stated that by looking at the methods of this class, we are able to say what their behavior is and what results we can expect. Thanks to this, knowledge of the internal implementation is not required and we could actually write tests based only on the methods specified in the CalculatorInterface interface. Tests with these properties are called blackbox tests. The black box test is the simplest type of test that makes it much easier to check your application. However, we rarely test simple calculators in our lives. So let's try to discuss a slightly more complex case.
Code 3: Currency exchange service
interface CurrencyExchangeRateRepositoryInterface
{
public function getExchangeRate(Currency $fromCurrency, Currency $toCurrency): ExchangeRate;
}
final class CurrencyExchangeService
{
private $currencyRepository;
public function __construct(CurrencyExchangeRateRepositoryInterface $currencyRepository)
{
$this->currencyRepository = $currencyRepository;
}
public function exchange(Currency $toCurrency, Money $money)
{
$exchangeRate = $this->currencyRepository->getExchangeRate($money->getCurrency(), $toCurrency);
$newValue = $money->getAmount() * $exchangeRate->getValue();
return new Money($newValue, $toCurrency);
}
}
The purpose of this class is to convert the value of one currency to another. It has a dependency in the form of a repository returning the current exchange rate. The specific implementation of this repository, as part of our Clean Architecture, should be placed at the infrastructure level. In our case, this type of service belongs to the application layer. In the domain layer, in turn, there will be concepts presented as value objects, such as Currency, Money or ExchangeRate. Let's try to add tests for this class.
Code 4: Tests for the CurrencyExchangeService class
class CurrencyExchangeServiceTest extends TestCase
{
public function testExchange()
{
// Arrange
$repositoryMock = \Mockery::mock(CurrencyExchangeRateRepositoryInterface::class);
$repositoryMock
->shouldReceive('getExchangeRate')
->withArgs([Currency::class, Currency::class])
->andReturn(new ExchangeRate(0.25));
$currencyExchangeService = new CurrencyExchangeService($repositoryMock);
$money = new Money(100, new Currency('PLN'));
$exchangeTo = new Currency('USD');
// act
$newValue = $currencyExchangeService->exchange($exchangeTo, $money);
// assert
$this->assertInstanceOf(Money::class, $newValue);
$this->assertEquals(new Money(25.0, $exchangeTo), $newValue);
}
public function tearDown()
{
\Mockery::close();
}
}
You can see at first sight that this test is much more complicated. What’s more, the number of lines of the test is bigger than the test code itself! This one test took me a lot more time than the 3 previous ones for the Calculator class combined. The test itself is not complicated, although some intellectual effort was required to have it prepared. It's worth noting that the Arrange section contains the most lines of code. As the CurrencyExchangeService class has an external dependency, it had to be replaced with an object of the class pretending to be a real repository. Such an object is called Test Double (the name refers to the term Stunt Double, i.e. a stuntman who replaces an actor in dangerous scenes of the movie). It is supposed to replace the original object on which it will be possible to call the necessary methods and return the previously prepared data. This is to ensure the repeatability of the test execution and to cut off the true dependencies in order to be able to test the code in isolation. In this particular case, I used a Mock-type test double, which allows you to verify whether a given method was used during the test. It also allows you to define what parameters the method should expect and the value to be returned.
In comparison to the previous example, in order to call and test the exchange method, I needed to know the internal implementation of the CurrencyExchangeService class. I needed to know that this method would call the getExchangeRate method inside and what value this method would give in return. Creating a test double complicated the process of writing the test. It is also not as transparent as the tests for the Calculator class. Tests where implementation knowledge is required are called whitebox tests. What is more interesting, almost all the tests I wrote and were dedicated to the problem described at the beginning of this article were white box tests. The tested classes had a large number of dependencies, so the Arrange stage first included the instance of many test doubles, which made the whole testing process difficult. I will also mention that the preparation of these test double files was extremely boring and required a thorough code review (as these tests were written in the form of TAD - Test After Development) in search of a specific use of them. It can be clearly stated that the white-box tests are much more difficult to write, although at the moment it seems that they are still much more “real life-like” than the black-box tests. Ideally, we should only be able to test the functionality with black-box testing. Then the question arises how to do it and how would our application architecture help us? Let's look at one more example and try to transform it to answer these questions.
A classic. Testing the cart
Code 5: The CartService class
class CartService
{
private CartRepositoryInterface $cartRepository;
private DiscountRepositoryInterface $discountRepository;
private EventDispatcherInterface $eventDispatcher;
public function __construct(
CartRepositoryInterface $cartRepository,
DiscountRepositoryInterface $discountRepository,
EventDispatcherInterface $eventDispatcher
) {
$this->cartRepository = $cartRepository;
$this->discountRepository = $discountRepository;
$this->eventDispatcher = $eventDispatcher;
}
public function applyDiscountHandler(ApplyDiscountCommand $applyDiscountCommand): void
{
try {
$cart = $this->cartRepository->getCart();
$discount = $this->discountRepository->getDiscountByCoupon($applyDiscountCommand->getDiscountCoupon());
if ($discount->isExpired()) {
throw new DiscountCannotBeApplied();
}
if ($discount->applyOnlyToTheThirdProduct()) {
$cart->calculateOnlyThirdProductDiscount($discount->getDiscountPercentage());
}
if ($discount->applyToAllProducts()) {
$cart->calculateForAllProducts($discount->getDiscountPercentage());
}
$this->discountRepository->disableDiscountCoupon($applyDiscountCommand->getDiscountCoupon());
$this->cartRepository->saveCart($cart);
$this->eventDispatcher->dispatch(new DiscountApplySuccessEvent());
} catch (CartNotExists | DiscountCouponNotExists | DiscountCannotBeApplied $e) {
$this->eventDispatcher->dispatch(new DiscountApplyFailedEvent());
}
}
}
In the presented section of the code, we have the part responsible for using the coupon with a percentage discount on the items added to the shopping cart. The applyDiscountHandler method takes a command (that is, an action request) and gets an instance of the current cart and finds the coupon in the database. Then it applies the coupon according to the business rules, saves the changed cart and blocks the coupon so that it cannot be used again. The code structure and dependencies are distributed within Clean Architecture.
Graph 2. Code structure of the Cart module We can list the characteristic blocks from which we build the application using DDD design. Let us think about how we could test this method. The CartService class has 3 dependencies. Two for the repositories for the cart and coupons and one for the service for broadcasting events (event - something important happened in the system). This means that at first we would have to create mocks for these dependencies. Then we should check what methods are used (how many times, with what parameters and what they return) and set them accordingly. The key action we are trying to perform is actually changing the state of the Cart object. However, the CartService class does not return an instance of this class in any way. Verification of its condition would also have to be done with a mock. Definitely, It would be a white box test. What's more, the code doesn't make it easy for us by returning nothing, which would require workarounds and tricks to make it work. This kind of test would be neither easy to write nor read (and we need to remember that tests are the best documentation!). At this point, we should have a feeling that something is wrong with our code. Let's try to refactor our code to make our tests a little easier to write and a little more readable. So what is wrong with the code presented?
-
The method definitely breaks the SRP (Single Responsibility Principle) principle and does too much - so it would be good to separate the operations on the coupon and the cart from each other.
-
The business logic of the discount calculation rules leaked from the domain to the application site. The application layer is responsible for the operational part of the program. It should not make business decisions.
-
Two different business logic are mixed up here. Logic of coupon management and blocking, and logic of the cart calculation.
First, let's deal with the separation of responsibilities.
Code 6: DiscountService class
class DiscountService
{
private DiscountRepositoryInterface $discountRepository;
private EventDispatcherInterface $eventDispatcher;
public function __construct(
DiscountRepositoryInterface $discountRepository,
EventDispatcherInterface $eventDispatcher
) {
$this->discountRepository = $discountRepository;
$this->eventDispatcher = $eventDispatcher;
}
public function applyDiscountHandler(ApplyDiscountCommand $applyDiscountCommand): void
{
try {
$discount = $this->discountRepository->getDiscountByCoupon($applyDiscountCommand->getDiscountCoupon());
$discountEvent = $discount->useDiscount();
$this->discountRepository->handleDiscount($discountEvent);
$this->eventDispatcher->dispatch($discountEvent);
} catch (DiscountCouponNotExists $e) {
$this->eventDispatcher->dispatch(new DiscountFailedEvent());
}
}
}
We have created a new class that deals only with the validation and coupon usage. On the found coupon, we call the useDiscount method, which returns the event informing about the change of the coupon state. Let's take a look at the Discount class.
Code 7: Discount class
class Discount
{
private DiscountType $discountType;
private DiscountPercentage $discountPercentage;
private DiscountId $id;
private DiscountExpirationDate $discountExpirationDate;
public function __construct(
DiscountType $discountType,
DiscountPercentage $discountPercentage,
DiscountExpirationDate $discountExpirationDate,
DiscountId $id
) {
$this->discountType = $discountType;
$this->discountPercentage = $discountPercentage;
$this->id = $id;
$this->discountExpirationDate = $discountExpirationDate;
}
public function useDiscount(): DiscountEventInterface
{
if ($this->discountExpirationDate->isExpired()) {
return new DiscountApplyFailedEvent();
}
// some logic to check if the discount is still valid after usage
return new DiscountCouponUsedEvent($this->discountType, $this->discountPercentage, $this->id);
}
}
The Discount class has no dependencies. All the information required to make the state change decision was provided in the initialization stage. So we have led to a situation where we can test our business logic with a ... black-box test!
Code 8: Tests for class Discount
class DiscountTest extends TestCase
{
public function testDiscountExpired(): void
{
// Arrange
$discount = new Discount(
new DiscountType(DiscountType::TO_ALL_PRODUCTS),
new DiscountPercentage(25.0),
new DiscountExpirationDate(new \DateTimeImmutable("2020-02-01")),
new DiscountId("88d881be-2e54-4c24-b0c4-bb226befd7a5")
);
// Act
$discountEvent = $discount->useDiscount();
// Assert
$this->assertInstanceOf(DiscountApplyFailedEvent::class, $discountEvent);
}
public function testDiscountUsedSuccessfully(): void
{
// Arrange
$discount = new Discount(
new DiscountType(DiscountType::TO_ALL_PRODUCTS),
new DiscountPercentage(25.0),
new DiscountExpirationDate(new \DateTimeImmutable("now + 2 days")),
new DiscountId("88d881be-2e54-4c24-b0c4-bb226befd7a5")
);
// Act
$discountEvent = $discount->useDiscount();
// Assert
$this->assertInstanceOf(DiscountCouponUsedEvent::class, $discountEvent);
}
}
Definitely, the test of our business logic looks much neater than the test of the currency conversion site and it took a lot less time to write it. Thanks to the returned events, we can examine the connections between the input parameters and the output parameters of the method. The use of the concept of events is also not accidental. By events, we mean a change of state that is significant for the system. This is also what happened in this case. Then, within DiscountService, we do two things with the event:
-
We save the change - depending on the system architecture, we can save this event in our database. The sum of all events (i.e. changes in state) on an object will always give us its current state. On the other hand, in a system where these historical state changes are irrelevant, we can update the state of the object directly. In the latter case, however, it is good to transfer a new, consistent instance of the object being provided inside the event.
-
We publish an event - thanks to this, other objects listening to specific events can take their own actions. This is how we make it possible to apply our coupon to the cart.
Code 9: The CartService class
class CartService
{
private CartRepositoryInterface $cartRepository;
private EventDispatcherInterface $eventDispatcher;
public function __construct(
CartRepositoryInterface $cartRepository,
EventDispatcherInterface $eventDispatcher
) {
$this->cartRepository = $cartRepository;
$this->eventDispatcher = $eventDispatcher;
}
public function applyDiscountHandler(DiscountCouponUsedEvent $discountUsedEvent): void
{
try {
$cart = $this->cartRepository->getCart();
$cartEvent = $cart->applyDiscount(
$discountUsedEvent->getDiscountPercentage(),
$discountUsedEvent->getDiscountType()
);
$this->cartRepository->handleCartEvent($cartEvent);
$this->eventDispatcher->dispatch($cartEvent);
} catch (CartNotExists $e) {
$this->eventDispatcher->dispatch(new DiscountFailedEvent());
}
}
}
The DiscountCouponUsedEvent event caused the applyDiscountHandler method to be called on a CartService object. This event brings the necessary information that we need to apply to our shopping cart. As you can see, there is no need to provide the entire discount object, only information about the discount type (DiscountType) and discount percentage (DiscountPercentage).
Code 10: Cart Class
class Cart
{
private Money $cartNetPrice;
public function __construct(Money $cartNetPrice)
{
$this->cartNetPrice = $cartNetPrice;
// other data required for cart, ie. list of products
}
public function applyDiscount(
DiscountPercentage $discountPercentage,
DiscountType $discountType
): CartEventInterface {
if ($discountType->applyOnlyToTheThirdProduct()) {
$cartNetValue = $this->calculateOnlyThirdProductDiscount($discountPercentage);
return new CartNetPriceUpdated($cartNetValue);
}
if ($discountType->applyToAllProducts()) {
$cartNetValue = $this->calculateForAllProducts($discountPercentage);
return new CartNetPriceUpdated($cartNetValue);
}
return new CartUpdateFailed();
}
private function calculateOnlyThirdProductDiscount(DiscountPercentage $discountPercentage): Money
{
$newValue = $this->cartNetPrice;
// some calculation logic
$newValue += $newValue * $discountPercentage->getValue() / 100;
return $newValue;
}
private function calculateForAllProducts(DiscountPercentage $discountPercentage): Money
{
$newValue = $this->cartNetPrice->getAmount();
// some calculation logic
$newValue += $newValue * $discountPercentage->getValue() / 100;
return new Money($newValue, $this->cartNetPrice->getCurrency());
}
}
Again, the Cart class has all the information required to apply the discount and returns an event as a result of calling the applyDiscount method. Then we persist in this event and publish it. This approach creates a cause and effect chain that is very easy to follow and understand. The Cart type tests will also be black box tests. However, the question arises, what about the CartService and DiscountService classes? Both have dependencies, so testing them forces us to do white-box testing. At this point, I would like to highlight the responsibilities of these services. Their task is to:
-
preparation of our object (Cart/Discount),
-
calling the appropriate method with appropriate parameters,
-
calling actions aimed at preserving and publishing events.
Isn't that a bit like the Arrange, Act, Assert pattern? We have to answer the question, what would we really like to test in these classes? There seems to be no point in testing them, as the essence of their responsibility is actually triggering our business logic. So these would be the same tests we wrote for the Discount and Cart classes, but wrapped in a complicated structure of stubs and mocks. Therefore, integration tests, not unit tests, would make much more sense here.
So where are these aggregates?
The title of this article promised the aggregates, and I still haven't mentioned anything about them. This is a deliberate case, as it seems to me that this topic is not fully understood by many programmers. In the given example, all we did is refactor a little and reorganize our code. This allowed us to bring it to a stage where we were able to test it with a much simpler black-box testing method. As a result, our Cart and Discount classes have acquired some properties that they did not have before, namely:
they have all the information required to make a business decision
As a result, they can protect data with consistent rules and make state change decisions without having to ask other parts of the system for additional conditions. Let's add to this supplementary conditions that do not result directly from the code:
-
Cart and Discount class objects should always be created in whole extend (all required data should be loaded),
-
state changes should be persisted atomically (i.e. always all together),
-
only the Cart and Discount classes can modify their state - no other part of the system can do so.
By adding these few additional conditions, it turns out that our Cart and Discount classes are technically... aggregates in the sense of the DDD. More precisely, the roots of their aggregates. To illustrate this better, take a look at how I reorganized the directory structure of our component.
Graph 3. Cart module structure containing aggregates
The Domain class contains 3 catalogs: Commons, which contains common concepts, and Cart and Discount, where our aggregates are located. I highlighted their roots in blue, i.e. classes that constitute a public interface for interacting with the aggregate. It seems to me that such a presentation of this concept can be much more accessible, because it not only shows us the benefits of a much better testability of our code, but also one of the ways in which we are able to extract aggregates from our system. Of course, there are much better and more appropriate tools for this (e.g. process and tactical sessions in Event Storming), but even if we do not know these concepts, we can improve our applications in the way presented.
Who is your boss?
In conclusion, I would like to present a certain analogy that comes to my mind when I think about the responsibilities of clean architecture. This analogy can be helpful when we are forced to decide how we should organize our application code.
Graph 4. Analogy of Clean Architecture to Organization, own diagram
Imagine your company has three levels:
-
01
The boss - the decision-maker
-
02
Middle-level manager
-
03
Employees
The responsibility of a middle-level manager is to execute the plan provided by the decision-making person. He does not do it by himself, but delegates work to employees. Employees do it physically. Moreover, the manager is responsible for providing the data the boss needs to make specific decisions. The preparation of these data is also delegated to employees, but the manager may also format or organize them differently before handing them over to the boss. In a large organization, the boss very often does not even know his employees. The data is provided to him by the manager, so there is no need to ask and search for data from the lowest-level employees. He is also not interested in the way the assigned tasks are performed.
Our application structure works similarly. The application layer orders the physical download and preparation of data for specific instances in the infrastructure layer, which retrieve it from the database and return our aggregate root. Then the parameters are called and passed to the aggregate method, which makes the decision about the state change already in the domain layer. This decision is returned by event to the application layer, which is then delegated back to the infrastructure layer for persistence and publication.
Try it yourself!
As part of this article, I also created an implementation of this solution. As you may have noticed, not everything I wrote about has been tested. What's more, the infrastructure layer has not been presented here in any way, which in our case can be completely replaced by tests. If you would like to practice, fork the codebase repository and try to extend the existing tests, modify the CurrencyExchangeService class the same way I did with the CartService class. Suggest your own solutions and share your implementation with everyone in the comments. I am curious about your opinion on how you understand the presented topic and ideas for implementation.
The blog post was created as a translation of a "Testowanie agregatów, czyli architektura wspierająca testowanie" entry on devkick.pl , written by Łukasz Duraj.
GitHub - lukasz-devkick /aggregates.testing