DEV Community

Cover image for Hexagonal Architecture Testing
Luis Mirabal
Luis Mirabal

Posted on • Edited on

Hexagonal Architecture Testing

The hexagonal architecture, also known as the ports and adapters pattern, helps us isolate an application business logic from any technology-specific code. It affords you extra flexibility. You can either defer decisions on what technology to use or migrate to a better alternative that might appear in the future.

If you're not familiar with it, the main principle is that your business logic, the hexagon, is protected and can only communicate with the outside world via interfaces, the ports, that are then implemented in your technology of choice, the adapters.

There are two types of ports:

  • Driving port: contract defining how the application is triggered.
  • Driven port: contract defining how the application communicates with the outside.

If you need more details, please read Alistair's Cockburn post or watch his presentation.

Testing

We've already touched on the main driver for implementing a hexagonal architecture, but it doesn't stop there. It also has a beautiful side-effect. It allows testing all your application functionality in business terms, removing added complexity from libraries and frameworks, as they're isolated outside the hexagon in adapters behind port interfaces.

Even though the adapters don't have any business logic, they play a vital role. They bring the application to life, allowing users to consume it. Without them, the user couldn't input any data or store it, just to mention every application's essential requirement. Therefore, making sure they work as expected is of the utmost importance.

Unfortunately, this tends to end up causing test duplication and blurring their focus with technology-specific details. For example, if you were building a web application with an HTTP backend, you'd have a test making sure the UI can take the details and store them properly and another testing the same in the HTTP API and even a third in the domain logic.

At this point, the hexagonal architecture shines. You can build a functional test suite interfacing with the application via driving ports. With a major benefit, they don't need to include any component-specific implementation details, which allows you to re-use the same test suite at every level of the application by implementing the given contract.

It all sounds great on paper but let's build an example from scratch to see the benefits in action.

Building a bank

To bring the theory to practice, we're going to build a basic bank. The requirements are as follows:

  • Can create accounts.
  • Can list accounts.
  • Can deposit money to an account.
  • Can withdraw money from an account.
  • Forbids withdrawals when there are not enough funds.

As a backend developer, I'm using a JVM language; kotlin. To build the HTTP API, and even a rudimentary Web UI, will use http4k. Hopefully, even if you're not familiar with either, the code is still going to be easy to read and understand.

Create account

Let's start implementing the first requirement by writing our first test.

abstract class BankContract {
    abstract val bank: Bank

    @Test
    fun `lists accounts`() {
        val account1 = bank.createAccount()
        val account2 = bank.createAccount()

        val accounts = bank.listAccounts()

        assertThat(accounts, equalTo(listOf(account1, account2)))
    }
}

interface Bank {
    fun createAccount(): BankAccount
    fun listAccounts(): List<BankAccount>
}
Enter fullscreen mode Exit fullscreen mode

This is a special kind of test, as it represents the application contract alongside the Bank interface. Typically, the interface is considered enough to enforce a contract, but it leaves out an application's fundamental element, its behaviour. If you have a test written in terms of the interface, you end up with an even stronger contract as the types and the behaviour are well defined.

For this reason, it makes sense this test is abstract as it's the template that implementations need to follow to comply with the bank behaviour.

Regarding the test itself, you can notice we're testing account listing. It's because we need to check the bank behaviour solely via its interface. Therefore, it makes sense to implement the operation that saves accounts and query them to check both are correct.

First Bank implementation

We're going to start from the application inner part, also known as the hexagon, so the domain is well understood from the beginning.

class BankLogicTest : BankContract() {
    val idFactory = RandomBankAccountIdFactory()
    val repository = InMemoryBankAccountRepository()
    override val bank = BankLogic(repository, idFactory)
}

class BankLogic(
    private val repository: BankAccountRepository,
    private val idFactory: () -> BankAccountId
) : Bank {
    override fun createAccount(): BankAccount =
        BankAccount(idFactory(), Amount.ZERO)
            .also { newAccount -> repository.add(newAccount) }

    override fun listAccounts(): List<BankAccount> = 
        repository.list()
}

interface BankAccountRepository {
    fun add(account: BankAccount)
    fun list(): List<BankAccount>
}
Enter fullscreen mode Exit fullscreen mode

also function: gives access to the caller object to apply side-effects, returning the unchanged object.

The first component to fulfil the contract is the business logic itself. It's the core of our application, the component making the ultimate decision on the bank's behaviour.

Creating accounts requires instantiating account objects with a unique id and getting them persisted. Persistence is an external concern; consequently, we introduce a driven port with the BankAccountRepository interface.

For brevity, the BankLogic dependencies are not included. If you want to check out the full solution, the GitHub repository link is at the end.

HTTP API

Now that the core of the application is ready, let's expose it via HTTP.

class BankHttpTest : BankContract() {
    override val bank = BankHttpClient(bankHttp())
}

class BankHttpClient(val http: HttpHandler) : Bank {
    override fun createAccount(): BankAccount {
        val response = http(Request(POST, BANK_ACCOUNTS_URL))
        return bankAccountLens(response)
    }

    override fun listAccounts(): List<BankAccount> {
        val response = http(Request(GET, BANK_ACCOUNTS_URL))
        return bankAccountListLens(response)
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing the contract needs a new implementation of Bank. In this case, it's a client to interact with the API.

Usually, tests for an HTTP application will directly use an HTTP client. In this case, the BankHttpClient hides these details.

This client provides another valuable benefit. Our API users can also use it, avoiding to learn details of our HTTP implementation (i.e. paths, HTTP methods, body format, etc.), and use domain types instead.

The implementation uses the aforementioned http4k library, particularly lenses which provide a type-safe mechanism to map an HTTP response to types and vice-versa.


Having a failing test, now we can implement the server code.

const val BANK_ACCOUNTS_URL = "/bank/accounts"
val accountLens = Body.auto<BankAccount>().toLens()
val accountListLens = Body.auto<List<BankAccount>>().toLens()

fun bankHttp(bank: Bank): HttpHandler {
    return routes(
        BANK_ACCOUNTS_URL bind routes(
            POST to { Response(OK).with(accountLens of bank.createAccount()) },
            GET to { Response(OK).with(accountListLens of bank.listAccounts()) }
        )
    )
}
Enter fullscreen mode Exit fullscreen mode

The server defines routes for the GET and POST methods on the /bank/accounts path, where their handlers call the Bank and map their result to HTTP responses.

This particular implementation of the test runs the application in memory, making it run fast even if testing the HTTP interface. However, nothing is stopping us from running it against a running HTTP server.

class BankHttpIntegrationTest : BankContract() {
    val server = bankHttp().asServer(SunHttp())
    override val bank = BankHttpClient(
        SetBaseUriFrom(
            Uri.of("http://localhost:${server.port()}")
        ).then(JavaHttpClient())
    )

    @BeforeEach
    fun setUp() {
        server.start()
    }

    @AfterEach
    fun tearDown() {
        server.stop()
    }
}
Enter fullscreen mode Exit fullscreen mode

The test runs its own HTTP server, but we could go further and run the test against a deployed environment.

Web UI

The testing approach used so far can also be applied to test our front-end.

class BankWebTest : BankContract() {
    val httpServer: HttpHandler = bankHttp()
    val httpClient: Bank = BankHttpClient(httpServer)
    val web: HttpHandler = bankWeb(httpClient)
    override val bank: Bank = BankWebDriver(web)
}
Enter fullscreen mode Exit fullscreen mode

You can see the pattern carries on. In this instance, a selenium-backed driver plays the role of client and is used to navigate to a particular URL, press some buttons and check that the HTML content is as expected. As we have access to the whole codebase in a single repo, it instantiates all the components, running the entire application in memory.


Implementing the web driver can be slightly trickier, and inevitably, it's coupled to the HTML structure.

class BankWebDriver(web: HttpHandler) : Bank {
    val driver = Http4kWebDriver(web)

    override fun createAccount(): BankAccount {
        driver.navigate().to("/")
        driver.getElement(By.id("create-account")).submit()

        return driver.getBankAccounts().last()
    }

    override fun listAccounts(): List<BankAccount> {
        return driver.getBankAccounts()
    }

    private fun SearchContext.getBankAccounts(): List<BankAccount> {
        return getTableRows().map { row -> row.toBankAccount() }
    }

    private fun WebElement.toBankAccount(): BankAccount {
        fun WebElement.getBankAccountId(): BankAccountId { ... }
        fun WebElement.getBalance(): Amount { ... }

        return BankAccount(getBankAccountId(), getBalance())
    }
}
Enter fullscreen mode Exit fullscreen mode

The driver uses selenium via an http4k wrapper, that makes the code more kotlin-friendly. Also, it uses extension functions to make the code read better.


As a backend developer, I did not attempt to build a proper UI. Instead, I opted for a server-side application using handlebars templates to produce HTML.

Implementation is not included to avoid distraction with a solution that doesn't represent a proper front-end and looks very similar to the HTTP API.

To help make sense of the driver code, this is the HTML template source.

<div>
    <h2>Create Account</h2>
    <form method="POST" action="/">
        <input type="submit" id="create-account" value="Create">
    </form>
    <h2>Bank Accounts</h2>
    <table>
        <thead>
        <tr>
            <th>ID</th>
            <th>Balance</th>
        </tr>
        </thead>
        <tbody>
        {{#accounts}}
            <tr>
                <td>{{id}}</td>
                <td>{{balance}}</td>
            </tr>
        {{/accounts}}
        </tbody>
    </table>
</div>
Enter fullscreen mode Exit fullscreen mode

This completes the full cycle, and now we have a working application fulfilling the initial requirements 🎉

Bank account deposits and withdrawals

What's a bank if you can't manage your account funds, so let's add tests to the contract.

abstract class BankContract {
    abstract val bank: Bank

    @Test
    fun `deposits into account`() {
        val bankAccount = bank.createAccount()

        bank.deposit(bankAccount.id, Amount(100))
        val updatedAccount = bank.deposit(bankAccount.id, Amount(200))

        assertThat(updatedAccount.balance, equalTo(Amount(300)))
    }

    @Test
    fun `withdraws from account`() {
        val bankAccount = bank.createAccount()

        bank.deposit(bankAccount.id, Amount(300))
        val updatedAccount = bank.withdraw(bankAccount.id, Amount(200))

        assertThat(updatedAccount.balance, equalTo(Amount(100)))
    }
}

interface Bank {
    fun deposit(id: BankAccountId, amount: Amount): BankAccount
    fun withdraw(id: BankAccountId, amount: Amount): BankAccount
}
Enter fullscreen mode Exit fullscreen mode

The bank is taking shape, and it can now operate on account balances. As the initial section, the contract additions drive the changes needed to the whole application.

The implementation is omitted, as the next section builds an iteration on the withdrawal functionality, and deposits work similarly.

Bank account balance validation

So far, we've been only handling happy path use cases, in part to keep things simple. Now, let's implement an error case to see the approach still stands.

abstract class BankContract {
    abstract val bank: Bank

    @Test
    fun `cannot withdraw from account more than balance`() {
        val bankAccount = bank.createAccount()

        bank.deposit(bankAccount.id, Amount(300))
        val updatedAccount: Result<BankAccount, NotEnoughFunds> = bank.withdraw(bankAccount.id, Amount(500))

        assertThat(
            updatedAccount.failureOrNull(),
            present(equalTo(NotEnoughFunds(bankAccount.id, Amount(300), Amount(200))))
        )
    }
}

interface Bank {
    fun withdraw(id: BankAccountId, amount: Amount): Result<BankAccount, NotEnoughFunds>
}

data class NotEnoughFunds(
    val id: BankAccountId,
    val balance: Amount,
    val additionalFundsRequired: Amount
)
Enter fullscreen mode Exit fullscreen mode

Having a strongly-typed contract is a must, particularly for error cases. That pre-requisite excludes exceptions immediately, as kotlin only has unchecked exceptions and they wouldn't be part of the Bank interface. Consequently, the natural option is to use a result type. They elegantly allow us to express the possible error when withdrawing funds.


So far, the BankAccount type has been used, but its implementation hasn't featured as it was only holding data. It now also includes behaviour, being the bulk of the changes needed to satisfy this requirement.

data class BankAccount(val id: BankAccountId, val balance: Amount) {
    fun withdraw(amount: Amount): Result<BankAccount, NotEnoughFunds> {
        return if (amount <= balance) Success(copy(balance = balance - amount))
        else Failure(NotEnoughFunds(id, balance, additionalFundsRequired = amount - balance))
    }
}
Enter fullscreen mode Exit fullscreen mode

And we subsequently use it in the logic.

class BankLogic(...) : Bank {

    override fun withdraw(id: BankAccountId, amount: Amount): Result<BankAccount, NotEnoughFunds> {
        val account = repository.get(id)
        return account.withdraw(amount)
            .peek { updatedAccount -> repository.update(updatedAccount) }
    }
}
Enter fullscreen mode Exit fullscreen mode

result peek function: gives access to value if the operation succeeds.

It only needs to delegate to the account object and ensure that the result gets persisted if successful. And with this, we have the core changes completed.


Now, we update the HTTP adapter.

class BankHttpClient(val http: HttpHandler) : Bank {

    override fun withdraw(id: BankAccountId, amount: Amount): Result<BankAccount, NotEnoughFunds> {
        val response = http(
            Request(POST, BANK_ACCOUNTS_BASE_URL + BANK_ACCOUNT_WITHDRAWAL_PATH)
                .with(accountIdLens of id, amountLens of amount)
        )
        return when (response.status) {
            Status.OK -> Success(bankAccountLens(response))
            Status.BAD_REQUEST -> Failure(notEnoughFundsLens(response))
            else -> throw Exception("Not expected: $response")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The client sets the expectation to signal the failure using an error status code. Not having enough funds is a recoverable client error (e.g. the user can decide to withdraw fewer funds); therefore, 400 Bad Request is the preferred choice.

As we need to fulfil the contract, the error response body needs to contain the JSON representation of the NotEnoughFunds type, the domain failure. It doesn't allow mapping the error into a message, as some might have attempted. Ultimately, giving more flexibility to the consumer interfacing with the user, as it has access to all the failure details and can decide how to communicate the operation's outcome best.


To get the test passing, we implement the server-side.

fun bankHttp(bank: Bank): HttpHandler {
    return routes(
        BANK_ACCOUNTS_BASE_URL bind routes(
            BANK_ACCOUNT_WITHDRAWAL_PATH bind POST to { request ->
                val accountId: BankAccountId = accountIdLens(request)
                val amount: Amount = amountLens(request)

                bank.withdraw(accountId, amount)
                    .map { updatedAccount -> Response(OK).with(bankAccountLens of updatedAccount) }
                    .recover { failure -> Response(BAD_REQUEST).with(notEnoughFundsLens of failure) }
            }
        )
    )
}
Enter fullscreen mode Exit fullscreen mode

result recover function: allows converting a failure value into a successful one.

Using lenses, we extract the withdrawal details from the HTTP request in a type-safe manner, call the Bank, and finally, map the domain type to HTTP terms.

With these changes completed, the HTTP tests are passing ✅


And to finish the exercise, we update the web adapter.

class BankWebDriver(web: HttpHandler) : Bank {
    val driver = Http4kWebDriver(web)

    override fun withdraw(id: BankAccountId, amount: Amount): Result<BankAccount, NotEnoughFunds> {
        val row = driver.getTableRows().first { row -> row.getBankAccountId() == id }
        val form = row.getElement(By.id("withdraw-form"))
        form.getElement(By.id("amount")).sendKeys(amount.format())
        form.getElement(By.id("withdraw")).submit()

        return if (driver.findElement(By.id("failure")) == null) {
            Success(driver.getBankAccounts().first { account -> account.id == id })
        } else {
            val balance = driver.getElement(By.id("balance")).getAttribute("content")
            val additionalFundsRequired = driver.getElement(By.id("additionalFundsRequired")).getAttribute("content")
            Failure(NotEnoughFunds(id, balance.toAmount(), additionalFundsRequired.toAmount()))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

A withdrawal in the UI works in 2 phases. First, we find the account in the list, input the amount, and press the withdraw button. Then, if the operation is successful, the list gets refreshed. Otherwise, it shows an error page.

The NotEnoughFunds failure value needs to be extracted from the HTML to get a compliant adapter. There are a couple of alternatives to achieve it: parse the error message to get the failure properties - or, read the details from meta tags in the HTML. The former means the HTML is not altered, but it only works if the message includes all the properties. The latter needs some tweaking in the HTML but makes it easier to test, so we went for that.

<html>
<head>
    <meta id="failure"/>
    <meta id="balance" content="{{balance}}"/>
    <meta id="additionalFundsRequired" content="{{additionalFundsRequired}}"/>
</head>
<body>
<div>
    <h2>Bank account with id {{id}} does not have enough funds</h2>
    <p>Current balance: {{balance}}</p>
    <p>Additional funds required: {{additionalFundsRequired}}</p>
</div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Having a working withdrawal gets all the requirements fulfilled 🍾

Conclusion

With this example we've managed to build an application taking advantage of hexagonal architecture principles to get the testing centralised in a single test suite that exercises our whole application, allowing quick feedback via in-memory tests and providing integration coverage if ran against deployed services.

These are simple but powerful object-oriented design principles that can considerably improve how you structure your code and test your software.

For more details, you can find the full source code on GitHub.

Cover Photo by Priscilla Flores on Unsplash

Top comments (0)