现代编程中最有价值的习惯之一,就是 unit testing。 你把测试直接构建进自己写的代码,并在每次修改后运行它们。 type checker 验证那些可以写成 annotation 的声明。 测试验证剩下的内容:一次取款是否减少余额, 账户是否拒绝透支。 测试明确陈述代码应该做什么,并检查它是否真的如此。

测试给你一张 safety net。 有了它,你可以大胆 refactor、改变设计、清理代码。 测试也会反过来约束设计。 一个很难测试的函数,通常会主动去寻找 clock、filesystem 或 network, 而不是把这些依赖作为输入接收。

也许更重要的是, 测试可以立刻告诉你刚刚做出的修改是否造成失败。 即时反馈能节省大量时间。 如果问题直到多次修改之后才暴露, 你就很难知道究竟是哪一次修改引入了 bug。

测试驱动开发(TDD)

先写代码、让它工作, 然后打算“以后再补测试”,看起来很轻松。 但那个“以后”很少真正到来。 代码一旦工作,测试就会显得没那么重要。

一种解决办法是在写实现之前先写测试。 先写测试时,你会:

  1. 用具体、可验证的方式描述代码应该做什么, 而不是把需求放在一个会逐渐过时的独立文档中。
  2. 提供一个如何使用代码的可运行示例。
  3. 得到清晰的完成标准:测试通过,代码才算完成。

这样,testing 就变成 design tool, 而不是当代码“看起来没问题”时可以跳过的 verification step。

不过,TDD 的前提是你已经知道自己要创造什么。 它假设你对设计本身有把握, 剩下的只是实现。 但很多时候,你并不知道程序最终应该朝哪个方向发展。 你是在实验,在寻找正确方案。 当你不是单纯生产代码,而是在探索设计时, TDD 反而会造成浪费。

pytest

Python 标准库自带测试框架 unittest, 它仿照 Java 的 JUnit。 它当然能工作,但也继承了 class-based boilerplate。 更广泛的 Python 社区已经普遍采用 pytest,本书也是如此。

pytest 建立在两个让测试保持简短的想法上。 测试只是一个名称以 test_ 开头的函数。 检查只是 Python 内置的 assert statement。 无需继承 base class, 也无需记忆专门的 assertion method。 pytest 会重写 assert,所以失败时仍然能展示比较两边的内容。

本章测试下面这个 Account class;它使用 @dataclass (12 · 将数据类作为类型会解释这个 decorator):

# account.py
from dataclasses import dataclass

class InsufficientFunds(Exception):
    def __init__(self,
                 balance: float, amount: float) -> None:
        super().__init__(
            f"balance{balance} is less than{amount}")

@dataclass
class Account:
    balance: float = 0.0

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self.balance += amount

    def withdraw(self, amount: float) -> None:
        if amount > self.balance:
            raise InsufficientFunds(self.balance, amount)
        self.balance -= amount

    def add_interest(self, rate: float) -> None:
        self.balance += self.balance * rate

按照惯例,测试放在名称以 test_ 开头的文件中:

# test_account.py
import pytest
from account import Account, InsufficientFunds

def test_new_account_is_empty() -> None:
    assert Account().balance == 0.0

def test_deposit_increases_balance() -> None:
    account = Account()
    account.deposit(100)
    assert account.balance == 100

# Make three tests, replacing "bad" with each list value
@pytest.mark.parametrize("bad", [0, -1, -100])
def test_nonpositive_deposit_raises(bad: float) -> None:
    with pytest.raises(ValueError):
        Account().deposit(bad)

@pytest.fixture
def funded() -> Account:
    account = Account()
    account.deposit(100)
    return account

def test_withdraw_reduces_balance(funded: Account) -> None:
    funded.withdraw(40)
    assert funded.balance == 60

def test_overdraft_raises(funded: Account) -> None:
    with pytest.raises(InsufficientFunds):
        funded.withdraw(1000)

def test_interest_uses_approx(funded: Account) -> None:
    funded.add_interest(0.05)
    assert funded.balance == pytest.approx(105.0)