Scala Testing
A collection of test-writing best practices for MUnit, ScalaTest, and ScalaCheck. Designed for AI agents and LLMs to write readable, maintainable, and trustworthy tests.
Categories
Async Testing [HIGH]
Test asynchronous and time-dependent code deterministically, without sleeping the test thread or reaching past the effect runner.
| Rule | Description |
|---|---|
| async-testing-avoid-thread-sleep | Never use Thread.sleep to wait for async work |
| async-testing-deterministic-clocks | Test time-dependent code with a simulated clock |
| async-testing-future-recovertosucceededif | Assert expected Future failures with recoverToSucceededIf |
| async-testing-io-testrunner | Let CatsEffectSuite run IO tests instead of calling unsafeRunSync yourself |
Mocking Discipline [HIGH]
Reserve mocks for genuine external boundaries, and prefer fakes and in-memory interpreters for everything else.
| Rule | Description |
|---|---|
| mocking-discipline-avoid-mockito-for-pure-fp | Avoid Mockito on pure, effect-typed code |
| mocking-discipline-in-memory-interpreters | Write in-memory interpreters for repository and client traits |
| mocking-discipline-mock-external-boundaries-only | Reserve mocks for the external system boundary |
| mocking-discipline-prefer-fakes-over-mocks | Prefer fakes over mocks |
ScalaCheck [MEDIUM]
Generate test inputs with ScalaCheck instead of hand-picking examples, and keep generators composable and shrinking-friendly.
| Rule | Description |
|---|---|
| scalacheck-arbitrary-instances-for-domain-types | Provide Arbitrary instances for domain types |
| scalacheck-forall-over-manual-loops | Use forAll instead of hand-rolled input loops |
| scalacheck-generator-composition | Compose generators from smaller generators |
| scalacheck-property-based-for-pure-functions | Reach for property-based tests on pure functions |
| scalacheck-shrinking-friendly-generators | Keep generators shrinking-friendly |
ScalaTest & MUnit Structure [MEDIUM]
Structure ScalaTest and MUnit suites for clarity: one behavior per test, arrange-act-assert bodies, and readable naming.
| Rule | Description |
|---|---|
| scalatest-munit-structure-aaa-pattern | Structure test bodies as arrange-act-assert |
| scalatest-munit-structure-nested-suites | Nest suites by shared context, not by file convenience |
| scalatest-munit-structure-one-behavior-per-test | Assert one behavior per test |
| scalatest-munit-structure-suite-naming | Name test suites after the unit under test |
| scalatest-munit-structure-tagged-tests | Tag slow or environment-dependent tests |
Fixtures [MEDIUM]
Scope and share test setup without leaking mutable state across tests.
| Rule | Description |
|---|---|
| fixtures-factory-methods-for-test-data | Build test data with factory methods, not copy-pasted literals |
| fixtures-munit-lifecycle | Prefer FunFixture over mutable beforeEach/afterEach in MUnit |
| fixtures-narrowest-scope | Scope fixtures to the narrowest level that needs them |
| fixtures-scalatest-beforeandafter-traits | Match ScalaTest fixture traits to setup cost |
Quick Reference
Async Testing
import org.scalatest.concurrent.Eventually.eventually
import org.scalatest.time.{Seconds, Span}
class CacheSuite extends AnyFunSuite with Eventually:
test("cache is populated after an async warm-up") {
cache.warmUpAsync()
eventually(timeout(Span(2, Seconds))) {
assert(cache.get("key").isDefined)
}
}
Mocking Discipline
test("processing an order saves it") {
val repo = InMemoryOrderRepository.withOrder(pendingOrder)
OrderService(repo).confirm(orderId)
assertEquals(repo.findById(orderId).map(_.status), Some(OrderStatus.Confirmed))
// asserts only the observable outcome, independent of how many times save() ran
}
ScalaCheck
import org.scalacheck.Prop.forAll
class SortedInsertSuite extends munit.ScalaCheckSuite:
property("inserting into a sorted list keeps it sorted") {
forAll { (xs: List[Int], x: Int) =>
val sorted = xs.sorted
val result = sortedInsert(sorted, x)
result.sorted == result && result.sorted(Ordering[Int]) == result
}
}
ScalaTest & MUnit Structure
test("checkout applies a discount") {
val cart = Cart.empty.addItem(Item("sku-1", price = 10.0))
val discounted = Checkout.applyDiscount(cart, DiscountCode("SAVE10"))
assertEquals(discounted.total, 9.0)
assertEquals(discounted.appliedCode, Some(DiscountCode("SAVE10")))
}
Fixtures
class OrderRepositorySuite extends munit.FunSuite:
private val repoFixture = FunFixture[InMemoryOrderRepository](
setup = _ => InMemoryOrderRepository.empty,
teardown = _ => ()
)
repoFixture.test("saves and retrieves an order") { repo =>
repo.save(sampleOrder)
assertEquals(repo.findById(sampleOrder.id), Some(sampleOrder))
}
See Also
- scala-coding-standards - General Scala coding standards and best practices
- scala-tooling - Build, compiler flags, Scalafix, Scalafmt, Scalastyle, and Wartremover rules