十分钟搞懂软件测试

好的测试套件不是测试数量最多的那个,而是能最快告诉你「用户还能不能做那些付钱买的事」的那个。测有风险的边界,让大多数检查保持廉价,拒绝去保护实现细节。

🎙️ 发布并录制于: ·

01测边界,别测每一行

从信息改变形态或归属的地方开始:一个 HTTP 请求变成一条命令,金额变成存起来的整数分,你的代码调用支付服务,或者一个数据库事务提交。这些接缝里的真 bug,比一个把两个字符串拼起来的私有 helper 多得多。每个边界覆盖一个正常情况、一次拒绝、一个别扭的边界值。

# pytest: observable behavior at the HTTP boundary
def test_rejects_zero_quantity(client):
    response = client.post("/orders", json={"sku": "A7", "quantity": 0})

    assert response.status_code == 422
    assert response.json()["error"] == "quantity must be at least 1"

别一上手就去测 getter、框架接线或者语言本身的行为。如果删掉某个测试之后,你并不会更担心哪个像样的退化,那它大概只是库存,不是保护。

02用测试金字塔,但别把它当教条

金字塔背后有用的那个想法是经济学:廉价且确定的检查,数量应该多于又慢又宽的检查。它不是配额。一个解析库可能值得上千个 unit test,几乎不需要浏览器测试。而一条下单流程值得几条真实的浏览器路径,因为浏览器、API、数据库和支付交接本身就是产品。

# A practical portfolio, not a mandated ratio
unit          many   milliseconds   pure rules and transformations
integration   enough seconds        database, queue, filesystem, adapters
end-to-end    few    minutes        revenue and account-critical journeys
我的原则

别去 mock 一个你正需要对它有信心的系统。如果 PostgreSQL 的语法、事务行为或者约束很重要,那就在 integration test 里真的跑一个 PostgreSQL。end-to-end 测试要少,是因为它失败之后定位成本很高,不是因为某张会议幻灯片上画了个三角形。

03Arrange、Act、Assert:只留一个失败理由

Arrange 造出最小的、有意义的那个世界。Act 执行一个行为。Assert 检查真正重要的那个结果。这个模式的价值在于,读的人几秒钟就能看出刺激和后果。它并不要求你写注释,也不要求只能有一个断言。围绕同一张返回的发票做几个断言,仍然只是一个失败理由。

def test_applies_member_discount():
    # Arrange
    cart = Cart(items=[Item(price_cents=2500)], member=True)

    # Act
    total = cart.total()

    # Assert
    assert total.subtotal_cents == 2500
    assert total.discount_cents == 250
    assert total.due_cents == 2250
真实失败:两个值都要读
AssertionError: assert 2249 == 2250
 +  where 2249 = Total(...).due_cents

第一步:单独重跑这一个测试,证明它是稳定的。第二步:看第一处不一样的值,这里是 2249 和 2250。第三步:追一下四舍五入是在哪儿发生的。第四步:去修生产代码里的取整规则,或者纠正预期的业务规则;绝不要为了让构建变绿,就把断言里的数字加一。

04断言行为;用最小的诚实 double

一个测试应该能活过任何保持行为不变的重构。断言收据、落库的行、发出的事件,或者返回的错误。别去断言私有方法的调用顺序和每一次内部调用。对协作者来说,stub 返回准备好的数据,fake 实现一个能跑的小版本,spy 记录一次交互。只有当这次交互本身就是契约时才用 mock 期望,比如「必须只发出一次退款请求」。

class FakeGateway:
    def __init__(self): self.charges = []
    def charge(self, cents):
        self.charges.append(cents)
        return {"id": "ch_test_1", "status": "paid"}

def test_checkout_charges_the_final_total():
    gateway = FakeGateway()
    receipt = checkout(Cart.totaling(4200), gateway)
    assert receipt.payment_id == "ch_test_1"
    assert gateway.charges == [4200]

一个假的支付网关很适合领域测试,但它证明不了你真实的 adapter 有没有发出服务商要求的那些请求头。给 adapter 配一个 contract test 或者 sandbox 集成测试。test double 是拿放弃真实性换来的速度;那就把你放弃的是哪一份真实性说清楚。

05把异步和时间攥在手里

sleep 不是同步。一个等 500 毫秒的测试,是在期望 worker 能及时干完;负载一高,有时候就干不完。要么 await 那个任务,要么带一个截止时间去轮询持久状态,要么暴露一个完成信号。注入一个 clock,而不是冻结全局时间,然后显式地把它往前拨。

// Jest: await the promise and control the clock
test("expires a reset token after 15 minutes", async () => {
  jest.useFakeTimers();
  jest.setSystemTime(new Date("2026-07-25T10:00:00Z"));
  const token = await issueResetToken("user_7");
  jest.setSystemTime(new Date("2026-07-25T10:16:00Z"));
  await expect(redeem(token)).rejects.toThrow("token expired");
  jest.useRealTimers();
});
真实失败:Jest 没有退出
Jest did not exit one second after the test run has completed.
This usually means that there are asynchronous operations that weren't stopped.

第一步:加上 --detectOpenHandles 重跑。第二步:看它报出来的是 server、socket、timer 还是数据库连接池。第三步:afterEachafterAll 里关掉那个资源,并 await 关闭操作。第四步:把 timer 恢复成真实的。别用强制退出,它只会把泄漏的生产资源藏起来。

06用真数据库,配可执行的契约

repository 测试要跑在和生产同一个数据库引擎上。SQLite 不是小一号的 PostgreSQL;约束、JSON、加锁和事务行为都不一样。起一个隔离的数据库,跑真实的 migration,每个测试重置状态。在服务边界上,让请求和响应都按一份共享 schema 校验,这样生产方和消费方的分歧会在上线前就失败。

# Integration test: prove the database enforces the invariant
def test_email_is_unique(db):
    db.users.insert(email="[email protected]")
    with pytest.raises(UniqueViolation):
        db.users.insert(email="[email protected]")

# Consumer contract: fields actually used by this client
{ "id": "usr_12", "plan": "pro", "active": true }
真实失败:fixture not found
fixture 'db' not found
available fixtures: capfd, caplog, capsys, monkeypatch, tmp_path

第一步:pytest --fixtures -q,确认这个 fixture 确实不在。第二步:确认 conftest.py 就在这个测试目录,或者它的某个父目录里。第三步:检查提供这个 fixture 的插件有没有装上并启用。第四步:修正 fixture 的名字或作用域。别去加一个空的 db fixture,那只是把一个 setup 错误变成一个会骗人的测试。

07先诊断失败的测试,再动代码

一个红的测试可能意味着生产退化、预期写错、setup 坏了、状态被污染,或者这个测试本身就不可靠。先只跑那一个。然后读第一条有用的应用栈帧,以及完整的「期望对实际」diff。动代码之前先复现两次。如果单独跑就能过,那就去查顺序依赖和泄漏的状态。

# Narrow first; add detail only when needed
pytest tests/orders/test_discount.py::test_member_discount -vv
pytest tests/orders/test_discount.py::test_member_discount -vv --showlocals

# Then prove or reject order dependence
pytest tests/orders/test_discount.py tests/users/test_profile.py -vv

在你能解释每一行变化之前,别更新 snapshot。也别因为失败让人不舒服,就把相等判断放松成「包含」。失败信息是证据。留住它,把复现缩到最小,找出那个被改掉的假设,然后再决定到底是生产代码、测试数据,还是预期错了。

08把 flaky 测试当缺陷处理

一个 flaky 测试会训练团队忽略红色构建。把它隔离出去,能让主 pipeline 多撑一天,但必须同时开一张有归属人的修复单,并且保持可见。常见原因是共享状态、真实时钟、没有固定种子的随机数据、网络调用、无序的结果、固定端口,还有 sleep。

# Expose intermittence and preserve the seed
pytest tests/test_worker.py -x --count=100

# Bad: result order belongs to the scheduler
assert [job.id for job in completed] == [1, 2, 3]

# Good, only if the product contract says order is irrelevant
assert {job.id for job in completed} == {1, 2, 3}
修掉它,别用重试换安静

第一步:记下失败时的随机种子、worker ID、时区和测试顺序。第二步:把最小复现循环跑起来。第三步:一次只去掉一个不受控的输入。第四步:用反复运行来证明修好了。自动重试可以用来收集证据,但一次重试通过之后,构建仍然要被标成不稳定。

09Coverage 能找出窟窿,证明不了质量

行 coverage 回答的是这一行有没有跑过,不是有没有人检查过结果。一个测试可以走遍每个分支却什么都不断言。用 coverage 去发现没被碰过的风险,特别是错误处理和鉴权。判断密集的代码优先看分支 coverage。一个不高但从不下降的阈值是有用的;追百分之百通常只是给琐碎代码买来一堆脆弱测试。

def can_refund(order, actor):
    if not actor.is_staff:        # authorization branch
        return False
    if order.age_days > 30:  # policy branch
        return False
    return order.status == "paid"

# Valuable cases: non-staff, too old, wrong status, allowed.
# Four lines of code can encode four different business risks.

按后果去审没覆盖的代码。一个没覆盖的日志格式化函数,跟一个没覆盖的权限拒绝,完全不是一回事。变异测试能揭出那些「行为变了却毫无反应」的断言,但先用在核心规则上;它是诊断工具,不是又一个虚荣分数。

10软件测试速查表

把这条决策路径放在测试运行器旁边。

# choose the test
pure rule → unit test
SQL / transaction / constraint → real database integration test
third-party request shape → adapter contract test
critical user journey → a small end-to-end test

# write the test
Arrange the smallest world → Act once → Assert observable behavior
normal case + refusal + awkward boundary

# when it fails
run it alone → read full diff → reproduce twice → inspect first useful frame
passes alone? check shared state, order, clock, random seed, ports

# reject these shortcuts
sleep for synchronization · force-exit leaked handles · mock the database
blind snapshot updates · retries that hide flakes · 100% coverage worship

我的硬底线是:测试存在是为了让改动更安全,不是为了让 dashboard 更绿。把只在复述实现的脆弱测试删掉。给上周二坏掉的那个行为补一个锋利的测试。凡是某个基础设施的行为属于你对外的承诺,就用真的去跑它。当一次失败说不出是哪条承诺被打破时,先重写这个测试,再去加下一个。

Tell me what missed

A correction is more useful than a compliment. This goes straight to the person who writes SwiftGrasp.

Was this page useful?
0/1000

Please do not include passwords, private keys, or personal information.