Creating Basic Custom Matchers in ScalaTest

If you are working in a large Scala codebase, it can sometimes be challenging to reduce the complexity of your tests. ScalaTest offers a vast array of tools to write clean, readable tests — including its Matcher class for creating our own custom Matcher.

While ScalaTest documentation dives into great detail (covering more advanced topics of custom Matchers), it lacks an excellent example of basic implementation. This article will look at the basics for setting up a custom Matcher using some simple example code.

The Code

Let’s look at some code we’d like to test:

class AssessmentEvaluator(correctAnswers: Assessment) {
  def evaluate(assessment: Assessment): AssessmentResult = {
    ...
  }
}

case class AssessmentResult(pass: Boolean, failures: Set[Failure])
case class Failure(questionName: String, correctAnswer: Answer, answerProvided: Answer)
case class Assessment(questionOne: Answer, questionTwo: Answer, questionThree: Answer)
object Answer extends Enumeration {
  type Answer = Value
  val A, B, C, D = Value
}

Here we have a class that is responsible for evaluating an Assessment of multiple-choice questions and providing an AssessmentResult.

Our tests might look like:

class AssessmentEvaluatorTest extends AnyFunSuite with Matchers {
  test("should fail last question") {
    val correctAnswers = Assessment(A, C, B)
    val providedAnswers = Assessment(A, C, A)
    val expectedFailures = Set(Failure("Question Three", correctAnswers.questionThree, providedAnswers.questionThree))

    val assessmentResult = new AssessmentEvaluator(correctAnswers).evaluate(providedAnswers)

    assessmentResult.pass shouldBe false
    assessmentResult.failures shouldBe expectedFailures
  }

  test("should pass") {
    val correctAnswers = Assessment(B, C, B)
    val providedAnswers = Assessment(B, C, B)

    val assessmentResult = new AssessmentEvaluator(correctAnswers).evaluate(providedAnswers)

    assessmentResult.pass shouldBe true
    assessmentResult.failures shouldBe Set.empty
  }
}

Now, in our simple example, these tests are probably sufficient, but we can use them to explore how we would implement a custom Matcher.

Passing Test Case

Let’s start with the “passing” case. We want to simplify our assertion down to assessmentResult should pass. Here’s the basic structure to get us there:

trait AssessmentResultMatcher extends Matchers {
  def pass = new PassingAssessmentResultMatcher

  class PassingAssessmentResultMatcher extends Matcher[AssessmentResult] {
    def apply(assessmentResult: AssessmentResult): MatchResult = {
      ...
    }
  }
}

We define in our trait the property pass, which returns us our PassingAssessmentResultMatcher. By extending Matcher[AssessmentResult], we get our apply method, the one parameter of which will be the value of the left side of should in our expression assessmentResult should pass and must be of type AssessmentResult.

Inside our apply method, we can define our assertion logic and return a MatchResult:

class PassingAssessmentResultMatcher extends Matcher[AssessmentResult] {
  def apply(assessmentResult: AssessmentResult): MatchResult = {
    val assessmentPasses = assessmentResult.pass && assessmentResult.failures.isEmpty
    MatchResult(assessmentPasses, "Assessment did not pass.", "Assessment passed when it should not have.")
  }
}

If we now go back to our test suite, we can mix in our trait so that it’s available for us to use and clean up our passing test case:

class AssessmentEvaluatorTest extends AnyFunSuite with AssessmentResultMatcher {
  ...
  test("should pass") {
    val correctAnswers = Assessment(B, C, B)
    val providedAnswers = Assessment(B, C, B)

    val assessmentResult = new AssessmentEvaluator(correctAnswers).evaluate(providedAnswers)

    assessmentResult should pass
  }
}

Failing Test Case

For the failing test case, we need to make a few changes. We want to be able to check not only that assessmentResult.pass is not true, but also that we have the correct expected set of Failure. To do this, we need to pass more information to our Matcher. We will define another property and corresponding class in our AssessmentResultMatcher:

trait AssessmentResultMatcher extends Matchers {
  def pass = new PassingAssessmentResultMatcher
  def failWith(expectedFailures: Set[Failure]) = new FailingAssessmentResultMatcher(expectedFailures)

  class PassingAssessmentResultMatcher extends Matcher[AssessmentResult] {
    def apply(assessmentResult: AssessmentResult): MatchResult = {
      val assessmentPasses = assessmentResult.pass && assessmentResult.failures.isEmpty
      MatchResult(assessmentPasses, "Assessment did not pass.", "Assessment passed when it should not have.")
    }
  }

  class FailingAssessmentResultMatcher(expectedFailures: Set[Failure]) extends Matcher[AssessmentResult] {
    def apply(assessmentResult: AssessmentResult): MatchResult = {
      ...
    }
  }
}

Notice that this time we pass along a parameter to failWith, which is our set of expected Failure. That is then used in the construction of our FailingAssessmentResultMatcher. Now that we have this additional information, we can implement our assertion logic:

class FailingAssessmentResultMatcher(expectedFailures: Set[Failure]) extends Matcher[AssessmentResult] {
  def apply(assessmentResult: AssessmentResult): MatchResult = {
    assessmentResult.pass match {
      case true => MatchResult(false, "Assessment passed when it was expected to fail.", "")
      case false => {
        if(assessmentResult.failures == expectedFailures) {
          MatchResult(true, "", "") //Notice we provide no failure messages here becuase this is our passing branch of logic
        } else {
          MatchResult(false, s"Assessment failed but failures do not match. Expected: ${expectedFailures}; Actual: ${expectedFailures}", "")
        }
      }
    }
  }
}

We can now simplify our test for the failing case:

test("should fail last question") {
  val correctAnswers = Assessment(A, C, B)
  val providedAnswers = Assessment(A, C, A)
  val expectedFailures = Set(Failure("Question Three", correctAnswers.questionThree, providedAnswers.questionThree))

  val assessmentResult = new AssessmentEvaluator(correctAnswers).evaluate(providedAnswers)

  assessmentResult should failWith(expectedFailures)
}

Bringing it All Together

And here’s our final product:

class AssessmentEvaluatorTest extends AnyFunSuite with AssessmentResultMatcher {
  test("should fail last question") {
    val correctAnswers = Assessment(A, C, B)
    val providedAnswers = Assessment(A, C, A)
    val expectedFailures = Set(Failure("Question Three", correctAnswers.questionThree, providedAnswers.questionThree))

    val assessmentResult = new AssessmentEvaluator(correctAnswers).evaluate(providedAnswers)

    assessmentResult should failWith(expectedFailures)
  }

  test("should pass") {
    val correctAnswers = Assessment(B, C, B)
    val providedAnswers = Assessment(B, C, B)

    val assessmentResult = new AssessmentEvaluator(correctAnswers).evaluate(providedAnswers)

    assessmentResult should pass
  }
}

trait AssessmentResultMatcher extends Matchers {
  def pass = new PassingAssessmentResultMatcher
  def failWith(expectedFailures: Set[Failure]) = new FailingAssessmentResultMatcher(expectedFailures)

  class PassingAssessmentResultMatcher extends Matcher[AssessmentResult] {
    def apply(assessmentResult: AssessmentResult): MatchResult = {
      val assessmentPasses = assessmentResult.pass && assessmentResult.failures.isEmpty
      MatchResult(assessmentPasses, "Assessment did not pass.", "Assessment passed when it should not have.")
    }
  }

  class FailingAssessmentResultMatcher(expectedFailures: Set[Failure]) extends Matcher[AssessmentResult] {
    def apply(assessmentResult: AssessmentResult): MatchResult = {
      assessmentResult.pass match {
        case true => MatchResult(false, "Assessment passed when it was expected to fail.", "")
        case false => {
          if(assessmentResult.failures == expectedFailures) {
            MatchResult(true, "", "")
          } else {
            MatchResult(false, s"Assessment failed but failures do not match. Expected: ${expectedFailures}; Actual: ${assessmentResult.failures}", "")
          }
        }
      }
    }
  }
}

This is a simple example, but it shows the potential power that custom Matchers can have in your codebase.

Related Posts