Python 的 unittest 的一些细节和笔记

作者: , 共 4224 字 , 共阅读 0

1、unittest 执行顺序以及共享数据的问题

unittest 是 python 官方的单元测试工具。最近发现一个之前没注意到的盲区:

unittetst 的每个测试用例都会重建一个 TestCase 类,因此会多次初始化,并且每次测试是 self 都指向独立的实例。

这样在测试用例之间共享数据,需要特别留意。如果数据只读还好,涉及到修改,大概率不符合预期。

例子:

import unittest

class TestClass(unittest.TestCase):
    num = 1

    def test_foo(self):
        self.assertEqual(self.num, 1)
        self.num = 2 

    def test_abc(self):
        self.assertEqual(self.num, 2)

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

一眼看上去上面的代码没有问题。实际问题很大:

  • unittest 同一个 TestCase 下的不同测试用例(即test_成员函数),不是按照定义顺序运行,而是按照字母排序执行!这意味着 test_abctest_foo之前执行。
  • test_abctest_foo事实上是不同的TestClass实例,这意味着test_foo里面的self.num=2的修改,不会被别的测试用例看到(即使是在它之后执行的测试用例)。

2、unittest 执行测试的逻辑

如果了解 unittest 的测试逻辑,就能理解上面这些问题:

  • 导入所有的 test 文件,通常是指定的单个文件,或者 discover 查找到的所有 test 文件(指定目录下以 test 开头的 python 文件)。
  • 所有类下面直接定义的类变量和语句将直接执行(比如上面的num = 1)。
  • 开始依次执行所有的 TestCase (是指继承 unittest.TestCase 且以 Test 开头的类):
    • 先执行该类的setUpClass函数(该函数只会被执行一次)。
    • 按字母顺序执行测试用例,对每个测试用例生成一个独立的类实例:
      • 并顺序执行 __init__setUp,当前测试函数(以test开头),tearDown
      • 在有多个测试函数时,__init__setUptearDown会被执行多次。
    • 执行该类的tearDownClass函数(该函数只会被执行一次)

从逻辑上看,不同 TestCase 之间的环境肯定是完全独立的。但 unittest 推荐测试用例的环境,也是完全独立的。

不推荐定义__init__函数,因为基类的初始化函数参数是未知的(实际测试发现初始化参数是测试用例函数的名字)。初始化可以放在setUp函数里面。

如果实在想写,必须下面这样:

class TestClass(unittest.TestCase):
    def __init__(self, *args, **kwargs): 
        unittest.TestCase.__init__(self, *args, **kwargs)
        print("init", args, kwargs)

3、如何共享数据

不同的测试用例之间共享一个不会被修改的数据,可以通过类成员变量来实现,一个最简单的就是上面示例中的num = 1,但需要注意后面的测试用例不能用self.num = 2来修改(该修改将只影响到该测试用例,其它测试用例的 num 仍等于 1 )。这个原因是,一旦重新定义self.num,只是重新定义了一个实例成员数据,覆盖了类成员数据。其它测试用例将重建一个类实例,看不到现在这个实例的成员数据。

class TestClass(unittest.TestCase):
    num = 1

    def test_foo(self):
        self.assertEqual(self.num, 1)       

    def test_abc(self):
        self.assertEqual(self.num + 1, 2)

一个更符合工程质量的方法是使用setUpClasstearDownClass

4、如何共享且修改数据?

一般不建议共享会被修改的数据,因为如上所示,测试用例的执行并不是按照定义的顺序从上到下执行。执行顺序可能和预期不一样,从而导致错误。

如果非要做,只能使用一个 dict 或者 list 来修改,通过修改 dict/list 内部的数据:

class TestClass(unittest.TestCase):
    self.shared = {
        "num": 1
    }

    def test_1(self):
        self.assertEqual(self.shared["num"], 1)
        self.shared["num"] = 2 

    def test_2(self):
        self.assertEqual(self.shared["num"], 2)

5、如何组织测试文件

一个文件里可以定义多个 TestCase ,每个 TestCase 下面可以定义多个测试用例(每个测试用例是一个test开头没有多余参数的类成员函数,其它名字的函数将会被忽略)。

文件的最后注意加上:

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

如果单个文件就能放下所有测试案例,那么直接命令行就能执行测试:

python ./your_test_file.py -v

其中-v参数可以显示测试明细和进度。

如果有多个文件,我们假设它们放在同一个目录./my_tests下,

python -m unittest discover -s ./my_tests

也可以写一个./my_tests/alltests.py的脚本:

#!/usr/bin/env python
# encoding: utf-8

import unittest
import os

def test_all():
    dir_path = os.path.dirname(os.path.realpath(__file__))
    discover = unittest.defaultTestLoader.discover(dir_path, pattern='test_*.py')
    return discover


def __main():
    runner = unittest.TextTestRunner(verbosity=2)
    res = runner.run(test_all())

    print(res)

    if res.errors or res.failures:
        # do with errors and failures
        pass


if __name__ == "__main__":
    __main()

然后执行该脚本:

./my_tests/alltests.py

这样做的好处是,可以对测试出现的错误做后续处理,比如发送邮件通知。

6、测试函数

下面这些测试函数,都可以再携带一个 msg 参数,当出错时打印相应的错误信息。

普通检查:

  • assertEqual(a, b): a == b
  • assertNotEqual(a, b): a != b
  • assertTrue(x): bool(x) is True
  • assertFalse(x): bool(x) is False
  • assertIs(a, b): a is b
  • assertIsNot: a is not b
  • assertIsNone: a is None
  • assertIsNotNone(a): a is not None
  • assertIn(a, b): a in b
  • assertNotIn(a, b): a not in b
  • assertCountEqual(a, b): sorted(a) == sorted(b)
  • assertIsInstance(a, b): isinstance(a, b)
  • assertNotIsInstance(a, b): not isinstance(a, b)
  • assertGreater(a, b): a > b
  • assertGreaterEqual(a, b): a >= b
  • assertLess(a, b): a < b
  • assertLessEqual(a, b): a <= b

注意优先使用 assertEqual(a, b),而不是 assertTrue(a == b),这是因为出错时,前者会能提示 a 和 b 具体的值。

浮点数检查(浮点数的检查和比较还是较为粗略,实际使用过程需要特别留意):

  • assertAlmostEqual(a, b, places=7): round(a - b, places) == 0
  • assertNotAlmostEqual(a, b, places=7): round(a - b, places) != 0

文本相关:

  • assertRegex(s, r): r.search(s)
  • assertNotRegex(s, r): not r.search(s)

异常检查:

  • assertRaises(exc, fun, *args, **kwargs): 执行 fun(*args, **kwargs)会引发 exc 的异常。

Q. E. D.

类似文章:
编程 » Python
今天写一段程序时遇到一个问题,查了好一会才搞清楚。代码可以简化为下面这个小代码:
编程 » Python
在 Python 中,有时候为了获取当前运行的脚本的同目录下的另外一个文件,会这么写:
Python 提供很多语法糖,用起来非常方便。@dataclass 就是其中之一。
Pyhon 的抽象基类( abstruct base class )库abc定义了类似于 C++的纯虚函数的功能:
假设你有多个很耗时的任务,比如训练多个神经网络模型:
编程 » Python
Python 在搜索模块时,依次搜索sys.path里的位置,直到找到模块为止。下面命令可以查看当前的搜索路径:
编程 » Python, 并行计算
核心就是threading.Thread
编程 » Python
imp.load_source在动态载入 python 模块时非常有用,但需要注意其特性。
在 Python 中操作文件或字符串时,有时候会碰到 UnicodeDecodeError 异常:
相似度: 0.072
编程 » pytorch, C++
目前 pytorch 已经升级到了 1.7.0 ,但在 ubuntu 20.04 下有一个非常诡异的 bug。为此,我们只能自己编译。
五一节回长沙,因为下雨,就去家旁边的石燕湖走了一下。而且原计划是8 公里的绕湖大环线,由于路况糟糕,最后只走了 4 公里的超小环线,爬升 200 米,轨迹上传到两步路:20240503 石燕湖超小环线
周末我们一起去打卡了北岳恒山,继衡山泰山嵩山之后的第四座五岳之一。