Redian新闻
>
你的测试代码太脆弱?可能是因为 DRY 过头了

你的测试代码太脆弱?可能是因为 DRY 过头了

科技

作者 | Kimberly Hendrick
译者 | 明知山
策划 | Tina

我们基于很多原因编写自动化测试用例,并从中获得了一些好处。我们增加了对代码正确性的信心,可以放心进行重构,并获得更快的反馈。

我是 TDD(测试驱动开发)的坚定支持者,我相信 TDD 为我们带来了上述的所有好处,同时还能获得更短的反馈周期和更好的测试覆盖率。

软件开发中有一个关键的设计原则,即 DRY——Don't Repeat Yourself。然而,正如我们将看到的,当将 DRY 原则应用于测试代码时,可能会导致测试套件变得脆弱——难以理解、维护和修改。当测试给我们带来维护方面的麻烦时,我们可能会质疑它们是否值得我们投入时间和精力。

我们的测试套件会因为“过于 DRY”而出现这种情况吗?我们该如何避免这个问题,并仍然能够从编写测试中获益?在本文中,我将深入探讨这个问题。我将介绍一些测试套件表现出脆弱性的迹象,减少测试重复应遵循的指南,以及更好的实现 DRY 测试的方法。

注意:本文不讨论不同类型测试的定义,而是关注测试中常见的重复性问题。

这些通常被认为会出现在单元测试中,但也可能出现在不符合“单元测试”严格定义的测试中。有关测试类型的另一种观点,请阅读 A Simpler Testing Pyramid: Getting the Most out of Your Tests

什么是 DRY?

DRY 是“Don’t Repeat Yourself”的缩写,Andy Hunt 和 Dave Thomas 在《The Pragmatic Programmer》一书中提出了这一理念。他们给出的定义是“系统中的每一个知识点都必须有一个单一、明确、权威的表达”。

DRY 代码的优势在于,如果应用程序中的一个概念发生了变化,只需要修改一个地方。这使得代码库更易于阅读和维护,并减少了出现错误的可能性。当领域概念在应用程序中以单一方式表示时,设计就会变得美观、清晰。

DRY 应用程序代码

但要实现 DRY 并非易事。事实上,代码重复可能会诱使我们创建不必要的抽象,导致设计变得更复杂而不是更清晰。DRY 关注的是在概念层面减少代码重复,而不是减少输入重复代码。这个想法有助于更好地应用这个原则,同时避免常见的陷阱。

例如,我们经常在代码中使用字面量。在不同位置出现的数字60是重复的实例,还是在不同的位置都有不同的含义?一个有用的评估方法是问自己:“如果需要修改这个值,是否希望所有的位置都被修改?” 60可能表示一分钟的秒数,但在其他地方可能表示限速。为了 DRY 而将这个整数变成全局共享变量并不是一个很好的选择。

再举一个例子,假设有一个方法,这个方法循环遍历一个集合并执行一些操作。这个方法看起来可能跟另一个循环遍历同一个集合并执行略有不同的操作的方法有点像。那么是否应该提取这两种方法来消除重复呢?也许吧,但也不一定。我们可以这么想,如果修改一个功能时需要同时修改它们,那么它们很可能是密切相关的,那就应该合并它们。决定是否要 DRY,不仅仅看代码的外在“形状”。

在概念层面来审视重复性有助于避免做出错误的决策。

DRY 测试

在测试中应用 DRY 原则时通常会出现类似的困境。虽然过多的重复可能使测试变得冗长且难以维护,但错误地应用 DRY 可能会导致测试套件变得脆弱。这是否意味着测试代码比应用程序代码需要有更多的重复?

DRY 与 DAMP/WET

解决测试脆弱性的常见方案是使用 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
@BeforeEach 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) }
@Test fun `filter by title`() { filter = Filter(searchTerm = "title") onlyFindsBooks(filter, book1, book3, book4) }
@Test fun `filter by last`() { filter = Filter(searchTerm = "title", last = "5 days") onlyFindsBooks(filter, book3) }
@Test fun `filter by released from and released to`() { filter = Filter(releasedFrom = "2000-01-10", releasedTo = "2000-01-20") onlyFindsBooks(filter, book2) }
@Test fun `filter by released from without released to`() { filter = Filter(releasedFrom = "2000-01-02") onlyFindsBooks(filter, book2, book3, book4) }
@Test fun `filter by released to without released from`() { filter = Filter(releasedTo = "2000-01-01") onlyFindsBooks(filter, book1) }
@Test fun `filter by language`() { filter = Filter(language = "EN") onlyFindsBooks(filter, book1, book3) }
@Test fun `filter by author ids`() { filter = Filter(authorUuids = author.uuid) onlyFindsBooks(filter, book4) }
@Test fun `filter by state`() { filter = Filter(state = "archived") onlyFindsBooks(filter, book3) }
@Test fun `filter by multiple item_uuids`() { filter = Filter(itemUuids = listOf(item1.uuid, item2.uuid)) onlyFindsBooks(filter, book1, book3) }
@Test fun `filtering by discounts with substring`() { filter = Filter(anyDiscount = listOf("discount")) assertTrue(filter.results().isEmpty()) }
@Test fun `filtering by discounts with single discount string`() { filter = Filter(anyDiscount = listOf("special")) onlyFindsBooks(filter, book2) }
@Test fun `filtering by discounts with non-existent discount`() { filter = Filter(anyDiscount = listOf("foobar")) assertTrue(filter.results().isEmpty()) }
@Test 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()的作用,你需要滚动更多的代码找到隐藏的断言。这个方法有很多逻辑,你需要花一些时间找到传入的东西与断言之间的联系。

最后,过滤器实例的声明与测试用例离得很远。

例如,我们来看看这个按照语言进行过滤的测试用例:

@Testfun `filter by language`() {   filter = Filter(language = "EN")   onlyFindsBooks(filter, book1, book3)}

是什么让book1book3与传入的language = "EN"匹配?为什么这个调用不返回book2?要回答这些问题,你需要滚动到 setUp 处,将整个上下文加载到你的大脑里,然后试着发现所有书籍之间的相似之处和不同之处。

更具挑战性的是:

@Testfun `filter by last`() {   filter = Filter(searchTerm = "title", last = "5 days")   onlyFindsBooks(filter, book3)}

5 days”从哪里来的?它是不是与createBook()方法中为book3隐藏的值相关?

写这段代码的人应用了 DRY 原则来消除重复,但最终得到了一个难以理解且容易出错的测试套件。

找到问题

上面代码中的许多线索表明 DRY 原则被错误地应用了。一些表明测试脆弱性且需要进行重构的迹象包括:

  • 测试用例没有自己的小宇宙(见 Mystery Guest):你是否需要不断滚动代码来理解每个测试用例?

  • 未突出显示相关细节:测试中是否需要用注释来澄清相关的测试细节?

  • 测试的意图不清楚:是否需要一些样板代码或与测试无直接关系的“噪音”来准备测试?

  • 重复的重复概念:改变应用程序代码是否会影响很多测试用例?

  • 测试不独立:修改一个测试用例是否会导致其他测试用例失败?

    解决方案

在这个小节中,我们将提出两种可能的解决方案来解决上述问题:“3A”原则和使用对象方法。

3A 原则

测试可以被看作由三个部分组成,通常被称为“3A”:

  • Arrange——任何必要的设置,包括重要的变量;

  • Act——对应用程序代码的调用(也称为 SUT,Subject Under Test);

  • Assert——包括预期或断言的验证步骤。这些步骤也被称为 Given、When 和 Then。

理想的测试只有三行,每个 A 一行。这在现实中可能不可行,但仍然是一个值得追求的目标。事实上,符合这种模式的测试更容易阅读:

// Arrangevar object = createObject()
// Actvar result = sut.findObject()
// AssertassertEquals(object, result)
对象创建方法

策略性地使用对象创建方法可以突出显示相关细节,并将无关(但必要)的样板代码隐藏在有意义的领域名称后面。这种策略受到了两种策略的启发:Builder Pattern 和 Object Mother。虽然之前的示例代码也使用方法构建测试对象,但仍缺少一些关键的东西。

对象创建方法应该:

  1. 使用领域名称(表示创建的对象的类型)来命名;

  2. 所有必填值都有默认值;

  3. 允许覆盖测试直接使用的值。

我们基于 3A 原则并使用对象创建方法来修改之前示例代码中的一个测试:

@Testfun `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 步骤的逻辑可以更好一些。这些逻辑和噪音给理解它为什么失败带来了一些麻烦。

我们对这两个地方进行改造:

@Testfun `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()中的东西,因此可以很轻松地理解上下文。你可以深入查看辅助方法(createBookWrittenInassertBooksEqual),但即使不这样做,测试也是可读的。

当我们在整个测试套件的其余部分应用这些修改时,我们不得不去考虑每个测试用例所需的书籍及其属性。随着我们继续进行,相关细节将会凸显出来。

如果我们将所有测试放在一起看,可能会感到不适,因为我们创建了这么多的书籍!但我们可以接受这种重复,因为我们知道,虽然从代码上看似乎是重复了,但在概念上并没有重复。每个测试都创建了代表不同想法的书籍,例如,一本用英语写的书与一本在特定日期发布的书。

好处

我们的 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 翻译,未经许可禁止转载。

今日好文推荐

谷歌大裁员引发元老集体抗议:领导脑袋空空,无能的中层管理团队不断扩大

谷歌裁掉整个 Python 团队!PyTorch 创始人急得直骂人:“WTF!核心语言团队无可替换”

德国再次拥抱Linux:数万系统从windows迁出,能否避开二十年前的“坑”?

系统 bug 致百人入狱,砸了 2.8 亿元仍上云失败!二十年了,这家大企业被日本软件坑惨了

微信扫码关注该文公众号作者

戳这里提交新闻线索和高质量文章给我们。
相关阅读
太脆弱!失控货轮撞马里兰州大桥瞬间垮塌,旧金山金门大桥若遭撞击会怎样?无药可救!法拉盛一华男吸毒吸过头!竟持刀在缅街繁华区域......女儿两岁,妈妈从未用洗发水给她洗过头,结果头发变成这样?“气死人了”用英文怎么说?可别说“angry to die"!​约克区一高中12年级学生三门课全获100分,不是因为学习好,而是因为...被催婚,是因为你的原生家庭太low父母太无能!汽车保费突然大涨!可能是因为你被车厂“出卖”风神颂探秘客机的「神秘空间」舒服过头等舱 比随意门惊奇查尔斯外网已过头七?官方辟谣去世传言!凯特王妃现身,瘦得人都认不出!Tesco放出10万英镑优惠活动西雅图无社交?单身即地狱?可能是打开方式不对你的大脑是否健康?可以通过脑波看出来Dry bar 的吹风发梳半价了,特别方便好用自组织临界性的发现:“回过头看,事情本可以更简单”电脑中毒?可能是这个华女搞的 一下骗走$20多万美元 受害者真的信雷人相亲第二弹,男生自信过头,女孩感觉窒息!别再甩锅地磁暴了!你睡不好觉可能是因为......西雅图家长警惕!孩子说出这句话,可能是求救信号,你的教育出问题了!两轮游中国 中华大地任我行 高铁边上的农村新貌一个准到吓人的测试,母亲节我建议你做一次!女性更有可能是双性恋?竟是因为身体的这种反应……还没有通过CFA?可能是因为你用的课不对!三八妇女节晨游---卖鱼哥终于操刀屠杀,let america be great again不想努力,可能是因为没在有效努力KP.2 从美国迅速蔓延至加拿大、英国,专家表示脆弱人群仍可能出现重症震惊!美国300架波音飞机存致命缺陷,可能导致空中爆炸,是因为建造时偷工减料?湾区无社交?单身即地狱?可能是打开方式不对!美股基本面 - 2024_03_08 * 午报 * 特斯拉工厂火灾疑为恐袭 德国最高检察官接管调查。比特币上涨4% 突破70,国家药监局关于发布《化妆品中双氟拉松丙酸酯的测定》化妆品补充检验方法的公告女儿两岁,妈妈从未用洗发水给她洗过头,结果头发变成这样高压控制社会,人的关系有多脆弱?吕洪来:取消国务院总理记者会究竟释放了什么政治信号特斯拉: 裁员过头了... 开岗招聘!老年人反应迟钝是因为年纪大?也可能是脑子“进水了”轰20一直没出来,很可能是因为太先进,有2个美国没有的技术!嗨过头!演唱会主唱突然跳下舞台,美24岁女歌迷被砸断脖子。
logo
联系我们隐私协议©2024 redian.news
Redian新闻
Redian.news刊载任何文章,不代表同意其说法或描述,仅为提供更多信息,也不构成任何建议。文章信息的合法性及真实性由其作者负责,与Redian.news及其运营公司无关。欢迎投稿,如发现稿件侵权,或作者不愿在本网发表文章,请版权拥有者通知本网处理。