一个数据库模块配 714 行测试:我的测试策略是怎么长出来的

举报
哦啦啦啦啦 发表于 2026/09/28 10:57:04 2026/09/28
【摘要】 先看数字:713 行测试,护住 436 行实现这个数据库模块本身不大:database.py 436 行,负责本地 SQLite 的全部读写——食材搜索、喂食日志、宠物档案、导出导入。但它配套的 tests/test_database.py 有 713 行、80 个用例,测试密度约 1.64 行测试 / 1 行实现。这个比例不是"为了凑覆盖率"堆出来的。我读完全部用例后发现,这套测试的成长逻...

先看数字: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、真实种子数据,而不是"模拟出来的数据库行为"。

这看起来"更重"(每个测试都要建一次库),但收益很大:

  1. 不需要维护双份 SQL 逻辑(真实现一套、mock 一套),不会出现"mock 里通过、真库里崩掉";
  2. 种子数据本身被测到了——TestInitDB 顺带验证了 130 条种子数据能正常入库;
  3. 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 单进程,并发风险低。但作为读者要知道这个边界:测试的完备性永远要对照项目的真实风险,而不是对照"测试理论大全"。如果哪天这工具要支持多用户并发写库,第一件事就是补并发用例。

总结:测试策略是怎么"长"出来的

复盘下来,这套策略的成长路径是:

  1. 实现一个函数区 → 补一类测试,不用"先写全套测试框架"再动手;
  2. 隔离用真实路径(换库文件)而不是 mock,测试可信度优先;
  3. 每修一个 bug,留一个回归钉,把"曾经错过"变成永久防线;
  4. 业务契约写成可执行断言,让安全规则不依赖人的记忆;
  5. 承认空白,测试边界跟着项目风险走,不为了好看而堆用例。

713 行不是负担,是这台"食材知识库"最值钱的部分——它让改代码的人敢改、让数据的安全规则不再靠嘴说。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。