3. Python单元测试框架unittest入门:TestCase、断言方法、测试固件

说到Python的单元测试,unittest是标准库自带的框架,不需要额外安装。我个人习惯把它当作嵌入式测试的"第一道防线"——在把代码烧到板子上之前,先用unittest把逻辑跑通。

你可能会问:嵌入式开发为什么要学Python的测试框架?嗯,原因很简单。我们的测试脚本、仿真控制脚本、数据处理脚本,大部分都是用Python写的。这些脚本如果出了问题,整个开发流程都会卡住。unittest就是用来保证这些脚本质量的工具。

3.1 TestCase:测试的基本单元

TestCase是unittest的核心。说白了,一个TestCase就是一个测试用例类。你写的每个测试方法,都对应一个具体的测试场景。

来看个最简单的例子:

import unittest

class TestMathOperations(unittest.TestCase):
    def test_addition(self):
        result = 1 + 1
        self.assertEqual(result, 2)

    def test_subtraction(self):
        result = 5 - 3
        self.assertEqual(result, 2)

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

运行这段代码,unittest会自动发现所有以test_开头的方法,逐个执行。我在项目中遇到过不少新手把测试方法写成def test(self):,结果只跑了一个测试——记住,方法名必须以test_开头,框架才会识别。

命名规范:测试方法名最好能描述测试场景,比如test_uart_send_returns_correct_bytes。这样测试失败时,从名字就能猜到问题出在哪。

3.2 断言方法:验证结果的武器库

断言是测试的灵魂。unittest提供了丰富的断言方法,我整理了一张表,方便你查阅:

断言方法 用途 示例
assertEqual(a, b) 检查 a == b self.assertEqual(calc.add(1,2), 3)
assertNotEqual(a, b) 检查 a != b self.assertNotEqual(result, None)
assertTrue(x) 检查 x 为 True self.assertTrue(flag.is_set())
assertFalse(x) 检查 x 为 False self.assertFalse(error_occurred)
assertIsNone(x) 检查 x 为 None self.assertIsNone(cache.get(key))
assertIsNotNone(x) 检查 x 不为 None self.assertIsNotNone(device.read())
assertIn(a, b) 检查 a in b self.assertIn('error', log_text)
assertRaises(Exc, fun) 检查是否抛出异常 self.assertRaises(ValueError, parse, 'abc')

我曾经在调试一个串口驱动时,用assertEqual比对发送和接收的数据,发现总是对不上。后来换成assertIn检查关键字段,才定位到是协议头解析出了问题。选对断言方法,能帮你更快找到问题。

避坑指南:不要滥用assertTrue。比如检查两个值相等,用assertEqual比assertTrue(a == b)好得多——前者在失败时会打印出a和b的具体值,后者只会告诉你"False"。

3.3 测试固件:setUp和tearDown

测试固件(Fixture)是unittest中一个非常实用的机制。它解决了一个常见问题:多个测试方法需要相同的初始化或清理操作。

举个例子,假设我们要测试一个LED控制器的Python封装:

import unittest

class TestLEDController(unittest.TestCase):
    def setUp(self):
        # 每个测试方法执行前都会运行
        self.led = LEDController(pin=17)
        self.led.off()
        print("LED已初始化")

    def tearDown(self):
        # 每个测试方法执行后都会运行
        self.led.off()
        print("LED已关闭")

    def test_turn_on(self):
        self.led.on()
        self.assertTrue(self.led.is_on())

    def test_turn_off(self):
        self.led.on()
        self.led.off()
        self.assertFalse(self.led.is_on())

    def test_toggle(self):
        self.led.toggle()
        self.assertTrue(self.led.is_on())
        self.led.toggle()
        self.assertFalse(self.led.is_on())

执行流程是这样的:

  1. setUp() → test_turn_on() → tearDown()
  2. setUp() → test_turn_off() → tearDown()
  3. setUp() → test_toggle() → tearDown()

每个测试方法都是独立的,互不干扰。我在项目中遇到过最典型的坑是:有人在setUp里打开了文件,但在tearDown里忘了关闭。跑一次测试没问题,跑一百次就报"打开文件太多"的错误。所以,tearDown一定要把setUp里申请的资源都释放掉。

核心原则:每个测试方法应该是独立的、可重复的、无副作用的。setUp和tearDown就是保证这一点的工具。

3.4 知识结构图

下面这张图展示了unittest测试框架的核心结构:

unittest 测试框架核心结构 TestSuite TestCase(测试用例类) setUp() 初始化 test_*() 测试方法 tearDown() 清理 执行顺序:setUp() → test_*() → tearDown() 每个test_*方法独立执行,互不干扰 断言方法:assertEqual / assertTrue / assertRaises / assertIn ... 用于验证测试结果是否符合预期

3.5 实战:测试一个简单的配置解析器

把上面的知识点串起来,写一个完整的测试用例。假设我们有一个解析嵌入式设备配置文件的函数:

# config_parser.py
def parse_config_line(line):
    """解析形如 'KEY=VALUE' 的配置行"""
    line = line.strip()
    if not line or line.startswith('#'):
        return None
    if '=' not in line:
        raise ValueError(f"无效配置行: {line}")
    key, value = line.split('=', 1)
    return key.strip(), value.strip()

对应的测试用例:

import unittest
from config_parser import parse_config_line

class TestConfigParser(unittest.TestCase):
    def setUp(self):
        # 准备测试数据
        self.valid_line = "BAUD_RATE=115200"
        self.comment_line = "# 这是注释"
        self.empty_line = "   "
        self.invalid_line = "NO_EQUALS"

    def test_parse_valid_line(self):
        result = parse_config_line(self.valid_line)
        self.assertIsNotNone(result)
        self.assertEqual(result, ("BAUD_RATE", "115200"))

    def test_parse_comment_line(self):
        result = parse_config_line(self.comment_line)
        self.assertIsNone(result)

    def test_parse_empty_line(self):
        result = parse_config_line(self.empty_line)
        self.assertIsNone(result)

    def test_parse_invalid_line_raises_error(self):
        with self.assertRaises(ValueError):
            parse_config_line(self.invalid_line)

    def tearDown(self):
        # 清理工作(本例中不需要,但保留以展示结构)
        pass

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

运行结果:

....
----------------------------------------------------------------------
Ran 4 tests in 0.002s

OK

四个点代表四个测试全部通过。如果某个测试失败,会显示F并给出详细的失败信息。

我的习惯:在写功能代码之前,先把测试用例写好。这样你会更清楚自己要实现什么,代码质量也会更高。这就是TDD的核心思想——测试先行。

3.6 常见陷阱与最佳实践

最后分享几个我在项目中踩过的坑:

  • 测试之间共享状态:不要在setUp里定义类变量,每个测试方法应该有自己的独立环境。我见过有人用self.counter在测试间传递数据,结果测试顺序一变就全崩了。
  • 忽略tearDown:如果setUp打开了文件、网络连接或硬件资源,tearDown一定要关闭。否则跑完100个测试,资源就泄漏光了。
  • 测试太"聪明":测试代码应该简单直接,不要用复杂的逻辑。如果测试本身有bug,那它就是在帮倒忙。
  • 只测正常路径:别忘了测试异常情况——空输入、非法参数、边界值。这些往往是bug藏身的地方。