Hi, I’m Phuc, software engineer at AI Tech Studio, Dynalyst Team.

Automated testing is an important part of Software development. A well implemented automated test system improves development productivity, but on the other hand is difficult to write, understand, and tests that are difficult to maintain will increase the software development effort. Gerard Meszaros pointed out in his famous xUnit Test Patterns: Refactoring Test Code book that test maintenance should not increase the cost of software development. Today, I would like to introduce some simple ways in which ScalaTest – a famous testing library in Scala, helps write better tests.

1. Sharing fixture setups

When multiple tests need to work with the same fixtures, we should try to avoid duplicated fixture setup code. A good way of structuring tests is separating the fixture setup and behavior assertions. ScalaTest provides withFixture to share fixtures across tests.

class AdServingServiceSpec extends FixtureAnyFlatSpec with Matchers {
  case class FixtureParam(
    userService: UserService,
    adCandidateService: AdCandidateService
  )

  override protected def withFixture(test: OneArgTest): Outcome = {
    val fixture = FixtureParam(
      new UserService(),
      new AdCandidateService()
    )

    withFixture(test.toNoArgTest(fixture))
  }

  it should "find right ad for right user" in { fixture =>
    val user = fixture.userService.getUser()
    val ad = fixture.adCandidateService.getBestCandidateFor(Some(user))

    ad shouldBe bestAdForUser
  }

  it should "return nothing when no user" in { fixture =>
    val ad = fixture.adCandidateService.getBestCandidateFor(None)

    ad shouldBe empty
  }
}

If your fixture setup is complicated, another approach is using fixture-context objects.

2. Type-safe assertions

ScalaTest users often use shouldBe (or should equal) for equality assertions.
For example:

"Hi" should equal "Hi"

How about this case?

Some("Hi") should equal "Hi"

Actually, it failed. But wait, can we do it better? Like, enforce type constraints, and raise an error at compile-time.
ScalaTest has triple equals syntax === and TypeCheckedTripleEquals trait to do this.

The following code raises compilation error with types Some[String] and String do not adhere to the type constraint message.

class SampleSpec extends AnyWordSpec with Matchers with TypeCheckedTripleEquals {
  "sample spec" should {
    "return true" in {
      Some("1") should ===("1")
    }
  }
}

For negative equality assertion and type constraints, we can use !== syntax.

3. Property-based testing

Property-based testing is a test writing strategy for checking whether the application behavior abides by the property or composed property rules. Instead of focusing on individual examples, property-based testing takes into consideration the higher-level behavior covered by multiple inputs at the same time. ScalaTest supports property-based testing as follows:

class TestAddMethodSpec 
  extends AnyFlatSpec 
  with Matchers 
  with TableDrivenPropertyChecks {

  "add method" should "return addition of 2 numbers" in {
    val combos = Table(
      ("a", "b"),
      (-10, -11),
      (0,   1),
      (10,  11),
      (20,  21)
    )

    forAll(combos) { (a: Int, b: Int) =>
      add(a, b) should ===(a + b)
    }
  }
}

You can also use ScalaCheck for generating the test data automatically.

4. Informative fail message

Informative fail messages save us a lot of time when debugging. For example, we have the following multiple assertions test:

class ExampleSpec extends AnyFlatSpec with Matchers {
  case class Obj(attr1: Int, attr2: Int, attr3: Int)

  "constructor" should "create new object" in {
    val data = Obj(2, 1, 2)

    data.attr1 shouldBe (2)
    data.attr2 shouldBe (2)
    data.attr3 shouldBe (2)
  }
}

The test will fail with:

[info] - should sample *** FAILED ***
[info]   1 was not equal to 2

Looking at the message we do not get an idea of where the failure occurs. Imagine the test failed on CI, to fix the test, we open the IDE, run tests locally, start debugging, add some println
We can reduce all the above steps by adding a clue to the failure message.

withClue("attr1 =") { data.attr1 shouldBe (2) }
withClue("attr2 =") { data.attr2 shouldBe (2) }
withClue("attr3 =") { data.attr3 shouldBe (2) }

Then, the failure message is [info] attr2 = 1 was not equal to 2.

Summary

In this blog, I have introduced four techniques in ScalaTest for writing better tests.
1. Share fixture setups
2. Use type-safe assertions
3. Use Property-based testing
4. Provide informative fail messages

I hope it will help you.
Happy codding!