跳至主要内容

如何使用 Pytest 进行单元测试

了解什么是 Pytest 及其用途,并与其他软件测试方法进行比较。
· 13分钟 阅读

使用 AI 探索

测试是软件开发中的一个庞大主题。在一款软件产品交到终端用户手中之前,它很可能已经历了多种测试,例如集成测试、系统测试和验收测试。如此严格测试的理念,是为了确保应用的行为从终端用户的角度来看与预期一致。这种测试方法称为行为驱动开发(BDD)。   

近些年,测试驱动开发(TDD)在开发者中显著升温。本文不打算深入其细枝末节,但总体思路是颠倒了传统的开发与测试流程——先编写单元测试,再实现代码直至测试通过。 

本文将聚焦单元测试,特别是如何使用一个流行的 Python 测试框架 Pytest 来完成单元测试。 

什么是单元测试?

单元测试属于自动化测试的一种——这意味着测试计划由脚本执行,而不是由人工手动完成。它是软件测试的第一层,通常以函数的形式编写,用来验证软件程序中各项功能的行为。 

The levels of software testing

软件测试的各个层级

这些测试的思想是允许开发者隔离出最小且具备逻辑意义的代码单元,并测试其是否按预期运行。换言之,单元测试验证软件程序的单个组件能否按开发者的意图工作。 

理想情况下,这些测试应该尽量小——越小越好。构建更小的测试有一个原因是效率更高,因为测试更小的单元可使测试代码执行得更快。另一个原因是,测试更小的组件可以让您更深入地了解当这些细粒度代码合并时的行为。 

我们为什么需要单元测试? 

开展单元测试的总体理由在于,开发者必须确保自己编写的代码达到质量标准,才允许其进入生产环境。当然,还有其他一些因素也使单元测试成为必要。我们来深入看看这些原因。

节省资源 

开展单元测试可帮助开发者在软件构建阶段捕获代码缺陷,防止它们深入开发生命周期。这样可以节省资源,因为开发者不必在后续开发阶段为修复缺陷付出成本——这也意味着终端用户更不可能遇到问题百出的代码。  

额外文档 

进行单元测试的另一个重要理由是,它们可为您的软件产品提供一层“活文档”。开发者只需查看单元测试,便能对整体系统有一个全局理解,因为测试详细描述了更小组件应当如何表现。 

增强信心

在编写功能时做出细微错误非常容易。然而,大多数开发者会同意,最好在代码进入生产之前就找出其断点:单元测试为开发者提供了这样的机会。 

可以公平地说,“被单元测试覆盖的代码比未覆盖的代码更可靠”。未来的代码破坏也能比无测试覆盖的代码更快被发现,从而节省时间和金钱。开发者还受益于额外的文档,从而更快理解代码库;同时还多了一份信心:如果他们在代码中犯错,将由单元测试捕获,而不是由终端用户发现。 

Python 测试框架

多年来,Python 的受欢迎程度大幅提升。伴随 Python 的发展,测试框架的数量也在增加,为您测试 Python 代码提供了大量可用工具。逐一深入每种工具超出了本文范围,但我们会简要介绍一些最常见的 Python 测试框架。 

unittest

Unittest 是一个用于单元测试的Python 内置框架。它受到 Java 语言中的JUnit 单元测试框架的启发。由于它随 Python 语言开箱即用,无需额外安装模块,多数开发者会用它来入门测试。  

Pytest

Pytest 可能是目前最广泛使用的 Python 测试框架——这意味着当您遇到问题时会有庞大的社区提供支持。它是一个开源框架,支持开发者编写简单、精炼的测试套件,同时支持单元测试、功能测试和 API 测试。

doctest

doctest 框架融合了软件工程的两个核心要素:文档与测试。该功能确保所有软件程序都有充分的文档与测试,从而按照应有的方式运行。doctest 随 Python 标准库一同提供,学习起来相当直观。 

nose2

Nose2 是 nose 的继任者,本质上是带插件的 unittest。由于与 Python 内置单元测试框架关系密切,人们常将 nose2 称为“扩展的单元测试”或“带插件的单元测试”。由于几乎是 unittest 的扩展,熟悉 unittest 的人会非常容易上手 nose2。

Testify

Testify 是一个用于单元、集成和系统测试的 Python 框架,以“旨在取代 unittest 和 nose 的框架”而知名。该框架内置了丰富的插件,如果您已熟悉 unittest,上手也比较顺滑。

Hypothesis

Hypothesis 让开发者更容易编写、也更强大地运行单元测试。由于该框架旨在支持数据科学项目,它通过为您定义的特定属性生成符合要求的输入样例,帮助发现您在编写测试时不易察觉的边界情况。

在本教程中,我们将使用 pytest。请查看下一节,了解为何您可能会选择 Pytest 而不是我们列出的其他框架。 

为什么使用 Pytest? 

除了庞大的支持社区之外,pytest 还有多项因素使其成为在 Python 中执行自动化测试套件的优秀工具。Pytest 的理念与功能旨在让软件测试的开发者体验更佳。Pytest 的创建者实现这一目标的方式之一,是大幅减少执行常见任务所需的代码量,并通过丰富的命令和插件让高级任务成为可能。 

选择 Pytest 的其他理由包括: 

易于学习 

Pytest 非常容易上手:如果您理解 Python 的 assert 关键字如何工作,那么您已经在掌握该框架的路上了。使用 pytest 的测试就是在函数名前加上“test_”或在函数名后加上“_test”的 Python 函数——当然,您也可以用类来组织多个测试。总体而言,pytest 的学习曲线比 unittest 等更平缓,因为您无需学习新的结构。 

测试过滤

您可能不希望每次执行都运行所有测试——随着测试套件的增长尤其如此。有时,您可能希望只针对某个新功能的少量测试进行隔离,以在开发过程中快速获得反馈,然后在确信一切按计划运行后再执行完整套件。Pytest 提供三种隔离测试的方式:1)基于名称的过滤,让 pytest 只运行名称匹配给定模式的测试;2)目录范围,这是默认设置,让 pytest 只运行当前目录内或其子目录中的测试;3)测试分类,允许您为测试定义类别,以便 pytest 包含或排除相应测试。 

参数化

Pytest 内置了一个名为 parametrize 的装饰器,可对测试函数的参数进行参数化。因此,如果您要测试的函数处理数据或执行通用转换,就无需编写多个类似的测试。我们将在文末进一步介绍参数化。 

我们就先说到这里,关于为何 pytest 是自动化测试套件的优选工具,理由还远不止这些。 

Pytest 与 unittest 对比

尽管上面已经阐述了诸多理由,仍可能有人因为 pytest 是第三方框架而对使用它存疑——“既然已经有内置框架,何必再安装一个?”这确实是个不错的观点,但为了让讨论更全面,我们也给出一些可供考虑的点。 

注意:如果您已经倾向于使用 pytest,请跳到下一节,我们将开始上手该框架的用法。 

更少样板代码

Unittest 要求开发者创建从 TestCase 模块派生的类,然后在类中将测试用例定义为方法。 

"""
An example test case with unittest.
See: https://docs.python.org/3/library/unittest.html
"""
import unittest

class TestStringMethods(unittest.TestCase):

    def test_upper(self):
        self.assertEqual('foo'.upper(), 'FOO')

    def test_isupper(self):
        self.assertTrue('FOO'.isupper())
        self.assertFalse('Foo'.isupper())

    def test_split(self):
        s = 'hello world'
        self.assertEqual(s.split(), ['hello', 'world'])
        # check that s.split fails when the separator is not a string
        with self.assertRaises(TypeError):
            s.split(2)

另一方面,Pytest 只要求您定义一个以“test_”开头的函数,并在其中使用 assert 条件即可。 

"""
An example test case with pytest.
See: https://docs.pytest.org/en/6.2.x/index.html
"""
# content of test_sample.py
def inc(x):
    return x + 1


def test_answer():
    assert inc(3) == 5

注意两者所需代码量的差异;unittest 需要大量样板代码,作为进行任何测试的最低要求。这意味着您很可能会多次重复编写相同的代码。相对地,Pytest 自带丰富功能,通过减少编写测试用例所需的代码,简化了这一流程。 

输出

两个框架提供的输出截然不同。以下是一次 pytest 执行的示例: 

"""
See: https://docs.pytest.org/en/6.2.x/index.html
"""
$ pytest
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-1.x.y
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collected 1 item

test_sample.py F                                                     [100%]

================================= FAILURES =================================
_______________________________ test_answer ________________________________

    def test_answer():
>       assert inc(3) == 5
E       assert 4 == 5
E        +  where 4 = inc(3)

test_sample.py:6: AssertionError
========================= short test summary info ==========================
FAILED test_sample.py::test_answer - assert 4 == 5
============================ 1 failed in 0.12s =============================

上述测试用例失败了,但请注意其对失败的分解有多详细。这让开发者更容易定位代码中的缺陷,在调试时非常有帮助。作为额外收获,它还提供了测试套件的总体状态报告,告诉我们失败的测试数量及耗时。 

我们再看一个 unittest 失败用例的示例。

import unittest

def square(n):
    return n*n

def cube(n):
    return n*n*n

class TestCase(unittest.TestCase):
    def test_square(self):
        self.asserEquals(square(4), 16)

    def test_cube(self):
        self.assertEquals(cube(4), 16)

运行脚本后,我们得到如下输出: 

---------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (errors=1)

上述输出告诉我们运行了两个测试,用时 0.001s,其中一个失败,但信息不多。总体而言,pytest 提供的信息反馈更为充分,在需要调试时尤为实用。 

总之,pytest 和 unittest 都是用于在 Python 中进行自动化测试的优秀工具。许多 Python 开发者可能更偏向 pytest,是因为它更紧凑、高效,也极易采用,并且提供了多种功能,帮助您构建高效的测试套件。 

接下来进入本文的重点部分。我们已经讨论了什么是单元测试,以及为什么 pytest 是 Python 自动化测试的理想工具。现在让我们看看如何使用它。 

Pytest 教程

让我们来看看这个我们一直提到的 Python 测试框架是如何工作的。 

第一步是安装该包,只需一条简单的 pip 命令即可完成。 

注意:pytest 的创建者建议您在开发中使用 venv,并使用 pip 安装您的应用、依赖以及 pytest 本身。 

pip install -U pytest

接下来,使用以下命令检查框架是否已安装: 

>>>> pytest --version

pytest 7.1.2

一切就绪。您现在可以开始运行一些测试了。 

创建一个简单测试

使用 Pytest 创建测试非常简单。为示范其功能,我们编写了一个名为 calcualte_age.py 的脚本。该脚本只有一个函数 get_age,负责根据用户的出生日期计算年龄。 

import datetime

def get_age(yyyy:int, mm:int, dd:int) -> int:
    dob = datetime.date(yyyy, mm, dd)
    today = datetime.date.today()
    age = round((today - dob).days / 365.25)
    return age

Pytest 会执行所有以 test_ 为前缀或以 _test 为后缀命名的 Python 文件。更具体地说,pytest 遵循以下测试发现约定[来源:文档]: 

  • 当未指定任何参数时,pytest 将从配置的 testpaths 开始收集:testpaths 是当未提供特定目录、文件或测试 ID 时,pytest 将搜索的目录列表。 
  • 随后 pytest 会递归进入目录,除非您通过设置 norecursedirs 告诉它不要递归;它会搜索以 test_*.py 开头或以 *_test.py 结尾的文件。
  • 在这些文件中,pytest 将按以下顺序收集测试项:
    • 类外以测试前缀命名的函数或方法
    • 以 Test 为前缀、且没有 __init__ 方法的测试类内,以测试前缀命名的函数或方法。 

我们没有指定任何参数,但在同一目录下创建了另一个脚本 test_calculate_age.py:因此,当目录被递归搜索时,测试会被发现。 在该脚本中,我们只有一个测试 test_get_age,用于验证我们的函数能按预期工作。 

注意:您也可以将测试放在应用之外的独立目录中;如果您有若干功能测试,或出于其他原因希望将测试代码与应用代码分离,这样做是个好主意。 

from calculate_age import get_age

def test_get_age():
    # Given.
    yyyy, mm, dd = map(int, "1996/07/11".split(""))   
    # When.
    age = get_age(yyyy, mm, dd)
    # Then.
    assert age == 26 

要执行该测试,请在命令行运行以下命令: 

py -m pytest 

The output of a successful pytest run

一次成功的 pytest 运行输出。

就是这样。 

但如果我们需要为测试提供一些数据才能通过呢?这就要用到 pytest fixtures 了。 

Pytest Fixtures 

Pytest 非常有价值的 fixtures 功能允许开发者将数据注入测试。它们本质上是一些在每个测试函数之前运行的函数,用于管理测试的状态。比如,我们有多个测试都使用相同的数据,此时就可以使用一个 fixture,通过单个函数拉取重复使用的数据。 

import pytest
from sklearn.model_selection import train_test_split
from fraud_detection_model.config.core import config
from fraud_detection_model.processing.data_manager import (

load_datasets, 

load_interim_data

) 


@pytest.fixture(scope="session")
def pipeline_inputs():
    # import the training dataset
    dataset = load_datasets(transaction=config.app_config.train_transaction, identity=config.app_config.train_identity, )
    # divide train and test
    X_train, X_test, y_train, y_test = train_test_split(

dataset[config.model_config.all_features], dataset[config.model_config.target],
test_size=config.model_config.test_size,
stratify=dataset[config.model_config.target],
random_state=config.model_config.random_state)
    return X_train, X_test, y_train, y_test

在上述代码中,我们使用 pytest.fixture 装饰器创建了一个 fixture。注意其 scope 设置为 "session",用于告知 pytest 我们希望在测试会话结束时销毁该 fixture。

注意:我们的 fixtures 存放在 conftest.py 中。

这段代码使用我们从其他模块导入的函数,将两个 csv 文件加载到内存并合并为单个数据集。随后将数据集拆分为训练集和测试集,并从函数返回。 

要在测试中使用该 fixture,我们必须将其作为参数传入测试函数。在此示例中,我们在 test_pipeline.py 测试脚本中如下使用该 fixture: 

import pandas as pd
from pandas.api.types import is_categorical_dtype, is_object_dtype
from fraud_detection_model import pipeline
from fraud_detection_model.config.core import configfrom fraud_detection_model.processing.validation import validate_inputs 


def test_pipeline_most_frequent_imputer(pipeline_inputs):
    # Given
    X_train, _, _, _ = pipeline_inputs assert all( x in X_train.loc[:, X_train.isnull().any()].columns for x in config.model_config.impute_most_freq_cols )
    # When
    X_transformed = pipeline.fraud_detection_pipe[:1].fit_transform(X_train[:50])
    # Then
    assert all( x not in X_transformed.loc[:, X_transformed.isnull().any()].columns for x in config.model_config.impute_most_freq_cols )
def test_pipeline_aggregate_categorical(pipeline_inputs):
    # Given
    X_train, _, _, _ = pipeline_inputs  

    assert X_train["R_emaildomain"].nunique() == 60
    # When
    X_transformed = pipeline.fraud_detection_pipe[:2].fit_transform(X_train[:50])
    # Then
    assert X_transformed["R_emaildomain"].nunique() == 2

不用太在意代码具体在做什么。我们最想强调的是,通过创建一个 fixture 并将其作为参数传入,用于在测试中拉取数据,我们显著减少了编写冗余代码的需求。 

不过,在某些情况下使用 fixtures 可能有些小题大做。比如,如果拉入测试的数据在每个测试用例中都必须再次处理——这几乎等同于用许多普通对象把代码塞满。尽管如此,fixtures 很可能会在您的测试套件中发挥关键作用,但何时使用或避免,仍需要实践与深思熟虑来权衡。  

Pytest 参数化

当您有多个输入相同的测试时,fixtures 很好用。那么如果您想用输入的微小变化来测试同一个函数呢?一种做法是针对不同情况编写多个测试。 

def test_eval_addition():
    assert eval("2 + 2") == 4

def test_eval_subtraction():
    assert eval("2 - 2") == 0

def test_eval_multiplication():
    assert eval("2 * 2") == 4

def test_eval_division():
    assert eval("2 / 2") == 1.0

这种方案当然可行,但并不高效:首先样板代码很多。更好的做法是使用 pytest.mark.parametrize() 装饰器,对测试函数的参数进行参数化。这样我们只需定义一个测试,pytest 就会使用我们指定的不同参数来运行测试。 

如果使用参数化,上述代码可以这样改写: 

import pytest

@pytest.mark.parametrize("test_input, expected_output", [("2+2", 4), ("2-2", 0), ("2*2", 4), ("2/2", 1.0)])
def test_eval(test_input, expected_output):
    assert eval(test_input) == expected_output

@parametrize 装饰器为 test_eval 函数定义了四组不同的测试输入与期望值——这意味着该函数将运行四次,依次使用每一组参数。 

Output from pytest for parametrize test

pytest 参数化测试的输出

本文涵盖了以下主题: 

  • 什么是单元测试
  • 我们为什么需要单元测试
  • Python 中的不同测试框架
  • 为什么 pytest 如此好用 
  • 如何使用 pytest 及其两个关键特性(fixtures 与参数化)

您现在已经具备使用 pytest 框架编写自己测试的基础。强烈建议您动手实践,以巩固本文所学。您也可以查看我们的课程Python 中的数据科学单元测试,获取更详细的讲解与示例。 

主题