ZOMBIES：为什么简洁性是交付健壮软件的关键（五）
当你坚持最简场景时，你最终会得到最简单的解决方案。在前面的文章中，我已经解释了为什么将编程问题看作一整群丧尸来处理是错误的。我用 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 上进行变异测试。看起来有一个存活的变异体。这可不是好兆头。这意味着什么呢？当你自认为解决方案无懈可击的时候，Stryker.NET 却表示在你的地盘上并非一切安好。让我们来看看这个存活下来的烦人变异体：变异测试工具将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 现在是揭晓真相的时刻。这个新增的期望能够清理掉所有的变异体吗？运行变异测试来看看结果：成功了！10 个变异体全都被干掉了。太棒了！现在你可以放心地发布这个 API 了。结语如果从这次练习中有什么收获的话，那就是 技术性拖延 skillful procrastination 这一概念的提出。这个是一个重要的概念，因为我们中的许多人往往会在客户描述完他们的问题之前，就盲目地去设想解决方案。主动拖延拖延对于软件工程师来说并不是一件容易的事情。我们总是急于动手写代码。我们熟悉各种设计模式、反模式、编程原则和现成的解决方案。我们总是迫不及待想将它们应用到可执行的代码中，并且倾向于一次性做大量的工作。所以在行动之前仔细考虑每个步骤是一种美德。这个练习说明 ZOMBIES 方法能够通过一系列深思熟虑的小步骤来帮你实现解决方案。但是有一点需要特别注意，根据 Yagni 原则，这些深思熟虑常常会飞得太远，以至于最终形成一个大而全的系统。这会产生臃肿、紧密耦合的系统。迭代式开发与增量式开发在这次练习给我们的另一个重要收获是让我们意识到保持系统持续可用的唯一方法是采用迭代式的开发方法。你通过改造已有代码开发出整个购物 API。这种改造工作是在迭代优化解决方案的过程中不可避免的。很多团队混淆了迭代和增量。这是两个完全不同的概念。增量式方法是假设是你有完整清晰的需求，然后通过增量累加的方式来构建解方案。大体上来说，你一点点地构建各个部分，然后将所有的部分组装在一起。大功告成！解决方案已经准备好交付了!相比之下，迭代式方法中，你并不很确定自己需要交付给客户的是什么。因为这个原因，你更加小心谨慎。你会小心翼翼地避免破坏能够运行的系统（也就是说系统处于稳态）。如果不得不扰动已有的平衡，你也会采取最小侵入性的方式。你专注于用尽可能小的工作量来快速完成每次得改动工作。你倾向于让系统尽快回到稳态。这就是为什么迭代式方法在真正实现一个功能之前总是先提供一个假实现。你硬编码一系列的期望，以验证小的修改不会导致系统无法运行。然后进行必要的修改，用实际处理代码替换硬编码的值。作为经验法则，在迭代方法中，你的目标是通过设计期望（微测试），对代码进行不断改进。每进行一次改进，你都要检验系统，以确保它处于工作状态。以这种方式不断前进，最终会达到满足所有期望的程度，并且此时代码已经被重构，没有任何存活的变异体了。一旦达到了这种状态，你就可以相当自信地交付解决方案了。由衷感谢 Kent Beck、 Ron Jeffries 和GeePaw 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 方法来解释为什么循序渐进地处理问题更好。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; } 运行，然后你会得到如下错误：系统在移除一个不在购物框的商品，这导致了系统崩溃。加一点点 防御式编程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 将硬编码值换成实际的处理代码。首先，检查接口是否具备计算订单总价的功能。根本没有这种东西。目前为止接口中只声明了三个功能：int NoOfItems();int AddItem(Hashtable item);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中国 荣誉推出
ZOMBIES：软件开发和测试中的构建与拓展（二）
在开发初期只对单个元素进行编码和测试，之后再拓展到多个元素上。在 上一篇文章 中我已经解释了为什么把所有编程问题当作一群丧尸一次性处理是错误的。我也解释了 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 请求。问题就是，怎样才能隔离这些代码？这就是我希望在这篇博文中回答的问题！我不仅会向你展示如果去做，而且也会权衡不同方法之间的优点和缺点。要求：Python 3.8pytest-mockrequestsflaskresponsesVCR.py使用一个天气状况 REST API 的演示程序为了更好的解决这个问题，假设你正在创建一个天气状况的应用。这个应用使用第三方天气状况 REST API 来检索一个城市的天气信息。其中一个需求是生成一个简单的 HTML 页面，像下面这个图片：伦敦的天气，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&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 代码中。安装 BATSBATS 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 块将运行一系列命令，这些命令准备测试环境、运行要测试的命令，并对被测试命令的退出和输出进行断言。许多断言函数是通过 bats、bats-assert 和 bats-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-assert 和 bats-support 甚至可以使用 BATS 进行测试。库可以和 BATS 脚本放在同一个测试目录下，如果测试目录下的文件数量过多，也可以放在 test/libs 目录下。BATS 提供了 load 函数，该函数接受一个相对于要测试的脚本的 Bash 文件的路径（例如，在我们的示例中的 test），并援引该文件。文件必须以后缀 .bash 结尾，但是传递给 load 函数的文件路径不能包含后缀。build.bats 加载 bats-assert 和 bats-support 库、一个小型 helpers.bash 库以及 docker_mock.bash 库（如下所述），以下代码位于测试脚本的开头，解释器魔力行下方：load 'libs/bats-support/load' load 'libs/bats-assert/load' load 'helpers' load 'docker_mock' 打标测试输入和模拟外部调用大多数 Bash 脚本和库运行时都会执行函数和/或可执行文件。通常，它们被编程为基于这些函数或可执行文件的输出状态或输出（stdout、stderr）以特定方式运行。为了正确地测试这些脚本，通常需要制作这些命令的伪版本，这些命令被设计成在特定测试过程中以特定方式运行，称为“ 打标 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 函数在内部使用的命令（例如 cat、sed 等）是可能的。在运行这些断言命令之前，必须对这些模拟/打标函数进行 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 测试有意识地违反了关于测试声明的规定：不要模拟没有拥有的！ 该规定要求调用开发人员没有编写代码的测试命令，例如 docker、cat、sed 等，应封装在自己的库中，应在使用它们脚本的测试中对其进行模拟。然后应该在不模拟外部命令的情况下测试封装库。这是一个很好的建议，而忽略它是有代价的。如果 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中国 荣誉推出
通过突变测试来修复未知的 bug。你一定对所有内容都进行了测试，也许你甚至在项目仓库中有一个徽章，标明有 100% 的测试覆盖率，但是这些测试真的帮到你了吗？你怎么知道的？开发人员很清楚单元测试的成本。测试必须要编写。有时它们无法按照预期工作：存在假告警或者抖动测试。在不更改任何代码的情况下有时成功，有时失败。通过单元测试发现的小问题很有价值，但是通常它们悄无声息的出现在开发人员的机器上，并且在提交到版本控制之前就已得到修复。但真正令人担忧的问题大多是看不见的。最糟糕的是，丢失的告警是完全不可见的：你看不到没能捕获的错误，直到出现在用户手上 —— 有时甚至连用户都看不到。有一种测试可以使不可见的错误变为可见： 突变测试 mutation testing 。变异测试通过算法修改源代码，并检查每次测试是否都有“变异体”存活。任何在单元测试中幸存下来的变异体都是问题：这意味着对代码的修改（可能会引入错误）没有被标准测试套件捕获。Python 中用于突变测试的一个框架是 mutmut。假设你需要编写代码来计算钟表中时针和分针之间的角度，直到最接近的度数，代码可能会这样写：def hours_hand(hour, minutes): base = (hour % 12 ) * (360 // 12) correction = int((minutes / 60) * (360 // 12)) return base + correction def minutes_hand(hour, minutes): return minutes * (360 // 60) def between(hour, minutes): return abs(hours_hand(hour, minutes) - minutes_hand(hour, minutes)) 首先，写一个简单的单元测试：import angle def test_twelve(): assert angle.between(12, 00) == 0 足够了吗？代码没有 if 语句，所以如果你查看覆盖率：$ coverage run `which pytest` ============================= test session starts ============================== platform linux -- Python 3.8.3, pytest-5.4.3, py-1.8.2, pluggy-0.13.1 rootdir: /home/moshez/src/mut-mut-test collected 1 item tests/test_angle.py . [100%] ============================== 1 passed in 0.01s =============================== 完美！测试通过，覆盖率为 100%，你真的是一个测试专家。但是，当你使用突变测试时，覆盖率会变成多少？$ mutmut run --paths-to-mutate angle.py <snip> Legend for output: ? Killed mutants. The goal is for everything to end up in this bucket. ⏰ Timeout. Test suite took 10 times as long as the baseline so were killed. ? Suspicious. Tests took a long time, but not long enough to be fatal. ? Survived. This means your tests needs to be expanded. ? Skipped. Skipped. <snip> ⠋ 21/21 ? 5 ⏰ 0 ? 0 ? 16 ? 0 天啊，在 21 个突变体中，有 16 个存活。只有 5 个通过了突变测试，但是，这意味着什么呢？对于每个突变测试，mutmut 会修改部分源代码，以模拟潜在的错误，修改的一个例子是将 > 比较更改为 >=，查看会发生什么。如果没有针对这个边界条件的单元测试，那么这个突变将会“存活”：这是一个没有任何测试用例能够检测到的潜在错误。是时候编写更好的单元测试了。很容易检查使用 results 所做的更改：$ mutmut results <snip> Survived ? (16) ---- angle.py (16) ---- 4-7, 9-14, 16-21 $ mutmut apply 4 $ git diff diff --git a/angle.py b/angle.py index b5dca41..3939353 100644 --- a/angle.py +++ b/angle.py @@ -1,6 +1,6 @@ def hours_hand(hour, minutes): hour = hour % 12 - base = hour * (360 // 12) + base = hour / (360 // 12) correction = int((minutes / 60) * (360 // 12)) return base + correction 这是 mutmut 执行突变的一个典型例子，它会分析源代码并将运算符更改为不同的运算符：减法变加法。在本例中由乘法变为除法。一般来说，单元测试应该在操作符更换时捕获错误。否则，它们将无法有效地测试行为。按照这种逻辑，mutmut 会遍历源代码仔细检查你的测试。你可以使用 mutmut apply 来应用失败的突变体。事实证明你几乎没有检查过 hour 参数是否被正确使用。修复它：$ git diff diff --git a/tests/test_angle.py b/tests/test_angle.py index f51d43a..1a2e4df 100644 --- a/tests/test_angle.py +++ b/tests/test_angle.py @@ -2,3 +2,6 @@ import angle def test_twelve(): assert angle.between(12, 00) == 0 + +def test_three(): + assert angle.between(3, 00) == 90 以前，你只测试了 12 点钟，现在增加一个 3 点钟的测试就足够了吗？$ mutmut run --paths-to-mutate angle.py <snip> ⠋ 21/21 ? 7 ⏰ 0 ? 0 ? 14 ? 0 这项新测试成功杀死了两个突变体，比以前更好，当然还有很长的路要走。我不会一一解决剩下的 14 个测试用例，因为我认为模式已经很明确了。（你能将它们降低到零吗？）变异测试和覆盖率一样，是一种工具，它允许你查看测试套件的全面程度。使用它使得测试用例需要改进：那些幸存的突变体中的任何一个都是人类在篡改代码时可能犯的错误，以及潜伏在程序中的隐藏错误。继续测试，愉快地搜寻 bug 吧。via: https://opensource.com/article/20/7/mutmut-python作者：Moshe Zadka 选题：lujun9972 译者：MjSeven 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
持续集成和持续交付是由测试驱动的。以下是如何做到的。“如果一切似乎都在控制之中，那只是你走的不够快而已。” —Mario Andretti测试自动化是指在软件开发过程中尽可能早、尽可能快地持续关注检测缺陷、错误和 bug。这是通过使用那些追求质量为最高价值的工具完成的，它们旨在确保质量，而不仅仅是追求质量。持续集成/持续交付（CI/CD）解决方案（也称为 DevOps 管道）最引人注目的功能之一是可以更频繁地进行测试，而又不会给开发人员或操作人员增加更多的手动工作。让我们谈谈为什么这很重要。为什么要在 CI/CD 中实现自动化测试？敏捷团队要更快的迭代，以更高的速度交付软件和客户满意度，而这些压力可能会危及质量。全球竞争制造了对缺陷的低容忍度，同时也增加了敏捷团队的压力，要求软件交付的迭代更快。减轻这种压力的行业解决方案是什么？是 DevOps。DevOps 是一个大概念，有很多定义，但是对 DevOps 成功至关重要的一项技术是 CI/CD。通过软件开发流程设计一个连续的改进循环，可以为测试带来新的机会。这对测试人员意味着什么？对于测试人员，这通常意味着他们必须：更早且更频繁地进行测试（使用自动化）持续测试“真实世界”的工作流（自动和手动）更具体地说，任何形式的测试，无论是由编写代码的开发人员运行还是由质量保证工程师团队设计，其作用都是利用 CI/CD 基础架构在快速推进的同时提高质量。测试人员还需要做什么？具体点说，测试人员负责：测试新的和现有的软件应用根据系统要求评估软件来验证和确认功能利用自动化测试工具来开发和维护可重复使用的自动化测试与 scrum 团队的所有成员合作，了解正在开发的功能以及实施的技术设计，以设计和开发准确、高质量的自动化测试分析记录在案的用户需求，并针对中等到高度复杂的软件或 IT 系统制定或协助设计测试计划开发自动化测试，并与功能团队一起审查和评估测试方案与技术团队合作，确定在开发环境中自动化测试的正确方法与团队合作，通过自动化测试来了解和解决软件问题，并回应有关修改或增强的建议参与需求梳理、估算和其他敏捷 scrum 仪式协助制定标准和流程，以支持测试活动和材料（例如脚本、配置、程序、工具、计划和结果）测试是一项艰巨的工作，但这是有效构建软件的重要组成部分。哪些持续测试很重要？你可以使用多种测试。不同的类型并不是学科之间的牢固界限。相反，它们是表示如何测试的不同方式。比较测试类型不太重要，更重要的是对每一种测试类型都要有覆盖率。功能测试： 确保软件具有其要求的功能单元测试： 独立测试软件的较小单元/组件以检查其功能负载测试： 测试软件在重负载或使用期间的性能压力测试： 确定软件承受压力（最大负载）时的断点集成测试： 测试组合或集成的一组组件的输出回归测试： 当修改任意组件（无论多么小），测试整个应用的功能总结任何包含持续测试的软件开发过程都将朝着建立关键反馈环路的方向发展，以实现快速和构建有效的软件。最重要的是，该实践将质量内置到 CI/CD 管道中，并意味着了解在软件开发生命周期中提高速度同时减少风险和浪费之间的联系。via: https://opensource.com/article/20/7/automation-testing-cicd作者：Taz Brown 选题：lujun9972 译者：geekpi 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
使用此框架编写断言，提高开发测试的准确性。在测试金字塔的底部是单元测试。单元测试每次只测试一个代码单元，通常是一个函数或方法。通常，设计单个单元测试是为了测试通过一个函数或特定分支的特定执行流程，这使得将失败的单元测试和导致失败的 bug 对应起来变得容易。理想情况下，单元测试很少使用或不使用外部资源，从而隔离它们并使它们更快。单元测试套件通过在开发过程的早期发现问题来帮助维护高质量的产品。有效的单元测试可以在代码离开开发人员机器之前捕获 bug，或者至少可以在特定分支上的持续集成环境中捕获 bug。这标志着好的和坏的单元测试之间的区别：好的测试通过尽早捕获 bug 并使测试更快来提高开发人员的生产力。坏的测试降低了开发人员的工作效率。当测试附带的特性时，生产率通常会降低。当代码更改时测试会失败，即使它仍然是正确的。发生这种情况是因为输出的不同，但在某种程度上是因为它不是 函数契约 function’s contract 的一部分。因此，一个好的单元测试可以帮助执行函数所提交的契约。如果单元测试中断，那意味着该契约被违反了，应该（通过更改文档和测试）明确修改，或者（通过修复代码并保持测试不变）来修复。虽然将测试限制为只执行公共契约是一项需要学习的复杂技能，但有一些工具可以提供帮助。其中一个工具是 Hamcrest，这是一个用于编写断言的框架。最初是为基于 Java 的单元测试而发明的，但它现在支持多种语言，包括 Python。Hamcrest 旨在使测试断言更容易编写和更精确。def add(a, b): return a + b from hamcrest import assert_that, equal_to def test_add(): assert_that(add(2, 2), equal_to(4)) 这是一个用于简单函数的断言。如果我们想要断言更复杂的函数怎么办？def test_set_removal(): my_set = {1, 2, 3, 4} my_set.remove(3) assert_that(my_set, contains_inanyorder([1, 2, 4])) assert_that(my_set, is_not(has_item(3)))注意，我们可以简单地断言其结果是任何顺序的 1、2 和 4，因为集合不保证顺序。我们也可以很容易用 is_not 来否定断言。这有助于我们编写精确的断言，使我们能够把自己限制在执行函数的公共契约方面。然而，有时候，内置的功能都不是我们真正需要的。在这些情况下，Hamcrest 允许我们编写自己的 匹配器 matchers 。想象一下以下功能：def scale_one(a, b): scale = random.randint(0, 5) pick = random.choice([a,b]) return scale * pick我们可以自信地断言其结果均匀地分配到至少一个输入。匹配器继承自 hamcrest.core.base_matcher.BaseMatcher，重写两个方法：class DivisibleBy(hamcrest.core.base_matcher.BaseMatcher): def __init__(self, factor): self.factor = factor def _matches(self, item): return (item % self.factor) == 0 def describe_to(self, description): description.append_text('number divisible by') description.append_text(repr(self.factor))编写高质量的 describe_to 方法很重要，因为这是测试失败时显示的消息的一部分。def divisible_by(num): return DivisibleBy(num)按照惯例，我们将匹配器包装在一个函数中。有时这给了我们进一步处理输入的机会，但在这种情况下，我们不需要进一步处理。def test_scale(): result = scale_one(3, 7) assert_that(result, any_of(divisible_by(3), divisible_by(7)))请注意，我们将 divisible_by 匹配器与内置的 any_of 匹配器结合起来，以确保我们只测试函数提交的内容。在编辑这篇文章时，我听到一个传言，取 “Hamcrest” 这个名字是因为它是 “matches” 字母组成的字谜。嗯…>>> assert_that("matches", contains_inanyorder(*"hamcrest") Traceback (most recent call last): File "<stdin>", line 1, in <module> File "/home/moshez/src/devops-python/build/devops/lib/python3.6/site-packages/hamcrest/core/assert_that.py", line 43, in assert_that _assert_match(actual=arg1, matcher=arg2, reason=arg3) File "/home/moshez/src/devops-python/build/devops/lib/python3.6/site-packages/hamcrest/core/assert_that.py", line 57, in _assert_match raise AssertionError(description) AssertionError: Expected: a sequence over ['h', 'a', 'm', 'c', 'r', 'e', 's', 't'] in any order but: no item matches: 'r' in ['m', 'a', 't', 'c', 'h', 'e', 's']经过进一步的研究，我找到了传言的来源：它是 “matchers” 字母组成的字谜。>>> assert_that("matchers", contains_inanyorder(*"hamcrest")) >>>如果你还没有为你的 Python 代码编写单元测试，那么现在是开始的好时机。如果你正在为你的 Python 代码编写单元测试，那么使用 Hamcrest 将允许你使你的断言更加精确，既不会比你想要测试的多也不会少。这将在修改代码时减少误报，并减少修改工作代码的测试所花费的时间。via: https://opensource.com/article/18/8/robust-unit-tests-hamcrest作者：Moshe Zadka 选题：lujun9972 译者：MjSeven 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
Selenium 是浏览器自动化的绝佳工具。使用 Selenium IDE，你可以录制命令序列（如单击、拖动和输入），验证结果并最终存储此自动化测试供日后使用。这非常适合在浏览器中进行活跃开发。但是当你想要将这些测试与 CI/CD 流集成时，是时候使用 Selenium WebDriver 了。WebDriver 公开了一个绑定了许多编程语言的 API，它允许你将浏览器测试与其他测试集成。这篇文章向你展示了如何在容器中运行 WebDriver 并将其与 Python 程序一起使用。使用 Podman 运行 SeleniumPodman 是下面例子的容器运行时。有关如何开始使用 Podman 的信息，请参见此前文章。此例使用了 Selenium 的独立容器，其中包含 WebDriver 服务器和浏览器本身。要在后台启动服务器容器，请运行以下命令：$ podman run -d --network host --privileged --name server docker.io/selenium/standalone-firefox当你使用特权标志和主机网络运行容器时，你可以稍后从在 Python 中连接到此容器。你不需要使用 sudo。在 Python 中使用 Selenium现在你可以提供一个使用此服务器的简单程序。这个程序很小，但应该会让你知道可以做什么：from selenium.webdriver.common.desired_capabilities import DesiredCapabilities server ="http://127.0.0.1:4444/wd/hub" driver = webdriver.Remote(command_executor=server, desired_capabilities=DesiredCapabilities.FIREFOX) print("Loading page...") driver.get("https://fedoramagazine.org/") print("Loaded") assert "Fedora" in driver.title driver.quit() print("Done.")首先，程序连接到你已经启动的容器。然后它加载 Fedora Magazine 网页并判断 “Fedora” 是页面标题的一部分。最后，它退出会话。需要 Python 绑定才能运行此程序。既然你已经在使用容器了，为什么不在容器中这样做呢？将以下内容保存到 Dockerfile 中：FROM fedora:29 RUN dnf -y install python3 RUN pip3 install selenium然后使用 Podman 在与 Dockerfile 相同的文件夹中构建容器镜像：$ podman build -t selenium-python .要在容器中运行程序，在运行容器时将包含 Python 代码的文件作为卷挂载：$ podman run -t --rm --network host \ -v $(pwd)/browser-test.py:/browser-test.py:z \ selenium-python python3 browser-test.py输出看上去像这样：Loading page... Loaded Done.接下来做什么上面的示例程序是最小的，也许没那么有用。但这仅仅是最表面的东西！查看 Selenium 和 Python 绑定 的文档。在那里，你将找到有关如何在页面中查找元素、处理弹出窗口或填写表单的示例。拖放也是可能的，当然还有等待事件。在实现一些不错的测试后，你可能希望将它们包含在 CI/CD 流程中。幸运的是，这是相当直接的，因为一切都是容器化的。你可能也有兴趣设置 grid 来并行运行测试。这不仅有助于加快速度，还允许你同时测试多个不同的浏览器。清理当你容器使用完后，可以使用以下命令停止并删除独立容器：$ podman stop server $ podman rm server如果你还想释放磁盘空间，请运行以下命令删除镜像：$ podman rmi docker.io/selenium/standalone-firefox $ podman rmi selenium-python fedora:29总结在本篇中，你已经看到使用容器技术开始使用 Selenium 是多么容易。它允许你自动化与网站的交互，以及测试交互。Podman 允许你在没有超级用户权限或 Docker 守护程序的情况下运行所需的容器。最后，Python 绑定允许你使用普通的 Python 代码与浏览器进行交互。via: https://fedoramagazine.org/automate-web-browser-selenium/作者：Lennart Jern 选题：lujun9972 译者：geekpi 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出