Tactical Design by Example - Using Kotlin and Spring Boot (Part 7) - Testing 101

After having fun with ArchUnit last week, I wanted to explore testing a little more. Let's revisit some basics this week. I will be focusing on integration testing in the next blog post, especially on Spring Boot test slices.

How Hexagon Architecture helps with testing

The Hexagon Architecture not only structures our application, but also helps us to distinguish between more valuable and more generic parts. Generally speaking,

  • the domain layer is most valuable, as it contains custom business logic, the whole reason for writing the software in the first place
  • the application service layer is not quite as valuable, they do describe actual business processes and workflows, but are usually more generic
  • the adapter layer is least valuable, they are mostly created with help from third-party libraries and should rarely be customized

And of course, adapters make use of actual infrastructure components that are, with today's cloud infrastructure, a commodity. All this can be visualized quite nicely with a Wardley Map:

wardley

You see that we should focus on the quality of the items to the upper left more, because these are the items that make our application unique. The items on the lower right, although necessary, are mostly well-tested themselves, so we should be spending less time there.

Of course there are applications which are themselves more generic - I'm talking about applications that belong into your core domain.

The Test Pyramid

Everyone knows the test pyramid. Here I want to show what layers are tested on which level.

test-pyramid

Because the domain is most valuable, we should obsess about tests here. Bring in Example Mapping to get every little edge case. This layer should be bulletproof! As the domain layer has no dependencies to the outside, we can use unit tests here. They are fast, so having many unit tests is not a problem.

Application services are less interesting, because they mostly contain just a few business rules and a process flow. They contain dependencies to the outside world via ports, and they can be simulated with mocks. But they also add to the complexity of a test, so make sure you are not losing yourself in details here. Unit tests are still a good choice here, but you won't need as many.

As adapters make use of the infrastructure, we usually don't want to unit test them. In Spring Boot, we need an application context with the infrastructure beans like repositories, controllers or consumers. Nevertheless, we want to develop them via Test-Driven Development as well, so they should be reasonably fast. You will usually have only a few tests per adapter.

Testing the whole application is expensive. We need the complete application context here. Usually, focusing on the happy path of the real-world use cases is sufficient here. So you will have very few integration tests here.

Of course this is just a rule of thumb, and there are always reasons for exceptions.

Test scope

Getting the scope right for each type of test is crucial if you don't want to suffer from bad performance, missing test cases or have duplicate test cases on different levels.

Let's take a look at an adapter test. Its job is to take data from the outside world, interpret it, and pass a query or a command to the application layer. This is just what happens here, with a Kafka consumer consuming price data

    @MockkBean
    lateinit var applicationService: Workflow<UpdateDepositPrice>

    @Test
    fun `published prices will be processed`() {
        // given:
        val articleId = "articleId"
        val currency = Currency.EUR
        val refund = 15
        val unit = CurrencyUnit.Cent

        // when:
        PriceUpdated(
            id = UUID.randomUUID().toString(),
            references = listOf(Reference("article", "id", articleId)),
            prices = listOf(Price(unit.name, currency.name, CustomAttributes(refund)))
        ).also {
            aKafkaProducer<PriceUpdated>(topic).sendMessage(it.id, it, PRICE_CREATED)
        }

        // then:
        val slot = slot<UpdateDepositPrice>()
        verify(timeout = 5000) { applicationService.process(capture(slot)) }
        slot.captured.let {
            assertThat(it.articleId).isEqualTo(articleId)
            assertThat(it.currency).isEqualTo(currency)
            assertThat(it.refundPrice).isEqualTo(refund)
            assertThat(it.currencyUnit).isEqualTo(unit)
        }
    }

We are using a mock bean to validate if the consumer works. Do not test what happens within the application service! This should already be covered by unit tests of the service itself.

There will be more examples next week! I will continue with the performance aspect of adapter tests. We will discuss the pros and cons of reusing application contexts, and explore Spring Boot test slices.

Move on to part eight or go back to part six.