Unit Test Scope (Domain)

By understanding what a function is and is not responsible for we reduce duplication and make our code easier to understand and more resilient to change.

Unit Test Scope (Domain)
Photo by Paul Skorupskas / Unsplash

This article discuses what I consider to be the missing piece to the unit testing puzzle. By understanding what a function is and is not responsible for we reduce duplication and make our code easier to understand and more resilient to change. We will be unit testing business logic (domain) specifically. Testing of exernal dependencies (adapters) will be covered in another article, but the message reamins the same - "keep your tests focused".

Scenario

Lets say that we have a function:

fun getNumbersBetween(val userKey: String, val a: Int, val b: Int): List<Int> {
	auditEventPublisher.publishEvent(userKey, a, b);
    return numberEngine.generateNumbersBetween(start, end);
} 

getNumbersBetween does 3 things:

  • Publish an audit event to record a user accessing this result.
  • Calls generateNumbersBetween with the input values.
  • Returns whatever generateNumbersBetween gives us.

Here are some ways that I've seen testing this kind of function go wrong.

Turning this into an Integration Test

AuditEventPublisher and NumberEngine might both be external applications. AuditEvent may publish through RabbitMQ, and NumberEngine might use some kind of Database. If the test needs to work with these external dependencies then it drastically increases the difficulty to setup this test.

Instead we can keep this as a unit test and use mock() implementations of AuditEventPublisher and NumberEngine. We use Dependency Injection to bind real implementations when running the application, and mock implementations while running tests (See How we enforce this).

In some cases NumberEngine could live inside this project, but AuditEventPublish outside of it. A developer might test numberEngine.generateNumbersBetween because its easy, but skip testing the audit event because it's hard. Someone could then remove the audit event line and not tests would fail. This is bad.

Calculating the return value yourself

val a = 1
val b = 1000
val results = listOf(1,2,3,4,5,6,...1000)

Another mistake I've seen in a similar scenario is using the real results in your test. Why is this a problem?

  • The result could be a large object or list adding a lot more code to your test to create it. Sometimes people will try to solve this by moving the result to the top of the file and then using it in multiple tests, unfortunately this couples all the tests that now depend on this value.
  • The answer may change. If during development we decide that the algorithm needs to change, then so does this test. We could switch from oddNumberEngine to evenNumberEngine. The developer that needs to update the List of 1000 Ints will be very unhappy.
  • Similar to above we may support multiple NumberEngines depending on an environment variable. Now which result do we use? Do we need to duplicate these tests for every engine that gets added?

Solution

This leads to my point. It is not this functions responsibility to return the correct value. This function is responsible for returning whatever numberEngine gives it. So there are 3 things that we want to assert on when testing this function:

  • auditEventPublisher.publishEvent is called once with the expected values.
  • The values passed into numberEngine.generateNumbersBetween are what we expect.
  • The return value is what we set on the numberEngine.generateNumbersBetween mock for our input values.
@Test
fun `test getNumbersBetween()` {
    val userKey = randomAlphanumeric()
    val a = RandomInt()
    val b = RandomInt()
    val expected = RandomList<Int>()
    
    val mockAuditEventPublisher = mock()
    val mockNumberEngine = mock()
    
    // generateNumbersBetween will only return this result for these 
    // specific input parameters
    whenever(mockNumberEngine.generateNumbersBetween(a, b))
    	.thenReturn(expected)
    
    val domain = Domain(mockAuditEventPublisher, mockNumberEngine)
    val result = domain.getNumbersBetween(userKey, a, b)
    
    // Assert that the audit event was called once
    // with the correct parameters
    verify(mockAuditEventPublisher, Times(1)).publishEvent(userKey, a, b)
    
    // Assert that the return value is the one that
    // we told the mock to provide
    result shouldBe expected

}

Great. Now the test is focused. As a result it's:

  • Easy to write (generate inputs/outputs, setup mocks, call target function, make assertions)
  • Smaller (no large hard-coded results objects).
  • Faster (unit test as opposed to integration test).
  • Resilient (if the number engine changes we don't need to modify our test).
  • Explicit (I don't care what my inputValues are, only how they are used: https://blog.jroddev.com/smaller-clearer-tests/)
  • High coverage (If someone removes the audit event your test will fail)

How we enforce this

Our applications use a Ports and Adapters architecture (Hexagonal) that splits a service into 3 distinct subprojects:

  • Domain: Pure Business Logic
  • Adapters: Code that handles data in and out. HTTP routes (in), Connecting to Databases (out), RabbitMQ messages (in and/or out).
  • Port: Common interfaces to be used by both Ports and Adapters.

The focus of this article is testing of the Domain. Domain and Adapters cannot talk to each other directly, instead they are connected via dependency injection at runtime.

While running tests:

  • Adapters receive mocked Domain implementations
  • Domain received mocked Adapters implementations.

While running the application:

  • Adapters receive real Domain implementations.
  • Domain receives real Adapters implementations.

In this article Simon goes over an early version our application structure:

Hexagonal Architecture with Kotlin, Ktor and Guice
A guided tour of our service template