现代编程中最有价值的习惯之一,就是 unit testing。 你把测试直接构建进自己写的代码,并在每次修改后运行它们。 type checker 验证那些可以写成 annotation 的声明。 测试验证剩下的内容:一次取款是否减少余额, 账户是否拒绝透支。 测试明确陈述代码应该做什么,并检查它是否真的如此。
测试给你一张 safety net。 有了它,你可以大胆 refactor、改变设计、清理代码。 测试也会反过来约束设计。 一个很难测试的函数,通常会主动去寻找 clock、filesystem 或 network, 而不是把这些依赖作为输入接收。
也许更重要的是, 测试可以立刻告诉你刚刚做出的修改是否造成失败。 即时反馈能节省大量时间。 如果问题直到多次修改之后才暴露, 你就很难知道究竟是哪一次修改引入了 bug。
先写代码、让它工作, 然后打算“以后再补测试”,看起来很轻松。 但那个“以后”很少真正到来。 代码一旦工作,测试就会显得没那么重要。
一种解决办法是在写实现之前先写测试。 先写测试时,你会:
这样,testing 就变成 design tool, 而不是当代码“看起来没问题”时可以跳过的 verification step。
不过,TDD 的前提是你已经知道自己要创造什么。 它假设你对设计本身有把握, 剩下的只是实现。 但很多时候,你并不知道程序最终应该朝哪个方向发展。 你是在实验,在寻找正确方案。 当你不是单纯生产代码,而是在探索设计时, TDD 反而会造成浪费。
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)