AI 技术博客
返回首页
Python 基础 34 分钟阅读

单元测试入门:unittest 与测试用例组织

Python单元测试unittest测试

引言

这是第九章「综合项目:命令行工具」的第 7 篇。前六篇,我们的命令行工具已经备齐三大件:第 1 篇用 if __name__ == '__main__' 搭起工程骨架,第 2、3 篇用 argparse 把参数解析做得专业(位置参数、类型转换、子命令、自动 help),第 4、5 篇用 logging 让程序”会说话”,第 6 篇用退出码与 JSON 输出让程序”能被机器调用”。到这里,“功能”层面已经齐全——但工具要交给别人使用,“功能正确”还远远不够:改了一行代码会不会把旧功能改坏?空字符串、不存在的编号这类边界输入真的有处理吗?这些问题靠人工点几遍命令回答不了,也回答不彻底。本篇引入软件工程里最基础也最划算的质量手段——单元测试(unit testing):用代码自动验证代码。

前置知识:函数与返回值(第四章)、正则与字符串处理(第五章)、第六章第 8 篇词频统计与第 9 篇排序聚合(本篇的测试对象)、第七章的文件读写与异常捕获。下一篇(第 8 篇)会把本篇知识直接用在一个完整的待办事项命令行工具上——它的存储层每个方法都要有测试,动手能力到此才算闭环。

概念与原理

单元:被测的最小行为单元

单元测试(unit test)的定义可以拆成两个词:单元(unit)指程序里最小的可测部分,Python 里通常是函数或类的方法;测试指把”验证”这件事本身程序化——写一段代码,调用被测函数、给出输入、断言输出,然后交给机器执行。机器不会累、不会漏、不会凭印象说”看起来对”。单元测试的三个价值:回归防护(regression,改动后重新跑一遍,改坏的行为立刻现形)、重构安全网(敢放心改内部实现,因为行为被测试钉死)、可执行文档(测试本身就在描述”什么输入对应什么输出”的契约)。

unittest 是 Python 标准库自带的测试框架,设计脱胎于 Java 的 JUnit(xUnit 家族的重要成员)。它的组织方式:一组相关的断言放进测试用例类(继承 unittest.TestCase 的类),类里每个以 test 开头的方法是一个独立的测试方法(test case)。框架自动发现并执行这些方法,汇总成一份报告:通过、失败(断言不成立)、错误(测试代码本身抛异常)分别统计。

Given-When-Then:把测试写成三幕剧

初学者写测试最常见的毛病是”想到哪写到哪”。Given-When-Then(给定—当—断言)是给测试方法定型的组织法,源自行为驱动开发(BDD)的叙事结构,把每个测试方法按时序分成三段:**Given(给定)**准备前置条件——输入数据、初始状态;**When(当)**执行被测动作——调用被测函数,最好只有一行;**Then(断言)**验证结果——检查返回值、副作用或异常。三段合起来,一个测试就是一个”输入 → 动作 → 输出”的完整故事。好处有三:目的清晰(读者一眼看到被测对象)、结构统一、失败时知道该查哪一段。本篇所有测试方法都按此结构撰写,注释里标出三段边界。

断言方法:unittest 的检查器清单

TestCase 的断言方法有几十个,本篇用到的六个覆盖最常见的检查需求:

断言方法检查内容典型场景
assertEqual(a, b)a 与 b 相等函数返回值
assertTrue(x) / assertFalse(x)x 为真 / 假条件与布尔结果
assertIn(item, container)item 是容器的成员结果列表包含某元素
assertRaises(Exc, func, *args)调用 func 抛出 Exc非法输入的报错行为
assertAlmostEqual(a, b)a、b 在容差内近似相等浮点运算结果

为什么需要这么多断言?因为失败信息的可读性:assertEqual(a, b) 失败时报告”0.3 != 0.5”直接给出两边的值;assertTrue(a == b) 失败时只会说”断言为假”,你还得回头猜是哪两个值。断言选得越精确,排查成本越低。assertRaises 尤其值得注意:上下文管理器写法断言”这段代码必须抛出指定异常”,异常对象还能用 as 接收做二次检查——第七章的 JSONDecodeError 就适合这样测。

浮点为什么不能 assertEqual

0.1 + 0.2 == 0.3 在几乎所有编程语言里都是 False。原因在二进制表示:0.1 与 0.2 在二进制小数里都是无限循环小数(就像 1/3 在十进制里),float64 只能存下逼近值,0.1 + 0.2 的真实结果是 0.30000000000000004。这不是 Python 的 bug,是 IEEE 754 浮点标准的固有代价。因此对浮点结果用 assertEqual 必败;assertAlmostEqual 比较的是两数之差是否小到可接受:默认按 round(a - b, 7) == 0 判断(小数点后 7 位舍入后相等),也可显式传 places(小数位)或 delta(绝对容差)。数值计算里”相等”必须翻译成”足够接近”。

测试行为,而不是实现细节

测试要守护的是”这个单元对外承诺的行为”,而不是”它内部怎么写”。工程道理很实在:实现细节是变动最频繁的东西——换算法、改数据结构、做提速,而对外行为是稳定契约。如果断言了内部中间变量、局部变量名、私有方法,哪怕行为完全正确,一改实现测试就红,测试成了重构的阻力。选择断言对象时问自己:实现重写但行为不变,这条断言还成立吗?成立才是好断言。后面为词频统计写测试时会反复体现这条原则。

测试金字塔:测试也要分层次

工业界的测试按成本分层,最著名的模型是测试金字塔(test pyramid):底层是海量的单元测试——针对单个函数,速度毫秒级、失败定位到一行;中间层是少数集成测试(integration test),验证多单元协作(如存储层与文件系统的配合);顶层是占比最少的端到端测试(end-to-end test),模拟真实用户跑完整流程。金字塔的形状是经济学决定的:越底层越便宜(好写、快跑、好修),越顶层越贵(依赖环境、运行慢、失败难定位),所以数量逐层递减。单元测试就是金字塔的底座,本篇先把它打牢。

操作与实现

第 1 步:第一个测试——Given-When-Then 落地

先写一个最小的完整例子。业务函数 discount 计算折扣价,测试方法内部严格按 GWT 三段组织:

# test_discount.py
import unittest

def discount(price, rate):
    """按折扣率打折:rate 是 0~1 的小数,结果保留两位。"""
    return round(price * (1 - rate), 2)

class TestDiscount(unittest.TestCase):
    def test_half_price(self):
        # Given:一件 100 元的商品,打八折(折扣率 0.2)
        price, rate = 100, 0.2
        # When:计算折后价
        result = discount(price, rate)
        # Then:折后价应为 80 元
        self.assertEqual(result, 80.0)

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

把文件保存为 test_discount.py 并运行 python test_discount.py -v(-v 即 verbose,显示每个测试方法的明细):

test_half_price (__main__.TestDiscount.test_half_price) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

unittest.main() 负责把当前文件里的测试收集起来执行:发现 TestDiscount 类、执行其中 test 开头的方法、打印汇总。文件名的 test_ 前缀与类名的 Test 前缀不是装饰,而是约定——框架按名字识别测试,这个约定在后面的”发现模式”里还会再次出现。

第 2 步:断言方法全览

用一个测试类把六个断言一次看清。业务函数 parse_json 解析文本,非法输入抛 json.JSONDecodeError(第七章讲过它是 ValueError 的子类):

# test_assertions.py
import json
import unittest

def parse_json(text):
    """解析 JSON 文本,格式非法时抛出 json.JSONDecodeError。"""
    return json.loads(text)

class TestAssertions(unittest.TestCase):
    def test_assertEqual_值相等(self):
        self.assertEqual('python'.upper(), 'PYTHON')

    def test_assertTrue_条件为真(self):
        self.assertTrue(len(['a', 'b']) == 2)

    def test_assertIn_成员包含(self):
        self.assertIn('code', ['code', 'text', 'project'])

    def test_assertRaises_上下文物(self):
        with self.assertRaises(json.JSONDecodeError):
            parse_json('{bad json')

    def test_assertRaises_函数式(self):
        self.assertRaises(json.JSONDecodeError, parse_json, '{bad json')

    def test_assertAlmostEqual_浮点(self):
        self.assertAlmostEqual(0.1 + 0.2, 0.3)
        self.assertAlmostEqual(1.2345, 1.23446, places=4)

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

运行结果(留意:方法按名字的字典序执行,所以 assertAlmostEqual 的用例排在最前):

test_assertAlmostEqual_浮点 (__main__.TestAssertions.test_assertAlmostEqual_浮点) ... ok
test_assertEqual_值相等 (__main__.TestAssertions.test_assertEqual_值相等) ... ok
test_assertIn_成员包含 (__main__.TestAssertions.test_assertIn_成员包含) ... ok
test_assertRaises_上下文物 (__main__.TestAssertions.test_assertRaises_上下文物) ... ok
test_assertRaises_函数式 (__main__.TestAssertions.test_assertRaises_函数式) ... ok
test_assertTrue_条件为真 (__main__.TestAssertions.test_assertTrue_条件为真) ... ok

----------------------------------------------------------------------
Ran 6 tests in 0.000s

OK

assertRaises 的两种写法等价:上下文管理器版能继续用异常对象做二次断言;函数式版适合”一行搞定”。places=4 的意思是按小数点后 4 位舍入比较,1.2345 与 1.23446 之差在第 5 位,所以判为相等。

第 3 步:浮点断言与一次真实的失败

看一次真实的失败,比背结论有用。把浮点比较写成两个测试,一个用 assertEqual、一个用 assertAlmostEqual:

# test_float_fail.py
import unittest

class TestFloatCompare(unittest.TestCase):
    def test_assertEqual_直接比浮点(self):
        self.assertEqual(0.1 + 0.2, 0.3)

    def test_assertAlmostEqual_容差比浮点(self):
        self.assertAlmostEqual(0.1 + 0.2, 0.3)

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

运行输出(Traceback 中的绝对路径取决于你保存文件的位置,其余逐字一致):

.F
======================================================================
FAIL: test_assertEqual_直接比浮点 (__main__.TestFloatCompare.test_assertEqual_直接比浮点)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\AI博客\blog-app\_tmp_verify\test_float_fail.py", line 6, in test_assertEqual_直接比浮点
    self.assertEqual(0.1 + 0.2, 0.3)
AssertionError: 0.30000000000000004 != 0.3

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)

第一行 .F 是结果进度条:. 表示一个测试通过,F 表示失败(是 E 则表示测试代码本身抛异常)。接下来是失败详情:哪个类、哪个方法、哪一行、断言的两边各自的值——0.30000000000000004 != 0.3 一目了然。末尾 FAILED (failures=1) 汇总。这就是机器可读的失败报告,也是单元测试”定位精准”的体现。

第 4 步:setUp/tearDown:每个测试独立的环境

测试需要临时文件、临时数据时,不能在测试方法里随手创建而不管清理——一个测试留下的脏文件可能影响下一个测试。setUp 与 tearDown 是框架给每个测试方法配的”开场”与”收场”:每个测试方法执行前都会调用 setUp,执行后(无论成败)都会调用 tearDown。也就是说,两个测试方法之间新建实例、互不共享状态。为第七章”报告落盘”逻辑写的测试示范:

# test_report.py
import tempfile
import unittest
from pathlib import Path

def write_report(lines, path):
    """把行列表写入 path,返回写入的行数(第七章报告落盘逻辑的简化版)。"""
    Path(path).write_text('\n'.join(lines), encoding='utf-8')
    return len(lines)

class TestWriteReport(unittest.TestCase):
    def setUp(self):
        # 每个测试之前:建立一个独立的临时目录
        self.tmp = Path(tempfile.mkdtemp())
        self.report = self.tmp / 'report.txt'

    def tearDown(self):
        # 每个测试之后:清理临时目录,互不污染
        for p in self.tmp.iterdir():
            p.unlink()
        self.tmp.rmdir()

    def test_行数正确(self):
        n = write_report(['第一行', '第二行', '第三行'], self.report)
        self.assertEqual(n, 3)

    def test_内容可回读(self):
        write_report(['词频统计报告', 'code 11'], self.report)
        self.assertEqual(self.report.read_text(encoding='utf-8'),
                         '词频统计报告\ncode 11')

    def test_文件真实落盘(self):
        write_report(['hello'], self.report)
        self.assertTrue(self.report.is_file())

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

运行:

test_内容可回读 (__main__.TestWriteReport.test_内容可回读) ... ok
test_文件真实落盘 (__main__.TestWriteReport.test_文件真实落盘) ... ok
test_行数正确 (__main__.TestWriteReport.test_行数正确) ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.017s

OK

三个测试共用同一个 self.report 路径,却绝不会互相干扰——因为每个方法跑之前 setUp 都新建了一个全新的临时目录。这组叫”前置条件准备与清理”的钩子,unittest 术语里叫 fixture(测试装置)。

第 5 步:为第六章的词频与排序逻辑写测试

现在把知识用在真实代码上。回顾第六章第 8 篇的词频统计实战与第 9 篇的排序聚合实战:管线逻辑内嵌在脚本主流程里,一个脚本从头做到尾——读文件、清洗、计数、过滤、排序、落盘。这样的脚本没法测:没有函数边界,输入输出纠缠在 I/O 里,测试无从下手。工程解法叫提炼(extract):把纯计算部分抽成无副作用的纯函数,I/O 留在外层。下面是从两篇实战中提炼出的业务函数库,保存为 word_stats.py(本块无输出,由后续测试驱动):

# word_stats.py:从第六章词频统计与排序实战中提炼的业务函数
import re
from collections import Counter

STOPWORDS = {
    'the', 'a', 'an', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'for',
    'with', 'is', 'are', 'was', 'were', 'be', 'been', 'it', 'its', 'this',
    'that', 'as', 'at', 'by', 'from', 'has', 'have', 'had', 'do', 'does',
    'did', 'not', 'so', 'than', 'too', 'very', 'can', 'will', 'you', 'your',
    'we', 'our', 'i', 'he', 'she', 'they', 'my', 'me',
}

def clean_words(text):
    """清洗文本:小写化 + 去标点切词(第六章第 8 篇的正则方案)。"""
    return re.findall(r"[a-z']+", text.lower())

def top_words(text, n=5, stopwords=STOPWORDS):
    """统计词频,过滤停用词,返回频次最高的 n 个 (词, 次数)。"""
    counts = Counter(clean_words(text))
    filtered = Counter({w: c for w, c in counts.items() if w not in stopwords})
    return filtered.most_common(n)

def rank_by_class_score(students):
    """第六章第 9 篇的多级排序:班级升序,班级内总分降序。"""
    return sorted(students, key=lambda s: (s['cls'], -s['total']))

注意观察三条函数的设计:它们不碰文件、不打印、不改全局——输入进来、结果返回,全部信息都藏在返回值里。这正是”可测性”的来源。测试文件 test_word_stats.py 里,断言全部指向观察得到的行为:

# test_word_stats.py
import unittest
from word_stats import clean_words, top_words, rank_by_class_score

SAMPLE = "Python code is easy to read. Python code code is everywhere!"

class TestCleanWords(unittest.TestCase):
    def test_小写化并去标点(self):
        words = clean_words("Python, Code! code?")
        self.assertEqual(words, ['python', 'code', 'code'])

    def test_保留撇号(self):
        self.assertIn("don't", clean_words("don't stop, ever!"))

class TestTopWords(unittest.TestCase):
    def test_停用词被过滤(self):
        top = top_words("the cat and the dog and the bird")
        self.assertEqual(top, [('cat', 1), ('dog', 1), ('bird', 1)])

    def test_空文本返回空列表(self):
        self.assertEqual(top_words(""), [])

    def test_样例文本最高频词(self):
        top = top_words(SAMPLE)
        self.assertEqual(top[0], ('code', 3))
        self.assertGreater(top[0][1], 1)

class TestRankByClassScore(unittest.TestCase):
    def test_班级内总分降序(self):
        students = [
            {'name': '张三', 'cls': '一班', 'total': 250},
            {'name': '李四', 'cls': '二班', 'total': 270},
            {'name': '王五', 'cls': '一班', 'total': 265},
        ]
        ranked = rank_by_class_score(students)
        self.assertEqual([s['name'] for s in ranked], ['王五', '张三', '李四'])

    def test_低分也不能越过班级边界(self):
        students = [
            {'name': '甲', 'cls': '二班', 'total': 400},
            {'name': '乙', 'cls': '一班', 'total': 1},
        ]
        ranked = rank_by_class_score(students)
        self.assertEqual(ranked[0]['cls'], '一班')

    def test_原始数据不被修改(self):
        students = [{'name': '甲', 'cls': '一班', 'total': 100}]
        rank_by_class_score(students)
        self.assertEqual(len(students), 1)

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

运行 python test_word_stats.py -v:

test_保留撇号 (__main__.TestCleanWords.test_保留撇号) ... ok
test_小写化并去标点 (__main__.TestCleanWords.test_小写化并去标点) ... ok
test_低分也不能越过班级边界 (__main__.TestRankByClassScore.test_低分也不能越过班级边界) ... ok
test_原始数据不被修改 (__main__.TestRankByClassScore.test_原始数据不被修改) ... ok
test_班级内总分降序 (__main__.TestRankByClassScore.test_班级内总分降序) ... ok
test_停用词被过滤 (__main__.TestTopWords.test_停用词被过滤) ... ok
test_样例文本最高频词 (__main__.TestTopWords.test_样例文本最高频词) ... ok
test_空文本返回空列表 (__main__.TestTopWords.test_空文本返回空列表) ... ok

----------------------------------------------------------------------
Ran 8 tests in 0.001s

OK

逐条品味几个断言的设计。test_停用词被过滤 用 assertEqual 断言整个 top 列表,因为”滤掉 the/and 后只剩三个内容词,且平票时保持插入顺序”是这条管线的完整行为;test_低分也不能越过班级边界 构造了一个极端输入——二班 400 分的学霸也必须排在一班 1 分的同学后面,钉死”多级排序的级间顺序”;test_原始数据不被修改 验证 sorted 不原地改列表、输入不被污染——第六章第 9 篇强调过的性质,用测试固化成契约。注意:没有一条断言去检查 Counter 变量的名字、lambda 的写法等实现细节。

第 6 步:三种运行方式

测试文件可以直接跑(unittest.main()),也可以用 python -m unittest 以模块方式运行,还可以让框架自动发现目录里的测试:

cd 项目目录                   # 包含 word_stats.py 与 test_word_stats.py 的目录
python -m unittest test_word_stats
python -m unittest -v test_word_stats
python -m unittest discover
........
----------------------------------------------------------------------
Ran 8 tests in 0.001s

OK
test_保留撇号 (test_word_stats.TestCleanWords.test_保留撇号) ... ok
test_小写化并去标点 (test_word_stats.TestCleanWords.test_小写化并去标点) ... ok
test_低分也不能越过班级边界 (test_word_stats.TestRankByClassScore.test_低分也不能越过班级边界) ... ok
test_原始数据不被修改 (test_word_stats.TestRankByClassScore.test_原始数据不被修改) ... ok
test_班级内总分降序 (test_word_stats.TestRankByClassScore.test_班级内总分降序) ... ok
test_停用词被过滤 (test_word_stats.TestTopWords.test_停用词被过滤) ... ok
test_样例文本最高频词 (test_word_stats.TestTopWords.test_样例文本最高频词) ... ok
test_空文本返回空列表 (test_word_stats.TestTopWords.test_空文本返回空列表) ... ok

----------------------------------------------------------------------
Ran 8 tests in 0.001s

OK
........
----------------------------------------------------------------------
Ran 8 tests in 0.001s

OK

三条命令的差别:不传 -v 时只显示进度点与统计;-v 时逐个列出方法名(注意此时类名前缀变成了模块名 test_word_stats,因为以模块方式导入);discover 是发现模式——它以当前目录为起点,按 test*.py 的命名约定递归找文件(所以测试文件必须以 test 开头),全部执行。工程惯例是把 python -m unittest discover 写进 CI(持续集成)脚本,提交代码自动跑全部测试。

第 7 步:mock——注入输入,隔离外部

单元测试的原则是”只测自己”,但被测函数往往要触碰外部世界:文件、网络、交互输入。unittest.mock 模块的 MagicMock 是用来隔离外部的法宝:它造一个”假对象”,你定义它返回什么,它记录怎么被调用,一切发生在内存里,不碰真 I/O。下面把词频管线中的”读取来源”抽象成一个无参可调用对象,用 MagicMock 注入固定输入:

# test_mock_read.py:用 MagicMock 注入"读取来源",不依赖真实文件
import unittest
from unittest.mock import MagicMock
from word_stats import top_words

def count_words_from(reader, n=3):
    """reader 是无参可调用对象,返回一段文本;复用 top_words 统计。"""
    return top_words(reader(), n)

class TestCountFromMock(unittest.TestCase):
    def test_注入固定输入(self):
        fake = MagicMock(return_value="one two one two three")
        result = count_words_from(fake)
        self.assertEqual(result, [('one', 2), ('two', 2), ('three', 1)])

    def test_调用约定被记录(self):
        fake = MagicMock(return_value="hello world")
        count_words_from(fake)
        fake.assert_called_once_with()

    def test_文本来源可为文件路径(self):
        # 模拟真实场景:reader 是包装了 Path.read_text 的函数
        fake = MagicMock(return_value="alpha beta alpha")
        result = count_words_from(fake, 2)
        self.assertEqual(result[0], ('alpha', 2))

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

运行:

test_文本来源可为文件路径 (__main__.TestCountFromMock.test_文本来源可为文件路径) ... ok
test_调用约定被记录 (__main__.TestCountFromMock.test_调用约定被记录) ... ok
test_注入固定输入 (__main__.TestCountFromMock.test_注入固定输入) ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.001s

OK

MagicMock(return_value=...) 让假对象被调用时返回指定值——这就是”注入输入”;assert_called_once_with() 断言它恰好被调用一次且带指定参数——这就是”验证输出(调用行为)“。下一步,测试真实文件场景时,可以用 MagicMock 替掉 Path.read_text 的返回值,测试就与磁盘完全解耦。mock 的完整谱系(patch、side_effect、spec)在课程 01 第 022 章《测试与质量》里展开,本篇只需记住:当被测函数的外部依赖难以在测试里真实构造时,用 MagicMock 换成可控的替身。

易错点与陷阱

1. assertEqual 比较浮点运算结果。 0.1 + 0.2 不等于 0.3 是 IEEE 754 的必然,用 assertEqual 断言必失败(本篇第 3 步实测过),还会在 CI 上间歇性红。浮点一律用 assertAlmostEqual,或先 round 成固定小数位再比。

2. 测试之间共享状态。 在测试类属性或模块级变量里存”上个测试写的数据”,会让测试结果依赖执行顺序——unittest 按方法名字典序执行,换个名字顺序就变,测试时而绿时而红。正确的隔离姿势:每个测试用 setUp 建全新数据(本篇第 4 步的临时目录),不改全局、不写真实文件。

3. 断言实现细节而非行为。 断言”内部局部变量等于 X""某方法被调用了两次”这类实现绑定的检查,在实现重构后必然误报。测试的红,应该只意味着”行为被破坏了”,而不是”代码换了写法”。

4. 忘记命名约定导致测试不被发现。 文件不以 test 开头、方法不以 test 开头、或漏写 unittest.main(),discover 与直接运行都会静默地”没测到”——输出一个 OK 但 0 个测试。刚上手时最危险的不是测试失败,而是”以为测了、其实没测”。可用 -v 运行确认方法确实被列出。

小结

本篇把单元测试从思想讲到落地:单元与测试用例类的组织方式;Given-When-Then 三段结构让测试目的清晰;assertEqual/assertTrue/assertIn/assertRaises/assertAlmostEqual 六个断言各自的分工与失败信息差异;浮点等值必败、须用容差比较;setUp/tearDown 保证每个测试独立;从第六章词频与排序脚本中提炼出可测的纯函数,用 8 条测试钉死真实行为;三种运行方式中的 discover 模式与工程 CI 挂钩;MagicMock 注入输入、隔离外部依赖。测试金字塔里,单元测试是最庞大也最便宜的底座——下一篇的待办事项工具,从存储层开始就带着测试写。

练习与思考题

  1. 为 top_words 补两个边界测试:top_words("a a b b c c", 2) 的正确返回是什么(提示:平票时保持首次出现顺序)?n=0 时应该返回什么?先写断言再运行验证。
  2. 给 discount 增加 assertRaises 测试:rate 传 1.5(超过 1)时按”应抛 ValueError”设计抛错,用给定的 GWT 结构补全测试方法,并解释为什么”非法输入必须抛异常”比”静默返回错误值”更利于调用方。
  3. 把第六章第 9 篇的”筛选:三门全部及格”提炼成函数 passed_students(students) 并写两个测试:全部及格者被保留、任一科 59 分者被剔除。提炼时遵守”不碰文件、不打印”的可测性法则。
  4. 思考题:测试金字塔中,为什么”端到端测试”数量最少而”单元测试”数量最多?结合运行速度、失败定位难度、维护成本三个维度给出理由;如果你的项目只有 3 个端到端测试没有单元测试,你认为最大的风险是什么?