在 Scala 生态中,测试框架的选择远比 Java 丰富。从经典的 ScalaTest,到轻量级的 MUnit,再到面向函数式效果系统的 Weaver,以及让”随机生成用例”成为常态的 ScalaCheck——每个框架都有它最擅长的场景。本文将从实战角度出发,带你完整搭建一套 Scala 测试体系,覆盖单元测试、异步测试、属性测试与效果系统测试,并给出在真实项目中的取舍建议。

一、为什么 Scala 测试生态如此分散
Java 开发者习惯了 JUnit 一统天下,到了 Scala 却发现 sbt test 默认能跑起四五种框架,常常一脸困惑。这种”分散”其实是 Scala 语言特性决定的:
- 多范式:Scala 既能写 OOP 也能写纯函数式,测试风格天然分化为”行为描述型”和”纯函数断言型”。
- 效果系统:Cats Effect、ZIO 的
1IO
/
1Task是惰性的,传统同步断言框架无法直接驱动它们,于是 Weaver、ZIO Test 应运而生。
- 强类型:ScalaCheck 的属性测试建立在类型类
1Arbitrary
之上,比 JUnit-Quickcheck 更贴合 Scala 的类型系统。
理解了这一点,就不会纠结”该统一用哪个框架”,而是按团队技术栈和代码风格选择合适组合。下面逐一展开。
二、ScalaTest:最全面的传统框架
ScalaTest 是 Scala 世界历史最悠久、覆盖面最广的测试框架。它提供了多种”风格 trait”,让你用最贴合业务的语言组织测试。
2.1 sbt 依赖与基本配置
1
2
3
4
5 // build.sbt
libraryDependencies ++= Seq(
"org.scalatest" %% "scalatest" % "3.2.19" % Test,
"org.scalatestplus" %% "scalacheck-1-18" % "3.2.19.0" % Test
)
ScalaTest 默认通过 sbt 的
1 | test |
任务执行,无需额外配置 runner。它支持多种风格,下面用
1 | AnyFunSpec |
(类似 RSpec 的描述式风格)演示:
2.2 FunSpec 风格示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 import org.scalatest.funspec.AnyFunSpec
import org.scalatest.matchers.should.Matchers
class ListSpec extends AnyFunSpec with Matchers {
describe("List") {
it("should reverse correctly") {
List(1, 2, 3).reverse shouldEqual List(3, 2, 1)
}
it("should head return the first element") {
List(1, 2, 3).head shouldBe 1
}
it("should be empty after clear in mutable variant") {
val buf = scala.collection.mutable.ListBuffer(1, 2, 3)
buf.clear()
buf shouldBe empty
}
}
}
ScalaTest 的 Matchers DSL 非常丰富:
1 | should contain |
、
1 | should have size 3 |
、
1 | should startWith |
、
1 | shouldBe < 5 |
等表达式让断言读起来接近自然语言。但 DSL 也是一把双刃剑——过度使用会让代码看起来像英语句子而难以快速定位逻辑。
2.3 FlatSpec 与 WordSpec 的取舍
1
2
3
4
5
6
7
8
9
10
11
12 import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class CalculatorSpec extends AnyFlatSpec with Matchers {
"A Calculator" should "add two numbers" in {
1 + 1 shouldBe 2
}
it should "subtract correctly" in {
5 - 3 shouldBe 2
}
}
1 | AnyFlatSpec |
适合”行为-结果”一对一的单元测试;
1 | AnyWordSpec |
更接近 BDD,适合按”主题-细节”组织。没有绝对优劣,团队统一即可。我个人经验:纯函数工具库用 FlatSpec,业务流程用 FunSpec,可读性最佳。
2.4 异步测试:ScalaTest 与 Future
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 import org.scalatest.AsyncFunSpec
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
class AsyncRepoSpec extends AsyncFunSpec {
describe("UserRepository") {
it("should fetch a user by id") {
val future: Future[Option[String]] = fetchUser(1)
future.map {
case Some(name) => assert(name.nonEmpty)
case None => fail("user not found")
}
}
}
def fetchUser(id: Int): Future[Option[String]] =
Future.successful(Some("Alice"))
}
1 | AsyncFunSpec |
会自动等待返回的
1 | Future[Assertion] |
完成,无需手动
1 | Await.result |
。但注意它使用的是全局 ExecutionContext,在 Cats Effect 项目里更推荐用 Weaver(后文详述)。
三、MUnit:轻量、直观、Scala 3 友好
MUnit 是 Typelevel 团队出品的极简测试框架,语法接近 JUnit,同时无缝支持 Scala 2 与 Scala 3。它的核心设计目标只有一个:让写测试像写普通方法一样简单。
3.1 依赖与基本用法
1
2 // build.sbt
libraryDependencies += "org.scalameta" %% "munit" % "1.0.0" % Test
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 class StringOpsSuite extends munit.FunSuite {
test("split should return expected parts") {
assertEquals("a,b,c".split(","), Array("a", "b", "c"))
}
test("parseInt should fail on invalid input") {
intercept[NumberFormatException] {
"abc".toInt
}
}
test("List should deduplicate") {
assertEquals(List(1, 1, 2).distinct, List(1, 2))
}
}
可以看到 MUnit 完全抛弃了 ScalaTest 的 DSL,直接用
1 | assertEquals |
、
1 | assert |
、
1 | intercept |
这类朴素断言。对于厌倦了花哨 DSL、追求”代码即测试”的团队,MUnit 是极佳选择。
3.2 MUnit 的 Fixtures 与异步支持
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 class DbSuite extends munit.FunSuite {
// 共享 fixture
val conn = FunFixture[Connection](
setup = { _ => DriverManager.getConnection("jdbc:h2:mem:test") },
teardown = { c => c.close() }
)
conn.test("connection should be valid") { c =>
assert(!c.isClosed)
}
// Scala.js / Future 异步
test("async fetch".only) {
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
Future {
assertEquals(2, 1 + 1)
}
}
}
MUnit 的
1 | FunFixture |
让 setup/teardown 变得显式且类型安全;异步测试只需返回
1 | Future |
即可自动等待。它还内置了
1 | .only |
、
1 | .ignore |
等标签,调试时非常方便。
四、Weaver:为 Cats Effect / ZIO 而生
当你用 Cats Effect 写服务时,测试逻辑往往本身就是
1 | IO[Assertion] |
。用 ScalaTest 需要手动
1 | unsafeRunSync() |
,既不优雅也有副作用管理风险。Weaver 框架则把效果系统当作一等公民,测试本身就在
1 | IO |
上下文中执行。
4.1 依赖配置
1
2
3
4
5
6 // build.sbt
libraryDependencies ++= Seq(
"com.disneystreaming" %% "weaver-cats" % "0.8.4" % Test,
"com.disneystreaming" %% "weaver-scalacheck" % "0.8.4" % Test
)
testFrameworks += new TestFramework("weaver.framework.CatsEffect")
4.2 Cats Effect 测试示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 import weaver.IOSuite
import cats.effect.IO
import cats.effect.kernel.Ref
object CounterSpec extends IOSuite {
override type Res = Ref[IO, Int]
override def sharedResource: IO[Res] = Ref.of[IO, Int](0)
test("increment should update ref") { ref =>
for {
_ <- ref.update(_ + 1)
v <- ref.get
} yield expect(v == 1)
}
test("two increments sum to 2") { ref =>
for {
_ <- ref.update(_ + 2)
v <- ref.get
} yield expect(v == 2)
}
}
Weaver 的几个亮点:
- sharedResource:跨测试共享一个资源(如数据库连接池),框架负责其生命周期。
- expect:返回
1Expectations
而非抛异常,组合性更强,可以一次收集多个断言结果。
- 与 ScalaCheck 无缝结合:通过
1weaver-scalacheck
直接在 for-comprehension 里写属性。
4.3 ZIO 版本
1
2
3
4
5
6
7
8
9
10
11
12
13
14 import weaver.zio.ZIOSuite
import zio.{Task, ZIO, Ref}
object ZCounterSpec extends ZIOSuite {
override type Res = Ref[Int]
override def sharedResource: Task[Res] = Ref.make(0)
test("increment") { ref =>
for {
_ <- ref.update(_ + 1)
v <- ref.get
} yield expect(v == 1)
}
}
同样的 API 风格,底层换成 ZIO 的效果类型即可。这种”框架不变、效果系统可换”的设计,让 Weaver 在多技术栈团队中极具吸引力。
五、ScalaCheck:属性测试的精髓

属性测试(Property-Based Testing, PBT)是 Scala 测试生态中最具”降维打击”感的能力。它不再让你写”输入 1+1 应等于 2″这种具体例子,而是声明”对任意整数 a、b,a+b 应等于 b+a”——框架自动生成数百个随机输入来验证你的声明。
5.1 独立使用 ScalaCheck
1
2 // build.sbt
libraryDependencies += "org.scalacheck" %% "scalacheck" % "1.18.1" % Test
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 import org.scalacheck.Prop.forAll
import org.scalacheck.Properties
object ListProps extends Properties("List") {
property("reverse twice = identity") = forAll { (xs: List[Int]) =>
xs.reverse.reverse == xs
}
property("head of non-empty = first") = forAll { (x: Int, xs: List[Int]) =>
(x :: xs).head == x
}
property("concat then size sums") = forAll { (a: List[Int], b: List[Int]) =>
(a ++ b).size == a.size + b.size
}
}
ScalaCheck 内置了
1 | Arbitrary[Int] |
、
1 | Arbitrary[String] |
、
1 | Arbitrary[List[T]] |
等实例,常见类型开箱即用。框架默认跑 100 次随机用例,一旦失败会自动 shrink(缩小)到最小失败用例,例如把
1 | List(-3481, 99, 0, 7) |
缩减到
1 | List(0) |
,让你瞬间看清 bug 触发条件。
5.2 自定义 Arbitrary:为领域模型生成数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 import org.scalacheck._
import org.scalacheck.Arbitrary.arbitrary
case class User(id: Long, name: String, age: Int)
object UserGen {
implicit val arbUser: Arbitrary[User] = Arbitrary {
for {
id <- Gen.choose(1L, 10000L)
name <- Gen.alphaNumStr.suchThat(_.nonEmpty)
age <- Gen.choose(0, 120)
} yield User(id, name, age)
}
}
// 在测试中使用
import UserGen._
object UserProps extends Properties("User") {
property("age is non-negative") = forAll { (u: User) =>
u.age >= 0
}
}
自定义 Generator 是 ScalaCheck 真正强大的地方。你可以用
1 | Gen.frequency |
、
1 | Gen.oneOf |
、
1 | Gen.listOf |
等组合器构造任意复杂的数据,甚至模拟”畸形单元”——比如生成超过数据库字段长度的超长字符串来测试边界行为。
5.3 与 ScalaTest 集成
1
2
3
4
5
6
7
8
9
10
11
12 import org.scalatest.matchers.should.Matchers
import org.scalatest.propspec.AnyPropSpec
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
class SetProps extends AnyPropSpec with Matchers with ScalaCheckPropertyChecks {
property("Set should contain added element") {
forAll { (x: Int, rest: Set[Int]) =>
(rest + x) should contain (x)
}
}
}
这样你既能享受 ScalaTest 的 DSL,又能用属性驱动测试。在团队引入 PBT 时,建议从”边缘条件密集的纯函数”开始(如解析器、序列化、集合操作),收益最明显。
六、框架横向对比与选型建议
下表汇总了四个框架的关键维度,帮助你在新项目中做决策:
| 维度 | ScalaTest | MUnit | Weaver | ScalaCheck |
|---|---|---|---|---|
| 定位 | 全功能传统框架 | 极简单元测试 | 效果系统专用 | 属性测试引擎 |
| 断言风格 | DSL (Matchers) | 朴素 assertEquals | expect 组合式 | Prop 布尔表达式 |
| 异步支持 | AsyncXxx trait | 返回 Future | 原生 IO/Task | 需配合框架 |
| 学习曲线 | 中等(DSL 多) | 低 | 中(需懂效果) | 中高(PBT 思维) |
| Scala 3 支持 | 良好 | 优秀 | 优秀 | 良好 |
| 最佳场景 | 通用业务、BDD | 工具库、快速单元 | Cats Effect/ZIO 服务 | 纯函数、边界测试 |
实操层面的推荐组合:
- 普通 Web 服务(akka-http / http4s 非 CE 路由):ScalaTest + ScalaCheck。生态成熟、文档多,新人上手快。
- Cats Effect / ZIO 纯函数式服务:Weaver + weaver-scalacheck。效果系统原生支持,避免
1unsafeRun
噪音。
- 轻量工具库 / 开源组件:MUnit。依赖少、构建快、CI 跑得稳。
- 需要大量随机输入验证的算法/解析器:任选主框架 + ScalaCheck 插件。PBT 的投入产出比在这类场景最高。
七、CI 中的实战技巧
7.1 sbt 测试任务编排
1
2
3
4
5
6
7 // build.sbt
Test / fork := true // 隔离 JVM,避免单测间静态状态污染
Test / testOptions += Tests.Argument("-oD") // 显示执行时长,定位慢测试
Test / parallelExecution := true // 默认并行,按需关闭
// 只跑某个 suite
// sbt > testOnly *.ListSpec
7.2 标记慢测试与集成测试
1
2
3
4
5
6
7
8
9
10
11
12
13 import org.scalatest.Tag
object Slow extends Tag("slow")
object Integration extends Tag("integration")
class DbSpec extends AnyFunSpec with Matchers {
it("should query db", Slow, Integration) {
// ...
}
}
// CI 快速通道:sbt "testOnly -- -l slow"
// 夜间全量:sbt test
在大型项目里,按标签分层执行测试是提升 CI 速度的关键——PR 触发只跑单元 + 快速属性测试,夜间流水线跑全量集成测试。
7.3 覆盖率:scoverage 集成
1
2
3
4
5
6
7
8 // project/plugins.sbt
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.11")
// build.sbt
coverageMinimumStmtTotal := 80
coverageMinimumBranchTotal := 75
coverageFailOnMinimum := true
coverageHighlighting := true
运行
1 | sbt clean coverage test coverageReport |
即可生成 HTML 覆盖率报告,并在低于阈值时让构建失败。覆盖率不是目标,但它是防止”测试覆盖率悬崖”的有效护栏。
八、常见陷阱与最佳实践
最后总结几个在真实项目中反复踩过的坑:
- 不要在异步测试里用
:让框架等待 Future,否则 ExecutionContext 阻塞会拖慢整个测试套件。1Await.result
- 属性测试的 shrink 要谨慎:自定义
1Shrink
实例时,若 shrink 路径不收敛会陷入死循环。先写最小可复现用例再调 shrink。
- 避免测试间共享可变状态:ScalaTest 的
1BeforeAndAfter
容易引入隐式耦合,优先用
1BeforeAndAfterEach或 Weaver 的 resource 模式。
- Mock 适度:Scala 的 trait 组合天然适合 stub,过度依赖 mockito-scala 反而让测试脆弱。能用真实实现(如内存 H2 数据库)就别 mock。
- 给属性测试设好边界:
1forAll { (n: Int) => ... }
若不约束范围,可能生成
1Int.MinValue触发意外溢出。用
1Gen.choose或
1Gen.posNum收敛输入域。
结语
Scala 测试生态的”碎片化”恰恰是它面向不同场景的精细化体现。理解每个框架的设计哲学——ScalaTest 的全面、MUnit 的极简、Weaver 的效果原生、ScalaCheck 的随机验证——你就能在项目中组合出最适合团队的测试体系。记住:测试框架是工具,写出能抵御重构、能暴露真实缺陷的断言,才是测试工程的本质目标。从今天起,给你的下一个 Scala 服务加上一组属性测试,你会立刻感受到 PBT 带来的”安全感升级”。
汤不热吧