How to write flexible unit tests in Scala

Barak Marciano
HiBob Engineering & Security Blog
8 min readFeb 20, 2023

--

Kevin Ku / Unsplash

I have always struggled with unit tests in the past. They were broken a lot of the time, and it was a nightmare to add new ones or to maintain existing tests. I would like to share with you some tips and tricks I have collected that have helped turn this experience into something more manageable and friendly.

I believe you would agree that unit tests should ensure that the inner components of the system work, including any edge cases that may come up when running our program.

A bit of context

In the following example, I would like to present a delivery company that schedules delivery time according to the availability windows in its calendar across all sites.

A site is a city around the world the company operates in, such as London, New York, Tel Aviv, etc. Each site has a calendar that contains the timeframes that were scheduled for delivery. We might have a calendar that is shared between multiple sites.

I went over our code and created the following test according to practicals I saw in some of the legacy tests that we can refactor:

Did you find this code easy or challenging to understand? How many times did you have to read it in order to understand the functionality that we checked here? If you are like me, I assume that it needed to be more straightforward and required too much effort to understand what the person who wrote the test intended to check.

Let’s dive into it and see what can be done to improve it.

Test as Google Maps

I like to refer to unit tests as the Google Maps concept.

Take a look at these images that were taken from the Google Maps application.

We can see that Google only shows us those details relevant to the specific zoom level we choose. Above, we can see a map of part of Manhattan. In the picture at the left, when we zoom out, we can see all of Central Park, with a few points of interest, but not much more than that. However, as we zoom in (the middle and right pictures), we start to see more details like the streets, buildings, pubs, etc.

My takeaway is that just like Google Maps encapsulates details by showing only the relevant ones, we should do the same when we write tests.

Use “given” methods

In unit tests, we mock other classes using libraries like Mockito. When we want to mock a response data, we write lines like the following in the test:

when(siteCalendarDao.getSiteIdsToCalendarIds(siteId1, siteId2)) thenReturn 
Map[SiteId, CalendarId](siteId1 -> calendarId1, siteId2 -> calendarId2)

The problem with this approach is that it needs to be more readable. Calls like when and thenReturn on the siteCalendarDao mock object are implementation details that obscure and distract from the real content of the test. As a reader of the test, these details don’t give me any valuable information to better understand what is being tested. Instead of focusing on the what, you focus on the how.

We can encapsulate the implementation details and extract this line into a method like this:

I prefer the convention of starting these methods with the word given. This method takes the parameters that the method we want to mock accepts, plus the return value we want it to return that I put as the last parameter in the given method.

The usage in the test will be:

givenCalendarsAssociatedToSitesFor(Seq(siteId1, siteId2), siteId1 -> calendarId1, siteId2 -> calendarId2)

I think you will agree that this creates a much more legible code. I’m not necessarily saying that it’s easier to maintain this code, but we also avoid code duplication while mocking the same function again and again.

Let’s extract all the mock responses into given methods and see what the test looks like:

These are the given methods I’ve created for our test:

It’s not perfect, but I hope you would agree that we created a much more legible and focused test by moving some of the implementation details to the outer level.

Create test fixtures

In most of our tests, we need to instantiate some case classes.

In this test, for example, we need to instantiate the following classes: DeliveryTime, CalendarWithDeliveryTimes, SiteOut, and SiteDeliveryTimes.

Let’s take a closer look at DeliveryTime:

One of the issues we face here is that not all the parameters that are required in order to instantiate the DeliveryTime are relevant to the test. Do I care about the modifiedBy param? Is it affecting the test result? Probably not.

Another issue is that it is pretty annoying to create the DeliveryTime. It takes many parameters. Sometimes we even have a case class that takes another case class as a parameter.

What can we do here? Here is a very simple solution — let’s create a test fixture method for it.

Instead of instantiating the DeliveryTime case class inside the test, we can extract this logic into a method:

In general, I prefer to name these methods as a/an<TheCaseClassName>.

Usually, these methods will take the same parameters as the case class takes, with default values, and instantiate it.

How does the usage look in the test?

val deliveryTime1 = aDeliveryTime(calendarId = calendarId1)
val deliveryTime2 = aDeliveryTime(calendarId = calendarId2)
val deliveryTime3 = aDeliveryTime(calendarId = calendarId2)

Now you can see how simpler it is to create a new delivery time in the test!

We will override only the parameters that are relevant to the test, which is only the calendar id. Even when we will change the case class and, let’s say, we would add a new parameter, we won’t need to go over millions of tests and start fixing the compilation error. We just need to add another parameter to the aDeliveryTime method with a default value and pass it to the case class. Now it is much easier to maintain our tests.

Another small note that I want to add here is that, as you can see, the default values I used are randomized wherever they can be.

Imagine you have a test that checks that we update the delivery time name in our DB, and we use it to initialize a calendar even the following:

val deliveryTimeToUpdate = aDeliveryTime(calendarId = calendarId2, name = "deliveryTime1")

Do we care if the name is deliveryTime1 or deliveryTime2? Does our test work only with delivery times whose name is deliveryTime1? Of course not. When we use methods like randomStr, we tell the reader that this parameter can be any string. That’s why I personally like working with random values and not specific ones.

Let’s create test fixtures methods for all the case classes we use in our test and see the result:

And these are the test fixtures methods I’ve been created:

Assert with Matchers

There are some cases where we want to assert the result of the method we want to check, but the result is pretty complex. Similarly, we may want to assert a specific aspect of the result and not the naive assertion of the entire result.

What is a matcher?

A matcher is a function that takes a parameter [T] and returns a MatchResult.

trait Matcher[-T] extends Function1[T, MatchResult]

The assertion in our test is pretty naive, and to be honest, I have seen worse examples than what I have presented here. I want to beg your indulgence, and let’s leave our test behind for a second in order to better explain the usage of matchers.

Let’s imagine we have a test where we get a LocalDateTime from the method we check and want to assert the date equals yesterday.

We can write something like

localDateTime.toLocalDate mustBe LocalDate.now().minusDays(1)

However, we add some irrelevant implementation details to the test in this case. The next person who will need to read this test will need to put in some effort in order to understand what we assert here, even though, in this example, it’s pretty simple.

What can we do about it?

We can create a matcher that will simplify the test:

And in the test, we can use it as

localDateTime must beYesterday

The next developer who will read this test can read the assertion clause as if it were written in plain English. The wording is straightforward and easily understandable.

Now that we have looked at this simple example let’s implement a matcher for our test:

And the test looks like that:

Now, as I said at the beginning, this is an old test that was written a long time ago, so let me clean it up a bit by taking some trivial steps, such as creating variables and using random values:

Of course, this could be better. We can continue to refactor the test more, but now the test is much more readable and easy to maintain. We avoided duplication, and if we want to add another test here, we can do so easily.

Parameterized Tests

The last thing that I want to mention is the concept of parameterized tests.

Sometimes we want to run the very same test on a different set of inputs. For example, let’s imagine that we have an enum with 10 values, and we want to run the test on each value.

One way to do this is to duplicate the test 10 times. I think you would agree that it’s not a best practice.

Another way we can do it is by using a parameterized test that will take the set of values and will run the test 10 times once for each one of the values.

Take a look at the following example:

Here, we want to check that when we call service.getCurrency for Germany, the currency we get is Euro.

The problem is that we want to check it for several countries where Euro is their currency.

So, I’ve created a list with all these countries in the test class:

  val countriesThatPayInEuro = Table("countries",
"Italy",
"Ireland",
"Greece",
"Germany",
"France",
"Finland",
)

And created a parameterized test:

The test runs on each country, as you can see, and asserts that for each of them, the method returns Euro.

Takeaways

  1. I would like to draw your attention to how much effort we put into making the tests more readable and easy to maintain. The test logic is the main thing and the most important part of the tests, and any other test components need to play second fiddle to the test logic itself and not distract from it.
  2. Tests are our really good friends. If we take care of them, they will return their love.
  3. If someone looking at our tests wants to understand more deeply how we create any of the finer implementation details of the test, they can always “zoom in” a bit and look at the methods we created — but at the high zoom level, they will read the test as a book.
  • These code examples were written in ScalaTest 3.0.8

--

--