标签 测试 下的文章

当你坚持最简场景时,你最终会得到最简单的解决方案。

在前面的文章中,我已经解释了为什么将编程问题看作一整群丧尸来处理是错误的。我用 ZOMBIES 方法来解释为什么循序渐进地处理问题更好。

ZOMBIES 表示以下首字母缩写:

  • Z – 最简场景(Zero)
  • O – 单元素场景(One)
  • M – 多元素场景(Many or more complex)
  • B – 边界行为(Boundary behaviors)
  • I – 接口定义(Interface definition)
  • E – 处理特殊行为(Exercise exceptional behavior)
  • S – 简单场景用简单的解决方案(Simple scenarios, simple solutions)

在系列的前四篇文章中,我展示了 ZOMBIES 方法的前六个原则(LCTT译注:原文为前五个,应为笔误)。

第一篇中 实现了最简场景,它为代码提供了最简可行路径。第二篇文章中执行了 单元素场景和多元素场景上的测试。第三篇中介绍了 边界和接口。 第四篇中处理 特殊行为。在本文中,我将介绍最后一项:简单场景用简单的解决方案。

简单场景用简单的解决方案

回顾这个网购 API 的实现过程,你会发现总是有目的性地坚持考虑最简单的场景。在这个过程中,最终你会得到最简单的解决方案。

ZOMBIES 方法通过坚持简洁性来帮助你交付健壮优雅的解决方案。

胜利了吗?

似乎一切工作都结束了,一个不那么认真负责的工程师很可能会宣布胜利。但开明的工程师总是会探索得更深一点。

我一直推荐做 变异测试 mutation testing 。在圆满结束这个练习项目,开始新的征程之前,用变异测试来敲打敲打你的解决方案是明智的。况且你也不得不承认,变异很适合对付丧尸的。

你可以在开源网站 Stryker.NET 上进行变异测试。

Mutation testing

看起来有一个存活的变异体。这可不是好兆头。

这意味着什么呢?当你自认为解决方案无懈可击的时候,Stryker.NET 却表示在你的地盘上并非一切安好。

让我们来看看这个存活下来的烦人变异体:

Surviving mutant

变异测试工具将

if(total > 500.00) {

变异为:

if(total >= 500.00) {

然后运行测试,结果对于这个变化没有一个测试失败。如果业务处理代码中发生了一处变动却没有任何一个测试失败,这就意味着你遇到一个存活的变异体。

为什么要在意变异体

为什么存活的变异体是麻烦的征兆呢?因为你写的业务处理逻辑代码控制着整个系统的行为。如果业务处理逻辑改变,系统的行为也应该随之改变。而系统行为的改变应该会导致测试表示的期望被违反。如果没有期望被违反,那就说明这些期望对系统行为的描述还不够准确。这也意味着你的业务处理逻辑中存在漏洞。

要解决这个问题,你需要干掉这个存活下来的变异体。要怎么做呢?一般来说,有存活的变异体意味着有期望被遗漏了。

仔细检查代码,梳理已定义的期望,看看漏掉了什么:

  • 期望 0:新建购物框里有零个商品(这隐含了总价为 ¥0)。
  • 期望 1:向购物框添加一件商品的结果是购物框里有一件商品,如果这件商品的价格是 ¥10,那么总价为 ¥10。
  • 期望 2:向购物框添里加入一件价值 ¥10 的商品,再加入一件价值 ¥20 的商品,总价是 ¥30 。
  • 期望 3:关于从购物框移除商品的期望。
  • 期望 4:总价大于 ¥500 时打,享受九折优惠。

缺了什么呢?根据变异测试报告,你没有定义订单总价刚好为 ¥500 的销售策略。你已经定义订单总额大于 ¥500 和小于 ¥500 时的情况。

定义边界情况的期望:

[Fact]
public void Add2ItemsTotal500GrandTotal500() {
    var expectedGrandTotal = 500.00;
    var actualGrandTotal = 450;
    Assert.Equal(expectedGrandTotal, actualGrandTotal);
}

第一步先写一个假的实现让测试失败。现在共有 9 个微测试。其中 8 个通过,第 9 个失败了:

[xUnit.net 00:00:00.57] tests.UnitTest1.Add2ItemsTotal500GrandTotal500 [FAIL]
  X tests.UnitTest1.Add2ItemsTotal500GrandTotal500 [2ms]
  Error Message:
   Assert.Equal() Failure
Expected: 500
Actual: 450
[...]
Test Run Failed.
Total tests: 9
     Passed: 8
     Failed: 1
 Total time: 1.5920 Seconds

将硬编码值替换成正样例的预期代码:

[Fact]
public void Add2ItemsTotal500GrandTotal500() {
    var expectedGrandTotal = 500.00;
    Hashtable item1 = new Hashtable();
    item1.Add("0001", 400.00);
    shoppingAPI.AddItem(item1);
    Hashtable item2 = new Hashtable();
    item2.Add("0002", 100.00);
    shoppingAPI.AddItem(item2);
    var actualGrandTotal = shoppingAPI.CalculateGrandTotal(); }

共添加了两件商品,一件价值 ¥400,另一件价值 ¥100,总价是 ¥500。调用计算总价的函数,期望的总价是 ¥500。

运行,9 个测试全部通过!

Total tests: 9
     Passed: 9
     Failed: 0
 Total time: 1.0440 Seconds

现在是揭晓真相的时刻。这个新增的期望能够清理掉所有的变异体吗?运行变异测试来看看结果:

Mutation testing success

成功了!10 个变异体全都被干掉了。太棒了!现在你可以放心地发布这个 API 了。

结语

如果从这次练习中有什么收获的话,那就是 技术性拖延 skillful procrastination 这一概念的提出。这个是一个重要的概念,因为我们中的许多人往往会在客户描述完他们的问题之前,就盲目地去设想解决方案。

主动拖延

拖延对于软件工程师来说并不是一件容易的事情。我们总是急于动手写代码。我们熟悉各种设计模式、反模式、编程原则和现成的解决方案。我们总是迫不及待想将它们应用到可执行的代码中,并且倾向于一次性做大量的工作。所以在行动之前仔细考虑每个步骤是一种美德。

这个练习说明 ZOMBIES 方法能够通过一系列深思熟虑的小步骤来帮你实现解决方案。但是有一点需要特别注意,根据 Yagni 原则,这些深思熟虑常常会飞得太远,以至于最终形成一个大而全的系统。这会产生臃肿、紧密耦合的系统。

迭代式开发与增量式开发

在这次练习给我们的另一个重要收获是让我们意识到保持系统持续可用的唯一方法是采用迭代式的开发方法。你通过改造已有代码开发出整个购物 API。这种改造工作是在迭代优化解决方案的过程中不可避免的。

很多团队混淆了迭代和增量。这是两个完全不同的概念。

增量式方法是假设是你有完整清晰的需求,然后通过增量累加的方式来构建解方案。大体上来说,你一点点地构建各个部分,然后将所有的部分组装在一起。大功告成!解决方案已经准备好交付了!

相比之下,迭代式方法中,你并不很确定自己需要交付给客户的是什么。因为这个原因,你更加小心谨慎。你会小心翼翼地避免破坏能够运行的系统(也就是说系统处于稳态)。如果不得不扰动已有的平衡,你也会采取最小侵入性的方式。你专注于用尽可能小的工作量来快速完成每次得改动工作。你倾向于让系统尽快回到稳态。

这就是为什么迭代式方法在真正实现一个功能之前总是先提供一个假实现。你硬编码一系列的期望,以验证小的修改不会导致系统无法运行。然后进行必要的修改,用实际处理代码替换硬编码的值。

作为经验法则,在迭代方法中,你的目标是通过设计期望(微测试),对代码进行不断改进。每进行一次改进,你都要检验系统,以确保它处于工作状态。以这种方式不断前进,最终会达到满足所有期望的程度,并且此时代码已经被重构,没有任何存活的变异体了。

一旦达到了这种状态,你就可以相当自信地交付解决方案了。

由衷感谢 Kent BeckRon JeffriesGeePaw Hill 在我的软件工程学习道路的启发。

愿 ZOMBIES 方法在软件开发的征程上帮助到你。

(题图:MJ/ca463fc6-021b-4818-ba3d-9cd3c8736577)


via: https://opensource.com/article/21/2/simplicity

作者:Alex Bunardzic 选题:lkxed 译者:toknow-gh 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

完善你的电商应用,使它能够正确处理业务规则。

在前面的文章中,我已经解释了为什么将编程问题看作一整群丧尸来处理是错误的。我用 ZOMBIES 方法来解释为什么循序渐进地处理问题更好。

ZOMBIES 表示以下首字母缩写:

  • Z – 最简场景(Zero)
  • O – 单元素场景(One)
  • M – 多元素场景(Many or more complex)
  • B – 边界行为(Boundary behaviors)
  • I – 接口定义(Interface definition)
  • E – 处理特殊行为(Exercise exceptional behavior)
  • S – 简单场景用简单的解决方案(Simple scenarios, simple solutions)

在系列的前三篇文章中,我展示了 ZOMBIES 方法的前五项。第一篇中 实现了最简场景,它为代码提供了最简可行路径。第二篇文章中执行了 单元素场景和多元素场景上的测试。第三篇中介绍了 边界和接口。在本文中,我将带你了解倒数第二个方法:处理特殊行为。

处理特殊行为

在开发一个电子购物应用时,你需要从产品负责人或赞助商那里了解需要采用什么销售策略。

毫无疑问,与任何电子商业活动一样,你需要通过制定销售策略来诱导顾客进行消费。假设有如下的销售策略:订单金额超过 ¥500 时可以享受一定的折扣优惠。

现在将这个销售策略转换为可运行期望:

[Fact]
public void Add2ItemsTotal600GrandTotal540() {
        var expectedGrandTotal = 540.00;
        var actualGrandTotal = 0.00;
        Assert.Equal(expectedGrandTotal, actualGrandTotal);
}

这个正面样例表示的销售策略是,如果订单总额为 ¥600.00,那么 shoppingAPI 会将其减价为 ¥540.00。上面的代码伪造了一个失败验证用例。现在修改它,让它能够通过测试:

[Fact]
public void Add2ItemsTotal600GrandTotal540() {
        var expectedGrandTotal = 540.00;
        Hashtable item = new Hashtable();
        item.Add("00000001", 200.00);
        shoppingAPI.AddItem(item);
        Hashtable item2 = new Hashtable();
        item2.Add("00000002", 400.00);
        shoppingAPI.AddItem(item2);
        var actualGrandTotal = shoppingAPI.CalculateGrandTotal();
        Assert.Equal(expectedGrandTotal, actualGrandTotal);
}

在这个正样例中,你向购物框加入一件价值 ¥200 的商品和一件价值 ¥400 的商品,使总价达到 ¥600 。当调用 CalculateGrandTotal() 方法时,你期望总价是 ¥540。

这个微测试能够通过吗?

[xUnit.net 00:00:00.57] tests.UnitTest1.Add2ItemsTotal600GrandTotal540 [FAIL]
  X tests.UnitTest1.Add2ItemsTotal600GrandTotal540 [2ms]
  Error Message:
   Assert.Equal() Failure
Expected: 540
Actual: 600
[...]

很可惜,它失败了。你期望的结果是 ¥540,但计算结果为 ¥600。为什么会这样呢?那是因为你还没有告诉系统在订单总价大于 ¥500 时怎么进行折扣计算。

现在来实现折扣计算逻辑。根据上面的正样例可知,当订单总价为 ¥600(超过了营销策略的阈值 ¥500)时,期望的最终总价为 ¥540。也就是说系统需要从订单总额中减去 ¥60。刚好是是原订单总价的 10%。因此该销售规则就是当订单总额超过 ¥500 时享受九折优惠。

ShippingAPI 类中实现该处理逻辑:

private double Calculate10PercentDiscount(double total) {
        double discount = 0.00;
        if(total > 500.00) {
                discount = (total/100) * 10;
        }
        return discount;
}

首先,检查订单总额是否大于 ¥500 。如果是,则计算出总额的 10%。

你还需要告诉系统怎么从订单总额中减去 10%。改动非常直接:

return grandTotal - Calculate10PercentDiscount(grandTotal);

到此,所有测试都能够通过。你又一次享受到系统处于稳态的欢愉。你的代码通过处理特殊行为实现了需要的销售策略。

最后一步

现在我已经介绍完 ZOMBIE 了,只剩下 S 了。我将会在最后一篇中介绍它。

(题图:MJ/7f8bf5d2-54ce-4d6e-9dbf-13abf6df966a)


via: https://opensource.com/article/21/2/exceptional-behavior

作者:Alex Bunardzic 选题:lujun9972 译者:toknow-gh 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

丧尸是没有边界感的,需要为你的软件设定限制和期望。

丧尸没有边界感。它们踩倒栅栏,推倒围墙,进入不属于它们的地盘。在前面的文章中,我已经解释了为什么把所有编程问题当作一群丧尸一次性处理是错误的。

ZOMBIES 代表首字母缩写:

  • Z – 最简场景(Zero)
  • O – 单个元素场景(One)
  • M – 多个元素场景(Many or more complex)
  • B – 边界行为(Boundary behaviors)
  • I – 接口定义(Interface definition)
  • E – 处理特殊行为(Exercise exceptional behavior)
  • S – 简单场景用简单的解决方案(Simple scenarios, simple solutions)

在本系列的前面两篇文章中,我演示了 ZOMBIES 方法的前三部分:最简场景、单元素场景和多元素场景。第一篇文章 实现了最简场景,它提供了代码中的最简可行路径。第二篇文章中针对单元素场景和多元素场景 运行测试。在这篇文章中,我将带你了解边界和接口。

回到单元素场景

要想处理边界,你需要绕回来(迭代)。

首先思考下面的问题:电子商务的边界是什么?我需要限制购物框的大小吗?(事实上,我不认为这有任何意义。)

目前唯一合理的边界条件是确保购物框里的商品数量不能为负数。将这个限制表示成可运行的期望:

[Fact]
public void Add1ItemRemoveItemRemoveAgainHas0Items() {
        var expectedNoOfItems = 0;
        var actualNoOfItems = -1;
        Assert.Equal(expectedNoOfItems, actualNoOfItems);
}

这就是说,如果你向购物框里添加一件商品,然后将这个商品移除两次,shoppingAPI 的实例应该告诉你购物框里有零个商品。

当然这个可运行期望(微测试)不出意料地会失败。想要这个微测试能够通过,最小改动是什么呢?

[Fact]
public void Add1ItemRemoveItemRemoveAgainHas0Items() {
        var expectedNoOfItems = 0;
        Hashtable item = new Hashtable();
        shoppingAPI.AddItem(item);
        shoppingAPI.RemoveItem(item);
        var actualNoOfItems = shoppingAPI.RemoveItem(item);
        Assert.Equal(expectedNoOfItems, actualNoOfItems);
}

这个期望测试依赖于 RemoveItem(item) 功能。目前的 shippingAPI 还不具备该功能,你需要增加该功能。

回到 app 文件夹,打开 IShippingAPI.cs 文件,新增以下声明:

int RemoveItem(Hashtable item);

ShippingAPI.cs 中实现该功能:

public int RemoveItem(Hashtable item) {
        basket.RemoveAt(basket.IndexOf(item));
        return basket.Count;
}

运行,然后你会得到如下错误:

 title=

系统在移除一个不在购物框的商品,这导致了系统崩溃。加一点点 防御式编程defensive programming

public int RemoveItem(Hashtable item) {
        if(basket.IndexOf(item) >= 0) {
                basket.RemoveAt(basket.IndexOf(item));
        }
        return basket.Count;
}

在移除商品之前先检查它是否在购物框中。(你可能试过用捕获异常的方式来处理,但是我认为上面的处理方式更具可读性。)

更多具体的期望

在讲更多具体的期望之前,让我们先探讨一下什么是接口。在软件工程中,接口表示一种规范,或者对能力的描述。从某种程度上来说,接口类似于菜谱。它罗列出了制作蛋糕的原材料,但它本身并不能吃。我们只是按照菜谱上的说明来烤蛋糕。

与此类似,我们首先通过说明这个服务能做什么的方式来定义我们的服务。这个描述说明就是所谓的接口。但是接口本身并不能向我们提供任何功能。它只是指导我们实现指定功能的蓝图而已。

到目前为止,我们已经实现了接口(只是某部分实现了,稍后还会增加新功能)和业务处理边界(也就是购物框里的商品不能是负数)。你指导了 shoppingAPI 怎么向购物框添加商品,并通过 Add2ItemsBasketHas2Items 测试验证了该功能的有效性。

然而仅仅具备向购物框添加商品的功能还不足以使其成为一个网购应用程序。它还需要能够计算购物框里的商品的总价。现在需要增加另一个期望。

按照惯例,从最直接明了的期望开始。当你向购物框里加入一件价值 ¥10 的商品时,你希望这个购物 API 能正确地计算出总价为 ¥10。

第五个测试(伪造版)如下:

[Fact]
public void Add1ItemPrice10GrandTotal10() {
        var expectedTotal = 10.00;
        var actualTotal = 0.00;
        Assert.Equal(expectedTotal, actualTotal);
}

还是一样的老把戏,通过硬编码一个错误的值让 Add1ItemPrice10GrandTotal10 测试失败。当然前三个测试成功通过,但第四个新增的测试失败了:

A total of 1 test files matched the specified pattern.
[xUnit.net 00:00:00.57] tests.UnitTest1.Add1ItemPrice10GrandTotal10 [FAIL]
  X tests.UnitTest1.Add1ItemPrice10GrandTotal10 [4ms]
  Error Message:
   Assert.Equal() Failure
Expected: 10
Actual: 0

Test Run Failed.
Total tests: 4
     Passed: 3
         Failed: 1
 Total time: 1.0320 Seconds

将硬编码值换成实际的处理代码。首先,检查接口是否具备计算订单总价的功能。根本没有这种东西。目前为止接口中只声明了三个功能:

  1. int NoOfItems();
  2. int AddItem(Hashtable item);
  3. int RemoveItem(Hashtable item);

它们都不具备计算总价的能力。所以需要声明一个新功能:

double CalculateGrandTotal();

这个新功能应该让 shoppingAPI 具备计算总价的能力。这是通过遍历购物框中的商品并把它们的价格累加起来实现的。

修改第五个测试:

[Fact]
public void Add1ItemPrice10GrandTotal10() {
        var expectedGrandTotal = 10.00;
        Hashtable item = new Hashtable();
        item.Add("00000001", 10.00);
        shoppingAPI.AddItem(item);
        var actualGrandTotal = shoppingAPI.CalculateGrandTotal();
        Assert.Equal(expectedGrandTotal, actualGrandTotal);
}

这个测试表明了这样的期望:如果向购物框里加入一件价格 ¥10 的商品,然后调用 CalculateGrandTotal() 方法,它会返回商品总价 ¥10。这是一个完全合理的期望,它完全符合商品总价计算的逻辑。

那么怎么实现这个功能呢?就像以前一样,先写一个假的实现。回到 ShippingAPI 类中,实现在接口中声明的 CalculateGrandTotal() 方法:

public double CalculateGrandTotal() {
                return 0.00;
}

现在先将返回值硬编码为 0.00,只是为了检验这个测试能否正常运行,并确认它是能够失败的。事实上,它能够运行,并且如预期一样失败。接下来的工作就是正确实现计算商品总价的处理逻辑:

public double CalculateGrandTotal() {
        double grandTotal = 0.00;
        foreach(var product in basket) {
                Hashtable item = product as Hashtable;
                foreach(var value in item.Values) {
                        grandTotal += Double.Parse(value.ToString());
                }
        }
        return grandTotal;
}

运行,五个测试全部通过!

从单元素场景到多元素场景

现在是时候进入下一轮迭代了。你已经通过处理最简场景、单元素场景和边界场景迭代地构建了系统,现在需要处理稍复杂的多元素场景了。

快捷提示:由于我们一直在针对单个元素场景、多元素场景和边界行为这三点上对软件进行迭代改进,一些读者可能会认为我们同样应该对接口进行改进。我们稍后就会发现,接口已经完全满足需要了,目前没有新增功能的必要。请记住,应该保持接口的简洁。(盲目地)扩增接口不会带来任何好处,只会引入噪音。我们要遵循 奥卡姆剃刀 Occam's Razor 原则:如无必要,勿增实体。 现在我们已经基本完成了接口功能描述的工作,是时候改进实现了。

通过上一轮的迭代,系统已经能够处理购物框里有超过一件商品的情况了。现在我么来让系统具备购物框里有超过一件商品时计算总价的能力。首先写可执行期望:

[Fact]
public void Add2ItemsGrandTotal30() {
        var expectedGrandTotal = 30.00;
        var actualGrandTotal = 0.00;
        Assert.Equal(expectedGrandTotal, actualGrandTotal);
}

硬编码所有值,尽量让期望测试失败。

测试确实失败了,现在得想办法让它通过。向购物框添加两件商品,然后调用 CalculateGrandTotal() 方法:

[Fact]
public void Add2ItemsGrandTotal30() {
          var expectedGrandTotal = 30.00;
        Hashtable item = new Hashtable();
        item.Add("00000001", 10.00);
        shoppingAPI.AddItem(item);
        Hashtable item2 = new Hashtable();
        item2.Add("00000002", 20.00);
        shoppingAPI.AddItem(item2);
        var actualGrandTotal = shoppingAPI.CalculateGrandTotal();
        Assert.Equal(expectedGrandTotal, actualGrandTotal);
}

测试通过。现在共有六个可以通过的微测试,系统回到了稳态。

设定期望

作为一个认真负责的工程师,你希望确保当用户向购物框添加一些商品然后又移除一些商品后系统仍然能够计算出正确出总价。下面是这个新的期望:

[Fact]
public void Add2ItemsRemoveFirstItemGrandTotal200() {
        var expectedGrandTotal = 200.00;
        var actualGrandTotal = 0.00;
        Assert.Equal(expectedGrandTotal, actualGrandTotal);
}

这个期望表示将两件商品加入到购物框,然后移除第一件后期望的总价是 ¥200。硬编码行为失败了。现在设计更具体的正面测试样例,然后运行代码:

[Fact]
public void Add2ItemsRemoveFirstItemGrandTotal200() {
        var expectedGrandTotal = 200.00;
        Hashtable item = new Hashtable();
        item.Add("00000001", 100.00);
        shoppingAPI.AddItem(item);
        Hashtable item2 = new Hashtable();
        item2.Add("00000002", 200.00);
        shoppingAPI.AddItem(item2);
        shoppingAPI.RemoveItem(item);
        var actualGrandTotal = shoppingAPI.CalculateGrandTotal();
        Assert.Equal(expectedGrandTotal, actualGrandTotal);
}

在这个正面测试样例中,先向购物框加入第一件商品(编号为 00000001,价格为 ¥100),再加入第二件商品(编号为 00000002,价格为 ¥200)。然后将第一件商品移除,计算总价,比较计算值与期望值是否相等。

运行期望测试,系统正确地计算出了总价,满足这个期望测试。现在有七个能顺利通过的测试了。系统运行良好,无异常!

Test Run Successful.
Total tests: 7
     Passed: 7
 Total time: 0.9544 Seconds

敬请期待

现在你已经学习了 ZOMBIES 方法中的 ZOMBI 部分,下一篇文章将介绍处理特殊行为。到那个时候,你可以试试自己的测试!

(题图:MJ/c4eb23b5-84aa-4477-a6b9-7d2a6d1aeee4)


via: https://opensource.com/article/21/2/boundaries-interfaces

作者:Alex Bunardzic 选题:lujun9972 译者:toknow-gh 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

在开发初期只对单个元素进行编码和测试,之后再拓展到多个元素上。

 title=

上一篇文章 中我已经解释了为什么把所有编程问题当作一群丧尸一次性处理是错误的。我也解释了 ZOMBIES 方法中的第一条:最简场景。本文中我将进一步介绍接下来的两条:单元素场景和多元素场景。

ZOMBIES 表示以下首字母缩写:

  • Z – 最简场景(Zero)
  • O – 单元素场景(One)
  • M – 多元素场景(Many or more complex)
  • B – 边界行为(Boundary behaviors)
  • I – 接口定义(Interface definition)
  • E – 处理特殊行为(Exercise exceptional behavior)
  • S – 简单场景用简单的解决方案(Simple scenarios, simple solutions)

在上一篇文章中,通过应用了最简场景,你在代码里构建了一条最简可行通路。这个代码里没有任何业务处理逻辑。现在是时候向系统中添加一个元素了。

最简场景表示系统中什么也没有,这是一个空的用例,我们什么也不用关心。单元素场景代表我们有一个元素需要关心考虑。这个单一元素可能是集合中的一个元素、一个访问着或者一个需要处理的事件。

对于多元素场景,我们需要处理更复杂的情况,比如两个或更多的集合元素或事件。

单元素场景

在上一篇文章的代码基础上,向虚拟购物筐里添加一些商品。首先,写一个伪测试:

[Fact]
public void Add1ItemBasketHas1Item() {
        var expectedNoOfItems = 1;
        var actualNoOfItems = 0;
        Assert.Equal(expectedNoOfItems, actualNoOfItems);
}

不出所料,这个测试失败了,因为硬编码了一个错误的值:

Starting test execution, please wait...

A total of 1 test files matched the specified pattern.
[xUnit.net 00:00:00.57] tests.UnitTest1.NewlyCreatedBasketHas0Items [FAIL]
  X tests.UnitTest1.NewlyCreatedBasketHas0Items [4ms]
  Error Message:
   Assert.Equal() Failure
Expected: 0
Actual: 1
[...]

现在是时候停止伪造了。现在你已经用 ArrayList 实现了购物筐。那么应该怎么实现商品呢?

简洁性应该一直是你的指导原则。在不了解商品的太多信息的情况下,你可以先用另一个集合来实现它。这个表示商品的集合应该包含些什么呢?由于你多半会关心计算购物筐中的商品总价,所以对商品的表示至少需要包含价格(可以是任意货币,为简单起见,不妨假设是人民币)。

(我们需要)一个简单的集合类型,它包含一个商品 ID(可以在系统中的其它地方使用 ID 来指向该商品)和这个商品的价格。

键值对类型的数据结构可以很容易满足这个需求。在 C# 中最先被想到的数据结构就是 Hashtable

在购物应用的代码中给 IShoppingAPI 增加一个新功能:

int AddItem(Hashtable item);

这个新功能以一个用 Hashtable 表示的商品为输入,返回购物筐中的商品数量。

将测试代码中硬编码的值提替换为对接口的调用:

[Fact]
public void Add1ItemBasketHas1Item() {            
    var expectedNoOfItems = 1;
    Hashtable item = [new][3] Hashtable();
    var actualNoOfItems = shoppingAPI.AddItem(item);
    Assert.Equal(expectedNoOfItems, actualNoOfItems);
}

在上面的代码中实例化了一个 Hashtable 并命名为 item,然后调用购物接口中的 AddItem(item) 方法,该方法会返回购物筐中实际的商品数量。

转到 ShoppingAPI 类中,实现这个方法:

public int AddItem(Hashtable item) {
    return 0;
}

这里再次通过写假代码来检验测试的效果(测试是业务代码的第一个调用者)。如果测试失败,将硬编码值换成实际的代码:

public int AddItem(Hashtable item) {
    basket.Add(item);
    return basket.Count;
}

在上面的代码中,向购物筐里添加了一件商品,然后返回购物筐中的商品数量:

Test Run Successful.
Total tests: 2
     Passed: 2
 Total time: 1.0633 Seconds

到目前为止,你通过了两个测试,同时也基本里解了 ZOMBIES 方法中的最简场景和单元素场景两部分。

反思总结

回顾前面所做的工作,你会发现通过将注意力集中到处理最简场景和单元素场景上,你在构建接口的同时也定义了一些业务逻辑边界!这不是很棒吗?现在你已经部分地实现了最关键的抽象逻辑,并且能够处理什么也没有和只有一个元素的的情况。因为你正在构建的是一个电子交易 API,所以你不能对顾客的购物行为预设其它限制。总而言之,虚拟购物筐应该是无限大的。

ZOMBIES 提供的逐步优化思路的另一个重要方面(虽然不是很明显)是从大概思路到具体实现的阻力。你也许已经注意到了,要具体实现某个东西总是困难重重。倒不如先用硬编码值来构造一个伪实现。只有看到接口与测试之间以一种合理的方式交互之后,你才会愿意开始完善实现代码。

即便如此,你也应该采用简单直接的代码结构,尽可能避免条件逻辑分支。

多元素场景

通过定义顾客向购物筐里添加两件商品时的期望来拓展应用程序。首先构造一个伪测试。它的期望值为 2,但是现在将实际值硬编码为 0,强制让测试失败:

[Fact]
public void Add2ItemsBasketHas2Items() {
        var expectedNoOfItems = 2;
        var actualNoOfItems = 0;
        Assert.Equal(expectedNoOfItems, actualNoOfItems);
}

执行测试,前两个测试用例通过了(针对最简场景和单元素场景的测试),而硬编码的测试不出所料地失败了:

A total of 1 test files matched the specified pattern.
[xUnit.net 00:00:00.57] tests.UnitTest1.Add2ItemsBasketHas2Items [FAIL]
  X tests.UnitTest1.Add2ItemsBasketHas2Items [2ms]
  Error Message:
   Assert.Equal() Failure
Expected: 2
Actual: 0

Test Run Failed.
Tatal tests: 3
     Passed: 2
     Failed: 1

将硬编码值替换为实际的代码调用:

[Fact]
public void Add2ItemsBasketHas2Items() {
        var expectedNoOfItems = 2;
        Hashtable item = [new][3] Hashtable();
        shoppingAPI.AddItem(item);
        var actualNoOfItems = shoppingAPI.AddItem(item);
        Assert.Equal(expectedNoOfItems, actualNoOfItems);
}

在这个测试中,你向购物筐中添加了两件商品(实际上是将同一件商品添加了两次),然后比较期望的商品数量和第二次添加商品后调用 shoppingAPI 返回的商品数量是否相等。

现在所有测试都能够通过!

敬请期待

现在你已经了解了最简场景、单元素场景和多元素场景。我将下一篇文章中介绍边界行为和接口定义。敬请期待!

(题图:MJ/e4679f1f-311a-4a41-80e8-8d2834b956f2)


via: https://opensource.com/article/21/2/build-expand-software

作者:Alex Bunardzic 选题:lujun9972 译者:toknow-gh 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

单元测试可能令人生畏,但是这些 Python 模块会使你的生活变得更容易。

在这个教程中,你将学到如何对执行 HTTP 请求代码的进行单元测试。也就是说,你将看到用 Python 对 API 进行单元测试的艺术。

单元测试是指对单个行为的测试。在测试中,一个众所周知的经验法则就是隔离那些需要外部依赖的代码。

比如,当测试一段执行 HTTP 请求的代码时,建议在测试过程中,把真正的调用替换成一个假的的调用。这种情况下,每次运行测试的时候,就可以对它进行单元测试,而不需要执行一个真正的 HTTP 请求。

问题就是,怎样才能隔离这些代码?

这就是我希望在这篇博文中回答的问题!我不仅会向你展示如果去做,而且也会权衡不同方法之间的优点和缺点。

要求:

使用一个天气状况 REST API 的演示程序

为了更好的解决这个问题,假设你正在创建一个天气状况的应用。这个应用使用第三方天气状况 REST API 来检索一个城市的天气信息。其中一个需求是生成一个简单的 HTML 页面,像下面这个图片:

web page displaying London weather

伦敦的天气,OpenWeatherMap。图片是作者自己制作的。

为了获得天气的信息,必须得去某个地方找。幸运的是,通过 OpenWeatherMap 的 REST API 服务,可以获得一切需要的信息。

好的,很棒,但是我该怎么用呢?

通过发送一个 GET 请求到:https://api.openweathermap.org/data/2.5/weather?q={city_name}&appid={api_key}&units=metric,就可以获得你所需要的所有东西。在这个教程中,我会把城市名字设置成一个参数,并确定使用公制单位。

检索数据

使用 requests 模块来检索天气数据。你可以创建一个接收城市名字作为参数的函数,然后返回一个 JSON。JSON 包含温度、天气状况的描述、日出和日落时间等数据。

下面的例子演示了这样一个函数:

def find_weather_for(city: str) -> dict:
    """Queries the weather API and returns the weather data for a particular city."""
    url = API.format(city_name=city, api_key=API_KEY)
    resp = requests.get(url)
    return resp.json()

这个 URL 是由两个全局变量构成:

BASE_URL = "https://api.openweathermap.org/data/2.5/weather"
API = BASE_URL + "?q={city_name}&appid={api_key}&units=metric"

API 以这个格式返回了一个 JSON:

{
  "coord": {
    "lon": -0.13,
    "lat": 51.51
  },
  "weather": [
    {
      "id": 800,
      "main": "Clear",
      "description": "clear sky",
      "icon": "01d"
    }
  ],
  "base": "stations",
  "main": {
    "temp": 16.53,
    "feels_like": 15.52,
    "temp_min": 15,
    "temp_max": 17.78,
    "pressure": 1023,
    "humidity": 72
  },
  "visibility": 10000,
  "wind": {
    "speed": 2.1,
    "deg": 40
  },
  "clouds": {
    "all": 0
  },
  "dt": 1600420164,
  "sys": {
    "type": 1,
    "id": 1414,
    "country": "GB",
    "sunrise": 1600407646,
    "sunset": 1600452509
  },
  "timezone": 3600,
  "id": 2643743,
  "name": "London",
  "cod": 200

当调用 resp.json() 的时候,数据是以 Python 字典的形式返回的。为了封装所有细节,可以用 dataclass 来表示它们。这个类有一个工厂方法,可以获得这个字典并且返回一个 WeatherInfo 实例。

这种办法很好,因为可以保持这种表示方法的稳定。比如,如果 API 改变了 JSON 的结构,就可以在同一个地方(from_dict 方法中)修改逻辑。其他代码不会受影响。你也可以从不同的源获得信息,然后把它们都整合到 from_dict 方法中。

@dataclass
class WeatherInfo:
    temp: float
    sunset: str
    sunrise: str
    temp_min: float
    temp_max: float
    desc: str

    @classmethod
    def from_dict(cls, data: dict) -> "WeatherInfo":
        return cls(
            temp=data["main"]["temp"],
            temp_min=data["main"]["temp_min"],
            temp_max=data["main"]["temp_max"],
            desc=data["weather"][0]["main"],
            sunset=format_date(data["sys"]["sunset"]),
            sunrise=format_date(data["sys"]["sunrise"]),
        )

现在来创建一个叫做 retrieve_weather 的函数。使用这个函数调用 API,然后返回一个 WeatherInfo,这样就可创建你自己的 HTML 页面。

def retrieve_weather(city: str) -> WeatherInfo:
    """Finds the weather for a city and returns a WeatherInfo instance."""
    data = find_weather_for(city)
    return WeatherInfo.from_dict(data)

很好,我们的 app 现在有一些基础了。在继续之前,对这些函数进行单元测试。

1、使用 mock 测试 API

根据维基百科 模拟对象 mock object 是通过模仿真实对象来模拟它行为的一个对象。在 Python 中,你可以使用 unittest.mock 库来 模拟 mock 任何对象,这个库是标准库中的一部分。为了测试 retrieve_weather 函数,可以模拟 requests.get,然后返回静态数据。

pytest-mock

在这个教程中,会使用 pytest 作为测试框架。通过插件,pytest 库是非常具有扩展性的。为了完成我们的模拟目标,要用 pytest-mock。这个插件抽象化了大量 unittest.mock 中的设置,也会让你的代码更简洁。如果你感兴趣的话,我在 另一篇博文中 会有更多的讨论。

好的,言归正传,现在看代码。

下面是一个 retrieve_weather 函数的完整测试用例。这个测试使用了两个 fixture:一个是由 pytest-mock 插件提供的 mocker fixture, 还有一个是我们自己的。就是从之前请求中保存的静态数据。

@pytest.fixture()
def fake_weather_info():
    """Fixture that returns a static weather data."""
    with open("tests/resources/weather.json") as f:
        return json.load(f)
def test_retrieve_weather_using_mocks(mocker, fake_weather_info):
    """Given a city name, test that a HTML report about the weather is generated
    correctly."""
    # Creates a fake requests response object
    fake_resp = mocker.Mock()
    # Mock the json method to return the static weather data
    fake_resp.json = mocker.Mock(return_value=fake_weather_info)
    # Mock the status code
    fake_resp.status_code = HTTPStatus.OK

    mocker.patch("weather_app.requests.get", return_value=fake_resp)

    weather_info = retrieve_weather(city="London")
    assert weather_info == WeatherInfo.from_dict(fake_weather_info)

如果运行这个测试,会获得下面的输出:

============================= test session starts ==============================
...[omitted]...
tests/test_weather_app.py::test_retrieve_weather_using_mocks PASSED      [100%]
============================== 1 passed in 0.20s ===============================
Process finished with exit code 0

很好,测试通过了!但是...生活并非一帆风顺。这个测试有优点,也有缺点。现在来看一下。

优点

好的,有一个之前讨论过的优点就是,通过模拟 API 的返回值,测试变得简单了。将通信和 API 隔离,这样测试就可以预测了。这样总会返回你需要的东西。

缺点

对于缺点,问题就是,如果不再想用 requests 了,并且决定回到标准库的 urllib,怎么办。每次改变 find_weather_for 的代码,都得去适配测试。好的测试是,当你修改代码实现的时候,测试时不需要改变的。所以,通过模拟,你最终把测试和实现耦合在了一起。

而且,另一个不好的方面是你需要在调用函数之前进行大量设置——至少是三行代码。

...
    # Creates a fake requests response object
    fake_resp = mocker.Mock()
    # Mock the json method to return the static weather data
    fake_resp.json = mocker.Mock(return_value=fake_weather_info)
    # Mock the status code
    fake_resp.status_code = HTTPStatus.OK
...

我可以做的更好吗?

是的,请继续看。我现在看看怎么改进一点。

使用 responses

mocker 功能模拟 requests 有点问题,就是有很多设置。避免这个问题的一个好办法就是使用一个库,可以拦截 requests 调用并且给它们 打补丁 patch 。有不止一个库可以做这件事,但是对我来说最简单的是 responses。我们来看一下怎么用,并且替换 mock

@responses.activate
def test_retrieve_weather_using_responses(fake_weather_info):
    """Given a city name, test that a HTML report about the weather is generated
    correctly."""
    api_uri = API.format(city_name="London", api_key=API_KEY)
    responses.add(responses.GET, api_uri, json=fake_weather_info, status=HTTPStatus.OK)

    weather_info = retrieve_weather(city="London")
    assert weather_info == WeatherInfo.from_dict(fake_weather_info)

这个函数再次使用了我们的 fake_weather_info fixture。

然后运行测试:

============================= test session starts ==============================
...
tests/test_weather_app.py::test_retrieve_weather_using_responses PASSED  [100%]
============================== 1 passed in 0.19s ===============================

非常好!测试也通过了。但是...并不是那么棒。

优点

使用诸如 responses 这样的库,好的方面就是不需要再给 requests 打补丁 patch 。通过将这层抽象交给库,可以减少一些设置。然而,如果你没注意到的话,还是有一些问题。

缺点

unittest.mock 很像,测试和实现再一次耦合了。如果替换 requests,测试就不能用了。

2、使用适配器测试 API

如果用模拟让测试耦合了,我能做什么?

设想下面的场景:假如说你不能再用 requests 了,而且必须要用 urllib 替换,因为这是 Python 自带的。不仅仅是这样,你了解了不要把测试代码和实现耦合,并且你想今后都避免这种情况。你想替换 urllib,也不想重写测试了。

事实证明,你可以抽象出执行 GET 请求的代码。

真的吗?怎么做?

可以使用 适配器 adapter 来抽象它。适配器是一种用来封装其他类的接口,并作为新接口暴露出来的一种设计模式。用这种方式,就可以修改适配器而不需要修改代码了。比如,在 find_weather_for 函数中,封装关于 requests 的所有细节,然后把这部分暴露给只接受 URL 的函数。

所以,这个:

def find_weather_for(city: str) -> dict:
    """Queries the weather API and returns the weather data for a particular city."""
    url = API.format(city_name=city, api_key=API_KEY)
    resp = requests.get(url)
    return resp.json()

变成这样:

def find_weather_for(city: str) -> dict:
    """Queries the weather API and returns the weather data for a particular city."""
    url = API.format(city_name=city, api_key=API_KEY)
    return adapter(url)

然后适配器变成这样:

def requests_adapter(url: str) -> dict:
    resp = requests.get(url)
    return resp.json()

现在到了重构 retrieve_weather 函数的时候:

def retrieve_weather(city: str) -> WeatherInfo:
    """Finds the weather for a city and returns a WeatherInfo instance."""
    data = find_weather_for(city, adapter=requests_adapter)
    return WeatherInfo.from_dict(data)

所以,如果你决定改为使用 urllib 的实现,只要换一下适配器:

def urllib_adapter(url: str) -> dict:
    """An adapter that encapsulates urllib.urlopen"""
    with urllib.request.urlopen(url) as response:
        resp = response.read()
    return json.loads(resp)
def retrieve_weather(city: str) -> WeatherInfo:
    """Finds the weather for a city and returns a WeatherInfo instance."""
    data = find_weather_for(city, adapter=urllib_adapter)
    return WeatherInfo.from_dict(data)

好的,那测试怎么做?

为了测试 retrieve_weather, 只要创建一个在测试过程中使用的假的适配器:

@responses.activate
def test_retrieve_weather_using_adapter(
    fake_weather_info,
):
    def fake_adapter(url: str):
        return fake_weather_info

    weather_info = retrieve_weather(city="London", adapter=fake_adapter)
    assert weather_info == WeatherInfo.from_dict(fake_weather_info)

如果运行测试,会获得:

============================= test session starts ==============================
tests/test_weather_app.py::test_retrieve_weather_using_adapter PASSED    [100%]
============================== 1 passed in 0.22s ===============================

优点

这个方法的优点是可以成功将测试和实现解耦。使用 依赖注入 dependency injection 在测试期间注入一个假的适配器。你也可以在任何时候更换适配器,包括在运行时。这些事情都不会改变任何行为。

缺点

缺点就是,因为你在测试中用了假的适配器,如果在实现中往适配器中引入了一个 bug,测试的时候就不会发现。比如说,往 requests 传入了一个有问题的参数,像这样:

def requests_adapter(url: str) -> dict:
    resp = requests.get(url, headers=<some broken headers>)
    return resp.json()

在生产环境中,适配器会有问题,而且单元测试没办法发现。但是事实是,之前的方法也会有同样的问题。这就是为什么不仅要单元测试,并且总是要集成测试。也就是说,要考虑另一个选项。

3、使用 VCR.py 测试 API

现在终于到了讨论我们最后一个选项了。诚实地说,我也是最近才发现这个。我用 模拟 mock 也很长时间了,而且总是有一些问题。VCR.py 是一个库,它可以简化很多 HTTP 请求的测试。

它的工作原理是将第一次运行测试的 HTTP 交互记录为一个 YAML 文件,叫做 cassette。请求和响应都会被序列化。当第二次运行测试的时候,VCT.py 将拦截对请求的调用,并且返回一个响应。

现在看一下下面如何使用 VCR.py 测试 retrieve_weather

@vcr.use_cassette()
def test_retrieve_weather_using_vcr(fake_weather_info):
    weather_info = retrieve_weather(city="London")
    assert weather_info == WeatherInfo.from_dict(fake_weather_info)

天呐,就这样?没有设置?@vcr.use_cassette() 是什么?

是的,就这样!没有设置,只要一个 pytest 标注告诉 VCR 去拦截调用,然后保存 cassette 文件。

cassette 文件是什么样?

好问题。这个文件里有很多东西。这是因为 VCR 保存了交互中的所有细节。

interactions:
- request:
    body: null
    headers:
      Accept:
      - '*/*'
      Accept-Encoding:
      - gzip, deflate
      Connection:
      - keep-alive
      User-Agent:
      - python-requests/2.24.0
    method: GET
    uri: https://api.openweathermap.org/data/2.5/weather?q=London&appid=<YOUR API KEY HERE>&units=metric
  response:
    body:
      string: '{"coord":{"lon":-0.13,"lat":51.51},"weather":[{"id":800,"main":"Clear","description":"clearsky","icon":"01d"}],"base":"stations","main":{"temp":16.53,"feels_like":15.52,"temp_min":15,"temp_max":17.78,"pressure":1023,"humidity":72},"visibility":10000,"wind":{"speed":2.1,"deg":40},"clouds":{"all":0},"dt":1600420164,"sys":{"type":1,"id":1414,"country":"GB","sunrise":1600407646,"sunset":1600452509},"timezone":3600,"id":2643743,"name":"London","cod":200}'
    headers:
      Access-Control-Allow-Credentials:
      - 'true'
      Access-Control-Allow-Methods:
      - GET, POST
      Access-Control-Allow-Origin:
      - '*'
      Connection:
      - keep-alive
      Content-Length:
      - '454'
      Content-Type:
      - application/json; charset=utf-8
      Date:
      - Fri, 18 Sep 2020 10:53:25 GMT
      Server:
      - openresty
      X-Cache-Key:
      - /data/2.5/weather?q=london&amp;units=metric
    status:
      code: 200
      message: OK
version: 1

确实很多!

真的!好的方面就是你不需要留意它。VCR.py 会为你安排好一切。

优点

现在看一下优点,我可以至少列出五个:

  • 没有设置代码。
  • 测试仍然是分离的,所以很快。
  • 测试是确定的。
  • 如果你改了请求,比如说用了错误的 header,测试会失败。
  • 没有与代码实现耦合,所以你可以换适配器,而且测试会通过。唯一有关系的东西就是请求必须是一样的。

缺点

再与模拟相比较,除了避免了错误,还是有一些问题。

如果 API 提供者出于某种原因修改了数据格式,测试仍然会通过。幸运的是,这种情况并不经常发生,而且在这种重大改变之前,API 提供者通常会给他们的 API 提供不同版本。

另一个需要考虑的事情是 就地 in place 端到端 end-to-end 测试。每次服务器运行的时候,这些测试都会调用。顾名思义,这是一个范围更广、更慢的测试。它们会比单元测试覆盖更多。事实上,并不是每个项目都需要使用它们。所以,就我看来,VCR.py 对于大多数人的需求来说都绰绰有余。

总结

就这么多了。我希望今天你了解了一些有用的东西。测试 API 客户端应用可能会有点吓人。然而,当武装了合适的工具和知识,你就可以驯服这个野兽。

我的 Github 上可以找到这个完整的应用。

这篇文章最早发表在 作者的个人博客,授权转载


via: https://opensource.com/article/21/9/unit-test-python

作者:Miguel Brito 选题:lujun9972 译者:Yufei-Yan 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

Bash 自动测试系统可以使 Bash 代码也通过 Java、Ruby 和 Python 开发人员所使用的同类测试过程。

用 Java、Ruby 和 Python 等语言编写应用程序的软件开发人员拥有复杂的库,可以帮助他们随着时间的推移保持软件的完整性。他们可以创建测试,以在结构化环境中通过执行一系列动作来运行应用程序,以确保其软件所有的方面均按预期工作。

当这些测试在持续集成(CI)系统中自动进行时,它们的功能就更加强大了,每次推送到源代码库都会触发测试,并且在测试失败时会立即通知开发人员。这种快速反馈提高了开发人员对其应用程序功能完整性的信心。

Bash 自动测试系统 Bash Automated Testing System BATS)使编写 Bash 脚本和库的开发人员能够将 Java、Ruby、Python 和其他开发人员所使用的相同惯例应用于其 Bash 代码中。

安装 BATS

BATS GitHub 页面包含了安装指令。有两个 BATS 辅助库提供更强大的断言或允许覆写 BATS 使用的 Test Anything Protocol(TAP)输出格式。这些库可以安装在一个标准位置,并被所有的脚本引用。更方便的做法是,将 BATS 及其辅助库的完整版本包含在 Git 仓库中,用于要测试的每组脚本或库。这可以通过 git 子模块 系统来完成。

以下命令会将 BATS 及其辅助库安装到 Git 知识库中的 test 目录中。

git submodule init
git submodule add https://github.com/sstephenson/bats test/libs/bats
git submodule add https://github.com/ztombol/bats-assert test/libs/bats-assert
git submodule add https://github.com/ztombol/bats-support test/libs/bats-support
git add .
git commit -m 'installed bats'

要克隆 Git 仓库并同时安装其子模块,请在 git clone 时使用 --recurse-submodules 标记。

每个 BATS 测试脚本必须由 bats 可执行文件执行。如果你将 BATS 安装到源代码仓库的 test/libs 目录中,则可以使用以下命令调用测试:

./test/libs/bats/bin/bats <测试脚本的路径>

或者,将以下内容添加到每个 BATS 测试脚本的开头:

#!/usr/bin/env ./test/libs/bats/bin/bats
load 'libs/bats-support/load'
load 'libs/bats-assert/load'

并且执行命令 chmod +x <测试脚本的路径>。 这将 a、使它们可与安装在 ./test/libs/bats 中的 BATS 一同执行,并且 b、包含这些辅助库。BATS 测试脚本通常存储在 test 目录中,并以要测试的脚本命名,扩展名为 .bats。例如,一个测试 bin/build 的 BATS 脚本应称为 test/build.bats

你还可以通过向 BATS 传递正则表达式来运行一整套 BATS 测试文件,例如 ./test/lib/bats/bin/bats test/*.bats

为 BATS 覆盖率而组织库和脚本

Bash 脚本和库必须以一种有效地方式将其内部工作原理暴露给 BATS 进行组织。通常,在调用或执行时库函数和运行诸多命令的 Shell 脚本不适合进行有效的 BATS 测试。

例如,build.sh 是许多人都会编写的典型脚本。本质上是一大堆代码。有些人甚至可能将这堆代码放入库中的函数中。但是,在 BATS 测试中运行一大堆代码,并在单独的测试用例中覆盖它可能遇到的所有故障类型是不可能的。测试这堆代码并有足够的覆盖率的唯一方法就是把它分解成许多小的、可重用的、最重要的是可独立测试的函数。

向库添加更多的函数很简单。额外的好处是其中一些函数本身可以变得出奇的有用。将库函数分解为许多较小的函数后,你可以在 BATS 测试中 援引 source 这些库,并像测试任何其他命令一样运行这些函数。

Bash 脚本也必须分解为多个函数,执行脚本时,脚本的主要部分应调用这些函数。此外,还有一个非常有用的技巧,可以让你更容易地用 BATS 测试 Bash 脚本:将脚本主要部分中执行的所有代码都移到一个函数中,称为 run_main。然后,将以下内容添加到脚本的末尾:

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]
then
  run_main
fi

这段额外的代码做了一些特别的事情。它使脚本在作为脚本执行时与使用 援引 source 进入环境时的行为有所不同。通过援引并测试单个函数,这个技巧使得脚本的测试方式和库的测试方式变得一样。例如,这是重构的 build.sh,以获得更好的 BATS 可测试性

编写和运行测试

如上所述,BATS 是一个 TAP 兼容的测试框架,其语法和输出对于使用过其他 TAP 兼容测试套件(例如 JUnit、RSpec 或 Jest)的用户来说将是熟悉的。它的测试被组织成单个测试脚本。测试脚本被组织成一个或多个描述性 @test 块中,它们描述了被测试应用程序的单元。每个 @test 块将运行一系列命令,这些命令准备测试环境、运行要测试的命令,并对被测试命令的退出和输出进行断言。许多断言函数是通过 batsbats-assertbats-support 库导入的,这些库在 BATS 测试脚本的开头加载到环境中。下面是一个典型的 BATS 测试块:

@test "requires CI_COMMIT_REF_SLUG environment variable" {
  unset CI_COMMIT_REF_SLUG
  assert_empty "${CI_COMMIT_REF_SLUG}"
  run some_command
  assert_failure
  assert_output --partial "CI_COMMIT_REF_SLUG"
}

如果 BATS 脚本包含 setup(安装)和/或 teardown(拆卸) 函数,则 BATS 将在每个测试块运行之前和之后自动执行它们。这样就可以创建环境变量、测试文件以及执行一个或所有测试所需的其他操作,然后在每次测试运行后将其拆卸。Build.bats 是对我们新格式化的 build.sh 脚本的完整 BATS 测试。(此测试中的 mock_docker 命令将在以下关于模拟/打标的部分中进行说明。)

当测试脚本运行时,BATS 使用 exec(执行)来将每个 @test 块作为单独的子进程运行。这样就可以在一个 @test 中导出环境变量甚至函数,而不会影响其他 @test 或污染你当前的 Shell 会话。测试运行的输出是一种标准格式,可以被人理解,并且可以由 TAP 使用端以编程方式进行解析或操作。下面是 CI_COMMIT_REF_SLUG 测试块失败时的输出示例:

 ✗ requires CI_COMMIT_REF_SLUG environment variable
   (from function `assert_output' in file test/libs/bats-assert/src/assert.bash, line 231,
    in test file test/ci_deploy.bats, line 26)
     `assert_output --partial "CI_COMMIT_REF_SLUG"' failed

   -- output does not contain substring --
   substring (1 lines):
     CI_COMMIT_REF_SLUG
   output (3 lines):
     ./bin/deploy.sh: join_string_by: command not found
     oc error
     Could not login
   --

   ** Did not delete , as test failed **

1 test, 1 failure

下面是成功测试的输出:

✓ requires CI_COMMIT_REF_SLUG environment variable

辅助库

像任何 Shell 脚本或库一样,BATS 测试脚本可以包括辅助库,以在测试之间共享通用代码或增强其性能。这些辅助库,例如 bats-assertbats-support 甚至可以使用 BATS 进行测试。

库可以和 BATS 脚本放在同一个测试目录下,如果测试目录下的文件数量过多,也可以放在 test/libs 目录下。BATS 提供了 load 函数,该函数接受一个相对于要测试的脚本的 Bash 文件的路径(例如,在我们的示例中的 test),并援引该文件。文件必须以后缀 .bash 结尾,但是传递给 load 函数的文件路径不能包含后缀。build.bats 加载 bats-assertbats-support 库、一个小型 helpers.bash 库以及 docker_mock.bash 库(如下所述),以下代码位于测试脚本的开头,解释器魔力行下方:

load 'libs/bats-support/load'
load 'libs/bats-assert/load'
load 'helpers'
load 'docker_mock'

打标测试输入和模拟外部调用

大多数 Bash 脚本和库运行时都会执行函数和/或可执行文件。通常,它们被编程为基于这些函数或可执行文件的输出状态或输出(stdoutstderr)以特定方式运行。为了正确地测试这些脚本,通常需要制作这些命令的伪版本,这些命令被设计成在特定测试过程中以特定方式运行,称为“ 打标 stubbing ”。可能还需要监视正在测试的程序,以确保其调用了特定命令,或者使用特定参数调用了特定命令,此过程称为“ 模拟 mocking ”。有关更多信息,请查看在 Ruby RSpec 中 有关模拟和打标的讨论,它适用于任何测试系统。

Bash shell 提供了一些技巧,可以在你的 BATS 测试脚本中使用这些技巧进行模拟和打标。所有这些都需要使用带有 -f 标志的 Bash export 命令来导出一个覆盖了原始函数或可执行文件的函数。必须在测试程序执行之前完成此操作。下面是重写可执行命令 cat 的简单示例:

function cat() { echo "THIS WOULD CAT ${*}" }
export -f cat

此方法以相同的方式覆盖了函数。如果一个测试需要覆盖要测试的脚本或库中的函数,则在对函数进行打标或模拟之前,必须先声明已测试脚本或库,这一点很重要。否则,在声明脚本时,打标/模拟将被原函数替代。另外,在运行即将进行的测试命令之前确认打标/模拟。下面是build.bats 的示例,该示例模拟 build.sh 中描述的raise 函数,以确保登录函数会引发特定的错误消息:

@test ".login raises on oc error" {
  source ${profile_script}
  function raise() { echo "${1} raised"; }
  export -f raise
  run login
  assert_failure
  assert_output -p "Could not login raised"
}

一般情况下,没有必要在测试后复原打标/模拟的函数,因为 export(输出)仅在当前 @test 块的 exec(执行)期间影响当前子进程。但是,可以模拟/打标 BATS assert 函数在内部使用的命令(例如 catsed 等)是可能的。在运行这些断言命令之前,必须对这些模拟/打标函数进行 unset(复原),否则它们将无法正常工作。下面是 build.bats 中的一个示例,该示例模拟 sed,运行 build_deployable 函数并在运行任何断言之前复原 sed

@test ".build_deployable prints information, runs docker build on a modified Dockerfile.production and publish_image when its not a dry_run" {
  local expected_dockerfile='Dockerfile.production'
  local application='application'
  local environment='environment'
  local expected_original_base_image="${application}"
  local expected_candidate_image="${application}-candidate:${environment}"
  local expected_deployable_image="${application}:${environment}"
  source ${profile_script}
  mock_docker build --build-arg OAUTH_CLIENT_ID --build-arg OAUTH_REDIRECT --build-arg DDS_API_BASE_URL -t "${expected_deployable_image}" -
  function publish_image() { echo "publish_image ${*}"; }
  export -f publish_image
  function sed() {
    echo "sed ${*}" >&2;
    echo "FROM application-candidate:environment";
  }
  export -f sed
  run build_deployable "${application}" "${environment}"
  assert_success
  unset sed
  assert_output --regexp "sed.*${expected_dockerfile}"
  assert_output -p "Building ${expected_original_base_image} deployable ${expected_deployable_image} FROM ${expected_candidate_image}"
  assert_output -p "FROM ${expected_candidate_image} piped"
  assert_output -p "build --build-arg OAUTH_CLIENT_ID --build-arg OAUTH_REDIRECT --build-arg DDS_API_BASE_URL -t ${expected_deployable_image} -"
  assert_output -p "publish_image ${expected_deployable_image}"
}

有的时候相同的命令,例如 foo,将在被测试的同一函数中使用不同的参数多次调用。这些情况需要创建一组函数:

  • mock_foo:将期望的参数作为输入,并将其持久化到 TMP 文件中
  • foo:命令的模拟版本,该命令使用持久化的预期参数列表处理每个调用。必须使用 export -f 将其导出。
  • cleanup_foo:删除 TMP 文件,用于拆卸函数。这可以进行测试以确保在删除之前成功完成 @test 块。

由于此功能通常在不同的测试中重复使用,因此创建一个可以像其他库一样加载的辅助库会变得有意义。

docker\_mock.bash 是一个很棒的例子。它被加载到 build.bats 中,并在任何测试调用 Docker 可执行文件的函数的测试块中使用。使用 docker_mock 典型的测试块如下所示:

@test ".publish_image fails if docker push fails" {
  setup_publish
  local expected_image="image"
  local expected_publishable_image="${CI_REGISTRY_IMAGE}/${expected_image}"
  source ${profile_script}
  mock_docker tag "${expected_image}" "${expected_publishable_image}"
  mock_docker push "${expected_publishable_image}" and_fail
  run publish_image "${expected_image}"
  assert_failure
  assert_output -p "tagging ${expected_image} as ${expected_publishable_image}"
  assert_output -p "tag ${expected_image} ${expected_publishable_image}"
  assert_output -p "pushing image to gitlab registry"
  assert_output -p "push ${expected_publishable_image}"
}

该测试建立了一个使用不同的参数两次调用 Docker 的预期。在对Docker 的第二次调用失败时,它会运行测试命令,然后测试退出状态和对 Docker 调用的预期。

一方面 BATS 利用 mock_docker.bash 引入 ${BATS_TMPDIR} 环境变量,BATS 在测试开始的位置对其进行了设置,以允许测试和辅助程序在标准位置创建和销毁 TMP 文件。如果测试失败,mock_docker.bash 库不会删除其持久化的模拟文件,但会打印出其所在位置,以便可以查看和删除它。你可能需要定期从该目录中清除旧的模拟文件。

关于模拟/打标的一个注意事项:build.bats 测试有意识地违反了关于测试声明的规定:不要模拟没有拥有的! 该规定要求调用开发人员没有编写代码的测试命令,例如 dockercatsed 等,应封装在自己的库中,应在使用它们脚本的测试中对其进行模拟。然后应该在不模拟外部命令的情况下测试封装库。

这是一个很好的建议,而忽略它是有代价的。如果 Docker CLI API 发生变化,则测试脚本不会检测到此变化,从而导致错误内容直到经过测试的 build.sh 脚本在使用新版本 Docker 的生产环境中运行后才显示出来。测试开发人员必须确定要严格遵守此标准的程度,但是他们应该了解其所涉及的权衡。

总结

在任何软件开发项目中引入测试制度,都会在以下两方面产生权衡: a、增加开发和维护代码及测试所需的时间和组织,b、增加开发人员在对应用程序整个生命周期中完整性的信心。测试制度可能不适用于所有脚本和库。

通常,满足以下一个或多个条件的脚本和库才可以使用 BATS 测试:

  • 值得存储在源代码管理中
  • 用于关键流程中,并依靠它们长期稳定运行
  • 需要定期对其进行修改以添加/删除/修改其功能
  • 可以被其他人使用

一旦决定将测试规则应用于一个或多个 Bash 脚本或库,BATS 就提供其他软件开发环境中可用的全面测试功能。

致谢:感谢 Darrin Mann 向我引荐了 BATS 测试。


via: https://opensource.com/article/19/2/testing-bash-bats

作者:Darin London 选题:lujun9972 译者:stevenzdg988 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出