一个数据库模块配 714 行测试:我的测试策略是怎么长出来的
先看数字:713 行测试,护住 436 行实现
这个数据库模块本身不大:database.py 436 行,负责本地 SQLite 的全部读写——食材搜索、喂食日志、宠物档案、导出导入。但它配套的 tests/test_database.py 有 713 行、80 个用例,测试密度约 1.64 行测试 / 1 行实现。
这个比例不是"为了凑覆盖率"堆出来的。我读完全部用例后发现,这套测试的成长逻辑很清楚:每个功能区先有实现,测试跟着功能长出来;有价值的不是用例数量,而是那几类"钉死历史"的用例。
分组:测试类与实现函数区一一对应
80 个用例不是散落的函数,而是 9 个测试类,每个类对准 database.py 的一个函数区:
| 测试类 | 覆盖的实现区 |
|---|---|
| TestInitDB | 建表 + 种子数据 |
| TestSearchIngredients | 食材搜索 |
| TestAddIngredient | 新增食材 |
| TestFeedingLog | 喂食日志 |
| TestGetLastFeedingDate | 冷却时间(最后喂食日期) |
| TestPetProfiles | 宠物档案 CRUD |
| TestAgeStage | 年龄阶段(测的是另一个模块 age_guidance.py) |
| TestExportImport | 导出 / 导入 |
这种"按功能分组"的好处是定位快:功能改了,先跑对应测试类;测试红了,能立刻判断是哪个函数区的问题。它比"按测试类型分组"(单元/集成/冒烟)更适合中小项目——因为测试的读者是改代码的自己,不是审计团。
隔离:用换库路径代替 mock
很多项目的测试隔离是 mock 掉数据库连接,让测试"不碰真库"。这里反其道而行:
@pytest.fixture
def tmp_db(tmp_path, monkeypatch):
"""Create a fresh temp database for each test and point database module at it."""
db_file = tmp_path / "test_pet_food.db"
monkeypatch.setattr(db, "DB_PATH", str(db_file))
db.init_db(str(db_file))
return str(db_file)
fixture 干了两件事:把 database.DB_PATH 指向临时文件,再真的执行 init_db 建表灌种子。测试跑的是 100% 真实代码路径——真实建表、真实 SQL、真实种子数据,而不是"模拟出来的数据库行为"。
这看起来"更重"(每个测试都要建一次库),但收益很大:
- 不需要维护双份 SQL 逻辑(真实现一套、mock 一套),不会出现"mock 里通过、真库里崩掉";
- 种子数据本身被测到了——
TestInitDB顺带验证了 130 条种子数据能正常入库; - fixture 对所有测试一视同仁,80 个用例共享同一套隔离机制,没有特例。
代价是每个用例多花几十毫秒建库。对 SQLite 这种文件型数据库,这笔账很划算。
最有价值的部分:三类"回归钉"
测试真正的价值不在"测新功能",在"防止旧 bug 回来"。这套用例里有三个典型的回归钉。
1. 直接钉死历史 bug 的用例
def test_pet_name_filter_isolates_pets(self, tmp_db):
"""CRITICAL BUG TEST: pet_name should isolate each pet's cooldown."""
...
assert db.get_last_feeding_date(ing_id) == "2025-01-25"
assert db.get_last_feeding_date(ing_id, pet_name="旺财") == "2025-01-10"
assert db.get_last_feeding_date(ing_id, pet_name="咪咪") == "2025-01-25"
docstring 里写着 CRITICAL BUG TEST。这是一个真实发生过的问题:多宠物家庭里,喂食冷却时间按不按宠物名隔离?如果不隔离,给"旺财"喂了鸡胸肉,系统会误以为"咪咪"也在冷却期。这个用例把修复后的行为(按 pet_name 隔离)焊死,任何人改动这个函数,只要破坏隔离,测试立刻翻红。
"记录 bug 时顺手写个用例"是性价比最高的测试投资——它覆盖的往往是最容易回归的边界逻辑。
2. 幂等性测试
def test_idempotent(self, tmp_db):
# 重复调用 init_db 不重复灌 130 条种子数据
初始化函数最容易被"包装"时改坏(比如有人想给 init_db 加个"重建"参数,顺手把幂等性破坏了)。幂等性用例防止的就是这类无意识的回归。
3. 端到端往返测试
导出 → 导入到全新库 → 断言数据还原(食材不重复、日志和宠物各就各位)。这是把"备份恢复"这条路从输入到输出完整走一遍,比单独测 export、单独测 import 更有说服力。
契约测试:把业务规则变成可执行的断言
最见功力的是用测试表达业务契约。典型如排序契约:
def test_unsafe_sorted_first(self, tmp_db):
rows = db.search_ingredients("葡")
unsafe_rows = [r for r in rows if r["safety"] == "unsafe"]
if unsafe_rows:
assert rows[0]["safety"] == "unsafe"
"搜索结果里危险食材必须排最前"——这是 UI 层的安全需求(用户第一眼看到最危险的),但在实现里只是排序逻辑。没有测试,这个排序随时可能被"顺手"改掉。写成用例后,契约变成代码的一部分。
还有逐物种的毒物测试(鹦鹉吃牛油果、蜥蜴碰萤火虫、仓鼠碰牛油果都标 unsafe)——每个物种单独一条,而不是"抽一个物种代表一下"。为什么?因为危险食材名单是"错一个就可能出安全事故"的数据,宁可啰嗦,不可漏测。
诚实的空白:没有并发测试
这套用例没测并发/线程场景(全文件找不到 thread/concurrent 相关测试)。对一个单用户本地工具这可以接受——SQLite 单写、Streamlit 单进程,并发风险低。但作为读者要知道这个边界:测试的完备性永远要对照项目的真实风险,而不是对照"测试理论大全"。如果哪天这工具要支持多用户并发写库,第一件事就是补并发用例。
总结:测试策略是怎么"长"出来的
复盘下来,这套策略的成长路径是:
- 实现一个函数区 → 补一类测试,不用"先写全套测试框架"再动手;
- 隔离用真实路径(换库文件)而不是 mock,测试可信度优先;
- 每修一个 bug,留一个回归钉,把"曾经错过"变成永久防线;
- 业务契约写成可执行断言,让安全规则不依赖人的记忆;
- 承认空白,测试边界跟着项目风险走,不为了好看而堆用例。
713 行不是负担,是这台"食材知识库"最值钱的部分——它让改代码的人敢改、让数据的安全规则不再靠嘴说。
- 点赞
- 收藏
- 关注作者
评论(0)