【ScalaTest系列4】Sharing fixtures共享测试夹具
文章目录
一个测试夹具由对象和其他工件(文件、套接字、数据库连接等)组成,用于执行测试工作。当多个测试需要使用相同的夹具时,尽量避免在这些测试中重复夹具代码是很重要的。测试中的代码重复越多,对实际生产代码进行重构的影响就越大。
ScalaTest推荐三种技术来消除这种代码重复:
- 使用Scala进行重构
- 重写withFixture方法
- 混入before-and-after特质
每种技术都旨在帮助您减少代码重复,而不引入实例变量、共享可变对象或测试之间的其他依赖关系。消除测试之间的共享可变状态将使您的测试代码更容易理解,并更适合并行执行测试。
下面的部分介绍了这些技术,包括每种技术的推荐用法解释。
首先,这里有一个总结选项的表格:
技术 | 描述 |
---|---|
get-fixture 方法 | 提供一个简单的方法来创建多个测试中使用的相同可变夹具对象,但不清理它们 |
fixture-context 对象 | 将夹具方法和字段放置在特质中,通过混合这些特质,您可以为每个测试提供所需的新创建的夹具。当不同的测试需要不同组合的可变夹具对象时,请使用此技术 |
loan-fixture 方法 | 当不同的测试需要不同的夹具,并且必须在使用后清理夹具时,可以使用loan模式来消除重复代码 |
Override withFixture | 当大多数或所有的测试都需要相同的夹具时,请重写withFixture方法 |
Mix in a before-and-after 特质 | 当夹具代码失败时,终止整个套件而不是测试失败时,请混入before-and-after特质 |
调用 get-fixture 方法
如果您需要在多个测试中创建相同的可变夹具对象,并且不需要在使用完之后进行清理,最简单的方法是编写一个或多个 get-fixture 方法。get-fixture 方法在每次调用时返回所需夹具对象(或包含多个夹具对象的 holder 对象)的新实例。您可以在每个需要夹具的测试开始处调用 get-fixture 方法,将返回的对象或对象存储在局部变量中。
以下是一个示例:
package org.scalatest.examples.flatspec.getfixture
import org.scalatest.flatspec.AnyFlatSpec
import collection.mutable.ListBuffer
class ExampleSpec extends AnyFlatSpec {
def fixture =
new {
val builder = new StringBuilder("ScalaTest is ")
val buffer = new ListBuffer[String]
}
"Testing" should "be easy" in {
val f = fixture
f.builder.append("easy!")
assert(f.builder.toString === "ScalaTest is easy!")
assert(f.buffer.isEmpty)
f.buffer += "sweet"
}
it should "be fun" in {
val f = fixture
f.builder.append("fun!")
assert(f.builder.toString === "ScalaTest is fun!")
assert(f.buffer.isEmpty)
}
}
在每个使用夹具对象的地方,前面加上"f."提供了哪些对象是夹具的一个视觉指示。但如果您喜欢,您可以使用 “import f._” 导入成员并直接使用名称。
如果您需要在不同的测试中以不同的方式配置夹具对象,可以将配置作为参数传递给 get-fixture 方法。例如,如果您可以将可变夹具对象的初始值作为参数传递给 get-fixture 方法。
实例化 fixture-context 对象
当不同的测试需要不同组合的夹具对象时,另一种有用的技术是将夹具对象定义为夹具上下文对象的实例变量,该对象的实例化形成测试的主体。与 get-fixture 方法类似,夹具上下文对象只适用于在使用完之后不需要清理夹具的情况。
要使用这种技术,您可以在特质和/或类中定义初始化为夹具对象的实例变量,然后在每个测试中实例化一个包含测试所需夹具对象的对象。特质允许您混合在一起只包含每个测试所需夹具对象的特质,而类允许您通过构造函数传递数据来配置夹具对象。以下是一个示例,其中夹具对象被分为两个特质,并且每个测试只混合所需的特质:
package org.scalatest.examples.flatspec.fixturecontext
import collection.mutable.ListBuffer
import org.scalatest.flatspec.AnyFlatSpec
class ExampleSpec extends AnyFlatSpec {
trait Builder {
val builder = new StringBuilder("ScalaTest is ")
}
trait Buffer {
val buffer = ListBuffer("ScalaTest", "is")
}
// This test needs the StringBuilder fixture
"Testing" should "be productive" in new Builder {
builder.append("productive!")
assert(builder.toString === "ScalaTest is productive!")
}
// This test needs the ListBuffer[String] fixture
"Test code" should "be readable" in new Buffer {
buffer += ("readable!")
assert(buffer === List("ScalaTest", "is", "readable!"))
}
// This test needs both the StringBuilder and ListBuffer
it should "be clear and concise" in new Builder with Buffer {
builder.append("clear!")
buffer += ("concise!")
assert(builder.toString === "ScalaTest is clear!")
assert(buffer === List("ScalaTest", "is", "concise!"))
}
}
通过混入 Builder 和 Buffer 两个特质,ExampleSpec 获取了两个夹具,这些夹具将在每个测试之前进行初始化,并在之后进行清理。特质的混合顺序决定了执行顺序。在这种情况下,Builder 在 Buffer 之上。如果您希望 Buffer 在 Builder 之上,请只需要交换它们的混合顺序,像这样:
class Example2Spec extends Suite with Buffer with Builder
如果只需要一个夹具,则只需混入该特质:
class Example3Spec extends Suite with Builder
通过重写 withFixture(NoArgTest)
尽管 get-fixture 方法和 fixture-context 对象方法可以在每个测试开始时设置夹具,但它们并不解决在测试结束时清理夹具的问题。如果只需要在测试开始之前执行一些副作用或在测试完成之后执行一些清理操作,并且实际上不需要将任何夹具对象传递给测试,您可以重写 withFixture(NoArgTest),这是 trait Suite 中定义的 ScalaTest 的生命周期方法之一。
Suite 特质的 runTest 实现将一个无参数测试函数传递给 withFixture(NoArgTest)。 withFixture 的职责是调用该测试函数。Suite 的 withFixture 实现只是简单地调用该函数,如下所示:
// Trait Suite 中的默认实现
protected def withFixture(test: NoArgTest) = {
test()
}
因此,您可以重写 withFixture 方法来在调用测试函数之前和之后执行设置和清理操作。如果有清理工作要做,您应该在 try 块中调用测试函数,并在 finally 子句中进行清理,以防止异常通过 withFixture 传播回来(如果测试由于异常而失败,由 withFixture 调用的测试函数将导致一个 Failed 包装异常。然而,最好在 finally 子句中执行清理工作,以防发生异常。
withFixture 方法被设计成可以堆叠的,并且为此,您应该始终调用 super.withFixture,让它来调用测试函数,而不是直接调用测试函数。也就是说,您应该写“super.withFixture(test)”而不是“test()”,如下所示:
// 您的实现
override def withFixture(test: NoArgTest) = {
// 执行设置操作
try super.withFixture(test) // 调用测试函数
finally {
// 执行清理操作
}
}
下面是一个示例,其中使用 withFixture(NoArgTest) 来在测试失败时对工作目录进行快照,并将该信息发送给报告器:
package org.scalatest.examples.flatspec.noargtest
import java.io.File
import org.scalatest._
import org.scalatest.flatspec.AnyFlatSpec
class ExampleSpec extends AnyFlatSpec {
override def withFixture(test: NoArgTest) = {
super.withFixture(test) match {
case failed: Failed =>
val currDir = new File(".")
val fileNames = currDir.list()
info("Dir snapshot: " + fileNames.mkString(", "))
failed
case other => other
}
}
"This test" should "succeed" in {
assert(1 + 1 === 2)
}
it should "fail" in {
assert(1 + 1 === 3)
}
}
在 Scala 解释器中运行 ExampleSuite 的此版本,并且在一个目录中具有两个文件 hello.txt 和 world.txt,会产生以下输出:
scala> new ExampleSuite execute
ExampleSuite:
This test
- should succeed
- should fail *** FAILED ***
2 did not equal 3 (<console>:33)
+ Dir snapshot: hello.txt, world.txt
请注意,传递给 withFixture 的 NoArgTest,除了执行测试的 apply 方法外,还包括 TestData,例如测试名称和传递给 runTest 的配置映射。因此,您也可以在 withFixture 实现中使用测试名称和配置对象。
调用 loan-fixture 方法
如果您需要将夹具对象传递到测试中,并在测试结束时进行清理,那么您需要使用 loan 模式来实现。如果不同的测试需要不同的必须在之后进行清理的夹具,则可以通过编写 loan-fixture 方法来直接实现 loan 模式。loan-fixture 方法接受一个函数作为参数,该函数的主体部分或全部形成测试代码的一部分。它创建一个夹具,通过调用函数将其传递给测试代码,然后在函数返回后清理夹具。
下面的示例显示了三个测试使用了两个夹具,一个数据库和一个文件。这两个都需要在使用后进行清理,因此每个夹具都通过一个 loan-fixture 方法提供。(在此示例中,数据库使用 StringBuffer 模拟。)
```scala
package org.scalatest.examples.flatspec.loanfixture
import java.util.concurrent.ConcurrentHashMap
object DbServer { // 模拟数据库服务器
type Db = StringBuffer
private val databases = new ConcurrentHashMap[String, Db]
def createDb(name: String): Db = {
val db = new StringBuffer
databases.put(name, db)
db
}
def removeDb(name: String) {
databases.remove(name)
}
}
import org.scalatest.flatspec.AnyFlatSpec
import DbServer._
import java.util.UUID.randomUUID
import java.io._
class ExampleSpec extends AnyFlatSpec {
def withDatabase(testCode: Db => Any) {
val dbName = randomUUID.toString
val db = createDb(dbName) // 创建夹具
try {
db.append("ScalaTest is ") // 执行设置操作
testCode(db) // "借用" 夹具给测试
}
finally removeDb(dbName) // 清理夹具
}
def withFile(testCode: (File, FileWriter) => Any) {
val file = File.createTempFile("hello", "world") // 创建夹具
val writer = new FileWriter(file)
try {
writer.write("ScalaTest is ") // 设置夹具
testCode(file, writer) // "借用" 夹具给测试
}
finally writer.close() // 清理夹具
}
// This test needs the file fixture
"Testing" should "be productive" in withFile { (file, writer) =>
writer.write("productive!")
writer.flush()
assert(file.length === 24)
}
// This test needs the database fixture
"Test code" should "be readable" in withDatabase { db =>
db.append("readable!")
assert(db.toString === "ScalaTest is readable!")
}
// This test needs both the file and the database
it should "be clear and concise" in withDatabase { db =>
withFile { (file, writer) => // loan-fixture 方法组合
db.append("clear!")
writer.write("concise!")
writer.flush()
assert(db.toString === "ScalaTest is clear!")
assert(file.length === 21)
}
}
}
正如最后一个测试所示,loan-fixture 方法可以组合。loan-fixture 方法不仅允许您为每个测试提供所需的夹具,还允许您为测试提供多个夹具,并在之后进行清理。
此示例还演示了给每个测试创建自己的“夹具沙盒”以供使用的技术。当夹具涉及到外部副作用时,比如创建文件或数据库,最好为每个文件或数据库分配一个唯一的名称,就像在这个示例中所做的那样。这样可以完全隔离测试,使您可以根据需要并行运行它们。
重写 withFixture(OneArgTest)
如果大多数或所有的测试都需要相同的夹具,您可以通过使用 FixtureAnyFlatSpec 并重写 withFixture(OneArgTest) 来避免使用 loan-fixture 方法时的一些样板代码。FixtureAnyFlatSpec 中的每个测试都将夹具作为参数传入,允许您将夹具传递到测试中。您必须通过指定 FixtureParam 来指示夹具参数的类型,并实现一个带有 OneArgTest 的 withFixture 方法。这个 withFixture 方法负责调用一个带有一个参数的测试函数,以便您可以在调用之前和之后执行夹具设置和清理操作。
为了能够堆叠定义了 withFixture(NoArgTest) 的特质,最好让 withFixture(NoArgTest) 调用测试函数,而不是直接调用测试函数。为此,您需要将夹具对象传递给 OneArgTest 的 toNoArgTest 方法,将 OneArgTest 转换为 NoArgTest。换句话说,您应该写“super.withFixture(test.toNoArgTest(theFixture))”,而不是“test(theFixture)”,如下所示:
package org.scalatest.examples.flatspec.oneargtest
import org.scalatest.flatspec.FixtureAnyFlatSpec
import java.io._
class ExampleSpec extends FixtureAnyFlatSpec {
case class FixtureParam(file: File, writer: FileWriter)
def withFixture(test: OneArgTest) = {
val file = File.createTempFile("hello", "world") // 创建夹具
val writer = new FileWriter(file)
val theFixture = FixtureParam(file, writer)
try {
writer.write("ScalaTest is ") // 设置夹具
withFixture(test.toNoArgTest(theFixture)) // "借用" 夹具给测试
}
finally writer.close() // 清理夹具
}
"Testing" should "be easy" in { f =>
f.writer.write("easy!")
f.writer.flush()
assert(f.file.length === 18)
}
it should "be fun" in { f =>
f.writer.write("fun!")
f.writer.flush()
assert(f.file.length === 17)
}
}
在此示例中,实际上需要两个夹具对象,一个 File 和一个 FileWriter。在这种情况下,您可以简单地将 FixtureParam 类型定义为包含这些对象的元组,或者如本示例所示,包含这些对象的 case class。有关 withFixture(OneArgTest) 技术的更多信息,请参阅 FixtureAnyFlatSpec 的文档。
混入 BeforeAndAfter
到目前为止,所展示的所有共享夹具示例中,创建、设置和清理夹具对象的活动都是在测试期间执行的。这意味着如果在任何这些活动中出现异常,它将作为测试失败进行报告。然而,有时您可能希望在测试开始之前进行设置,在测试完成后进行清理,以便如果在设置或清理过程中出现异常,则整个套件中止,并且不再尝试执行更多的测试。在 ScalaTest 中实现这一点最简单的方法是混入 trait BeforeAndAfter。使用此 trait,您可以使用 before 和/或 after 来指定在每个测试之前运行的代码,以及在每个测试之后运行的代码,例如:
package org.scalatest.examples.flatspec.beforeandafter
import org.scalatest._
import collection.mutable.ListBuffer
class ExampleSpec extends AnyFlatSpec with BeforeAndAfter {
val builder = new StringBuilder
val buffer = new ListBuffer[String]
before {
builder.append("ScalaTest is ")
}
after {
builder.clear()
buffer.clear()
}
"Testing" should "be easy" in {
builder.append("easy!")
assert(builder.toString === "ScalaTest is easy!")
assert(buffer.isEmpty)
buffer += "sweet"
}
it should "be fun" in {
builder.append("fun!")
assert(builder.toString === "ScalaTest is fun!")
assert(buffer.isEmpty)
}
}
请注意,在测试代码之前和之后,只有通过某种副作用机制,通常是通过重新分配实例变量或更改从实例值持有的可变对象的状态(如此示例中所示),才能与测试代码进行通信。如果使用实例变量或从实例值持有的可变对象,您将无法在同一测试类实例中并行运行测试,除非对共享的可变状态进行同步访问。这就是为什么ScalaTest的ParallelTestExecution特质扩展了OneInstancePerTest的原因。通过在类的每个实例中运行每个测试,每个测试都有自己的实例变量副本,因此不需要同步。如果将ParallelTestExecution混入上面的ExampleSuite中,测试将可以正常并行运行,而不需要在可变的StringBuilder和ListBuffer[String]对象上进行任何同步。
虽然BeforeAndAfter提供了一种执行测试之前和之后代码的最简单方式,但它并不设计成支持可堆叠特质,因为执行顺序将变得不明显。如果要提取出在多个测试套件中通用的前置和后置代码,应该使用BeforeAndAfterEach特质,如下一节中所示,通过堆叠特质来组合夹具。
通过堆叠特质组合构建测试夹具
在大型项目中,团队往往会使用多种不同的夹具组合来满足测试类的需求,并可能按照不同的顺序进行初始化(和清理)。在ScalaTest中,一种好的方式是将单独的夹具因子提取为特质,并使用可堆叠特质模式进行组合。这可以通过在多个特质中放置withFixture方法来实现,每个特质都调用super.withFixture。下面是一个示例,其中前面的示例中使用的StringBuilder和ListBuffer[String]夹具已被提取为两个可堆叠的夹具特质Builder和Buffer:
package org.scalatest.examples.flatspec.composingwithfixture
import org.scalatest._
import collection.mutable.ListBuffer
trait Builder extends SuiteMixin { this: Suite =>
val builder = new StringBuilder
abstract override def withFixture(test: NoArgTest) = {
builder.append("ScalaTest is ")
try super.withFixture(test) // To be stackable, must call super.withFixture
finally builder.clear()
}
}
trait Buffer extends SuiteMixin { this: Suite =>
val buffer = new ListBuffer[String]
abstract override def withFixture(test: NoArgTest) = {
try super.withFixture(test) // To be stackable, must call super.withFixture
finally buffer.clear()
}
}
class ExampleSpec extends AnyFlatSpec with Builder with Buffer {
"Testing" should "be easy" in {
builder.append("easy!")
assert(builder.toString === "ScalaTest is easy!")
assert(buffer.isEmpty)
buffer += "sweet"
}
it should "be fun" in {
builder.append("fun!")
assert(builder.toString === "ScalaTest is fun!")
assert(buffer.isEmpty)
buffer += "clear"
}
}
通过混入Builder和Buffer两个特质,ExampleSpec将获得两个夹具,在每个测试之前进行初始化,并在测试后进行清理。特质的混入顺序决定了执行顺序。在这种情况下,Builder比Buffer“超级”。如果希望Buffer是Builder的“超级”,只需改变它们混入的顺序,如下所示:
class Example2Spec extends Suite with Buffer with Builder
如果只需要一个夹具,只需混入该特质:
class Example3Spec extends Suite with Builder
另一种创建堆叠夹具特质的方式是通过扩展BeforeAndAfterEach和/或BeforeAndAfterAll特质。BeforeAndAfterEach具有beforeEach方法,该方法将在每个测试之前运行(类似于JUnit的setUp),并且afterEach方法将在每个测试之后运行(类似于JUnit的tearDown)。类似地,BeforeAndAfterAll具有beforeAll方法,该方法将在所有测试之前运行,并且afterAll方法将在所有测试之后运行。如果要使用BeforeAndAfterEach方法而不是withFixture重写以重新编写先前显示的示例,可以按照以下方式进行:
package org.scalatest.examples.flatspec.composingbeforeandaftereach
import org.scalatest._
import collection.mutable.ListBuffer
trait Builder extends BeforeAndAfterEach { this: Suite =>
val builder = new StringBuilder
override def beforeEach() {
builder.append("ScalaTest is ")
super.beforeEach() // To be stackable, must call super.beforeEach
}
override def afterEach() {
try super.afterEach() // To be stackable, must call super.afterEach
finally builder.clear()
}
}
trait Buffer extends BeforeAndAfterEach { this: Suite =>
val buffer = new ListBuffer[String]
override def afterEach() {
try super.afterEach() // To be stackable, must call super.afterEach
finally buffer.clear()
}
}
class ExampleSpec extends AnyFlatSpec with Builder with Buffer {
"Testing" should "be easy" in {
builder.append("easy!")
assert(builder.toString === "ScalaTest is easy!")
assert(buffer.isEmpty)
buffer += "sweet"
}
it should "be fun" in {
builder.append("fun!")
assert(builder.toString === "ScalaTest is fun!")
assert(buffer.isEmpty)
buffer += "clear"
}
}
与withFixture相比,使用BeforeAndAfterEach扩展的特质之间的差异在于,在BeforeAndAfterEach中,设置和清理代码在测试之前和之后发生,而在withFixture中则在测试的开始和结束时发生。因此,如果withFixture方法异常终止,它被视为失败的测试。相比之下,如果BeforeAndAfterEach的任何beforeEach或afterEach方法异常终止,则被视为中止的套件,并且将导致SuiteAborted事件的发生。
推荐阅读
【ScalaTest系列1】由来场景用法示例详解
【ScalaTest教程2】使用ScalaTest进行测试步骤详解指南
【ScalaTest系列3】 使用断言示例使用词典
【ScalaTest系列4】Sharing fixtures共享测试夹具
【ScalaTest系列5】ScalaTest + JUnit 5+gradle+idea