1. 概述

ScalaTest 是 Scala 生态中最流行、功能最完整且易于使用的测试框架之一。

与其他测试工具相比,ScalaTest 的独特之处在于它开箱即支持多种测试风格,比如 XUnit 和 BDD(行为驱动开发)。

本入门教程将从创建第一个测试开始,逐步介绍其对 XUnit 和 BDD 的支持。我们还将探索一些其他关键特性,如匹配器(Matchers)和模拟(Mocking),并展示如何写出简洁易懂的测试代码。

2. 依赖配置

配置 ScalaTest 非常简单,只需在 build.sbt 文件中添加如下依赖:

libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.19" % "test"

如果你使用的是 Maven 项目,可以在 pom.xml 中添加以下依赖:

<dependency>
  <groupId>org.scalatest</groupId>
  <artifactId>scalatest_3</artifactId>
  <version>3.2.19</version>
  <scope>test</scope>
</dependency>

你也可以从 Maven Central 获取最新版本。

3. 核心概念

在深入示例之前,先了解几个 ScalaTest 的核心概念:

  • ✅ ScalaTest 的核心是 Suite(测试套件),它是一组零个或多个测试的集合。
  • Suite trait 声明了多个生命周期方法,定义了如何编写和运行测试。
  • ✅ ScalaTest 提供了多种风格的 trait(如 AnyFunSuiteAnyFlatSpec),扩展自 Suite,支持不同测试风格。
  • ✅ 我们可以将这些风格 trait 与 ScalaTest 提供的 stackable traits(如 MatchersBeforeAndAfter)组合使用。
  • ✅ 通过这种方式,我们可以快速构建出结构清晰、可读性强的测试。
  • ✅ 当然,我们也可以在 ScalaTest 之外扩展这些 trait。

4. 编写第一个单元测试

配置好 ScalaTest 后,我们来编写一个简单的测试,用于测试 List 的行为:

class ListFunSuite extends AnyFunSuite {

  test("An empty List should have size 0") {
    assert(List.empty.size == 0)
  }

}

这里,我们的测试类继承了 AnyFunSuite trait。✅ 这个 trait 面向熟悉 XUnit 的开发者,允许我们将测试组织为带有描述性名称的 test() 块。

在上面的例子中,我们断言一个空的 List 大小为 0。使用 sbt 运行该测试:

sbt:scala-tutorials> testOnly com.baeldung.scala.scalatest.ListFunSuite
...
[info] Done compiling.
[info] ListFunSuite:
[info] - An empty List should have size 0
[info] ScalaTest
[info] Run completed in 87 milliseconds.
[info] Total number of tests run: 1
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] Total time: 1 s, completed Mar 25, 2020 4:32:17 PM

不出所料,测试通过了,sbt 输出了测试报告。

我们也可以添加更复杂的测试,例如检查是否抛出预期异常:

test("Accessing invalid index should throw IndexOutOfBoundsException") {
  val fruit = List("Banana", "Pineapple", "Apple");
  assert(fruit.head == "Banana")
  assertThrows[IndexOutOfBoundsException] {
    fruit(5)
  }
}

在这个测试中,我们首先确认列表的第一个元素是 "Banana",然后访问一个非法索引,验证是否抛出 IndexOutOfBoundsException

5. 使用不同测试风格

ScalaTest 支持多种测试风格,前面我们使用了 AnyFunSuite,下面我们来看其他几种常见的风格。

5.1. 使用 AnyFlatSpec

AnyFlatSpec 旨在支持 BDD 风格的开发。它采用扁平结构,鼓励我们编写具有描述性名称的规范式测试。

✅ ScalaTest 官方推荐将 AnyFlatSpec 作为默认的测试风格。我们用它重写之前的例子:

class ListFlatSpec extends AnyFlatSpec {

  "An empty List" should "have size 0" in {
    assert(List.empty.size == 0)
  }

  it should "throw an IndexOutOfBoundsException when trying to access any element" in {
    val emptyList = List();
    assertThrows[IndexOutOfBoundsException] {
      emptyList(1)
    }
  }
}

测试现在读起来更像是一个规范,即 “X should Y”。使用 it 是对上一个描述的引用。

运行测试后输出如下:

[info] ListFlatSpec:
[info] An empty List
[info] - should have size 0
[info] - should throw an IndexOutOfBoundsException when trying to access any element

5.2. 使用 AnyFunSpec

我们可以更进一步使用 AnyFunSpec 实现更深层次的嵌套结构:

class ListFunSpec extends FunSpec {

  describe("A List") {
    describe("when empty") {
      it("should have size 0") {
        assert(List.empty.size == 0)
      }

      it("should throw an IndexOutOfBoundsException when to access an element") {
        val emptyList = List();
        assertThrows[IndexOutOfBoundsException] {
          emptyList(1)
        }
      }
    }
  }
}

这种语法对熟悉 RSpec 或 JavaScript 测试框架的开发者来说会很熟悉。

运行后输出:

[info] ListFunSpec:
[info] A List
[info]   when empty
[info]   - should have size 0
[info]   - should throw an IndexOutOfBoundsException when to access an element

6. 测试前后处理

很多时候我们需要在测试前后执行一些通用逻辑,比如初始化资源或清理状态。

✅ 在 ScalaTest 中,我们可以混入 BeforeAndAfter trait,并通过 beforeafter 代码块注册前置和后置逻辑:

class StringFlatSpecWithBeforeAndAfter extends AnyFlatSpec with BeforeAndAfter {

  val builder = new StringBuilder;

  before {
    builder.append("Baeldung ")
  }

  after {
    builder.clear()
  }
  
  // ...

在每个测试前,我们会向 builder 添加字符串;测试结束后清空它。

测试代码中可以直接使用 builder

"Baeldung" should "be interesting" in {
  assert(builder.toString === "Baeldung ")

  builder.append("is very interesting!")
  assert(builder.toString === "Baeldung is very interesting!")
}

it should "have great tutorials" in {
  assert(builder.toString === "Baeldung ")

  builder.append("has great tutorials!")
  assert(builder.toString === "Baeldung has great tutorials!")
}

⚠️ 注意:beforeafter 代码块只能通过副作用与测试通信,例如修改共享变量。

7. 使用 Matchers

前面我们使用的是标准断言,但 ScalaTest 提供了强大的 DSL —— Matchers,使用 should 关键字来表达断言。

✅ 只需混入 Matchers trait 即可使用:

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

class ExampleFlatSpecWithMatchers extends AnyFlatSpec with Matchers {
...

7.1. 基础匹配器

我们来看一些常用的匹配器:

"A matcher" should "let us check equality" in {
  val number = 25
  number should equal (25)
  number shouldEqual 25
}

it should "also let us check equality using be" in {
  val number = 25
  number should be (25)
  number shouldBe 25
}
  • equalshouldEqual 用于检查相等性。
  • beshouldBe 是另一种方式,不支持自定义相等性检查。
  • 如果需要自定义比较逻辑,可以使用 equal 并传入 Equality[T]

例如:

it should "also let us customize equality" in {
  " baeldung  " should equal("baeldung")(after being trimmed)
}

还可以检查字符串长度或集合大小:

it should "let us check the length of strings" in {
  val result = "baeldung"
  result should have length 8
}

it should "let us check the size of collections" in {
  val days = List("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
  days should have size 7
}

7.2. 字符串匹配器

字符串匹配非常常见:

it should "let us check different parts of a string" in {
  val headline = "Baeldung is really cool!"
  headline should startWith("Baeldung")
  headline should endWith("cool!")
  headline should include("really")
}

验证邮箱格式:

it should "let us check that an email is valid" in {
  "[email protected]" should fullyMatch regex """[^@]+@[^\.]+\..+"""
}

7.3. 其他常用匹配器

it should "let us check a list is empty" in {
  List.empty shouldBe empty
}

it should "let us check a list is NOT empty" in {
  List(1, 2, 3) should not be empty
}

it should "let us check a map contains a given key and value" in {
  Map('x' -> 10, 'y' -> 20, 'z' -> 30) should contain('y' -> 20)
}

it should "let us check the type of an object" in {
  List(1, 2, 3) shouldBe a[List[_]]
  List(1, 2, 3) should not be a[Map[_, _]]
}

✅ 使用 Matchers 可以让测试代码更自然、更具可读性。

8. 测试标记

有时我们需要临时跳过某个测试,比如排查偶发失败时。

✅ ScalaTest 提供了 ignore 标记:

ignore should "let us check a list is empty" in {
  List.empty shouldBe empty
}

运行测试时会看到:

[info] - should let us check a list is empty !!! IGNORED !!!

我们也可以自定义标记:

object BaeldungJavaTag extends Tag("com.baeldung.scala.scalatest.BaeldungJavaTag")

class TaggedFlatSpec extends AnyFlatSpec with Matchers {

  "Baeldung" should "be interesting" taggedAs (BaeldungJavaTag) in {
    "Baeldung has articles about Java" should include("Java")
  }
}

运行特定标记的测试:

sbt "testOnly -- -n com.baeldung.scala.scalatest.BaeldungJavaTag"

9. 模拟对象(Mocking)

ScalaTest 支持任何 Java 模拟框架,也可以使用 ScalaMock

✅ 首先添加依赖:

libraryDependencies += "org.scalamock" %% "scalamock" % "5.2.0" % Test

然后混入 MockFactory

class ScalaMockFlatSpec extends AnyFlatSpec with MockFactory with Matchers {

示例:

"A mocked Foo" should "return a mocked bar value" in {
  val mockFoo = mock[Foo]
  (mockFoo.bar _).expects().returning(6)

  mockFoo.bar should be(6)
}

class Foo {
  def bar = 100
}

10. 总结

本教程中,我们初步了解了 ScalaTest 测试框架的核心特性:

  • ✅ 如何编写第一个测试
  • ✅ 多种测试风格的使用
  • ✅ Matchers 的强大表达能力
  • ✅ 测试标记与模拟对象的使用

更多详细内容可参考 ScalaTest 官方文档

源码可在 GitHub 获取。


原始标题:Introduction to Testing With ScalaTest