Advanced Specs2: Custom Matchers, Data Tables, and ScalaCheck Integration

Advanced Specs2: Custom Matchers, Data Tables, and ScalaCheck Integration

Specs2 is a mature Scala testing library with two distinct styles — Acceptance (readable specification documents) and Unit (closer to xUnit). This guide focuses on the advanced features that make Specs2 stand out: custom matchers, data tables, ScalaCheck integration, and async testing.

Two Styles, One Library

Specs2 supports two test styles in the same project:

Acceptance style — readable specification documents:

import org.specs2.Specification

class UserSpec extends Specification {
  def is = s2"""
    A User should
      have a valid email              $validEmail
      reject empty names              $rejectEmptyName
      hash passwords before saving    $hashPassword
  """

  def validEmail = User("alice@example.com", "Alice") must beRight
  def rejectEmptyName = User("a@b.com", "") must beLeft
  def hashPassword = {
    val user = User("a@b.com", "Alice", password = "secret")
    user.hashedPassword must not(be_===("secret"))
  }
}

Unit style — closer to xUnit/ScalaTest:

import org.specs2.mutable.Specification

class UserSpec extends Specification {
  "A User" should {
    "have a valid email" in {
      User("alice@example.com", "Alice") must beRight
    }

    "reject empty names" in {
      User("a@b.com", "") must beLeft
    }
  }
}

Custom Matchers

Custom matchers make tests read like requirements:

import org.specs2.matcher.{Matcher, MatchResult}

trait HttpMatchers {
  def haveStatus(expected: Int): Matcher[HttpResponse] =
    (response: HttpResponse) => {
      (response.status == expected,
       s"Response had status ${response.status}, expected $expected",
       s"Response should not have status $expected")
    }

  def beSuccessful: Matcher[HttpResponse] =
    (response: HttpResponse) => {
      val ok = response.status >= 200 && response.status < 300
      (ok,
       s"Response ${response.status} is not successful",
       s"Response ${response.status} should not be successful")
    }

  def haveBody(content: String): Matcher[HttpResponse] =
    (response: HttpResponse) => {
      (response.body.contains(content),
       s"""Response body does not contain "$content": ${response.body}""",
       s"""Response body should not contain "$content"""")
    }
}

class APISpec extends Specification with HttpMatchers {
  "GET /users" should {
    "return 200 with user list" in {
      val response = API.get("/users")
      response must beSuccessful
      response must haveBody("alice@example.com")
    }

    "return 404 for unknown endpoint" in {
      API.get("/unknown") must haveStatus(404)
    }
  }
}

Typed Matchers with TypeclassMatchers

import org.specs2.matcher.Matcher

def beNonEmpty[A]: Matcher[Seq[A]] =
  (seq: Seq[A]) => (seq.nonEmpty, "Sequence is empty", "Sequence should be empty")

def beWithinRange(min: Double, max: Double): Matcher[Double] =
  (d: Double) => (
    d >= min && d <= max,
    s"$d is not within [$min, $max]",
    s"$d should not be within [$min, $max]"
  )

Data Tables

Specs2's data tables are one of its killer features — parameterized tests with inline tabular data:

import org.specs2.Specification
import org.specs2.specification.Tables

class CalculatorSpec extends Specification with Tables {
  def is = s2"""
    Calculator should add correctly $additionTable
    Calculator should handle edge cases $edgeCases
  """

  def additionTable =
    "a"  | "b"  | "result" |>
     2   !  3   !  5       |
    -1   !  1   !  0       |
     0   !  0   !  0       |
    100  ! -50  !  50      | { (a, b, result) =>
      Calculator.add(a, b) must_== result
    }

  def edgeCases =
    "input"  | "expected"         |>
     Int.MaxValue ! Int.MinValue  |
     Int.MinValue ! Int.MaxValue  | { (input, expected) =>
      Calculator.negate(input) must_== expected
    }
}

Data tables work in both Acceptance and Unit styles and produce clear output when they fail:

Calculator should add correctly
  | a   | b   | result | #  |
  | 2   | 3   | 5      | ok |
  | -1  | 1   | 0      | ok |
  | 0   | 0   | 0      | ok |
  | 100 | -50 | 50     | ok |

ScalaCheck Integration

ScalaCheck property testing integrates directly into Specs2:

import org.specs2.ScalaCheck
import org.specs2.mutable.Specification
import org.scalacheck.{Arbitrary, Gen, Prop}
import org.scalacheck.Prop.forAll

class StringUtilsSpec extends Specification with ScalaCheck {
  "StringUtils.reverse" should {
    "return same string when reversed twice" in prop { (s: String) =>
      StringUtils.reverse(StringUtils.reverse(s)) must_== s
    }

    "have same length as input" in prop { (s: String) =>
      StringUtils.reverse(s).length must_== s.length
    }

    "handle empty string" in {
      StringUtils.reverse("") must_== ""
    }
  }

  "StringUtils.palindrome check" should {
    "always accept palindromes" in prop { (s: String) =>
      val palindrome = s + s.reverse
      StringUtils.isPalindrome(palindrome) must beTrue
    }
  }
}

Custom Generators

import org.scalacheck.Gen

val emailGen: Gen[String] = for {
  user <- Gen.alphaNumStr.suchThat(_.nonEmpty)
  domain <- Gen.alphaNumStr.suchThat(_.nonEmpty)
  tld <- Gen.oneOf("com", "org", "net", "io")
} yield s"$user@$domain.$tld"

val userGen: Gen[User] = for {
  email <- emailGen
  name <- Gen.alphaStr.suchThat(_.length > 1)
} yield User(email = email, name = name)

"User validation" should {
  "accept valid generated users" in prop { implicit val arb = Arbitrary(userGen)
    (user: User) => User.validate(user) must beRight
  }
}

Configuring ScalaCheck

import org.specs2.ScalaCheck
import org.scalacheck.Test.Parameters

class HeavyPropertySpec extends Specification with ScalaCheck {
  implicit val params: Parameters =
    Parameters.default.withMinSuccessfulTests(500).withMaxSize(100)

  "my property" should {
    "hold for many inputs" in prop { (n: Int) =>
      n * 2 must_== n + n
    }
  }
}

Async Testing

Testing Future-based code:

import org.specs2.concurrent.ExecutionEnv
import org.specs2.mutable.Specification

import scala.concurrent.Future

class AsyncServiceSpec(implicit ee: ExecutionEnv) extends Specification {
  "UserService" should {
    "fetch user asynchronously" in {
      val result: Future[User] = UserService.findById(1)
      result must be_==(User(id = 1, name = "Alice")).await
    }

    "return None for missing user" in {
      UserService.findById(999) must beNone.await
    }

    "reject with error for invalid ID" in {
      UserService.findById(-1) must throwAn[IllegalArgumentException].await
    }
  }
}

The await combinator waits for the Future to complete and converts it to a synchronous result for assertion.

Akka and Cats Effect

For Cats Effect IO:

import cats.effect.IO
import org.specs2.mutable.Specification

class IOServiceSpec extends Specification {
  "IOService" should {
    "compute result" in {
      val result = IOService.compute(42).unsafeRunSync()
      result must_== 84
    }

    "handle errors" in {
      val io = IOService.fetchUser(-1)
      io.attempt.unsafeRunSync() must beLeft[Throwable]
    }
  }
}

Mocking with Mockito

Specs2 integrates with Mockito through specs2-mock:

libraryDependencies += "org.specs2" %% "specs2-mock" % "5.x.x" % Test
import org.specs2.mock.Mockito
import org.specs2.mutable.Specification

class OrderServiceSpec extends Specification with Mockito {
  "OrderService" should {
    "process payment and send confirmation" in {
      val paymentGateway = mock[PaymentGateway]
      val emailService = mock[EmailService]

      paymentGateway.charge(any[BigDecimal], anyString) returns Right(Transaction("txn_123"))
      emailService.sendConfirmation(any[Order]) returns Right(())

      val service = new OrderService(paymentGateway, emailService)
      val order = Order(total = BigDecimal("99.99"), email = "user@example.com")

      service.process(order) must beRight

      there was one(paymentGateway).charge(BigDecimal("99.99"), "USD")
      there was one(emailService).sendConfirmation(order)
    }

    "not send email if payment fails" in {
      val paymentGateway = mock[PaymentGateway]
      val emailService = mock[EmailService]

      paymentGateway.charge(any, anyString) returns Left(PaymentError("Declined"))

      val service = new OrderService(paymentGateway, emailService)
      service.process(order) must beLeft

      there was no(emailService).sendConfirmation(any[Order])
    }
  }
}

Mockito matchers in Specs2:

  • any[T] — any value of type T
  • anyString — any String
  • there was one(mock).method(args) — called once
  • there was no(mock).method(args) — never called
  • there were two(mock).method(args) — called twice
  • there was atLeastOne(mock).method(args) — called one or more times

Shared Examples

Reuse behavior specifications across multiple test classes:

import org.specs2.mutable.Specification
import org.specs2.specification.core.SpecStructure

trait RepositoryBehaviors { self: Specification =>
  def aRepository(repo: UserRepository): SpecStructure = s2"""
    Repository should
      create a user       ${createUser(repo)}
      find by email       ${findByEmail(repo)}
      delete a user       ${deleteUser(repo)}
  """

  def createUser(repo: UserRepository) = {
    val user = User("new@example.com", "New User")
    repo.create(user) must beRight
    repo.findByEmail("new@example.com") must beSome
  }

  def findByEmail(repo: UserRepository) = {
    val email = "test@example.com"
    repo.create(User(email, "Test"))
    repo.findByEmail(email) must beSome(hasEmail(email))
  }

  def deleteUser(repo: UserRepository) = {
    val user = repo.create(User("del@example.com", "Delete Me")).toOption.get
    repo.delete(user.id) must beRight
    repo.findByEmail("del@example.com") must beNone
  }
}

class InMemoryRepositorySpec extends Specification with RepositoryBehaviors {
  def is = aRepository(new InMemoryUserRepository)
}

class PostgresRepositorySpec extends Specification with RepositoryBehaviors {
  def is = aRepository(new PostgresUserRepository(testDb))
}

Running Specs2 Tests

# sbt
sbt test
sbt "testOnly *UserSpec*"
sbt "testOnly *UserSpec -- ex valid"  # filter by example name

# Run only acceptance specs
sbt "testOnly *.AcceptanceSpec"

Configuration in build.sbt:

Test / testOptions += Tests.Argument(TestFrameworks.Specs2,
  "console",          // output format
  "junitxml",         // also generate JUnit XML
  "html",             // generate HTML report
  "failtrace"         // show full stack traces
)

Formatters and Reporters

# Console output (default)
sbt "testOnly *Spec -- console"

# JUnit XML (for CI)
sbt "testOnly *Spec -- junitxml"

# HTML report
sbt "testOnly *Spec -- html"

# Notification only (minimal output)
sbt "testOnly *Spec -- notifier"

# Statistics
sbt "testOnly *Spec -- stats"

Summary

Specs2 offers features that few other Scala testing libraries match:

  • Data tables — clean parameterized tests with tabular inline data
  • ScalaCheck integration — property-based testing built into the framework
  • Async support — await combinator for Future and IO-based code
  • Custom matchers — domain-specific assertions that read like requirements
  • Shared examples — behavior specifications reused across implementations
  • Two styles — Acceptance for readable specs, Unit for familiar xUnit feel

The library rewards investment. The more you use its features, the more your tests read like living documentation rather than technical scaffolding.

Read more

Start now free