你的测试代码太脆弱?可能是因为 DRY 过头了
我们基于很多原因编写自动化测试用例,并从中获得了一些好处。我们增加了对代码正确性的信心,可以放心进行重构,并获得更快的反馈。
我是 TDD(测试驱动开发)的坚定支持者,我相信 TDD 为我们带来了上述的所有好处,同时还能获得更短的反馈周期和更好的测试覆盖率。
软件开发中有一个关键的设计原则,即 DRY——Don't Repeat Yourself。然而,正如我们将看到的,当将 DRY 原则应用于测试代码时,可能会导致测试套件变得脆弱——难以理解、维护和修改。当测试给我们带来维护方面的麻烦时,我们可能会质疑它们是否值得我们投入时间和精力。
我们的测试套件会因为“过于 DRY”而出现这种情况吗?我们该如何避免这个问题,并仍然能够从编写测试中获益?在本文中,我将深入探讨这个问题。我将介绍一些测试套件表现出脆弱性的迹象,减少测试重复应遵循的指南,以及更好的实现 DRY 测试的方法。
注意:本文不讨论不同类型测试的定义,而是关注测试中常见的重复性问题。
这些通常被认为会出现在单元测试中,但也可能出现在不符合“单元测试”严格定义的测试中。有关测试类型的另一种观点,请阅读 A Simpler Testing Pyramid: Getting the Most out of Your Tests。
DRY 是“Don’t Repeat Yourself”的缩写,Andy Hunt 和 Dave Thomas 在《The Pragmatic Programmer》一书中提出了这一理念。他们给出的定义是“系统中的每一个知识点都必须有一个单一、明确、权威的表达”。
DRY 代码的优势在于,如果应用程序中的一个概念发生了变化,只需要修改一个地方。这使得代码库更易于阅读和维护,并减少了出现错误的可能性。当领域概念在应用程序中以单一方式表示时,设计就会变得美观、清晰。
但要实现 DRY 并非易事。事实上,代码重复可能会诱使我们创建不必要的抽象,导致设计变得更复杂而不是更清晰。DRY 关注的是在概念层面减少代码重复,而不是减少输入重复代码。这个想法有助于更好地应用这个原则,同时避免常见的陷阱。
例如,我们经常在代码中使用字面量。在不同位置出现的数字60
是重复的实例,还是在不同的位置都有不同的含义?一个有用的评估方法是问自己:“如果需要修改这个值,是否希望所有的位置都被修改?” 60
可能表示一分钟的秒数,但在其他地方可能表示限速。为了 DRY 而将这个整数变成全局共享变量并不是一个很好的选择。
再举一个例子,假设有一个方法,这个方法循环遍历一个集合并执行一些操作。这个方法看起来可能跟另一个循环遍历同一个集合并执行略有不同的操作的方法有点像。那么是否应该提取这两种方法来消除重复呢?也许吧,但也不一定。我们可以这么想,如果修改一个功能时需要同时修改它们,那么它们很可能是密切相关的,那就应该合并它们。决定是否要 DRY,不仅仅看代码的外在“形状”。
在概念层面来审视重复性有助于避免做出错误的决策。
在测试中应用 DRY 原则时通常会出现类似的困境。虽然过多的重复可能使测试变得冗长且难以维护,但错误地应用 DRY 可能会导致测试套件变得脆弱。这是否意味着测试代码比应用程序代码需要有更多的重复?
解决测试脆弱性的常见方案是使用 DAMP 来描述应该如何编写测试。DAMP 是“Descriptive and Meaningful Phrases(描述性和有意义的短语)”或“Don’t Abstract Methods Prematurely(不要过早抽象方法)”的缩略语。另一个缩略语(我们都喜欢好的缩略语!)是 WET:“Write Everything Twice(所有东西都编写两次)”、“Write Every Time(每一次都重写编写)”、“We Enjoy Typing(我们喜欢打字)”或“Waste Everyone’s Time(浪费每个人的时间)”。
从字面上看,DAMP 有着良好的意图——描述性、有意义的短语以及在编写软件时知道何时该抽象化方法,这些至关重要。然而,从更一般的意义上讲,WET 和 DRY 是 DAMP 的反义词。其主要思想可概括为:在测试中比在应用程序代码中更倾向于重复。
然而,应用程序代码和测试代码中存在着同样的可读性和可维护性问题。概念重复在测试代码中和应用程序代码中一样会导致可维护性问题。
我们来看一些用 Kotlin 编写的脆弱的测试代码。
下面的示例展示了一种常见模式,可能会因不同的测试语言和框架而有所不同。例如,在 RSpec 中,setUp()
方法可能会由许多 let!
语句 * 组成。
class FilterTest {
private lateinit var filter: Filter
private lateinit var book1: Book
private lateinit var book2: Book
private lateinit var book3: Book
private lateinit var book4: Book
private lateinit var author: Author
private lateinit var item1: Item
private lateinit var item2: Item
fun setUp() {
book1 = createBook("Test title", "Test subtitle",
"2000-01-01", "2012-02-01")
book2 = createBook("Not found", "Not found",
"2000-01-15", "2012-03-01")
book3 = createBook("title 2", "Subtitle 2", null,
"archived", "mst")
createBookLanguage("EN", book1)
createBookLanguage("EN", book3)
author = createAuthor()
book4 = createBook("Another title 2", "Subtitle 2",
null, "processed", "", "",
listOf("b", "c"), author)
val user = createUser()
createProduct(user, null, book4)
val salesTeam = createSalesTeam()
createProduct(null, salesTeam, book4)
val price1 = createPrice(book1)
val price2 = createPrice(book3)
item1 = createItem("item")
createPriceTag(item1, price1)
item2 = createItem("item2")
createPriceTag(item2, price2)
val mstDiscount = createDiscount("mstdiscount")
val specialDiscount = createDiscount("special")
createBookDiscount(mstDiscount, book1)
createBookDiscount(specialDiscount, book2)
createBookDiscount(mstDiscount, book2)
}
fun `filter by title`() {
filter = Filter(searchTerm = "title")
onlyFindsBooks(filter, book1, book3, book4)
}
fun `filter by last`() {
filter = Filter(searchTerm = "title", last = "5 days")
onlyFindsBooks(filter, book3)
}
fun `filter by released from and released to`() {
filter = Filter(releasedFrom = "2000-01-10",
releasedTo = "2000-01-20")
onlyFindsBooks(filter, book2)
}
fun `filter by released from without released to`() {
filter = Filter(releasedFrom = "2000-01-02")
onlyFindsBooks(filter, book2, book3, book4)
}
fun `filter by released to without released from`() {
filter = Filter(releasedTo = "2000-01-01")
onlyFindsBooks(filter, book1)
}
fun `filter by language`() {
filter = Filter(language = "EN")
onlyFindsBooks(filter, book1, book3)
}
fun `filter by author ids`() {
filter = Filter(authorUuids = author.uuid)
onlyFindsBooks(filter, book4)
}
fun `filter by state`() {
filter = Filter(state = "archived")
onlyFindsBooks(filter, book3)
}
fun `filter by multiple item_uuids`() {
filter = Filter(itemUuids = listOf(item1.uuid, item2.uuid))
onlyFindsBooks(filter, book1, book3)
}
fun `filtering by discounts with substring`() {
filter = Filter(anyDiscount = listOf("discount"))
assertTrue(filter.results().isEmpty())
}
fun `filtering by discounts with single discount string`() {
filter = Filter(anyDiscount = listOf("special"))
onlyFindsBooks(filter, book2)
}
fun `filtering by discounts with non-existent discount`() {
filter = Filter(anyDiscount = listOf("foobar"))
assertTrue(filter.results().isEmpty())
}
fun `filtering by discounts with multiple of the same discount`() {
filter = Filter(anyDiscount =
listOf("mstdiscount", "mstdiscount", "special"))
onlyFindsBooks(filter, book1, book2)
}
private fun onlyFindsBooks(filter: Filter, vararg foundBooks: Book) {
val uuids = foundBooks.map { it.uuid }.toSet()
assertEquals(uuids, filter.results().map { it.uuid }.toSet())
}
}
在阅读这样的代码时,通常先关注 setUp 步骤,然后消化每个测试用例,并弄清它们与 setUp 的关系(或反过来)。单独看 setUp 本身并不能提供清晰的信息,单独看每个测试也不能。这种迹象说明这个测试套件是脆弱的。理想情况下,每个测试都可以被视为自身的小宇宙,其中定义了所有的上下文。
在上面的示例中,setup()
方法为所有测试用例创建了所有的书籍和相关数据,因此不清楚哪些书籍是哪些测试用例需要的。此外,因为细节太多,很难区分哪些是相关的,哪些是创建书籍所需的。请注意,如果用于创建书籍的数据发生变化,会有多少东西受到影响。
对于测试用例本身,每个测试用例都尽可能最小限度地调用应用程序代码并断言结果。用于断言的特定的书籍实例被隐藏在顶部的setUp()
方法中。我们不清楚onlyFindsBooks
在测试中的作用是什么。你可能会在这些测试代码中添加注释,用于说明每个书籍属性在每个测试中的相关性。
显然,开发人员最开始在一个地方创建所有对象的意图是好的。如果最初的功能只有两三个过滤器,那么在顶部创建所有对象可能会使代码更简洁。然而,随着测试和对象数量的增长,这种方法无法满足需求。后续的过滤功能需要开发人员向书籍添加更多的字段,并期望返回符合条件的书籍。想象一下,当我们开始组合不同的过滤器时,试图弄清楚应该返回哪个对象将会有多困难!
要弄清楚onlyFindsBooks()
的作用,你需要滚动更多的代码找到隐藏的断言。这个方法有很多逻辑,你需要花一些时间找到传入的东西与断言之间的联系。
最后,过滤器实例的声明与测试用例离得很远。
例如,我们来看看这个按照语言进行过滤的测试用例:
fun `filter by language`() {
filter = Filter(language = "EN")
onlyFindsBooks(filter, book1, book3)
}
是什么让book1
和book3
与传入的language = "EN"
匹配?为什么这个调用不返回book2
?要回答这些问题,你需要滚动到 setUp 处,将整个上下文加载到你的大脑里,然后试着发现所有书籍之间的相似之处和不同之处。
更具挑战性的是:
fun `filter by last`() {
filter = Filter(searchTerm = "title", last = "5 days")
onlyFindsBooks(filter, book3)
}
“5 days
”从哪里来的?它是不是与createBook()
方法中为book3
隐藏的值相关?
写这段代码的人应用了 DRY 原则来消除重复,但最终得到了一个难以理解且容易出错的测试套件。
上面代码中的许多线索表明 DRY 原则被错误地应用了。一些表明测试脆弱性且需要进行重构的迹象包括:
测试用例没有自己的小宇宙(见 Mystery Guest):你是否需要不断滚动代码来理解每个测试用例?
未突出显示相关细节:测试中是否需要用注释来澄清相关的测试细节?
测试的意图不清楚:是否需要一些样板代码或与测试无直接关系的“噪音”来准备测试?
重复的重复概念:改变应用程序代码是否会影响很多测试用例?
测试不独立:修改一个测试用例是否会导致其他测试用例失败?
解决方案
在这个小节中,我们将提出两种可能的解决方案来解决上述问题:“3A”原则和使用对象方法。
测试可以被看作由三个部分组成,通常被称为“3A”:
Arrange——任何必要的设置,包括重要的变量;
Act——对应用程序代码的调用(也称为 SUT,Subject Under Test);
Assert——包括预期或断言的验证步骤。这些步骤也被称为 Given、When 和 Then。
理想的测试只有三行,每个 A 一行。这在现实中可能不可行,但仍然是一个值得追求的目标。事实上,符合这种模式的测试更容易阅读:
// Arrange
var object = createObject()
// Act
var result = sut.findObject()
// Assert
assertEquals(object, result)
策略性地使用对象创建方法可以突出显示相关细节,并将无关(但必要)的样板代码隐藏在有意义的领域名称后面。这种策略受到了两种策略的启发:Builder Pattern 和 Object Mother。虽然之前的示例代码也使用方法构建测试对象,但仍缺少一些关键的东西。
对象创建方法应该:
使用领域名称(表示创建的对象的类型)来命名;
所有必填值都有默认值;
允许覆盖测试直接使用的值。
我们基于 3A 原则并使用对象创建方法来修改之前示例代码中的一个测试:
fun `filter by language`() {
var englishBook = createBook()
createBookLanguage("EN", englishBook)
var germanBook = createBook()
createBookLanguage("DE", germanBook)
var results = Filter(language = "EN").results()
val expectedUuids = listOf(englishBook).map { it.uuid }
val actualUuids = results.map { it.uuid }
assertEquals(expectedUuids, actualUuids)
}
所做的更改包括:
我们修改了
createBook()
方法来隐藏样板代码,并允许覆盖语言(没有显示createBook()
定义)。我们重命名了书籍变量来表明它们的差异。
我们内联了
filter
变量,让 Act 步骤可见。它还可以成为常量而不只是变量,从而减少可变性。我们内联了
onlyFindsBooks()
方法,并重命名了临时变量。这样就可以将 Act 步骤与 Assert 步骤分开。
现在,这三个步骤要容易识别得多。我们可以很容易地看到为什么要创建两本书及它们之间的区别。Act 步骤明确指定了仅查找"EN"
书籍,并且我们期望只返回英文书籍。
Arrange 步骤用了 4 行代码,这比理想情况要长一些。尽管是 4 行代码,但它们都与这个测试相关,并且很容易看出为什么。我们可以将创建书籍和语言关联合并为一个方法,但这会让测试代码变得更加复杂,并将书籍的创建与语言紧密耦合起来,因此可能会导致混乱而不是清晰。但是,如果“用某种语言撰写的书籍”是领域中存在的概念,那么这么做可能是个好主意。
Assert 步骤的逻辑可以更好一些。这些逻辑和噪音给理解它为什么失败带来了一些麻烦。
我们对这两个地方进行改造:
fun `filter by language`() {
val englishBook = createBookWrittenIn("EN")
val germanBook = createBookWrittenIn("DE")
val results = Filter(language = "EN").results()
assertBooksEqual(listOf(englishBook), results)
}
private fun createBookWrittenIn(language: String): Book {
val book = createBook()
createBookLanguage(language, book)
return book
}
private fun assertBooksEqual(expected: List<Book>, actual: List<Book>) {
val expectedUuids = expected.map { it.uuid }
val actualUuids = actual.map { it.uuid }
assertEquals(expectedUuids, actualUuids)
}
这个测试不需要setUp()
中的东西,因此可以很轻松地理解上下文。你可以深入查看辅助方法(createBookWrittenIn
和assertBooksEqual
),但即使不这样做,测试也是可读的。
当我们在整个测试套件的其余部分应用这些修改时,我们不得不去考虑每个测试用例所需的书籍及其属性。随着我们继续进行,相关细节将会凸显出来。
如果我们将所有测试放在一起看,可能会感到不适,因为我们创建了这么多的书籍!但我们可以接受这种重复,因为我们知道,虽然从代码上看似乎是重复了,但在概念上并没有重复。每个测试都创建了代表不同想法的书籍,例如,一本用英语写的书与一本在特定日期发布的书。
我们的 setUp 方法是空的,并且每个测试用例都可以独立理解。如果修改了应用程序代码(例如,书籍构造函数),只需要修改一个地方。修改单个测试的 setUp 或预期不会导致所有测试失败。提取出来的辅助方法的名称符合 3A 模式并且是有意义的。
下面概括了我们遵循的主要原则和其他原则:
每个测试用例都符合 3A 模式:Arrange、Act 和 Assert。在查看测试用例时,应该很容易区分这三部分(设置、动作、期望)。
Arrange
设置代码不包含断言。
每个测试清楚地表明与其他测试的相关差异。
设置方法不包含相关差异(而是针对每个测试的局部定义)。
提取样板“噪音”,易于重用。
每个测试独立运行,都有它们自己的小宇宙,即它们所需的所有上下文。
避免导致测试不确定性的随机性。测试失败应该具有确定性,我们应避免间歇性失败的不稳定测试。
Act
SUT(被测试对象)和测试内容(目标行为)易于识别。
Assert
在断言中使用字面量(硬编码)而不是变量,但可以提供额外清晰度的良好命名的变量除外。
测试中没有复杂的逻辑或循环。循环会导致测试用例相互依赖。复杂的逻辑不仅脆弱且难以理解。
断言不重复实现代码。
每个测试中少一些断言。将具有大量断言的测试分解为具有较少断言的多个测试,可以提供更多关于失败的反馈。断言太多说明应用程序代码的职责太多。
选择在失败时可以提供更多信息的断言。例如,一个断言结果匹配一个数组所提供的信息比多个断言统计数组中的项目并逐个验证每个条目所提供的信息更多。测试在出现第一个失败时就停止了,无法获得后续断言的反馈。
关于设计
有时候很难遵循上述原则,因为测试用例在试图告诉你有关应用程序设计的信息。可以根据下面的描述来甄别那些为应用程序代码设计提供反馈的测试用例:
如果是这样:
太多的 setUp 说明测试表面积太大,测试的内容太多了。
想要提取变量(耦合测试用例),因为需要反复测试字面量,这说明应用程序的职责太多了。
那么:
考虑应用单一职责原则,减少应用程序代码的职责。如果是这样:
需要添加注释才能让测试可理解。
那么:
重命名变量、方法或测试名称,让它们变得更有意义。
考虑重构应用程序代码,提供更有意义的名称或拆分职责。
此外,在可以删除重复代码之前耐心等待。在弄清楚测试用例提供的信息之前,可以选择让代码重复。如果提取代码或重构出现问题,最好采用内联代码,然后重试。
导致开发人员想要提取重复代码的另一个原因是性能问题。慢测试是一个值得关注的问题,但担心创建重复对象的担忧通常被过度夸大了,尤其是与维护脆弱的测试所花费的时间相比。重新设计应用程序代码,减轻由大量的 setUp 所带来的痛苦,这可以带来更好的设计和轻量化的测试。
如果你在测试中遇到了性能问题,请先查清楚原因。看看测试用例是否告诉你一些有关架构的信息,你可能会找到一种不影响测试清晰度的性能解决方案。
DRY 是一个对应用程序代码和测试代码都适用的原则。然而,在将 DRY 应用于测试代码时,要清楚地区分测试的三个步骤:Arrange、Act 和 Assert。这将有助于突出每个测试之间的差异,并防止样板代码让测试变得嘈杂。如果你的测试代码表现出了脆弱(经常因应用程序代码发生改动而失败)或难以阅读,请不要害怕对它们进行内联,并沿着更有意义的领域边界重新提取。
重要的是要记住,好的设计原不仅适用于应用程序代码,也适用于测试代码。测试代码需要与应用程序代码一样易于维护和阅读,虽然代码重复可能对测试不是那么有害,但允许概念层面的重复给测试代码带来的维护性问题并不比应用程序代码小。因此,应该给予测试代码同样程度的关注。
查看英文原文:
https://www.infoq.com/articles/brittle-test-suite-maybe-too-dry/
声明:本文由 InfoQ 翻译,未经许可禁止转载。
谷歌大裁员引发元老集体抗议:领导脑袋空空,无能的中层管理团队不断扩大
德国再次拥抱Linux:数万系统从windows迁出,能否避开二十年前的“坑”?
微信扫码关注该文公众号作者