Alex Bunardzic 发布的文章

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

在前面的文章中,我已经解释了为什么将编程问题看作一整群丧尸来处理是错误的。我用 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中国 荣誉推出

编程过程有时候就像一场与丧尸群之间的战斗。在这个系列文章中,我将带你了解怎样将 ZOMBIES 方法应用到实际工作中。

 title=

很久以前,在我还是一个萌新程序员的时候,我们曾经被分配一大批工作。我们每个人都被分配了一个编程任务,然后回到自己的小隔间里噼里啪啦地敲键盘。我记得团队里的成员在自己的小隔间里一呆就是几个小时,为打造无缺陷的程序而奋斗。当时流行的思想是:能一次性做得越多,能力越强。

对于我来说,能够长时间编写或者修改代码而不用中途停下来检验这些代码是否有效,就像荣誉勋章一样。那个时候我们都认为停下来检验代码是否工作是能力不足的表现,菜鸟才这么干。一个“真正的开发者”应该能一口气构建起整个程序,中途不用停下来检查任何东西!

然而事与愿违,当我停止在开发过程中测试自己的代码之后,来自现实的检验狠狠地打了我的脸。我的代码要么无法通过编译,要么构建失败,要么无法运行,或者不能按预期处理数据。我不得不在绝望中挣扎着解决这些烦人的问题。

避开丧尸群

如果你觉得旧的工作方式听起来很混乱,那是因为它确实是这样的。我们一次性处理所有的任务,在问题堆里左砍右杀,结果只是引出更多的问题。着就像是跟一大群丧尸间的战斗。

如今我们已经学会了避免一次性做太多的事情。在最初听到一些专家推崇避免大批量地开发的好处时,我觉得这很反直觉,但我已经从过去的犯错中吸取了教训。我使用被 James Grenning 称为 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)

我将在本系列文章中对它们进行分析讲解。

最简场景

最简场景指可能出现的最简单的情况。

人们倾向于最开始的时候使用硬编码值,因为这是最简单的方式。通过在编码活动中使用硬编码值,可以快速构建出一个能即时反馈的解决方案。不需要几分钟,更不用几个小时,使用硬编码值让你能够马上与正在构建的系统进行交互。如果你喜欢这个交互,就朝这个方向继续做下去。如果你发现不喜欢这种交互,你可以很容易抛弃它,根本没有什么可损失。

本系列文章将以构建一个简易的购物系统的后端 API 为例进行介绍。该服务提供的 API 允许用户创建购物筐、向购物筐添加商品、从购物筐移除商品、计算商品总价。

首先,创建项目的基本结构(将购物程序的代码和测试代码分别放到 apptests 目录下)。我们的例子中使用开源的 xUnit 测试框架。

现在撸起你的袖子,在实践中了解最简场景吧!

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

这是一个伪测试,它测试的是硬编码值。新创建的购物筐是空的,所以购物筐中预期的商品数是 0。通过比较期望值和实际值是否相等,这个预期被表示成一个测试(或者称为断言)。

运行该测试,输出结果如下:

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
[...]

这个测试显然无法通过:期望商品数是 0,但是实际值被硬编码为了 1。

当然,你可以马上把硬编码的值从 1 改成 0,这样测试就能通过了:

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

与预想的一样,运行测试,测试通过:

Starting test execution, please wait...

A total of 1 test files matched the specified pattern.

Test Run Successful.
Total tests: 1
     Passed: 1
 Total time: 1.0950 Seconds

你也许会认为执行一个被强迫失败的测试完全没有意义,但是不管一个测试多么简单,确保它的可失败性是绝对有必要的。只有这样才能够保证如果在后续工作中不小心破坏了程序的处理逻辑时该测试能够给你相应的警告。

现在停止伪造数据,将硬编码的值替换成从 API 中获取的值。我们已经构造了一个能够可靠地失败的测试,它期望一个空的购物筐中有 0 个商品,现在是时候编写一些应用程序代码了。

就跟常见的软件建模活动一样,我们先从构造一个简单的接口开始。在 app 目录下新建文件 IShoppingAPI.cs(习惯上接口名一般以大写 I 开头)。在该接口中声明一个名为 NoOfItems() 的方法,它以 int 类型返回商品的数量。下面是接口的代码:

using System;

namespace app {    
    public interface IShoppingAPI {
        int NoOfItems();
    }
}

当然这个接口什么事也做不了,在你需要实现它。在 app 目录下创建另一个文件 ShoppingAPI。在其中将 ShoppingAPI 声明为一个实现了 IShoppingAPI 的公有类。在类中定义方法 NoOfItems 返回整数 1:

using System;

namespace app {
    public class ShoppingAPI : IShoppingAPI {
        public int NoOfItems() {
            return 1;
        }
    }
}

从上面代码中你发现自己又在通过返回硬编码值 1 的方式来伪造代码逻辑。现阶段这是一件好事,因为你需要保持一切超级无敌简单。现在还不是仔细构想如何实现购物筐的处理逻辑时候。这些工作后续再做!到目前为止,你只是通过构建最简场景来检验自己是否满意现在的设计。

为了确定这一点,将硬编码值换成这个 API 在运行中收到请求时应该返回的值。你需要通过 using app; 声明来告诉测试你使用的购物逻辑代码在哪里。

接下来,你需要 实例化 instantiate IShoppingAPI 接口:

IShoppingAPI shoppingAPI = new ShoppingAPI();

这个实例用来发送请求并接收返回的值。

现在,代码变成了这样:

using System;
using Xunit;
using app;

namespace tests {
    public class ShoppingAPITests {
        IShoppingAPI shoppingAPI = [new][3] ShoppingAPI();
 
        [Fact]        
        public void NewlyCreatedBasketHas0Items() {
            var expectedNoOfItems = 0;
            var actualNoOfItems = shoppingAPI.NoOfItems();
            Assert.Equal(expectedNoOfItems, actualNoOfItems);
        }
    }
}

显然执行这个测试的结果是失败,因为你硬编码了一个错误的返回值(期望值是 0,但是返回的是 1)。

同样的,你也可以通过将硬编码的值从 1 改成 0 来让测试通过,但是现在做这个是在浪费时间。现在设计的接口已经跟测试关联上了,你剩下的职责就是编写代码实现预期的行为逻辑。

在编写应用程序代码时,你得决定用来表示购物筐得数据结构。为了保持设计的简单,尽量选择 C# 中表示集合的最简单类型。第一个想到的就是 ArrayList。它非常适合目前的使用场景——可以保存不定个数的元素,并且易于遍历访问。

因为 ArrayListSystem.Collections 包的一部分,在你的代码中需要声明:

using System.Collections;

然后 basket 的声明就变成这样了:

ArrayList basket = new ArrayList();

最后将 NoOfItems() 中的因编码值换成实际的代码:

public int NoOfItems() {
    return basket.Count;
}

这次测试能够通过了,因为最初购物筐是空的,basket.Count 返回 0。

这也是你的第一个最简场景测试要做的事情。

更多案例

目前的课后作业是处理一个丧尸,也就是第 0 个丧尸。在下一篇文章中,我将带你了解单元素场景和多元素场景。不要错过哦!

(题图:MJ/7917bc47-5325-4c0f-a2dd-4e444f57a46c)


via: https://opensource.com/article/21/2/development-guide

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

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

基于 .NET 的 xUnit.net 测试框架,开发一款自动猫门的逻辑,让门在白天开放,夜间锁定。

在本系列的第一篇文章中,我演示了如何使用设计的故障来确保代码中的预期结果。在第二篇文章中,我将继续开发示例项目:一款自动猫门,该门在白天开放,夜间锁定。

在此提醒一下,你可以按照此处的说明使用 .NET 的 xUnit.net 测试框架。

关于白天时间

回想一下,测试驱动开发(TDD)围绕着大量的单元测试。

第一篇文章中实现了满足 Given7pmReturnNighttime 单元测试期望的逻辑。但还没有完,现在,你需要描述当前时间大于 7 点时期望发生的结果。这是新的单元测试,称为 Given7amReturnDaylight

       [Fact]
       public void Given7amReturnDaylight()
       {
           var expected = "Daylight";
           var actual = dayOrNightUtility.GetDayOrNight();
           Assert.Equal(expected, actual);
       }

现在,新的单元测试失败了(越早失败越好!):

Starting test execution, please wait...
[Xunit.net 00:00:01.23] unittest.UnitTest1.Given7amReturnDaylight [FAIL]
Failed unittest.UnitTest1.Given7amReturnDaylight
[...]

期望接收到字符串值是 Daylight,但实际接收到的值是 Nighttime

分析失败的测试用例

经过仔细检查,代码本身似乎已经出现问题。 事实证明,GetDayOrNight 方法的实现是不可测试的!

看看我们面临的核心挑战:

  1. GetDayOrNight 依赖隐藏输入。

dayOrNight 的值取决于隐藏输入(它从内置系统时钟中获取一天的时间值)。

  1. GetDayOrNight 包含非确定性行为。

从系统时钟中获取到的时间值是不确定的。(因为)该时间取决于你运行代码的时间点,而这一点我们认为这是不可预测的。

  1. GetDayOrNight API 的质量差。

该 API 与具体的数据源(系统 DateTime)紧密耦合。

  1. GetDayOrNight 违反了单一责任原则。

该方法实现同时使用和处理信息。优良作法是一种方法应负责执行一项职责。

  1. GetDayOrNight 有多个更改原因。

可以想象内部时间源可能会更改的情况。同样,很容易想象处理逻辑也将改变。这些变化的不同原因必须相互隔离。

  1. 当(我们)尝试了解 GetDayOrNight 行为时,会发现它的 API 签名不足。

最理想的做法就是通过简单的查看 API 的签名,就能了解 API 预期的行为类型。

  1. GetDayOrNight 取决于全局共享可变状态。

要不惜一切代价避免共享的可变状态!

  1. 即使在阅读源代码之后,也无法预测 GetDayOrNight 方法的行为。

这是一个严重的问题。通过阅读源代码,应该始终非常清晰,系统一旦开始运行,便可以预测出其行为。

失败背后的原则

每当你遇到工程问题时,建议使用久经考验的 分而治之 divide and conquer 策略。在这种情况下,遵循 关注点分离 separation of concerns 的原则是一种可行的方法。

关注点分离(SoC)是一种用于将计算机程序分为不同模块的设计原理,以便每个模块都可以解决一个关注点。关注点是影响计算机程序代码的一组信息。关注点可以和要优化代码的硬件的细节一样概括,也可以和要实例化的类的名称一样具体。完美体现 SoC 的程序称为模块化程序。

出处

GetDayOrNight 方法应仅与确定日期和时间值表示白天还是夜晚有关。它不应该与寻找该值的来源有关。该问题应留给调用客户端。

必须将这个问题留给调用客户端,以获取当前时间。这种方法符合另一个有价值的工程原理—— 控制反转 inversion of control 。Martin Fowler 在这里详细探讨了这一概念。

框架的一个重要特征是用户定义的用于定制框架的方法通常来自于框架本身,而不是从用户的应用程序代码调用来的。该框架通常在协调和排序应用程序活动中扮演主程序的角色。控制权的这种反转使框架有能力充当可扩展的框架。用户提供的方法为框架中的特定应用程序量身制定泛化算法。

Ralph Johnson and Brian Foote

重构测试用例

因此,代码需要重构。摆脱对内部时钟的依赖(DateTime 系统实用程序):

 DateTime time = new DateTime();

删除上述代码(在你的文件中应该是第 7 行)。通过将输入参数 DateTime 时间添加到 GetDayOrNight 方法,进一步重构代码。

这是重构的类 DayOrNightUtility.cs

using System;

namespace app {
   public class DayOrNightUtility {
       public string GetDayOrNight(DateTime time) {
           string dayOrNight = "Nighttime";
           if(time.Hour >= 7 && time.Hour < 19) {
               dayOrNight = "Daylight";
           }
           return dayOrNight;
       }
   }
}

重构代码需要更改单元测试。 需要准备 nightHourdayHour 的测试数据,并将这些值传到GetDayOrNight 方法中。 以下是重构的单元测试:

using System;
using Xunit;
using app;

namespace unittest
{
   public class UnitTest1
   {
       DayOrNightUtility dayOrNightUtility = new DayOrNightUtility();
       DateTime nightHour = new DateTime(2019, 08, 03, 19, 00, 00);
       DateTime dayHour = new DateTime(2019, 08, 03, 07, 00, 00);

       [Fact]
       public void Given7pmReturnNighttime()
       {
           var expected = "Nighttime";
           var actual = dayOrNightUtility.GetDayOrNight(nightHour);
           Assert.Equal(expected, actual);
       }

       [Fact]
       public void Given7amReturnDaylight()
       {
           var expected = "Daylight";
           var actual = dayOrNightUtility.GetDayOrNight(dayHour);
           Assert.Equal(expected, actual);
       }

   }
}

经验教训

在继续开发这种简单的场景之前,请先回顾复习一下本次练习中所学到的东西。

运行无法测试的代码,很容易在不经意间制造陷阱。从表面上看,这样的代码似乎可以正常工作。但是,遵循测试驱动开发(TDD)的实践(首先描述期望结果,然后才描述实现),暴露了代码中的严重问题。

这表明 TDD 是确保代码不会太凌乱的理想方法。TDD 指出了一些问题区域,例如缺乏单一责任和存在隐藏输入。此外,TDD 有助于删除不确定性代码,并用行为明确的完全可测试代码替换它。

最后,TDD 帮助交付易于阅读、逻辑易于遵循的代码。

在本系列的下一篇文章中,我将演示如何使用在本练习中创建的逻辑来实现功能代码,以及如何进行进一步的测试使其变得更好。


via: https://opensource.com/article/19/9/mutation-testing-example-failure-experimentation

作者:Alex Bunardzic 选题:lujun9972 译者:Morisun029 校对:wxy

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