
在本文中,我想提出一个使用Scala中的函数式编程概念的传统测试设计风格的替代方案。 这种方法的灵感来自维持数十项失败的测试所经历的数月之久的痛苦,以及使它们变得更加直接和易于理解的强烈愿望。
即使代码在Scala中,建议的想法也适合使用支持函数式编程的语言的开发人员和QA工程师。 您可以在本文末尾找到包含完整解决方案的Github链接和示例。
问题
如果您曾经必须处理测试(与哪个测试无关:单元测试,集成测试或功能测试),则它们很可能被编写为一组顺序指令。 例如:
以我的经验,大多数开发人员都喜欢使用这种编写测试的方式。 我们的项目有大约一千个关于不同隔离级别的测试,并且直到最近才全部以这种风格编写。 随着项目的发展,我们开始注意到严重的问题和维护此类测试的速度变慢:修复这些问题至少需要花费与编写生产代码相同的时间。
在编写新测试时,我们总是必须想出从头开始准备数据的方法,通常是通过复制和粘贴相邻测试中的步骤。 结果,当应用程序的数据模型发生变化时,存储卡的空间就会崩溃,我们将不得不修复所有失败的测试:在最坏的情况下,请深入研究每个测试并重写它。
当测试“诚实地”失败时(即由于业务逻辑中的实际错误),就不可能在没有调试的情况下了解出了什么问题。 因为测试是如此难以理解,所以没有人总是拥有关于系统应该如何运行的全面知识。
在我看来,所有这些痛苦都是这种测试设计的两个更深层问题的症状:
- 没有明确而实用的测试结构。 每个测试都是独一无二的雪花。 缺乏结构会导致冗长,这会浪费大量时间并使您失去动力。 无关紧要的细节分散了最重要的内容-测试所确定的要求。 复制和粘贴成为编写新测试用例的主要方法。
- 测试不能帮助开发人员定位缺陷。 他们只表示存在某种问题。 要了解测试运行的状态,您必须将其绘制在脑海中或使用调试器。
造型
我们可以做得更好吗? (扰流板警报:我们可以。)让我们考虑一下该测试可能具有的结构。
val db: Database = Database.forURL(TestConfig.generateNewUrl()) migrateDb(db) insertUser(db, id = 1, name = "test", role = "customer") insertPackage(db, id = 1, name = "test", userId = 1, status = "new") insertPackageItems(db, id = 1, packageId = 1, name = "test", price = 30) insertPackageItems(db, id = 2, packageId = 1, name = "test", price = 20) insertPackageItems(db, id = 3, packageId = 1, name = "test", price = 40)
根据经验,要测试的代码需要一些明确的参数(标识符,大小,数量,过滤器,仅举几例)以及一些外部数据(来自数据库,队列或某些其他实际服务)。 为了使我们的测试可靠运行,它需要一个固定装置 -一种将系统和/或数据提供者放入其中的状态。
有了这个工具,我们准备了一个依赖项来初始化被测代码-填充数据库,创建特定类型的队列,等等。
val svc = new SomeProductionLogic(db) val result = svc.calculatePrice(packageId = 1)
在某些输入参数上运行被测代码之后,我们会收到一个输出 -显式(由被测代码返回)和隐式(状态变化)。
result shouldBe 90
最后,我们检查输出是否符合预期,并通过一个或多个断言完成测试。

可以得出结论,测试通常由相同的阶段组成:输入准备,代码执行和结果声明。 通过明确地将测试主体分为多个阶段,我们可以利用这一事实来摆脱测试的第一个问题 ,即过于自由的形式。 在BDD样式( 行为驱动的开发 )测试中可以看到这种想法并不新鲜。
扩展性又如何呢? 测试过程的任何步骤都可以包含任意数量的中间步骤。 例如,我们可以采取一个大而复杂的步骤,例如构建一个灯具,然后将其拆分为几个,一个接一个地链接。 这样,测试过程可以无限扩展,但最终总是由相同的几个常规步骤组成。

运行测试
让我们尝试实现将测试分为多个阶段的想法,但是首先,我们应该确定我们希望看到什么样的结果。
总体而言,我们希望编写和维护测试变得更省力,更愉快。 测试具有的显式非唯一指令越少,在更改合同或重构后将需要对它进行的更改就越少,并且读取测试所花费的时间也越少。 测试的设计应促进通用代码段的重用,并避免盲目的复制和粘贴。 如果测试具有统一的形式,那也很好。 可预测性提高了可读性并节省了时间。 例如,想象如果有抱负的科学家将教科书中的公式自由地用通用语言而不是数学来编写,那么学习所有公式将花费多少时间。
因此,我们的目标是隐藏任何分散注意力和不必要的内容,仅保留对于理解至关重要的内容:正在测试的内容,预期的输入和输出。
让我们回到测试结构的模型。

从技术上讲,它的每个步骤都可以由数据类型表示,而每个转换都可以由函数表示。 通过将每个函数应用于上一个的结果,可以从初始数据类型获取最后一个数据类型。 换句话说,通过使用数据准备的函数组成(我们称其为prepare
),代码执行( execute
)和检查预期结果( check
)。 此合成的输入将是第一步-固定装置。 让我们将生成的高阶函数称为测试生命周期函数 。
测试生命周期功能 def runTestCycle[FX, DEP, OUT, F[_]]( fixture: FX, prepare: FX => DEP, execute: DEP => OUT, check: OUT => F[Assertion] ): F[Assertion] =
出现一个问题,这些特定功能从何而来? 嗯,关于数据准备,只有有限的方法可以做到这一点-填充数据库,模拟等。 因此, prepare
在所有测试中共享的prepare
函数的专用变体很方便。 结果,针对每种情况进行专门的测试生命周期功能将变得更加容易,这将隐藏数据准备的具体实现。 由于代码执行和断言在每个测试(或一组测试)中或多或少是唯一的,因此每次必须显式编写execute
和check
。
通过将所有管理上的细微差别委派给测试生命周期功能,我们可以扩展测试过程而无需进行任何给定的测试。 通过利用功能组合,我们可以干预过程的任何步骤并提取或添加数据。
为了更好地说明这种方法的功能,让我们解决初始测试的第二个问题 -缺少用于查明问题的补充信息。 让我们添加对返回的任何代码执行的日志记录。 我们的日志记录不会更改数据类型。 它只会产生副作用 -将消息输出到控制台。 产生副作用后,我们将其照原样返回。
使用日志记录测试生命周期功能 def logged[T](implicit loggedT: Logged[T]): T => T = (that: T) => {
通过这一简单更改,我们在每个测试中都添加了对执行代码输出的记录。 如此小的函数的优点是易于理解,编写和在需要时摆脱它们。

结果,我们的测试现在看起来像这样:
val fixture: SomeMagicalFixture = ???
测试的主体变得简洁,夹具和检查可以在其他测试中重复使用,我们不再在任何地方手动准备数据库。 仍然只有一个小问题...
治具准备
在上面的代码中,我们假设将固定装置从某个地方提供给我们。 由于数据是可维护和直接测试的关键要素,因此我们必须探讨如何轻松进行测试。
假设我们的被测试商店有一个典型的中型关系数据库(为简单起见,在此示例中,它只有4个表,但实际上可以有数百个表)。 有些表具有参考数据,有些表具有业务数据,并且所有这些都可以在逻辑上分组为一个或多个复杂实体。 关系与外键链接在一起,以创建Bonus
,需要Package
,而Package
则需要一个User
,依此类推。

解决方法和黑客攻击只会导致数据不一致,从而导致调试工作耗时数小时。 因此,我们不会以任何方式更改架构。
我们可以使用一些生产方法来填充它,但是即使在经过严格审查的情况下,这也提出了许多难题。 测试中将为该生产代码准备哪些数据? 如果该代码的合同发生更改,我们是否必须重写测试? 如果数据完全来自其他地方怎么办? 创建一个依赖许多其他实体的实体需要多少个请求?
在初始测试中填写数据库 insertUser(db, id = 1, name = "test", role = "customer") insertPackage(db, id = 1, name = "test", userId = 1, status = "new") insertPackageItems(db, id = 1, packageId = 1, name = "test", price = 30) insertPackageItems(db, id = 2, packageId = 1, name = "test", price = 20) insertPackageItems(db, id = 3, packageId = 1, name = "test", price = 40)
像我们第一个示例中那样,分散的辅助方法在不同的表述下是相同的问题。 他们负有管理依赖关系的责任,这是我们试图避免的。
理想情况下,我们希望某些数据结构能够一目了然地显示整个系统的状态。 合适的人选是表(或数据集 ,如PHP或Python),除了对业务逻辑至关重要的字段外,没有任何其他内容。 如果发生变化,维护测试将很容易:我们只需更改数据集中的字段即可。 范例:
val dataTable: Seq[DataRow] = Table( ("Package ID", "Customer's role", "Item prices", "Bonus value", "Expected final price") , (1, "customer", Vector(40, 20, 30) , Vector.empty , 90.0) , (2, "customer", Vector(250) , Vector.empty , 225.0) , (3, "customer", Vector(100, 120, 30) , Vector(40) , 210.0) , (4, "customer", Vector(100, 120, 30, 100) , Vector(20, 20) , 279.0) , (5, "vip" , Vector(100, 120, 30, 100, 50), Vector(10, 20, 10), 252.0) )

在表格中,我们创建键 -按ID的实体链接。 如果一个实体依赖于另一个实体,则还将创建该另一个实体的密钥。 两个不同的实体可能会创建具有相同ID的依赖关系,这可能会导致主键冲突 。 但是,在此阶段,对重复项进行重复数据删除非常便宜-由于它们包含的所有都是ID,因此我们可以将它们放入为我们进行重复数据删除的集合中,例如Set
。 如果结果不足,我们总是可以将更智能的重复数据删除实现为单独的功能,并将其组合到测试生命周期功能中。
按键(示例) sealed trait Key case class PackageKey(id: Int, userId: Int) extends Key case class PackageItemKey(id: Int, packageId: Int) extends Key case class UserKey(id: Int) extends Key case class BonusKey(id: Int, packageId: Int) extends Key
为字段(例如名称)生成伪造数据被委派给单独的类。 然后,通过使用该类和键的转换规则,我们获得了打算插入数据库的Row对象。
行(示例) object SampleData { def name: String = "test name" def role: String = "customer" def price: Int = 1000 def bonusAmount: Int = 0 def status: String = "new" } sealed trait Row case class PackageRow(id: Int, name: String, userId: Int, status: String) extends Row case class PackageItemRow(id: Int, packageId: Int, name: String, price: Int) extends Row case class UserRow(id: Int, name: String, role: String) extends Row case class BonusRow(id: Int, packageId: Int, bonusAmount: Int) extends Row
虚假数据通常是不够的,因此我们需要一种方法来覆盖特定字段。 幸运的是, 镜头正是我们所需要的-我们可以使用它们遍历所有已创建的行并仅更改我们需要的字段。 由于镜头是变相的功能,因此我们可以照常构图,这是它们的强项。
镜头(示例) def changeUserRole(userId: Int, newRole: String): Set[Row] => Set[Row] = (rows: Set[Row]) => rows.modifyAll(_.each.when[UserRow]) .using(r => if (r.id == userId) r.modify(_.role).setTo(newRole) else r)
多亏了组合,我们可以在流程中进行不同的优化和改进:例如,我们可以按表对行进行分组,以使用单个INSERT
以减少测试执行时间或记录数据库的整个状态。
治具准备功能 def makeFixture[STATE, FX, ROW, F[_]]( state: STATE, applyOverrides: F[ROW] => F[ROW] = x => x ): FX = (extractKeys andThen deduplicateKeys andThen enrichWithSampleData andThen applyOverrides andThen logged andThen buildFixture) (state)
最后,整个过程为我们提供了解决方案。 在测试本身中,除了初始数据集以外,没有显示任何其他内容-所有详细信息都由函数组成隐藏。

现在,我们的测试套件如下所示:
val dataTable: Seq[DataRow] = Table( ("Package ID", "Customer's role", "Item prices", "Bonus value", "Expected final price") , (1, "customer", Vector(40, 20, 30) , Vector.empty , 90.0) , (2, "customer", Vector(250) , Vector.empty , 225.0) , (3, "customer", Vector(100, 120, 30) , Vector(40) , 210.0) , (4, "customer", Vector(100, 120, 30, 100) , Vector(20, 20) , 279.0) , (5, "vip" , Vector(100, 120, 30, 100, 50), Vector(10, 20, 10), 252.0) ) "If the buyer's role is" - { "a customer" - { "And the total price of items" - { "< 250 after applying bonuses - no discount" - { "(case: no bonuses)" in calculatePriceFor(dataTable, 1) "(case: has bonuses)" in calculatePriceFor(dataTable, 3) } ">= 250 after applying bonuses" - { "If there are no bonuses - 10% off on the subtotal" in calculatePriceFor(dataTable, 2) "If there are bonuses - 10% off on the subtotal after applying bonuses" in calculatePriceFor(dataTable, 4) } } } "a vip - then they get a 20% off before applying bonuses and then all the other rules apply" in calculatePriceFor(dataTable, 5) }
和帮助程序代码:
在表中添加新的测试用例是一项琐碎的任务,它使我们可以专注于覆盖更多的边缘用例,而不是编写样板代码。
在不同项目上重复使用夹具准备
好的,所以我们写了很多代码来准备在一个特定项目中的固定装置,这花了很多时间。 如果我们有几个项目怎么办? 我们是否注定每次都会从头开始彻底改造整个事情?
我们可以在具体的领域模型上抽象出夹具的准备。 在函数式编程世界中,有一个类型类的概念。 在不深入细节的情况下,它们不像OOP中的类,而是更像接口,因为它们定义了某些类型组的特定行为。 根本的区别是它们不是继承的,而是像变量一样实例化的。 但是,类似于继承,类型类实例的解析在编译时进行 。 从这个意义上讲,可以像Kotlin和C#的 扩展方法一样掌握类型类。
要记录一个对象,我们不需要知道里面有什么,它有什么字段和方法。 我们只关心具有特定签名的行为log()
。 用Logged
接口扩展每个类非常繁琐,即使在很多情况下也是如此(例如,对于库或标准类)。 使用类型类,这要容易得多。 例如,我们可以创建一个称为Logged
的类型类的实例,以供灯具将其记录为人类可读的格式。 对于没有Logged
实例的所有其他内容,我们可以提供一个后备:类型为Any
的实例,该实例使用标准方法toString()
免费记录其内部表示形式中的每个对象。
Logged类型类及其实例的示例 trait Logged[A] { def log(a: A)(implicit logger: Logger): A }
除了记录之外,我们可以在制作夹具的整个过程中使用这种方法。 我们的解决方案提出了一种抽象的方法来制作数据库装置和一组与此相关的类型类。 这是项目使用解决方案的责任来实现这些类型类的实例,以使整个工作正常进行的过程。
在设计此夹具准备工具时,我使用SOLID原理作为指南针,以确保其可维护和可扩展:
- 单一职责原则 :每个类型类仅描述一种类型的行为。
- 开放/封闭原则 :我们不修改任何生产类; 相反,我们使用类型类的实例对其进行扩展。
- Liskov替代原则在这里不适用,因为我们不使用继承。
- 接口隔离原则 :我们使用许多专门的类型类,而不是全局类型。
- 依赖倒置原则 :夹具准备功能不依赖于具体类型,而是依赖于抽象类型类。
在确保满足所有原则之后,我们可以安全地假定我们的解决方案是可维护和可扩展的,足以在不同项目中使用。
在编写了测试生命周期功能和夹具准备解决方案(也与任何给定应用程序上的具体领域模型无关)之后,我们将着手改善所有其余测试。
底线
我们已经从传统的(逐步的)测试设计风格转变为实用的设计风格。 分步样式在早期和较小的项目中很有用,因为它不会限制开发人员,也不需要任何专业知识。 但是,当测试量变得太大时,这种样式倾向于下降。 以功能风格编写测试可能无法解决您所有的测试问题,但可能会显着改善其中有成百上千个项目的项目的可伸缩性和维护性。 用功能样式编写的测试变得更加简洁,并且侧重于基本内容(例如数据,被测代码和预期结果),而不是中间步骤。
此外,我们还探讨了函数组合和类型类在函数式编程中的功能有多强大。 在他们的帮助下,设计具有可扩展性和可重用性的解决方案非常简单。
自从几个月前采用该样式以来,我们的团队不得不花一些精力进行调整,但是最终,我们享受了结果。 新测试的编写速度更快,日志使生活更加舒适,并且只要对某些逻辑的复杂性有疑问,就可以方便地检查数据集。 我们的团队旨在逐步将所有测试转换为这种新样式。
链接到该解决方案,并且可以在这里找到完整的示例: Github 。 祝您测试愉快!