1. Overview

Testing is integral to software development, ensuring our code behaves as expected and is free from unintended bugs. In the Scala ecosystem, ScalaTest stands out as a powerful tool. ScalaTest offers many features for writing comprehensive tests. Even so, assertions remain at the heart of verifying our code’s behavior.

In this guide, we’ll delve into three fundamental assertions provided by ScalaTest. Moreover, we’ll look at compile-time assertions and their utility for testing DSLs. Using these assertions, our code will be more reliable and maintainable.

Before diving into the specifics, setting up the necessary dependencies is essential. Luckily for us, Baeldung already has an article dealing with it.

2. Shadowing the Prelude’s assert

In ScalaTest, the assert macro is a fundamental tool for verifying conditions within your tests. ScalaTest’s assert macro shadows the assert function from Scala’s Prelude. This overlap can be a potential source of confusion for those unfamiliar with the distinction.

The assert function in Scala’s Prelude is a standard feature available in the default environment where Scala code operates. In contrast, ScalaTest’s assert is tailored explicitly for testing scenarios, offering features that enhance the testing experience.

One of the primary distinctions between the two is their nature and the exceptions they throw upon failure. While Prelude’s assert is a function that throws an AssertionError when the assertion fails, ScalaTest’s version is a macro that throws a TestFailedException.

This difference is more than just semantic: It directly impacts the feedback you receive during test failures. ScalaTest’s macro-based approach provides detailed error messages that display better hints leading to the failure — a feature not present in Prelude’s function-based assert.

For instance, when using assert in ScalaTest to verify conditions, the test proceeds without interruption if the condition holds. If it doesn’t, ScalaTest promptly throws a TestFailedException. Let’s see a quick example:

val sum = 1 + 1

assert(sum == 2) // Passes smoothly

assert(sum == 3) // Fails, triggering a TestFailedException("Expected 3, but got 2") 

2.1. assertResult

ScalaTest’s assertResult is a specialized assertion designed to compare the result of a code block against an expected value. Using assertResult, we improve the failure report and clarify the intention of the test.

Let’s see it in action:

assertResult(7) {
  val sum = 3 + 4
  sum
}

In the code above, assertResult checks if the code block produces the expected value of 7. If the result of the block (the value of sum) were anything other than 7, ScalaTest would throw a TestFailedException, detailing the discrepancy between the expected and actual values.

3. Testing the Unhappy Path with assertThrows

In many scenarios, we don’t just want to verify that our code produces the correct result; we also want to ensure it fails correctly. This is where ScalaTest’s assertThrows comes into play. We can use it to check that a code block throws a specific exception, making it invaluable for testing error-handling logic.

Consider a function that parses a string to extract an age:

def parseAge(ageString: String): Int = {
  val age = ageString.toInt
  if (age < 0) throw new IllegalArgumentException("Age cannot be negative")
  age
}

This function should throw an IllegalArgumentException if provided with a negative age. We can use assertThrows to verify this behavior:

3.1. Using withClue to Improve Failure Messages

Sometimes, we want to provide additional information about the context or why we expect the exception. This is where withClue shines.

Consider a scenario where we’re testing a function that processes user input. We expect an IllegalArgumentException if the input is invalid. Using assertThrows with withClue can provide a clearer picture:

def parseAge(ageString: String): Int = {
  if (ageString.isEmpty)
    throw new IllegalArgumentException(
    "Cannot convert and empty String to an age"
    )
  val age = ageString.toInt
  if (age < 0) throw new IllegalArgumentException("Age cannot be negative")
  age
}

"withClue" should "provide additional information in case of test failure" in {
  withClue("Parsing an empty input should throw an IllegalArgumentException: ") {
    try {
      parseAge("")
    } catch {
      case e: IllegalArgumentException =>
        assert(e.getMessage().equals("Cannot convert and empty String to an age"))
    }
  }
}

If the test fails (if the exception is not thrown), the error message will be prefixed with the clue provided, consequently giving the failure a more precise context. Combining withClue and assertThrows, we ensure that our code behaves as expected and that any deviations are reported with the necessary context to facilitate a solution.

4. Ensuring Currency Type Safety with ScalaTest

Scala’s powerful type system allows us to create a type-safe DSL. Let’s create a simple one for currency operations. Combined with ScalaTest’s compile-time assertions, we can ensure that our design is robust and prevent common mistakes:

sealed trait Currency
case object USD extends Currency
case object EUR extends Currency

case class CurrencyAmount[C <: Currency](amount: Double, cur: C) {
  def +(other: CurrencyAmount[C]): CurrencyAmount[C] =
    CurrencyAmount(this.amount + other.amount, this.cur)
}

object Currency {
  implicit class RichDouble(value: Double) {
    def usd: CurrencyAmount[USD.type] = CurrencyAmount(value, USD)
    def eur: CurrencyAmount[EUR.type] = CurrencyAmount(value, EUR)
  }
}

4.1. Leveraging assertDoesNotCompile, assertCompiles, and assertTypeError

With this setup, adding two USD values is type-safe, but trying to add a USD to a EUR would be a type error. Consequently, we can use ScalaTest’s assertions to ensure our DSL enforces this restriction:

class CurrencyTests extends AnyFlatSpec with Matchers {
  import Currency.RichDouble

  "Currency DSL" should "allow adding amounts of the same currency" in {
    val a = 80.0.usd
    val b = 70.0.usd
    (a + b) should be(150.0.usd)
  }

  it should "not compile when trying to add different currencies" in {
    assertDoesNotCompile("50.0.usd + 100.0.eur")
  }

  it should "not compile due to a type error when adding different currencies" in {
    assertTypeError("50.usd + 100.eur")
  }
}

Both assertDoesNotCompile and assertTypeError ensure that a code snippet doesn’t compile. However, while the former checks for non-compilability for any reason, the latter checks explicitly for type errors.

For DSL writers, assertTypeError is particularly valuable. It confirms that an operation is disallowed and ensures the reason is type-related, providing more precise feedback to the DSL users.

For instance, if the above assertTypeError test fails, the error message would highlight the type mismatch, guiding users towards correctly using the DSL.

5. Conclusion

In this tutorial, we’ve navigated the rich landscape of ScalaTest’s assertion mechanisms, from the primary assert to the specialized assertResult and assertThrows.

We’ve also explored compile-time assertions, a feature that sets ScalaTest apart, especially for DSL writers. These assertions not only validate the correctness of our code but also enhance its reliability and maintainability.

The detailed feedback provided by ScalaTest’s macro-based assertions is invaluable for debugging. Understanding when and how to use these tools is crucial for writing robust, meaningful tests. With this knowledge, we can build more reliable and maintainable Scala applications.

As always, the complete source code for the examples is available over on GitHub.