Europe Union
Published: 31/08/2023

How to write better business tests with Kotlin

You probably know the feeling – you are joining a new project, and you are excited, happy, and motivated to change the world. You’ve already heard that the application is a well-tested Kotlin microservice in which business requirements are directly translated to functional tests. The team is even starting to use the TDD (test-driven development) approach every now and then. This makes you even happier. You finally got access to the repository, so you are cloning the project. You want to check what exactly given microservice is doing. What are the high-level operations exposed by it? You are opening the tests and…

You see the very technical tests with huge given / then sections. You need to go back to the domain model and database model to figure out what is going on. You need to read a lot of code and spend your energy trying to understand what the test is actually doing. I bet this is a feeling you also know quite well.

So, what can you do to prevent this? How can you refactor these tests to make them easily understandable for you and future developers? Maybe there are some Kotlin constructions that will make it easier? How can you communicate with the non-technical product owner, when all you can see are technical words like “insert”, “random”, “not null”, “request”, “query”, “coVerify”, “capture”, “slot”, etc.?

Let’s try to answer these questions below by refactoring a sample application.

Technical setup

The sample application is being built by gradle. For tests, I used kotest (with junit5 runner).

Some aspects of the application are significantly simplified (business rules, database in memory, ignoring the HTTP layer, etc.), so we can focus on the tests, and not on technical difficulties or complicated corner cases in business logic.

Implementation

Application is a part of a bigger veterinarian system. It stores pets, their diseases, and medications inside the database. It is made up of one service, a few repositories, some DTOs (data transfer objects), and custom exceptions.

Tests analysis

Include only important details to create concise tests

Since we have business requirements covered as functional tests, let’s open the service tests and analyze the first one.

"Should diagnose existing, unhealthy pet with existing disease" {
   val petRequest = PetRequest(
       status = PetStatus.UNHEALTHY,
       name = "Marcel",
       breed = "Birman Cat",
       age = 4
   )


   val petId = petRepository.insertPet(petRequest)
   val marcel = Pet(petId, petRequest.status, petRequest.name, petRequest.breed, petRequest.age)


   val diseaseName = "Toxoplasmosis"
   val diseaseId = diseaseRepository.insert(diseaseName)
   val toxoplasmosis = Disease(diseaseId, diseaseName)


   val pyrimethamineId = medicineRepository.addMedicineForDisease(diseaseId, medicineName = "Pyrimethamine")


   val recommendedMedicines = veterinarianService.diagnosePet(marcel, toxoplasmosis)


   recommendedMedicines.map { it.id }
       .shouldContainOnly(pyrimethamineId)


   petDiseaseRepository.getDiseasesForPet(petId)
       .shouldContainOnly(diseaseId)


   petRepository.get(petId)
       .shouldNotBeNull()
       .status.shouldBe(PetStatus.DIAGNOSED)
}

What can we learn from the test above? Well, we can see that Marcel is a Birman cat, which is unhealthy and quite young. We can see that we can insert it to the database in the form of a PetRequest DTO. We can see toxoplasmosis being saved to the database as well, and then pyrimethamine added as a medicine that is used to cure toxoplasmosis. The next line is veterinarianService.diagnosePet(…) which looks like a start for the when section. Based on that, the first part looks like a given section of the test. Then we are testing the diagnosePet method, which returns recommendedMedicines. We map the returned medicines to their ids and check if they contain only pyrimethamine (which we set up in the given section). We also get pet diseases from the database and check if toxoplasmosis was added correctly. At the end, we retrieve the pet from the database to check if it is not null and if the status was changed correctly (to DIAGNOSED).

In other words – we have an existing, unhealthy pet, an existing disease, and a medicine that cures it. Later, we diagnose the pet and check if the correct medicines were recommended to the veterinarian, and if the pet was diagnosed with the correct disease.

But wait a minute… Why does the description of the test contain so many sentences, but our latter business summary is so short? Maybe it is possible to write the test in such a way that its description will be concise and understandable from a business point of view, like our summary? Let’s try.

First, let’s think about which parts of the test are unnecessarily exposed and don’t add any value to the test from the business requirements perspective. These parts make us read more code and spend more energy trying to understand it, but they are not business related. After all, we are reading functional tests for the business service. Understanding business processes from these should be the biggest value.

As much as I love attention to details, I don’t think that we need to know that the pet is named Marcel, and is a four years old Birman cat. What I need to know is if the pet exists in the database. On this level of abstraction, I don’t care how the pet is being inserted into the database as well. If I need to understand the repository layer, I will open repository classes (or tests, if present), not service tests. The exact same rule applies to the code related to saving a disease. It doesn’t add any value to the test itself; it is boilerplate code. 

At the end of the test, I want to check if the correct medicines were recommended and if the pet’s disease history was properly stored. I don’t need to know how to retrieve diseases from the database, which repository I should use, that repository stores only IDs and I need to map a DTO to an ID, etc. Of course – there will be times when I will need to learn these details as well, don’t get me wrong. But when it happens, I will use different parts of the application to figure it out. I should not need to use service functional tests for this, for sure.

Hide unnecessary object creation details

When refactoring this test, I will use Kotlin’s extension functions a lot. They help with creating a fluent, easily readable API which can then be read almost like a test scenario.

Firstly, we will create some helpers to hide object creation. We will expose only these properties of the objects which are relevant to the test case that

private fun pet(petStatus: PetStatus): Pet =
   Pet(
       id = PetId(UUID.randomUUID()),
       status = petStatus,
       name = "Pet name",
       breed = "Pet breed",
       age = 8
   )
private fun disease(diseaseName: String): Disease =
   Disease(DiseaseId(UUID.randomUUID()), diseaseName)
private fun Disease.isCuredByMedicine(medicineName: String): Medicine {
   val medicineId = medicineRepository.addMedicineForDisease(id, medicineName)
   return Medicine(medicineId, medicineName, id)
}

Now we know how to easily create objects, without caring about the details inside, which are not relevant to the test. I personally wouldn’t even care about the disease and medicine names and would hide them as well. But the author of the tests put so much energy into matching diseases with their names, we can leave it to make him happy. Also, sometimes, such business names are helpful when debugging more complicated tests, so there can also be technical value in leaving them.

Hide unnecessary technical details

Another thing is hiding all details regarding database structure – we don’t need it here.

private fun Pet.savedInDatabase(): Pet {
   val petRequest = PetRequest(status, name, breed, age)
   val petId = petRepository.insertPet(petRequest)


   return copy(id = petId)
}
private fun Disease.savedInDatabase(): Disease {
   val diseaseId = diseaseRepository.insert(name)
   return copy(id = diseaseId)
}

We created two helpers that allow us to save data in the database. One for saving pets, another for saving diseases. We need to know, reading the test, that these entities are saved in the database, but we don’t need to know how it is being done under the hood.

As you can see – we used extension functions again to make the test more natural and fluent when reading.

The last part is an assertion. Let’s make them less technical and more businesslike. We will use extension functions once more.

private fun Pet.shouldBeDiagnosedWith(disease: Disease): Pet {
   petRepository.get(id)
       .shouldNotBeNull()
       .status.shouldBe(PetStatus.DIAGNOSED)


   petDiseaseRepository.getDiseasesForPet(id)
       .shouldContainOnly(disease.id)


   return this
}
private fun Collection<Medicine>.shouldRecommendOnly(medicineId: MedicineId) =
   map { it.id }.shouldContainOnly(medicineId)

Putting all of above together, we can rewrite our test to:

"Should diagnose existing, unhealthy pet with existing disease" {
   val pet = pet(PetStatus.UNHEALTHY)
       .savedInDatabase()
   val disease = disease("Toxoplasmosis")
       .savedInDatabase()
   val medicine = disease.isCuredByMedicine("Pyrimethamine")


   val recommendedMedicines = veterinarianService.diagnosePet(pet, disease)


   pet.shouldBeDiagnosedWith(disease)
   recommendedMedicines.shouldRecommendOnly(medicine.id)
}

As you can see, the given / when / then sections are more visible. The test can be read almost naturally, and you can easily understand what is being tested. We can see at first glance, what is important in the test, what we set up, what we test, and what we check. It is written mostly in business language, so you can talk with the product owner using the same terms, and he should easily understand it. You are using a similar level of abstraction throughout the test, so it is consistent and light. You will not have much trouble reading it after a few months.

Reusability of helpers and asserts

Let’s take a look at the next test.

"Should diagnose existing, already cured pet with still existing disease" {
   val petRequest = PetRequest(
       status = PetStatus.CURE_IN_PROGRESS,
       name = "Marcel",
       breed = "Birman Cat",
       age = 4
   )


   val petId = petRepository.insertPet(petRequest)
   val marcel = Pet(petId, petRequest.status, petRequest.name, petRequest.breed, petRequest.age)


   val diseaseName = "Toxoplasmosis"
   val diseaseId = diseaseRepository.insert(diseaseName)
   val toxoplasmosis = Disease(diseaseId, diseaseName)


   val pyrimethamine = medicineRepository.addMedicineForDisease(diseaseId, medicineName = "Pyrimethamine")


   val recommendedMedicines = veterinarianService.diagnosePet(marcel, toxoplasmosis)


   recommendedMedicines.map { it.id }
       .shouldContainOnly(pyrimethamine)


   petDiseaseRepository.getDiseasesForPet(petId)
       .shouldContainOnly(diseaseId)


   petRepository.get(petId)
       .shouldNotBeNull()
       .status.shouldBe(PetStatus.DIAGNOSED)
}

What can we see here? Marcel is being cured but is still sick, unfortunately. The medicines clearly don’t work since we can diagnose him again with the same disease. Again, this is a nice story, but it doesn’t help with understanding the test. Fortunately, we have our helpers from the previous test. We can use them here as well.

"Should diagnose existing, already being cured pet with still existing disease" {
   val pet = pet(PetStatus.CURE_IN_PROGRESS)
       .savedInDatabase()
   val disease = disease("Toxoplasmosis")
       .savedInDatabase()
   val medicine = disease.isCuredByMedicine("Pyrimethamine")


   val recommendedMedicines = veterinarianService.diagnosePet(pet, disease)


   pet.shouldBeDiagnosedWith(disease)
   recommendedMedicines.shouldRecommendOnly(medicine.id)
}

Since writing these helpers, or asserts, can take some time (which is a disadvantage, of course), you can see that with many tests you can reuse them easily. And you usually have multiple tests for one service operation (you have, right…?). It turns out that you actually saved some time and energy, because you don’t need to type a lot of code when creating, mapping, and inserting DTOs into the database, filled with unnecessary details, etc.. After this refactor, tests look more readable, AND it takes less time to write them. Yea, count me in.

Wrapping technical code into business methods

In the given section, we can, of course, do more than just hide object creation and insertion into the database. We can wrap, as a business method, every single thing that is not readable, too technical, or we simply want to ememphasise. Take a look at the test:

"Should not diagnose pet which does not exist" {
   val schrodinger = Pet(
       id = PetId(UUID.randomUUID()),
       status = PetStatus.UNHEALTHY,
       name = "Schrodinger",
       breed = "Abyssinian Cat",
       age = 8
   )
   petRepository.exist(schrodinger.id).shouldBeFalse()


   val diseaseName = "Roundworms"
   val diseaseId = diseaseRepository.insert(diseaseName)
   val roundworms = Disease(diseaseId, diseaseName)


   shouldThrow<PetNotFoundException> {
       veterinarianService.diagnosePet(schrodinger, roundworms)
   }
}

The important part here is that the pet is not present in the database. We have a line with the database’s exist implementation that is kind of lost among the unnecessary details. We can add simple helpers like:

private fun Pet.notSavedInDatabase(): Pet {
   petRepository.exist(id).shouldBeFalse()
   return this
}

and rewrite this test using our helper to:

"Should not diagnose pet which does not exist" {
   val pet = pet(PetStatus.UNHEALTHY)
       .notSavedInDatabase()
   val disease = disease("Roundworms")
       .savedInDatabase()


   shouldThrow<PetNotFoundException> {
       veterinarianService.diagnosePet(pet, disease)
   }
}

Now it is easily noticeable that the pet is not saved in the database (in contrast to the disease).

When to wrap technical code into business methods

Another example of hiding unnecessary technical details is hiding all the random draws we are doing in the code. Instead of this:

val undiagnosedStatuses = PetStatus.values().toMutableList() - PetStatus.DIAGNOSED
val undiagnosedRandomStatus = undiagnosedStatuses.random()
val petRequest = PetRequest(
   status = undiagnosedRandomStatus,
   name = "Prince",
   breed = "Domestic Shorthair Cat",
   age = 2
)


val petId = petRepository.insertPet(petRequest)
val prince = Pet(petId, petRequest.status, petRequest.name, petRequest.breed, petRequest.age)

We can have that:

val pet = pet(undiagnosed())
   .savedInDatabase()

if we add a helper

fun undiagnosed(): PetStatus {
   val undiagnosedStatuses = PetStatus.values().toMutableList() - PetStatus.DIAGNOSED
   return undiagnosedStatuses.random()
}

When creating such helpers, we need to ask ourselves a question. Do we care about what we are doing in the test? Or do we care how we do it? If the first is more important – it is a nice place to think about the helper to hide details. If the latter – it should not be hidden. Here, we care about getting an undiagnosed status. How we do it doesn’t really matter. So we hid the details about drawing a random status from the subset of statuses.

How to figure out the correct helper’s name

When creating such an API, try to put it in context when thinking of the name of the method. You should aim for the greatest readability of the test. You shouldn’t be afraid of experimenting and using method names, which can be quite unusual. We have a simple, one line helper:

private fun withAnyStatus() =
   PetStatus.values().random()

The name of the method, without the context, isn’t the best. But if you read the code which uses it,

val pet = pet(withAnyStatus())
   .savedInDatabase()

It suddenly starts to make sense. Since you create a private method in the test, in a very small scope, I think this name is quite good and can be put into the code without any regrets.

End result

After refactoring all of the tests, we reduced the number of code lines in the whole file from 262 to 225. Isn’t impressive, right? Agreed, it is not. But what is far more important, we reduced the number of code lines in the tests themselves from 224 to 111. So the next time, after a few months, when you will need to remind yourself what these tests and the service do, you will need to read half of the code you needed before. You can skip the rest if you don’t need specific details about some operations. I think this is actually impressive for such a small, simple application. Some of the rules presented here can also be used when implementing production code. You can find the whole code with implementation here – https://github.com/jmatacz/simple-business-tests-example.

Business requirements of the application

Now, the tests are easy to read, and we can simply identify the business rules for the service:

  • you need to be able to diagnose a pet, that already exists in the database
  • pets can only be diagnosed with diseases that already exist in the database
  • you can only diagnose a pet if it is unhealthy or its treatment is in progress (you need to check if medications work or not)
  • application should recommend medicines after a diagnosis
  • pet’s treatment can start only after a diagnosis, used medicines need to be saved
  • application should allow to mark pet as healthy, no matter what

When to use / not to use this approach

As you can see, you increased the readability of these business tests, at the expense of adding helper’s code. This code, of course can have bugs in it (but the rule of thumb is to make helpers so small and simple that making a mistake is almost not possible). You also need to maintain it. Sometimes you will need to remove some methods, generalize others, etc. The clear value though is readability and reusability. You can tell the difference even with such a small example as the presented application with such simple business logic. Try to imagine how much it can do in real, big applications. 

In general, the more business processes, the greater the value. If you have applications with a big service layer, and many complicated business rules implemented there, this approach will be for you for sure.

On the other hand, if you have a CRUD application (even a big one, it doesn’t matter) with a service layer mostly used to proxy calls to the repository, it is probably not for you. You will pay the price of creating and maintaining helpers, but you will not increase readability that much. Of course, you can create some simple asserts (f.e. isTheSameAs to compare different objects with the same data inside) but creational helpers won’t help you too much. There will be too many combinations to cover with helpers. You also won’t increase the readability of the business rules because you usually don’t have many business rules in CRUD applications.

The same applies to other layers of application. Testing the repository layer, or mapping layer with such an approach usually will not pay off. The result will be similar to the CRUD application. Technical tests in these layers are perfectly fine because they mostly focus on how to do things. Business tests are related to what you want to do instead of how.

tldr;

You can hide many technical details, (for example, object creation, database technical structure, etc.) with Kotlin’s extension functions. It can increase readability at the expense of helpers’ code to maintain. You emphasize what is important to the test and hide what is only a technical detail. You show what you want to achieve, and you hide how you achieve it.

You use the same terms in tests as business people, which helps with communication later. 

You use terms on a similar abstraction level, which makes tests more consistent and understandable.

You will get the most value from this approach when you have large service layer with many business processes implemented there. Usually, you will not achieve much with this approach in CRUD applications or when testing technical layers.

References

Join the Kotlin Crew

Kotlin Crew is the fastest-growing Kotlin community! It was created by DAC.digital to establish a vibrant and inclusive place for Kotlin enthusiasts to network, exchange knowledge, and learn.

If you are looking for real-life Kotlin use-cases, insights about language features, and integrations with other technologies – take your seat because you are in the right place. 

What can you find in the Kotlin Crew Community?

  • technical articles by developers who program in Kotlin on a daily basis,
  • video interviews with business owners and developers who use Kotlin in their projects and share their stories and insights with us,
  • links to articles on Kotlin-related news,
  • a space to ask questions, share ideas and seek new solutions.

Join the Kotlin Crew!

Learn more about Kotlin Crew Community!
ornament ornament