avatarLuís Soares

Summary

The context discusses a testing strategy for a domain-centric architecture, specifically the hexagonal architecture.

Abstract

The hexagonal architecture, also known as the domain-centric architecture, separates the application into a 'domain' and 'infrastructure'. The domain represents the business domain, while the infrastructure represents the technology. The testing strategy for this architecture includes testing use cases and entities in isolation, testing secondary adapters against realistic services, and targeting primary adapters while using necessary secondary adapters. The testing unit in this architecture is a use case, and the testing strategy should consider the size of the unit being tested. The strategy involves testing the domain through the app's entry points, using actual databases for testing purposes, and using test doubles to replace secondary adapters. The strategy aims to provide a balance between speed and realism in testing.

Bullet points

  • The hexagonal architecture separates the application into a 'domain' and 'infrastructure'.
  • The domain represents the business domain and includes use cases, entities, value objects, and ports.
  • The infrastructure includes primary/driving adapters and secondary/driven adapters.
  • The testing strategy includes testing use cases and entities in isolation, testing secondary adapters against realistic services, and targeting primary adapters while using necessary secondary adapters.
  • The testing unit in this architecture is a use case.
  • The strategy involves testing the domain through the app's entry points.
  • The strategy uses actual databases for testing purposes and test doubles to replace secondary adapters.
  • The strategy aims to provide a balance between speed and realism in testing.

A testing strategy for a domain-centric architecture (e.g. hexagonal)

I’ll propose a testing strategy for the hexagonal architecture.

Hexagonal architecture

The hexagonal architecture and the clean architecture are domain-centric architectural patterns. They’re powerful and adaptable to your needs; even simple apps like command-line tools benefit from centralizing the domain (to separate the I/O from the actual algorithm).

Photo by LekoArts on Unsplash

Here’s what you need to know:

  • The main principle is that the app (e.g. a bounded context) is comprised of a ‘domain’ (core) and an ‘infrastructure’ (I/O). The ‘domain’ represents the business domain (higher-level code); the ‘infrastructure’ represents the technology (lower-level code).
  • The domain should make use of the ubiquitous language; it’s mostly comprised of use cases (business commands and queries), entities (business objects), value objects, and ports (domain boundaries). Entities are not ORM objects or DTOs (e.g. view models, serializers). Entities and value objects have their logic.
  • The infrastructure is made up of primary/driving adapters and secondary/driven adapters. Primary adapters (i.e. entry points or delivery mechanisms) are how users and other systems interact with your app (e.g. a web handler, a CLI, a server-side UI). Secondary adapters are how your app interacts with the outside (e.g. with the clock, with an external API, with a database).
  • The adapters should isolate their technology: it makes everything more replaceable and testable, reduces the buy-in into specific technologies, and insulates code changes. That’s achieved by following the dependency inversion principle. A typical example is that you can plug in-memory databases to run tests faster since the domain doesn’t know the actual adapters it’s using.

Here’s the long story:

Towards a testing strategy

It’s difficult to provide an ideal testing strategy because that depends on the nature of what you’re testing and what are your values and principles. One thing is certain: that strategy should serve the goals of automated testing. With all that as a starting point, defining a testing strategy can be a challenge. Let’s see what is possible and go from there. Within a domain-centric architecture, there’s a limited number of levels of testing, from low-level to high-level:

  • use cases and entities tested in isolation;
  • testing a secondary adapter against a realistic service (e.g. testing a data repository against a testing database, testing a REST client with a fake server);
  • targeting a primary adapter (e.g. web), passing through the domain, and using the necessary secondary adapters (e.g. fake in-memory versions).
  • the same, but against realistic services.
Visualizing the testing strategy within a domain-centric architecture

The practical testing pyramid shows that you should not pick just one level. Instead, you should do a combination that better serves the testing goals.

A pervasive myth

There’s a recurring myth that states that unit tests are function tests, but that is not true (or they would have called it function/class tests). Unit tests can target different unit sizes but generally, bigger is better. The smaller ones are called solitary; at the other end, there are sociable which can interact with databases or fake services under your control (although the distinction is a bit irrelevant to me, integration tests are the ones that involve other systems with their own identity).

In a nutshell, a unit can have different sizes (it’s a spectrum) and your testing strategy should consider that.

solitary unit tests
◦ faster
◦ better at testing combinations
◦ useful to isolate very specific vehavior (e.g. caching, retries)
◦ good for London-style TDD (can be deleted later)

   ↑
   ⋮
   ↓

sociable unit test
◦ confidence and realism (better safety net)
◦ better as documentation (document real-life usage)
◦ better as a refactoring tool (no coupling to details)

   📝 In short, the default should be sociable tests, 
   unless there's a strong argument to isolate components in tests.

A hexagonal strategy

Applying a small set of testing patterns helps to create new functionality, which is why I’m proposing a strategy with some discipline. I’ll present you the testing strategy that I use by default, which of course needs to be adapted as needed.

In a domain-centric architecture, the testing unit is a use case — a vertical slice of code passing through an entry point, the actual use case in the domain, secondary adapters, and possibly even databases. I like to do vertical tests until proven I shouldn’t. This means that in simple apps, I probably only have vertical tests that cross all the layers and exercise the app to its boundaries. In more complex apps, I compromise strategically only when:

  • there are performance concerns;
  • it’s awkward to set up network requests everywhere (e.g. with WireMock);
  • I want to test things that are exclusively part of a component’s identity (e.g. test a retrying mechanism in an event emitter or caching in a data fetcher) that are not supposed to leak to the rest of the app;
  • I need to try combinations (to prevent combinatorial explosions).

The first compromises happen at the app’s boundaries:

Usually, these compromises are enough but in some rare cases, I may write solitary unit tests for domain entities if they have overly complex logic; the same applies to use cases and secondary adapters (e.g. caching, retrying, request chunking). Note that I don’t do these tests by default but rather as a fallback. I don’t compromise anymore by testing lower-level components (e.g. serializers) because those are technicalities.

Some options for trading off realism for speed within the hexagonal architecture.

We save Implementation Detail Tests for parts of the code that are naturally isolated and have an internal complexity of their own. Testing of Microservices (Spotify)

But not all unit testers use solitary unit tests. Indeed when xunit testing began in the 90’s we made no attempt to go solitary unless communicating with the collaborators was awkward (such as a remote credit card verification system). Unit Test (Martin Fowler)

Testing with the driving adapters

You may be concerned that I test the domain through the app’s entry points (i.e. primary adapters like controllers, event handlers, and command-line handlers), which is fair. In the past, my opinion was that the domain was ‘sacred’ and had to be tested in isolation. Nowadays, the domain is still ‘sacred’ but I like to test it realistically, from the outside in. Let me tell you why I changed my mind.

What are the options to test the domain?

  • Test the entry points and the domain separately and in isolation. Test them together only for the happy case and accept the risk that the remaining scenarios (e.g. error cases) aren’t tested realistically.
  • Test the domain directly and repeat that whole set of tests with the entry points included. This way, there's a lot of repetition so refactoring gets cumbersome.
  • Test the domain only through the actual entry points.

I prefer the third option, the most pragmatic. The domain is invaluable but alone it’s not worth much — it always needs a driving adapter (i.e. delivery mechanism, entry point, primary adapter) to be useful. Also, the domain is not something that you stick in a library and share, so what’s the point of testing it in isolation? Entry points such as web handlers exist solely for one reason — to provide access to the domain; so why not exercise them in that context? Refactoring is less painful because there are fewer tests and they’re less coupled with implementation details [tests as a refactoring tool]. Realism is much higher since the domain is tested close to real-life usage [tests as a safety net].

Let’s imagine a command-line canvas app. Points cannot have negative coordinates. You can have a test for the Point value object but you also need to show how your app deals with invalid points, to ensure that the user doesn’t get ugly stack traces. Why do you need two tests? Why not have just one test that covers that rule in one place? Besides, vertical tests self-document code as they describe your app as it’s used in real life [tests as documentation].

I get paid for code that works, not for tests, so my philosophy is to test as little as possible to reach a given level of confidence. (Kent Beck)

I defend this approach by default, but I compromise one level if the need arises.

Testing with the driven adapters

This would need its article but let me try to summarize it. Let’s consider the most typical driven/secondary adapters: repositories that access databases and gateways encapsulating third-party web APIs.

For database-related adapters, I use an actual database for testing purposes for repositories that deal with databases. I set up a “before all” to prepare the database schema and a “before each” routine to reset the database data. This way, I always know I have a clean and fresh database to start with. It’s much easier to have deterministic tests.

For gateway adapters, depending on the language, I see two options:

  • Launch a fake server that simulates the external party (e.g. Javalin, WireMock, JSON Server, etc.);
  • Use a proven library that spies the HTTP calls at the network level (e.g. vrc, ExVCR, VCR.py, MSW, which you can use to assert the recorded calls (this is only an option if I don’t get coupled to a concrete HTTP client).

I value as important the ability to set up the stubs in each test. This way, the stubs are contextual, lean, and self-explanatory. You only see what you need, where you need it.

In both cases, there’s a third option, if I don’t want to test the app to its boundaries (for example, because it’s not practical), which is to simply replace the adapter with a test double. That is the most commonly stated advantage of hexagonal architectures. To do it, I just rely on dependency injection and replace them with stubs (I don’t use mocks because I prefer state verification).

Most of my tests are vertical but I may need to compromise and test a secondary adapter in isolation. For example, it may have concurrency, caching, or retrying and those are very specific concerns to the adapter.

I never mock the driver/library that does the actual calls to the outside (don’t mock what you don’t own). Notice that there are no mocks at all in my proposed approach; there are only stubs, spies, and fakes.

Finally, beware this was written from the perspective of a single app. If you have a distributed system, you need integration testing, contract testing, or end-to-end testing. For third parties that you don’t control, you may also need monitoring because they can change their APIs without you noticing.

Common questions

What about pinpointing bugs? [tests as defect locators] Pinpointing bugs can appear to be more challenging, but remember that adapters are supposed to be thin without logic; therefore, finding the root cause of bugs gets just marginally worse. Sure, vertical tests target more layers but they target thin slices of value — the use cases, which are discrete pieces of functionality. When there’s a bug in a use case, you just need to tell which layer. Hopefully, you have no more than 3 layers (or you may have bigger problems to solve). My advice is to organize your whole codebase by use cases.

We didn’t find it difficult to track down the actual fault, even if it caused neighboring tests to fail. So we felt allowing our tests to be sociable didn’t lead to problems in practice. Unit Test (Martin Fowler)

Won’t it be hard to develop code like that with TDD? [tests as specification] In the beginning, you may need to create lower-level tests that help to drive the implementation. However, once you have a process in place, you just repeat it to create more functionalities. In a web service, my process is: 1. write a basic test just to force me to wire the handler in the app; 2. make it pass. 3. expect a proper result; 4. hardcode it in the handler; 5. do the same in the use case; 6. do the same in the repository; 7. do the proper implementation. This baby-step approach allows for several rounds of feedback [tests as a feedback mechanism].

Finally, any tests that were motivated solely by the development process should be considered for deletion. Working Effectively with Unit Tests

Won’t the tests be bigger and more verbose? Yes, and that’s why you should apply a proper set of testing patterns (e.g. run one action per test, assert one behavior per test, consider a SUT client, etc.). In fact, without them, these tests can quickly become messy. Also, make sure you split your tests by use case (e.g. “Make Loan”, “List Debts”, “Sell Car”). This way, the file listing reflects what the app can do for you.

What about combinatorial explosions? Preventing it is a reason to trade off and do lower-level tests. However, I have to ask: why would you have that if each use case is solely doing one thing? Each vertical test is supposed to have just a few variations because it hits only one vertical slice.

Won’t tests be slower? My current project runs around 400 tests (most of them are vertical) in less than 30 seconds. Surely, if I only did solitary unit testing, it would be faster but I enjoy the trade-off as it provides me a great safety net while refactoring or adding features. Note that these are not end-to-end or integration tests (with other systems); all they need is a local database and some fake web services.

Am I coupling the domain tests to a concrete technology? This is a valid point but how often do you replace an app’s entry point? You may argue that the entry point tests are supposed to be technical (e.g. check HTTP status code) and domain tests are business-oriented. My argument is that there are no “technical tests”; ultimately, everything should be done for the sake of a business need or it wouldn't be needed (it would be considered waste). Yes, a JSON is technical, but why not use it to test the business need behind it? In the same way you never directly test a private method because it’s a detail of a public one, I see secondary adapters as details of something more meaningful, business-wise.

Based on my experience, all these advantages easily pay off the downsides of vertical tests.

Learn more

Hexagonal Architecture
Automated Testing
Clean Architecture
Recommended from ReadMedium