Administrator
Administrator
发布于 2026-10-31 / 0 阅读
0
0

Python 从入门到初级(19):自动化测试:保护报表与文件整理规则

本篇是《Python 从入门到初级》第 19 / 20 篇。示例使用 Python 3.12 兼容语法,完整目录结构与数据见配套源码包。

修改一段代码后,最容易漏掉的是以前能工作的情况。自动化测试把输入和预期结果写下来,让重复检查由程序完成。本篇用 unittest 保护订单统计与文件整理的关键行为。

准备固定输入,执行函数,再比较结果与异常

1. 先决定哪些行为值得保护

对于订单工具,至少检查正常汇总、空表、中文与引号字段、缺少表头、重复编号、负金额、非法状态和拒绝覆盖。

对于文件整理,检查分类、预览不写入、复制后保留源文件、同名拒绝、目标不能在源目录内部。

测试的价值在于验证这些业务与操作约定,而不是仅断言“函数被调用了一次”。通过测试也不等于已经覆盖真实系统的一切条件。

2. 用临时目录隔离测试数据

下面的测试会自己创建小文件,不依赖你手工准备的 output。TemporaryDirectory 为每个测试准备独立目录,测试完成后清理。

保存为 tests/test_orders.py:

import csv
import json
import tempfile
import unittest
from pathlib import Path
from ops_tools.orders import read_orders, save_json, summarize

FIELDS = ["order_id", "category", "amount_cents", "status"]


class OrderTests(unittest.TestCase):
    def setUp(self):
        self.temp = tempfile.TemporaryDirectory()
        self.addCleanup(self.temp.cleanup)
        self.root = Path(self.temp.name)
        self.path = self.root / "orders.csv"

    def write_rows(self, rows, fields=FIELDS):
        with self.path.open("w", newline="", encoding="utf-8-sig") as handle:
            writer = csv.writer(handle)
            writer.writerow(fields)
            writer.writerows(rows)

    def test_paid_summary(self):
        self.write_rows([["A1", "图书", 5900, "paid"], ["A2", "办公", 12500, "paid"],
                         ["A3", "图书", 3900, "pending"]])
        result = summarize(read_orders(self.path))
        self.assertEqual(result["total_orders"], 3)
        self.assertEqual(result["paid_orders"], 2)
        self.assertEqual(result["paid_amount_cents"], 18400)
        self.assertEqual(result["by_category_cents"], {"办公": 12500, "图书": 5900})

    def test_empty_table(self):
        self.write_rows([])
        self.assertEqual(summarize(read_orders(self.path))["paid_amount_cents"], 0)

    def test_bom_and_quoted_category(self):
        self.write_rows([["A1", "图书,杂志", 100, "paid"]])
        self.assertEqual(read_orders(self.path)[0]["category"], "图书,杂志")

    def test_missing_header(self):
        self.write_rows([], ["order_id"])
        with self.assertRaises(ValueError):
            read_orders(self.path)

    def test_negative_amount(self):
        self.write_rows([["A1", "图书", -1, "paid"]])
        with self.assertRaises(ValueError):
            read_orders(self.path)

    def test_non_integer_amount(self):
        self.write_rows([["A1", "图书", "1.50", "paid"]])
        with self.assertRaises(ValueError):
            read_orders(self.path)

    def test_duplicate_order(self):
        self.write_rows([["A1", "图书", 100, "paid"], ["A1", "图书", 100, "paid"]])
        with self.assertRaises(ValueError):
            read_orders(self.path)

    def test_unknown_status(self):
        self.write_rows([["A1", "图书", 100, "unknown"]])
        with self.assertRaises(ValueError):
            read_orders(self.path)

    def test_empty_category(self):
        self.write_rows([["A1", " ", 100, "paid"]])
        with self.assertRaises(ValueError):
            read_orders(self.path)

    def test_missing_value(self):
        self.write_rows([["A1", "图书", 100]])
        with self.assertRaises(ValueError):
            read_orders(self.path)

    def test_extra_value(self):
        self.write_rows([["A1", "图书", 100, "paid", "extra"]])
        with self.assertRaises(ValueError):
            read_orders(self.path)

    def test_missing_file(self):
        with self.assertRaises(FileNotFoundError):
            read_orders(self.path)

    def test_save_and_refuse_overwrite(self):
        path = self.root / "out" / "report.json"
        save_json({"paid_amount_cents": 100}, path)
        self.assertEqual(json.loads(path.read_text(encoding="utf-8"))["paid_amount_cents"], 100)
        with self.assertRaises(FileExistsError):
            save_json({"paid_amount_cents": 999}, path)
        self.assertEqual(json.loads(path.read_text(encoding="utf-8"))["paid_amount_cents"], 100)


if __name__ == "__main__":
    unittest.main()

测试类继承 unittest.TestCase,名字以 test_ 开头的方法可以被发现。setUp 在每个测试前建立环境,addCleanup 注册收尾动作。

3. 结果相同和预期报错都可以测试

assertEqual 比较实际值与预期;assertRaises(ValueError) 声明某种非法输入应该被拒绝。如果函数悄悄接受了这个输入,测试反而应失败。

拒绝覆盖的测试还会再次读取原文件,确认原值没有被新值替换。它保护的是具体结果,而不只是出现了一个异常。

框架机制见 unittest 官方文档。

4. 从项目根目录执行发现

配套源码还包含 tests/test_organizer.py 的五项文件整理测试。进入同时包含 ops_tools、lesson17.py 和 tests 的示例根目录运行:

python -m unittest discover -s tests -v

完整示例预期发现 18 个测试并通过;只保留 test_orders.py 时是 13 个。数量不是质量目标,这里列出来是为了确认你运行的是哪一组测试。

如果出现模块导入错误,先检查运行目录、环境和项目结构。不要为了运行一个测试,把包复制到系统目录。

5. 故意让测试失败一次

在学习副本中,把 summarize 的已支付筛选去掉,再运行测试。预期正常汇总的结果与人工基准不符,测试会显示实际值和预期值。

恢复代码后再运行,确认重新通过。这样能验证测试真的能够捕捉一个有意义的回归,而不只是无论怎么改都显示 OK。

不要为了“保持绿色”把预期值随手改成错误实现的输出。先回到需求和人工计算判断谁应该修正。

6. 下一步还要测什么

单元测试重点覆盖函数与受控文件。CLI 还应通过进程方式检查参数错误、退出码和 dry-run;HTTP 客户端还应对接教学服务检查成功、错误状态和连接失败。

真实生产环境中的权限、网络、并发和大数据规模也需要相应验证。每一类检查都有明确范围,不必把所有测试都堆进同一个函数。

练习:增加一项“未支付订单不计入金额”的独立测试。准备一条 paid 和一条 pending,写下人工预期,再检查结果。你已经开始从“自己觉得代码对”走向“有证据说明某些行为符合约定”。


上一篇:命令行与 logging:把脚本做成工具 · 系列目录 · 下一篇:综合项目:交付一个可测试的订单报表工具


评论