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" % Testimport 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 TanyString— any Stringthere was one(mock).method(args)— called oncethere was no(mock).method(args)— never calledthere were two(mock).method(args)— called twicethere 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 —
awaitcombinator 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.