Python-鲁棒编程-五-

68 阅读23分钟

Python 鲁棒编程(五)

原文:annas-archive.org/md5/42e1aab1e8f4063de5f6437ba1b9efff

译者:飞龙

协议:CC BY-NC-SA 4.0

第二十二章:验收测试

作为开发者,很容易专注于直接涉及你代码库的测试:单元测试、集成测试、UI 测试等等。这些测试验证代码是否按照你的意图执行,是保持代码库无回归问题的宝贵工具。然而,它们完全是用来构建客户期望的错误工具。

开发人员在编写这些测试时对代码了如指掌,这意味着测试结果偏向于开发者的期望。尽管如此,不能保证这些测试覆盖的行为确实符合客户的需求。

考虑以下单元测试:

def test_chili_has_correct_ingredients():
    assert make_chili().ingredients() == [
        "Ground Beef",
        "Chile Blend",
        "Onion",
        ...
        "Tomatoes",
        "Pinto Beans"
    ]

这个测试可能是无懈可击的;它能通过并捕捉代码中的任何回归。然而,当呈现给客户时,你可能会遇到:“不,我想要德州风味的辣椒!你知道的,没有番茄或豆子?” 即使世界上所有的单元测试也无法阻止你构建错误的东西。

这就是验收测试的用武之地。验收测试检查你是否正在构建正确的产品。虽然单元测试和集成测试是一种验证形式,验收测试则是确认。它们验证你是否正在构建用户期望的东西。

在本章中,你将了解 Python 中的验收测试。我将向你展示使用 Gherkin 语言定义需求的behave框架,以全新的方式进行行为驱动开发。¹ 你将学习 BDD 作为澄清对话的工具。验收测试是构建安全网的重要组成部分;它将保护你免受构建错误东西的风险。

行为驱动开发

客户期望与软件行为之间的不匹配问题由来已久。这个问题源于将自然语言转换为编程语言。自然语言充满了歧义、不一致和细微差别。编程语言则是严格的。计算机会严格按照你告诉它的去执行(即使这不是你的本意)。更糟糕的是,这就像是一个电话游戏²,需求经过几个人(客户、销售、经理、测试人员)传递,最后才编写测试。

就像软件生命周期中的所有事情一样,这种错误案例越晚发现修复代价越高。理想情况下,你希望在制定用户需求时就发现这些问题。这就是行为驱动开发发挥作用的时候。

Gherkin 语言

行为驱动开发,最初由丹尼尔·特霍斯特-诺斯首创,是一种侧重于定义系统行为的实践。BDD 着重于澄清沟通;你与最终用户一起迭代需求,定义他们想要的行为。

在您编写任何代码之前,确保您已就要构建的正确内容达成一致。定义的行为集将推动您编写的代码。您与最终用户(或其代理人,如业务分析师或产品经理)合作,将您的需求定义为一种规范。这些规范遵循一种正式的语言,以在其定义中引入更多的严格性。指定需求的最常见语言之一是 Gherkin。

Gherkin 是一种遵循Given-When-Then(GWT)格式的规范。每个需求都组织如下:

Feature: Name of test suite

  Scenario: A test case
    Given some precondition
    When I take some action
    Then I expect this result

例如,如果我想捕捉一个检查菜肴素食替代的需求,我会这样写:

Feature: Vegan-friendly menu

  Scenario: Can substitute for vegan alternative
    Given an order containing a Cheeseburger with Fries
    When I ask for vegan substitutions
    Then I receive the meal with no animal products

另一个要求可能是某些菜品不能做成素食:

  Scenario: Cannot substitute vegan alternatives for certain meals
    Given an order containing Meatloaf
    When I ask for vegan substitutions
    Then an error shows up stating the meal is not vegan substitutable
注意

如果 GWT 格式感觉熟悉,那是因为它与您在第二十一章中学到的 AAA 测试组织完全相同。

通过与最终用户合作,以此方式编写您的需求,您将从以下几个关键原则中获益:

使用简单的语言编写

没有必要深入任何编程语言或正式逻辑。所有内容都以一种对业务人员和开发人员都能理解的形式编写。这使得非常容易抓住最终用户实际想要的东西。

建立共享词汇

随着需求数量的增加,您会发现多个需求中开始有相同的条款(如上所示,使用When I ask for vegan substitutions)。这会建立起您的领域语言,并使所有相关方更容易理解需求。

需求是可测试的

这可能是这种需求格式的最大好处。因为您正在以 GWT 方式编写需求,所以您在本章中使用的辣椒示例作为 Gherkin 测试的指定方式:

  Scenario: Texas-Style Chili
    Given a Chili-Making Machine
    When a Chili is dispensed
    Then that dish does not contain beans
    And that dish does not contain tomatoes

清楚地表明了需要编写哪些测试作为验收测试。如果 Gherkin 测试存在任何歧义,您可以与最终用户合作,找出一个具体的测试应该是什么样子。这也可以帮助解决传统上模糊的需求,例如,“辣椒制作机应该快速。”相反,通过专注于具体的测试,您最终得到像这样的测试:

Scenario: Chili order takes less than two minutes
Given a Chili-Making Machine
When a Chili is ordered
Then the Chili is dispensed to the customer within two minutes
警告

这些需求规格并非消除需求中错误的灵丹妙药,而是一种缓解策略。如果在编写代码之前让技术和业务人员审查它们,您将更有可能发现歧义或意图不匹配。

一旦您开始用 Gherkin 定义您的测试,您可以做一些令人惊讶的事情:您可以使您的规格可执行

可执行规格

可执行规范直接将一组需求转换为代码。这意味着您的需求不仅是可测试的,而且也是测试。当需求变化时,您的测试也会同时变化。这是可追溯性的最终形式,或者连接您的需求到具体测试或代码的能力。

讨论话题

您的组织如何跟踪需求?如何将这些需求追溯到测试用例?如何处理需求变更?讨论如果您的需求和测试是相同的东西,您的流程会如何变化。

Python 模块behave允许您用具体的测试支持您的 Gherkin 需求。它通过将函数与需求中的特定条款关联起来来实现这一点。

提示

默认情况下,behave期望您的 Gherkin 文件在名为features的文件夹中,并且您的 Python 函数(称为步骤)在名为features/steps的文件夹中。

让我们来看看我在本章前面展示的第一个 Gherkin 需求:

Feature: Vegan-friendly menu

  Scenario: Can substitute for vegan alternative
    Given an order containing a Cheeseburger with Fries
    When I ask for vegan substitutions
    Then I receive the meal with no animal products

使用behave,我可以编写与每个 GWT 语句相对应的 Python 代码:

from behave import given, when, then

@given("an order containing a Cheeseburger with Fries")
def setup_order(ctx):
    ctx.dish = CheeseburgerWithFries()

@when("I ask for vegan substitutions")
def substitute_vegan(ctx):
    ctx.dish.substitute_vegan_ingredients()

@then("I receive the meal with no animal products")
def check_all_vegan(ctx):
    assert all(is_vegan(ing) for ing in ctx.dish.ingredients())

每个步骤表示为与 Gherkin 需求条款匹配的装饰器。装饰的函数是作为规范的一部分执行的。在上面的示例中,Gherkin 需求将由以下代码表示(您无需编写此代码;Gherkin 为您完成):

from behave.runner import Context
context = Context()
setup_order(context)
substitute_vegan(context)
check_all_vegan(context)

要运行此操作,请先安装behave

pip install behave

然后,在包含您的需求和步骤的文件夹上运行behave

behave code_examples/chapter22/features

您将看到以下输出:

Feature: Vegan-friendly menu

  Scenario: Can substitute for vegan alternatives
    Given an order containing a Cheeseburger with Fries
    When I ask for vegan substitutions
    Then I receive the meal with no animal products

1 feature passed, 0 failed, 0 skipped
1 scenario passed, 0 failed, 0 skipped
3 steps passed, 0 failed, 0 skipped, 0 undefined
Took 0m0.000s

当此代码在终端或 IDE 中运行时,所有步骤显示为绿色。如果任何步骤失败,该步骤将变为红色,并显示失败的详细信息。

现在,您可以直接将您的需求与验收测试联系起来。如果最终用户改变主意,他们可以编写新的测试。如果 GWT 条款已经存在于新测试中,那是一个胜利;新测试可以在没有开发人员帮助的情况下编写。如果条款尚不存在,那也是一个胜利,因为当测试立即失败时,它会引发一场对话。您的最终用户和业务人员不需要 Python 知识即可理解您正在测试的内容。

使用 Gherkin 规范来推动关于需要构建的软件的对话。behave允许您直接将验收测试与这些需求联系起来,它们作为聚焦对话的一种方式。使用 BDD 防止您直接开始编写错误的内容。正如流行的说法所说:“几周的编码将节省您几小时的计划。”³

额外的 behave 特性

前面的示例有些基本,但幸运的是,behave提供了一些额外的功能,使测试编写更加简便。

参数化步骤

你可能已经注意到,我有两个非常相似的Given步骤:

Given an order containing a Cheeseburger with Fries

Given an order containing Meatloaf

在 Python 中编写两个类似的函数将是愚蠢的。behave 允许您参数化步骤,以减少编写多个步骤的需要。

@given("an order containing {dish_name}")
def setup_order(ctx, dish_name):
    if dish_name == "a Cheeseburger with Fries":
        ctx.dish = CheeseburgerWithFries()
    elif dish_name == "Meatloaf":
        ctx.dish = Meatloaf()

或者,如果需要的话,您可以在函数上堆叠从句:

@given("an order containing a Cheeseburger with Fries")
@given("a typical drive-thru order")
def setup_order(context):
    ctx.dish = CheeseBurgerWithFries()

参数化和重用步骤将帮助您构建直观易用的词汇表,从而减少编写 Gherkin 测试的成本。

表驱动需求

在 第二十一章 中,我提到您可以参数化测试,以便在表中定义所有的前置条件和断言。behave 提供了非常相似的功能:

Feature: Vegan-friendly menu

Scenario Outline: Vegan Substitutions
  Given an order containing <dish_name>,
  When I ask for vegan substitutions
  Then <result>

 Examples: Vegan Substitutable
   | dish_name                  | result |
   | a Cheeseburger with Fries  | I receive the meal with no animal products  |
   | Cobb Salad                 | I receive the meal with no animal products  |
   | French Fries               | I receive the meal with no animal products  |
   | Lemonade                   | I receive the meal with no animal products  |

 Examples: Not Vegan Substitutable
   | dish_name     | result |
   | Meatloaf      | a non-vegan-substitutable error shows up |
   | Meatballs     | a non-vegan-substitutable error shows up |
   | Fried Shrimp  | a non-vegan-substitutable error shows up |

behave 将自动为每个表项运行一个测试。这是在非常相似的数据上运行相同测试的绝佳方式。

步骤匹配

有时,基本的装饰器不足以捕获您尝试表达的内容。您可以告诉 behave 在装饰器中使用正则表达式解析。这对于使 Gherkin 规范编写起来更加自然(特别是在处理复杂数据格式或奇怪的语法问题时)非常有用。这里有一个示例,允许您在菜名前面加上可选的“a”或“an”(以简化菜名)。

from behave import use_context_matcher

use_step_matcher("re")

@given("an order containing [a |an ]?(?P<dish_name>.*)")
def setup_order(ctx, dish_name):
    ctx.dish = create_dish(dish_name)

定制测试生命周期

有时候您需要在测试运行之前或之后运行代码。比如,在所有规范设置之前需要设置数据库,或者告诉服务在测试运行之间清除其缓存。就像内置的 unittest 模块中的 setUptearDown 一样,behave 提供了让您在步骤、特性或整个测试运行之前或之后挂接函数的功能。使用这个功能可以整合通用的设置代码。为了充分利用这个功能,您可以在名为 environment.py 的文件中定义具体命名的函数。

def before_all(ctx):
    ctx.database = setup_database()

def before_feature(ctx, feature):
    ctx.database.empty_tables()

def after_all(ctx):
    ctx.database.cleanup()

查看 behave documentation 以获取有关控制环境的更多信息。如果您更喜欢 pytest 的 fixture,请查看 behavefixtures,它们具有非常相似的思想。

小贴士

before_featurebefore_scenario 这样的函数会将相应的特性或场景传递给它们。您可以根据这些特性和场景的名称来执行特定的动作,以处理测试的特定部分。

使用标签选择性运行测试

behave 还提供了标记某些测试的能力,这些标记可以是任何您想要的:@wip 用于正在进行的工作,@slow 用于运行缓慢的测试,@smoke 用于选择性运行的少数测试等。

要在 behave 中标记测试,只需装饰您的 Gherkin 场景:

Feature: Vegan-friendly Menu

  @smoke
  @wip
  Scenario: Can substitute for vegan alternatives
    Given an order containing a Cheeseburger with Fries
    When I ask for vegan substitutions
    Then I receive the meal with no animal products

要仅运行带有特定标签的测试,可以在 behave 调用时传递 --tags 标志:

behave code_examples/chapter22 --tags=smoke
小贴士

如果您想要排除运行某些测试,可以在标签前加一个连字符,就像在这个例子中,我排除了带有 wip 标签的测试:

behave code_examples/chapter22 --tags=-wip

报告生成

如果你不涉及最终用户或其代理,使用behave和 BDD 进行验收测试将毫无意义。找到让他们易于理解和使用 Gherkin 需求的方法。

你可以通过调用behave --steps-catalog获取所有步骤定义的列表。

当然,你还需要一种方法来展示测试结果,让最终用户了解什么在运行,什么不在运行。behave允许你以多种不同的方式格式化输出(你也可以定义自己的格式)。开箱即用,还可以从JUnit创建报告,JUnit 是为 Java 语言设计的单元测试框架。JUnit 将其测试结果写成 XML 文件,并构建了许多工具来接收和可视化测试结果。

要生成 JUnit 测试报告,你可以在behave调用中传递--junit。然后,你可以使用junit2html工具为所有测试用例生成报告:

pip install junit2html
behave code_examples/chapter22/features/ --junit
# xml files are in the reports folder
junit2html <filename>

示例输出显示在图 22-1 中。

ropy 2201

图 22-1. 使用junit2html的示例behave报告

有很多 JUnit 报告生成器,所以找一个你喜欢的并使用它生成你的测试结果的 HTML 报告。

结语

如果所有的测试都通过了,但未提供最终用户想要的内容,则浪费了时间和精力。构建正确的东西是昂贵的;你希望第一次就做对。使用 BDD 来推动关于系统需求的关键对话。一旦有了需求,使用behave和 Gherkin 语言编写验收测试。这些验收测试成为确保你提供最终用户所需内容的安全网。

在下一章中,你将继续学习如何修补你的安全网中的漏洞。你将了解使用名为Hypothesis的 Python 工具进行基于属性的测试。它可以为你生成测试用例,包括你可能从未想过的测试。你可以更放心地知道,你的测试覆盖范围比以往任何时候都要广泛。

¹ Gherkin 语言由 Aslak Hellesøy 创建。他的妻子建议他的 BDD 测试工具命名为黄瓜(显然没有具体原因),他希望将规范语言与测试工具本身区分开来。由于黄瓜是一种小型的腌制黄瓜,他延续了这个主题,于是 Gherkin 规范语言诞生了。

² 电话是一个游戏,每个人坐在一个圈子里,一个人对另一个人耳语一条消息。消息继续在圈子里传递,直到回到原点。每个人都会因为消息被扭曲而发笑。

³ 虽然这句话的作者是匿名的,但我最先看到它是在Programming Wisdom Twitter 账号上。

第二十三章:属性化测试

在您的代码库中不可能测试所有东西。您能做的最好的事情就是在如何针对特定用例上变得聪明。您寻找边界情况,代码路径,以及代码的其他有趣属性。您的主要希望是您没有在安全网中留下任何大漏洞。然而,您可以做得比希望更好。您可以使用属性化测试填补这些空白。

在本章中,您将学习如何使用名为Hypothesis的 Python 库进行基于属性的测试。您将使用Hypothesis来为您生成测试用例,通常是以您意想不到的方式。您将学习如何跟踪失败的测试用例,以新的方式制定输入数据,甚至让Hypothesis创建算法的组合来测试您的软件。Hypothesis将保护您的代码库免受一系列新错误的影响。

使用 Hypothesis 进行属性化测试

属性化测试是一种生成式测试形式,工具会为您生成测试用例。与基于特定输入/输出组合编写测试用例不同,您定义系统的属性。在这个上下文中,属性是指系统中成立的不变量(在第十章中讨论)的另一个名称。

考虑一个菜单推荐系统,根据顾客提供的约束条件选择菜肴,例如总热量、价格和菜系。对于这个特定示例,我希望顾客能够订购一顿全餐,热量低于特定的热量目标。以下是我为此功能定义的不变量:

  • 客户将收到三道菜:前菜、沙拉和主菜。

  • 当将所有菜肴的热量加在一起时,总和会小于它们的预期目标。

如果我要将此作为pytest测试来专注于测试这些属性,它会看起来像以下内容:

def test_meal_recommendation_under_specific_calories():
    calories = 900
    meals = get_recommended_meal(Recommendation.BY_CALORIES, calories)
    assert len(meals) == 3
    assert is_appetizer(meals[0])
    assert is_salad(meals[1])
    assert is_main_dish(meals[2])
    assert sum(meal.calories for meal in meals) < calories

将此与测试特定结果进行对比:

def test_meal_recommendation_under_specific_calories():
    calories = 900
    meals = get_recommended_meal(Recommendation.BY_CALORIES, calories)
    assert meals == [Meal("Spring Roll", 120),
                     Meal("Green Papaya Salad", 230),
                     Meal("Larb Chicken", 500)]

第二种方法是测试非常具体的一组餐点;这种测试更具体,但也更脆弱。当生产代码发生变化时,比如引入新菜单项或更改推荐算法时,它更容易出现问题。理想的测试是只有在出现真正的错误时才会失败。请记住,测试并非免费。您希望减少维护成本,缩短调整测试所需的时间是一个很好的方法。

在这两种情况下,我正在使用特定输入进行测试:900 卡路里。为了建立更全面的安全网,扩展您的输入领域以测试更多案例是一个好主意。在传统的测试案例中,您通过执行边界值分析来选择编写哪些测试。边界值分析是指分析待测试的代码,寻找不同输入如何影响控制流程或代码中的不同执行路径。

例如,假设 get_recommended_meal 在卡路里限制低于 650 时引发错误。在这种情况下的边界值是 650;这将输入域分割成两个等价类或具有相同属性的值集。一个等价类是所有低于 650 卡路里的数字,另一个等价类是 650 及以上的值。通过边界值分析,应该有三个测试:一个测试低于 650 卡路里的卡路里,一个测试刚好在 650 卡路里的边界处,以及一个测试一个高于 650 卡路里的值。实际上,这验证了开发人员没有搞错关系运算符(例如写成 <= 而不是 <)或者出现了差一错误。

然而,边界值分析仅在你能够轻松分割输入域时才有用。如果确定在哪里应该分割域很困难,那么挑选边界值将不容易。这就是 Hypothesis 的生成性质发挥作用的地方;Hypothesis 为测试用例生成输入。它将为你找到边界值。

你可以通过 pip 安装 Hypothesis

pip install hypothesis

我将修改我的原始属性测试,让 Hypothesis 负责生成输入数据。

from hypothesis import given
from hypothesis.strategies import integers

@given(integers())
def test_meal_recommendation_under_specific_calories(calories):
    meals = get_recommended_meal(Recommendation.BY_CALORIES, calories)
    assert len(meals) == 3
    assert is_appetizer(meals[0])
    assert is_salad(meals[1])
    assert is_main_dish(meals[2])
    assert sum(meal.calories for meal in meals) < calories

只需一个简单的装饰器,我就可以告诉 Hypothesis 为我选择输入。在这种情况下,我要求 Hypothesis 生成不同的 integers 值。Hypothesis 将运行此测试多次,尝试找到违反预期属性的值。如果我用 pytest 运行这个测试,我会看到以下输出:

Falsifying example: test_meal_recommendation_under_specific_calories(
    calories=0,
)
============= short test summary info ======================
FAILED code_examples/chapter23/test_basic_hypothesis.py::
    test_meal_recommendation_under_specific_calories - assert 850 < 0

Hypothesis 在我的生产代码中早期发现了一个错误:代码不能处理零卡路里限制。现在,对于这种情况,我想指定我只应该测试某个特定数量的卡路里或以上:

@given(integers(min_value=900))
def test_meal_recommendation_under_specific_calories(calories)
    # ... snip ...

现在,当我用 pytest 命令运行时,我想展示一些关于 Hypothesis 的更多信息。我会运行:

py.test code_examples/chapter23 --hypothesis-show-statistics

这将产生以下输出:

code_examples/chapter23/test_basic_hypothesis.py::
    test_meal_recommendation_under_specific_calories:

  - during generate phase (0.19 seconds):
    - Typical runtimes: 0-1 ms, ~ 48% in data generation
    - 100 passing examples, 0 failing examples, 0 invalid examples

  - Stopped because settings.max_examples=100

Hypothesis 为我检查了 100 个不同的值,而我不需要提供任何具体的输入。更重要的是,每次运行这个测试时,Hypothesis 都会检查新值。与一次又一次地限制自己于相同的测试用例不同,你可以在你测试的内容上获得更广泛的覆盖面。考虑到所有不同的开发人员和持续集成管道系统执行测试,你会意识到你可以多快地捕获边缘情况。

提示

你还可以通过使用 hypothesis.assume 来在你的领域上指定约束条件。你可以在你的测试中写入假设,比如 assume(calories > 850),告诉 Hypothesis 跳过违反这些假设的任何测试用例。

如果我引入一个错误(例如因某种原因在 5,000 到 5,200 卡路里之间出错),Hypothesis 将在四次测试运行内捕获错误(你的测试运行次数可能会有所不同):

_________ test_meal_recommendation_under_specific_calories _________

    @given(integers(min_value=900))
>   def test_meal_recommendation_under_specific_calories(calories):

code_examples/chapter23/test_basic_hypothesis.py:33:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

calories = 5001

    @given(integers(min_value=900))
    def test_meal_recommendation_under_specific_calories(calories):
        meals = get_recommended_meal(Recommendation.BY_CALORIES, calories)
>       assert len(meals) == 3
E       TypeError: object of type 'NoneType' has no len()

code_examples/chapter23/test_basic_hypothesis.py:35: TypeError
------------------------ Hypothesis --------------------------------
Falsifying example: test_meal_recommendation_under_specific_calories(
    calories=5001,
)
=========== Hypothesis Statistics ========================
code_examples/chapter23/test_basic_hypothesis.py::
   test_meal_recommendation_under_specific_calories:

  - during reuse phase (0.00 seconds):
    - Typical runtimes: ~ 1ms, ~ 43% in data generation
    - 1 passing examples, 0 failing examples, 0 invalid examples

  - during generate phase (0.08 seconds):
    - Typical runtimes: 0-2 ms, ~ 51% in data generation
    - 26 passing examples, 1 failing examples, 0 invalid examples
    - Found 1 failing example in this phase

  - during shrink phase (0.07 seconds):
    - Typical runtimes: 0-2 ms, ~ 37% in data generation
    - 22 passing examples, 12 failing examples, 1 invalid examples
    - Tried 35 shrinks of which 11 were successful

  - Stopped because nothing left to do

当您发现错误时,Hypothesis会记录失败的错误,以便将来可以专门检查该值。您还可以通过hypothesis.example装饰器确保Hypothesis始终测试特定情况:

@given(integers(min_value=900))
@example(5001)
def test_meal_recommendation_under_specific_calories(calories)
    # ... snip ...

魔法假设

Hypothesis非常擅长生成会发现错误的测试用例。这似乎像是魔法,但实际上相当聪明。在前面的例子中,您可能已经注意到Hypothesis在值 5001 上出现了错误。如果您运行相同的代码并为大于 5000 的值引入一个错误,您将发现测试仍在 5001 处出现错误。如果Hypothesis正在测试不同的值,我们难道不应该看到稍微不同的结果吗?

Hypothesis发现失败时,它会为您做一些非常好的事情:它缩小了测试用例。缩小是指Hypothesis尝试找到仍然导致测试失败的最小输入。对于integers()Hypothesis会尝试依次更小的数字(或处理负数时更大的数字),直到输入值达到零。Hypothesis试图聚焦(无意冒犯)于仍然导致测试失败的最小值。

要了解有关Hypothesis如何生成和缩小值的更多信息,值得阅读原始QuickCheck 论文。QuickCheck 是最早的基于属性的工具之一,尽管它涉及 Haskell 编程语言,但信息量很大。大多数基于属性的测试工具(如Hypothesis)都是基于 QuickCheck 提出的思想的后继者。

与传统测试的对比

基于属性的测试可以极大地简化编写测试的过程。有一整类问题是你不需要担心的:

更容易测试非确定性

非确定性是大多数传统测试的祸根。随机行为、创建临时目录或从数据库中检索不同的记录可能会使编写测试变得非常困难。您必须在测试中创建一组特定的输出值,为此,您需要是确定性的;否则,您的测试将一直失败。通常,您会尝试通过强制特定行为来控制非确定性,例如强制创建相同的文件夹或对随机数生成器进行种子化。

使用基于属性的测试,非确定性是其中的一部分。Hypothesis将为每个测试运行提供不同的输入。您不必再担心测试特定值了;定义属性并接受非确定性。您的代码库会因此变得更好。

更少的脆弱性

在测试特定输入/输出组合时,您受到一大堆硬编码假设的影响。您假设列表始终按照相同的顺序排列,字典不会添加任何键-值对,并且您的依赖项永远不会改变其行为。这些看似不相关的变化中的任何一个都可能破坏您的一个测试。

当测试由于与被测试功能无关的原因而失败时,这是令人沮丧的。测试因易出错而声名狼藉,要么被忽视(掩盖真正的失败),要么开发者不得不面对不断需要修复测试的烦恼。使用基于属性的测试增强您的测试的韧性。

更好地找到错误的机会

基于属性的测试不仅仅是为了减少测试创建和维护成本。它将增加您发现错误的机会。即使今天您编写的测试覆盖了代码的每条路径,仍然有可能会遗漏某些情况。如果您的函数以不向后兼容的方式更改(例如,现在对您先前认为是正常的值出现错误),那么您的运气取决于是否有一个特定值的测试用例。基于属性的测试,通过生成新的测试用例,将在多次运行中更有可能发现该错误。

讨论主题

检查您当前的测试用例,并选择阅读起来复杂的测试。搜索需要大量输入和输出以充分测试功能的测试。讨论基于属性的测试如何取代这些测试并简化您的测试套件。

充分利用Hypothesis

到目前为止,我只是初步了解了Hypothesis。一旦你真正深入进行基于属性的测试,你会为自己打开大量的机会。Hypothesis提供了一些非常酷的功能,可以显著改进您的测试体验。

Hypothesis 策略

在上一节中,我向您介绍了integers()策略。Hypothesis策略定义了如何生成测试用例以及在测试用例失败时如何收缩数据。Hypothesis内置了大量的策略。类似于将integers()传递给您的测试用例,您可以传递floats()text()times()来生成浮点数、字符串或datetime.time对象的值。

Hypothesis还提供了一些可以组合其他策略的策略,例如构建策略的列表、元组或字典(这是组合性的一个很好的例子,如第十七章所述)。例如,假设我想创建一个策略,将菜名(文本)映射到卡路里(在 100 到 2000 之间的数字):

from hypothesis import given
from hypothesis.strategies import dictionary, integers, text

@given(dictionaries(text(), integers(min_value=100, max_value=2000)))
def test_calorie_count(ingredient_to_calorie_mapping : dict[str, int]):
    # ... snip ...

对于更复杂的数据,你可以使用Hypothesis来定义自己的策略。您可以使用mapfilter策略,这些策略的概念类似于内置的mapfilter函数。

您还可以使用hypothesis.composite策略装饰器来定义自己的策略。我想创建一个策略,为我创建三道菜的套餐,包括前菜、主菜和甜点。每道菜包含名称和卡路里计数:

from hypothesis import given
from hypothesis.strategies import composite, integers

ThreeCourseMeal = tuple[Dish, Dish, Dish]

@composite
def three_course_meals(draw) -> ThreeCourseMeal:
    appetizer_calories = integers(min_value=100, max_value=900)
    main_dish_calories = integers(min_value=550, max_value=1800)
    dessert_calories = integers(min_value=500, max_value=1000)

    return (Dish("Appetizer", draw(appetizer_calories)),
            Dish("Main Dish", draw(main_dish_calories)),
            Dish("Dessert", draw(dessert_calories)))

@given(three_course_meals)
def test_three_course_meal_substitutions(three_course_meal: ThreeCourseMeal):
    # ... do something with three_course_meal

此示例通过定义一个名为three_course_meals的新复合策略来工作。我创建了三种整数策略;每种类型的菜品都有自己的策略及其自己的最小/最大值。然后,我创建了一个新的菜品,它具有名称和从策略中绘制的值。draw是一个传递给您的复合策略的函数,您可以使用它来选择策略中的值。

一旦您定义了自己的策略,就可以在多个测试中重复使用它们,从而轻松为系统生成新数据。要了解更多关于Hypothesis策略的信息,建议您阅读Hypothesis文档

生成算法

在先前的示例中,我专注于生成输入数据以创建您的测试。然而,Hypothesis可以进一步生成操作的组合。Hypothesis称之为有状态测试

考虑我们的餐饮推荐系统。我展示了如何按卡路里进行过滤,但现在我还想按价格、课程数、接近用户等进行过滤。以下是我想要对系统断言的一些属性:

  • 餐饮推荐系统始终返回三种餐饮选择;可能不是所有推荐的选项都符合用户的所有标准。

  • 所有三种餐饮选择都是唯一的。

  • 餐饮选择是基于最近应用的过滤器排序的。在出现平局的情况下,使用次新的过滤器。

  • 新的过滤器会替换相同类型的旧过滤器。例如,如果您将价格过滤器设置为<20,然后将其更改为<20,然后将其更改为<15,则只应用<$15 过滤器。设置像卡路里过滤器这样的内容,例如<1800 卡路里,并不会影响价格过滤器。

而不是编写大量的测试用例,我将使用hypothesis.stateful.RuleBasedStateMachine来表示我的测试。这将允许我使用Hypothesis测试整个算法,同时检查不变量。有点复杂,所以我会先展示整段代码,然后逐部分解释。

from functools import reduce
from hypothesis.strategies import integers
from hypothesis.stateful import Bundle, RuleBasedStateMachine, invariant, rule

class RecommendationChecker(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.recommender = MealRecommendationEngine()
        self.filters = []

    @rule(price_limit=integers(min_value=6, max_value=200))
    def filter_by_price(self, price_limit):
        self.recommender.apply_price_filter(price_limit)
        self.filters = [f for f in self.filters if f[0] != "price"]
        self.filters.append(("price", lambda m: m.price))

    @rule(calorie_limit=integers(min_value=500, max_value=2000))
    def filter_by_calories(self, calorie_limit):
        self.recommender.apply_calorie_filter(calorie_limit)
        self.filters = [f for f in self.filters if f[0] != "calorie"]
        self.filters.append(("calorie", lambda m: m.calories))

    @rule(distance_limit=integers(max_value=100))
    def filter_by_distance(self, distance_limit):
        self.recommender.apply_distance_filter(distance_limit)
        self.filters = [f for f in self.filters if f[0] != "distance"]
        self.filters.append(("distance", lambda m: m.distance))

    @invariant()
    def recommender_provides_three_unique_meals(self):
        assert len(self.recommender.get_meals()) == 3
        assert len(set(self.recommender.get_meals())) == 3

    @invariant()
    def meals_are_appropriately_ordered(self):
        meals = self.recommender.get_meals()
        ordered_meals = reduce(lambda meals, f: sorted(meals, key=f[1]),
                               self.filters,
                               meals)
        assert ordered_meals == meals

TestRecommender = RecommendationChecker.TestCase

这是相当多的代码,但它真的很酷,因为它是如何运作的。让我们逐步分解它。

首先,我将创建一个hypothesis.stateful.RuleBasedStateMachine的子类:

from functools import reduce
from hypothesis.strategies import integers
from hypothesis.stateful import Bundle, RuleBasedStateMachine, invariant, rule

class RecommendationChecker(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.recommender = MealRecommendationEngine()
        self.filters = []

此类将负责定义我想要以组合形式测试的离散步骤。在构造函数中,我将self.recommender设置为MealRecommendationEngine,这是我在此场景中正在测试的内容。我还将跟踪作为此类的一部分应用的过滤器列表。接下来,我将设置hypothesis.stateful.rule函数:

    @rule(price_limit=integers(min_value=6, max_value=200))
    def filter_by_price(self, price_limit):
        self.recommender.apply_price_filter(price_limit)
        self.filters = [f for f in self.filters if f[0] != "price"]
        self.filters.append(("price", lambda m: m.price))

    @rule(calorie_limit=integers(min_value=500, max_value=2000))
    def filter_by_calories(self, calorie_limit):
        self.recommender.apply_calorie_filter(calorie_limit)
        self.filters = [f for f in self.filters if f[0] != "calorie"]
        self.filters.append(("calorie", lambda m: m.calories))

    @rule(distance_limit=integers(max_value=100))
    def filter_by_distance(self, distance_limit):
        self.recommender.apply_distance_filter(distance_limit)
        self.filters = [f for f in self.filters if f[0] != "distance"]
        self.filters.append(("distance", lambda m: m.distance))

每个规则都充当您想要测试的算法的步骤。Hypothesis将使用这些规则生成测试,而不是生成测试数据。在这种情况下,每个规则都将一个过滤器应用于推荐引擎。我还将这些过滤器保存在本地,以便稍后检查结果。

然后,我使用hypothesis.stateful.invariant装饰器来定义应在每次规则更改后检查的断言。

    @invariant()
    def recommender_provides_three_unique_meals(self):
        assert len(self.recommender.get_meals()) == 3
        # make sure all of the meals are unique - sets de-dupe elements
        # so we should have three unique elements
        assert len(set(self.recommender.get_meals())) == 3

    @invariant()
    def meals_are_appropriately_ordered(self):
        meals = self.recommender.get_meals()
        ordered_meals = reduce(lambda meals, f: sorted(meals, key=f[1]),
                               self.filters,
                               meals)
        assert ordered_meals == meals

我写了两个不变量:一个声明推荐器始终返回三个唯一的餐点,另一个声明这些餐点根据所选择的过滤器按正确的顺序排列。

最后,我将RecommendationChecker中的TestCase保存到一个以Test为前缀的变量中。这样做是为了让pytest能够发现这个有状态的Hypothesis测试。

TestRecommender = RecommendationChecker.TestCase

一旦所有东西都组装好了,Hypothesis将开始生成具有不同规则组合的测试用例。例如,通过一个Hypothesis测试运行(故意引入错误),Hypothesis生成了以下测试。

state = RecommendationChecker()
state.filter_by_distance(distance_limit=0)
state.filter_by_distance(distance_limit=0)
state.filter_by_distance(distance_limit=0)
state.filter_by_calories(calorie_limit=500)
state.filter_by_distance(distance_limit=0)
state.teardown()

当我引入不同的错误时,Hypothesis会展示给我一个不同的测试用例来捕获错误。

state = RecommendationChecker()
state.filter_by_price(price_limit=6)
state.filter_by_price(price_limit=6)
state.filter_by_price(price_limit=6)
state.filter_by_price(price_limit=6)
state.filter_by_distance(distance_limit=0)
state.filter_by_price(price_limit=16)
state.teardown()

这对于测试复杂算法或具有非常特定不变量的对象非常方便。Hypothesis会混合匹配不同的步骤,不断寻找能产生错误的步骤顺序。

讨论主题

你的代码库中的哪些区域包含难以测试、高度相关的函数?写几个有状态的Hypothesis测试作为概念验证,并讨论这些测试如何增强你的测试套件的信心。

总结思考

基于属性的测试并不是为了取代传统测试;它是为了补充传统测试。当你的代码具有明确定义的输入和输出时,使用硬编码的前提条件和预期断言进行测试就足够了。然而,随着你的代码变得越来越复杂,你的测试也变得越来越复杂,你会发现自己花费的时间比想象中多,解析和理解测试。

在 Python 中,使用Hypothesis很容易实现基于属性的测试。它通过在代码库的整个生命周期内生成新的测试来修补你的安全网。你可以使用hypothesis.strategies来精确控制测试数据的生成方式。你甚至可以通过将不同的步骤组合进行hypothesis.stateful测试来测试算法。Hypothesis将让你专注于代码的属性和不变量,并更自然地表达你的测试。

在下一章中,我将用变异测试来结束本书。变异测试是填补安全网漏洞的另一种方法。与找到测试代码的新方法不同,变异代码专注于衡量你的测试的有效性。它是你更强大测试工具中的另一个工具。

第二十四章:突变测试

当你编织静态分析和测试的安全网时,你如何知道你是否尽可能多地进行了测试?测试绝对所有内容是不可能的;你需要在编写测试时聪明地选择。设想每个测试都是安全网中的一根单独的绳索:你拥有的测试越多,你的网越宽。然而,这并不意味着你的安全网就一定构建得很好。一张由破旧、脆弱绳索编织的安全网比没有安全网更糟糕;它会产生安全的错觉,并提供虚假的信心。

目标是加强你的安全网,使其不易破损。你需要一种方式确保当代码中存在 bug 时,你的测试确实能失败。在本章中,你将学习如何通过突变测试来做到这一点。你将学习如何使用一个名为 mutmut 的 Python 工具进行突变测试。你将使用突变测试来检查你的测试与代码之间的关系。最后,你将了解代码覆盖工具,如何最佳地使用这些工具,以及如何将 mutmut 与你的覆盖报告集成。学习如何进行突变测试将为你提供一种衡量你的测试有效性的方法。

什么是突变测试?

突变测试 是有意在源代码中引入 bug 的操作。¹ 你每次以这种方式进行的更改称为 突变体。然后你运行你的测试套件。如果测试失败,那是好消息;你的测试成功消除了突变体。但是,如果你的测试通过了,这意味着你的测试不够强大,无法捕获合法的失败;突变体存活了下来。突变测试是一种 元测试 形式,因为你在测试你的测试有多好。毕竟,你的测试代码应该是代码库中的一等公民;它也需要一定程度的测试。

考虑一个简单的卡路里追踪应用程序。用户可以输入一系列餐食,并在超出他们每日卡路里预算时收到通知。核心功能由以下函数实现:

def check_meals_for_calorie_overage(meals: list[Meal], target: int):
    for meal in meals:
        target -= meal.calories
        if target < 0:
            display_warning(meal, WarningType.OVER_CALORIE_LIMIT)
            continue
        display_checkmark(meal)

这里是一组针对此功能的测试,全部通过了:

def test_no_warnings_if_under_calories():
    meals = [Meal("Fish 'n' Chips", 1000)]
    check_meals_for_calorie_overage(meals, 1200)
    assert_no_warnings_displayed_on_meal("Fish 'n' Chips")
    assert_checkmark_on_meal("Fish 'n' Chips")

def test_no_exception_thrown_if_no_meals():
    check_meals_for_calorie_overage([], 1200)
    # no explicit assert, just checking for no exceptions

def test_meal_is_marked_as_over_calories():
    meals = [Meal("Fish 'n' Chips", 1000)]
    check_meals_for_calorie_overage(meals, 900)
    assert_meal_is_over_calories("Fish 'n' Chips")

def test_meal_going_over_calories_does_not_conflict_with_previous_meals():
    meals = [Meal("Fish 'n' Chips", 1000), Meal("Banana Split", 400)]
    check_meals_for_calorie_overage(meals, 1200)
    assert_no_warnings_displayed_on_meal("Fish 'n' Chips")
    assert_checkmark_on_meal("Fish 'n' Chips")
    assert_meal_is_over_calories("Banana Split")

这是一个思维练习,我希望你能审视这些测试(暂且不论这是关于突变测试的一章),并问问自己如果在生产环境中发现这些测试,你的观点会是什么。你对它们的正确性有多大信心?你确信我没有漏掉任何东西吗?你相信这些测试能在代码变更时捕获错误吗?

本书的核心主题是软件将会不断变化。你需要让你未来的合作者能够轻松地维护你的代码库,尽管这些变化。你需要编写不仅可以捕获你所写内容中的错误,还能捕获其他开发者在修改你的代码时产生的错误的测试。

无论未来的开发人员是重构方法以使用常用库,更改单个行还是向代码添加更多功能,您希望您的测试都能捕捉到他们引入的任何错误。要进入变异测试的思维方式,您需要考虑可能对代码进行的所有更改,并检查您的测试是否能捕捉到任何错误的变化。表 24-1 逐行分解上述代码,并显示如果缺少该行,则测试的结果。

表 24-1。删除每一行的影响

代码行删除后的影响
for meal in meals:测试失败:语法错误,代码不执行循环
target -= meal.calories测试失败:从未显示任何警告
if target < 0测试失败:所有餐点显示警告
display_warning(meal, WarningType.OVER_CALO⁠RIE_LIMIT)测试失败:未显示任何警告
continue测试通过
display_checkmark(meal)测试失败:餐点上没有显示勾号

查看表 24-1 中continue语句所在的行。如果删除该行,则所有测试都通过。这意味着发生了三种情况之一:该行不需要;该行是需要的,但不重要到需要进行测试;或者我们的测试套件中存在覆盖不足。

前两种情况很容易处理。如果不需要该行,请删除它。如果该行不重要到需要进行测试(这在诸如调试日志语句或版本字符串等情况下很常见),则可以忽略对此行的变异测试。但是,如果第三种情况属实,则意味着测试覆盖率不足。你发现了安全网中的一个漏洞。

如果从算法中删除continue,则在超过卡路里限制的任何餐点上将显示一个勾号和一个警告。这不是理想的行为;这是一个信号,表明我应该有一个测试来覆盖这种情况。如果我只是添加一个断言,即带有警告的餐点也没有勾号,那么我们的测试套件就会捕捉到这个变异。

删除行只是变异的一个示例。我可以对上述代码应用许多其他的变异。事实上,如果我将continue改为break,测试仍然通过。浏览我可以想到的每个变异是件令人厌烦的事情,所以我希望有一个自动化工具来为我完成这个过程。进入mutmut

使用 mutmut 进行变异测试

mutmut是一个 Python 工具,用于为您进行变异测试。它附带了一组预编程的变异,可以应用于您的代码库,例如:

  • 查找整数字面量并将其加 1 以捕捉偏移一个错误

  • 通过在字符串字面量中插入文本来更改字符串字面量

  • 交换breakcontinue

  • 交换TrueFalse

  • 否定表达式,例如将x is None转换为x is not None

  • 更改运算符(特别是从///

这绝不是一个全面的列表;mutmut有很多巧妙的方式来变异你的代码。它通过进行离散变异,运行你的测试套件,然后显示哪些变异在测试过程中存活下来。

要开始使用,您需要安装mutmut

pip install mutmut

然后,你运行mutmut对所有测试进行测试(警告,这可能需要一些时间)。你可以使用以下命令在我上面的代码片段上运行mutmut

mutmut run --paths-to-mutate code_examples/chapter24
提示

对于长时间运行的测试和大型代码库,你可能需要将mutmut运行分开,因为它确实需要一些时间。然而,mutmut足够智能,可以将其进度保存到名为*.mutmut-cache*的文件夹中,因此如果中途退出,未来的运行将从相同的点继续执行。

mutmut在运行时会显示一些统计信息,包括存活的变异数量、被消除的变异数量以及哪些测试耗时过长(例如意外引入无限循环)。

执行完成后,你可以使用mutmut results查看结果。在我的代码片段中,mutmut识别出三个存活的变异。它将变异列为数字 ID,你可以使用mutmut show <id>命令显示具体的变异。

这是我代码片段中存活的三个变异:

mutmut show 32
--- code_examples/chapter24/calorie_tracker.py
+++ code_examples/chapter24/calorie_tracker.py
@@ -26,7 +26,7 @@
 def check_meals_for_calorie_overage(meals: list[Meal], target: int):
     for meal in meals:
         target -= meal.calories
-        if target < 0:
+        if target <= 0:
             display_warning(meal, WarningType.OVER_CALORIE_LIMIT)
             continue
         display_checkmark(meal)

mutmut show 33
--- code_examples/chapter24/calorie_tracker.py
+++ code_examples/chapter24/calorie_tracker.py
@@ -26,7 +26,7 @@
 def check_meals_for_calorie_overage(meals: list[Meal], target: int):
     for meal in meals:
         target -= meal.calories
-        if target < 0:
+        if target < 1:
             display_warning(meal, WarningType.OVER_CALORIE_LIMIT)
             continue
         display_checkmark(meal)

mutmut show 34
--- code_examples/chapter24/calorie_tracker.py
+++ code_examples/chapter24/calorie_tracker.py
@@ -28,6 +28,6 @@
         target -= meal.calories
         if target < 0:
             display_warning(meal, WarningType.OVER_CALORIE_LIMIT)
-            continue
+            break
         display_checkmark(meal)

在每个示例中,mutmut差异符号显示结果,这是一种表示文件从一个变更集到另一个变更集变化的方法。在这种情况下,任何以减号“-”开头的行表示由mutmut更改的行;以加号“+”开头的行是mutmut进行的更改;这些就是你的变异。

这些情况中的每一个都是我测试中的潜在漏洞。通过将<=更改为<,我发现我没有覆盖当餐点的卡路里恰好等于目标时的情况。通过将0更改为1,我发现我在输入域的边界上没有覆盖(参见第二十三章讨论的边界值分析)。通过将continue更改为break,我提前终止循环,可能会错过标记后续餐点为 OK 的机会。

修复变异

一旦确定了变异,开始修复它们。最好的方法之一是将变异应用到你磁盘上的文件中。在我之前的示例中,我的变异有 32、33 和 34。我可以这样将它们应用到我的代码库中:

mutmut apply 32
mutmut apply 33
mutmut apply 34
警告

只对通过版本控制备份的文件执行此操作。这使得完成后还原变异变得容易,恢复原始代码。

一旦 一旦变异已应用到磁盘上,你的目标是编写一个失败的测试。例如,我可以编写以下代码:

def test_failing_mutmut():
    clear_warnings()
    meals = [Meal("Fish 'n' Chips", 1000),
             Meal("Late-Night Cookies", 300),
             Meal("Banana Split", 400)
             Meal("Tub of Cookie Dough", 1000)]

    check_meals_for_calorie_overage(meals, 1300)

    assert_no_warnings_displayed_on_meal("Fish 'n' Chips")
    assert_checkmark_on_meal("Fish 'n' Chips")
    assert_no_warnings_displayed_on_meal("Late-Night Cookies")
    assert_checkmark_on_meal("Late-Night Cookies")
    assert_meal_is_over_calories("Banana Split")
    assert_meal_is_over_calories("Tub of Cookie Dough")

即使你只应用了一个变异,你也应该看到这个测试失败。一旦你确信已经捕获了所有变异,就还原变异,并确保测试现在通过。重新运行mutmut,应该显示你已消除变异。

变异测试报告

mutmut 还提供了一种将其结果导出为 JUnit 报告格式的方法。在本书中已经看到其他工具导出为 JUnit 报告(例如第二十二章),mutmut 也不例外:

mutmut junitxml > /tmp/test.xml

正如第二十二章中提到的那样,我可以使用 junit2html 为变异测试生成一个漂亮的 HTML 报告,如图 24-1 所示。

ropy 2401

Figure 24-1. 用 junit2html 生成的 mutmut 报告示例

采用突变测试

突变测试在今天的软件开发社区中并不普遍。我认为原因有三:

  • 人们对它及其带来的好处并不了解。

  • 一个代码库的测试还不够成熟,以至于无法进行有用的突变测试。

  • 成本与价值比例太高。

本书正在积极努力改进第一个观点,但第二和第三观点确实有其道理。

如果您的代码库没有成熟的测试集,那么引入突变测试将毫无意义。这将导致信号与噪声比过高。与其试图找到所有的突变体,不如通过改进测试套件来获得更多价值。考虑在代码库中那些已经具有成熟测试套件的较小部分上运行突变测试。

突变测试确实成本高;为了使突变测试值得进行,必须最大化收益。由于多次运行测试套件,突变测试非常缓慢。将突变测试引入现有代码库也很痛苦。从一开始就在全新代码上进行远比较轻松。

但是,由于您正在阅读一本关于提高潜在复杂代码库健壮性的书,很可能您正在处理现有的代码库。如果您想引入突变测试,还是有希望的。与提高健壮性的任何方法一样,关键是选择性地在需要进行突变测试的地方进行。

寻找有大量 bug 的代码区域。查阅 bug 报告并找出表明某个代码区域有问题的趋势。还要考虑找出代码变动频繁的区域,因为这些区域最有可能引入当前测试尚未完全覆盖的变更。² 找到突变测试将多倍回报成本的代码区域。您可以使用 mutmut 选择性地在这些区域上运行突变测试。

此外,mutmut 还提供了一种只对具有行覆盖率的代码库进行突变测试的选项。如果一行代码至少被任何测试执行过一次,则该行代码具有测试套件覆盖率。还存在其他覆盖类型,如 API 覆盖率和分支覆盖率,但mutmut专注于行覆盖率。mutmut只会为您实际上已经有测试的代码生成突变体。

要生成覆盖率,请首先安装 coverage

pip install coverage

然后使用coverage命令运行你的测试套件。例如,我运行:

coverage run -m pytest code_examples/chapter24

接下来,你只需在你的mutmut运行中传递--use-coverage标志:

mutmut run --paths-to-mutate code_examples/chapter24 --use-coverage

有了这个,mutmut将忽略任何未经测试的代码,大大减少了噪音量。

覆盖率的谬论(及其他度量标准)

每当有一种衡量代码的方法出现时,都会急于将该衡量作为一个指标或目标,作为商业价值的代理预测器。然而,在软件开发历史上,出现了许多不明智的度量标准,其中没有比使用编写的代码行数作为项目进展指标更臭名昭著的了。这种想法认为,如果你能直接测量任何一个人编写的代码量,你就能直接衡量该人的生产力。不幸的是,这导致开发人员操纵系统,并试图故意编写冗长的代码。这种指标反而适得其反,因为系统变得复杂且臃肿,开发由于维护性差而放缓。

作为一个行业,我们已经超越了衡量代码行数(希望如此)。然而,一个指标消失之处,另外两个指标便会顶替其位置。我见过其他受到责难的指标出现,如修复的错误数量或编写的测试数量。表面上,这些都是应该做的好事情,但问题在于,当它们作为与商业价值相关联的指标被审视时。在每个指标中都有操纵数据的方法。你是否因为修复的错误数量而受到评判?那么,首先只需写更多的错误!

不幸的是,代码覆盖率在近年来也陷入了同样的陷阱。你会听到诸如“这段代码应该 100%覆盖每一行”或“我们应该争取 90%的分支覆盖率”的目标。这在孤立情况下值得赞扬,但未能预测商业价值。它忽略了首先为什么你要设定这些目标的原因

代码覆盖率预示着健壮性的缺失,而非许多人所认为的质量。覆盖率低的代码可能会或可能不会做你需要的每件事;你无法可靠地知道。这表明你在修改代码时会遇到挑战,因为你的系统中没有任何安全网围绕该部分建立。你绝对应该寻找覆盖率非常低的区域,并改善其周围的测试情况。

相反,这导致许多人认为高覆盖率预示着健壮性,但实际上并非如此。你可以测试每一行和每一个分支,但仍然可能维护性糟糕。测试可能会变得脆弱甚至彻底无用。

我曾在一个开始采用单元测试的代码库中工作过。我遇到了一个类似以下内容的文件:

def test_foo_can_do_something():
    foo = Thingamajiggy()
    foo.doSomething()
    assert foo is not None

def test_foo_parameterized_still_does_the_right_thing():
    foo = Thingamajiggy(y=12)
    foo.doSomethingElse(15)
    assert foo is not None

大约有 30 个这样的测试,所有的测试都有良好的名称,并遵循 AAA 模式(如在第二十一章中所见)。但它们实际上是完全无用的:它们只是确保没有抛出异常。最糟糕的是,这些测试实际上具有 100%的行覆盖率和接近 80%的分支覆盖率。这些测试检查没有抛出异常并不是件坏事;坏的是,它们实际上没有测试实际的函数,尽管表面上看起来不是这样。

变异测试是防止关于代码覆盖的错误假设的最佳防御。当你衡量你的测试的有效性时,编写无用、无意义的测试变得更加困难,同时还要消除突变体。变异测试将覆盖率测量提升为更真实的健壮性预测器。覆盖率指标仍然不能完美地代表业务价值,但变异测试确实使它们作为健壮性指标更有价值。

警告

随着变异测试变得越来越流行,我完全预料到,“消除突变体的数量”将成为取代“100%代码覆盖率”的新流行度量标准。虽然你确实希望更少的突变体存活,但要注意任何脱离上下文的单一指标目标;这个数字可以像其他所有指标一样被操控。你仍然需要一个完整的测试策略来确保代码库的健壮性。

总结思考

变异测试可能不会是你首选的工具。然而,它是你测试策略的完美补充;它找出你安全网中的漏洞并引起你的注意。通过像mutmut这样的自动化工具,你可以利用现有的测试套件轻松进行变异测试。变异测试帮助你提高测试套件的健壮性,进而帮助你编写更加健壮的代码。

这是本书的第四部分的结尾。你从学习静态分析开始,它以低成本提供早期反馈。然后你了解了测试策略以及如何问自己你希望你的测试回答什么样的问题。从那里,你学习了三种具体的测试类型:验收测试、基于属性的测试和变异测试。所有这些都是增强你现有测试策略的方式,为你的代码库建立更密集、更强大的安全网。有了强大的安全网,你将为未来的开发人员提供信心和灵活性,让他们按需发展你的系统。

这也标志着整本书的结束。这是一个漫长的旅程,你在这条路上学到了各种技巧、工具和方法。你深入研究了 Python 的类型系统,学会了如何编写自己的类型以及如何编写可扩展的 Python 代码。本书的每一部分都为你提供了构建块,将帮助你的代码库经受住时间的考验。

虽然这是书的结尾,但这并非关于 Python 鲁棒性终结的故事。我们这个相对年轻的行业仍在不断演变和转型,随着软件不断“吞噬世界”,复杂系统的健康性和可维护性变得至关重要。我预计我们对软件理解的方式将持续变化,并出现新的工具和技术来构建更好的系统。

永远不要停止学习。Python 将继续发展,添加功能并提供新工具。每一个功能都有可能改变你编写代码的方式。我无法预测 Python 或其生态系统的未来。随着 Python 引入新功能,问问自己这个功能表达了什么意图。如果他们看到了这个新功能,代码读者会假设什么?如果没有使用这个功能,他们会假设什么?了解开发者如何与你的代码库交互,并与他们产生共鸣,以创建开发愉悦的系统。

此外,将本书中的每一个观点都经过批判性思考。问问自己:提供了什么价值,以及实施它需要什么代价?我不希望读者把本书的建议完全当作箴言,并用它作为强迫代码库遵循“书中说要用”的标准的工具(任何在 90 年代或 00 年代工作过的开发者可能还记得“设计模式热”,你走 10 步都会碰到一个AbstractInterfaceFactorySingleton)。本书中的每一个概念都应被视为工具箱中的一种工具;我希望你已经学到了足够的背景知识,能够在使用它们时做出正确的决策。

最重要的是,记住你是一个在复杂系统上工作的人类,而其他人也将与你一起或在你之后继续工作。每个人都有他们自己的动机、目标和梦想。每个人都会面对自己的挑战和困难。错误会发生。我们永远无法完全消除所有错误。相反,我希望你看待这些错误,并通过从中学习推动我们的领域向前发展。我希望你能帮助未来建立在你工作基础上。尽管软件开发中存在各种变化、歧义、截止日期和范围扩展的困难,以及所有问题,我希望你能站在你的工作背后并说:“我为构建这个系统感到自豪。这是一个好系统。”

感谢你抽出时间阅读本书。现在,继续前进,编写经得起时间考验的精彩代码吧。

¹ 突变测试最初由理查德·A·德米洛(Richard A. DeMillo)、理查德·J·利普顿(Richard J. Lipton)和弗雷德·G·塞沃德(Fred G. Sayward)在“测试数据选择提示:为实践程序员提供帮助”(IEEE Computer,11(4): 34–41,1978 年 4 月)中于 1971 年首次提出。首个实现于 1980 年由蒂姆·A·布德(Tim A. Budd)完成,详见“程序测试数据的突变分析”,耶鲁大学博士论文,1980 年。

² 通过统计具有最高提交次数的文件,您可以找到代码更新频繁的代码。我在快速谷歌搜索后找到了以下 Git 一行命令:git rev-list --objects --all | awk '$2' | sort -k2 | uniq -cf1 | sort -rn | head。这是由 sehe这个 Stack Overflow 问题中提供的。