原文 https://zhangyuyu.github.io/python-unit-test/
06 Apr 2021
Reading time ~15 minutes

前言

单元测试成神之路系列里,GoLang篇作为首篇,除了介绍Golang单元测试的选型,还介绍了单元测试的意义和编写单元测试的一般方法。C篇介绍了基于C的单元测试框架的选型以及Mock框架。而本篇文章则主要介绍如何在Python中写单元测试以及单元测试报告的生成。

本文直接从常用的Python单元测试框架出发,分别对几种框架进行了简单的介绍和小结,然后介绍了Mock的框架,以及测试报告生成方式,并以具体代码示例进行说明,最后列举了一些常见问题。

一、常用Python单测框架

Unittest Nose Pytest
特点 Python标准库中自带的单元测试框架。 Python的一个第三方单元测试框架,带插件的unittest。nose停止维护了,nose2并不支持nose的全部功能。 Python的一个第三方单元测试框架,丰富的插件生态,兼容unittest和nose测试集, 社区繁荣
Mock框架 unittest.mock unittest.mock pytest-mock
推荐指数 ★★★★☆ ★★☆☆☆ ★★★★★

若你不想安装或不允许第三方库,那么 unittest 是最好也是唯一的选择。反之,pytest 无疑是最佳选择,众多 Python 开源项目(如大名鼎鼎的 requests)都是使用 pytest 作为单元测试框架。甚至,连 nose2 在官方文档上都建议大家使用 pytest。我们知道,nose已经进入了维护模式,取代者是nose2。相比nose2,pytest的生态无疑更具优势,社区的活跃度也更高。

总体来说,unittest用例格式复杂,兼容性无,插件少,二次开发方便。pytest更加方便快捷,用例格式简单,可以执行unittest风格的测试用例,较好的兼容性,插件丰富。

二、unittest

1. 基本概念

unittest中最核心的四个概念是:test fixture、test case、test suite、test runner

  • test fixture:表示执行一个或多个测试所需的准备,以及任何关联的清理操作。例如这可能涉及创建临时或代理数据库、目录或启动服务器进程。
  • test case:测试用例是最小的测试单元。它检查特定的输入集的响应。单元测试提供了一个基类测试用例,可用于创建新的测试用例。
  • test suite:测试套件是测试用例、测试套件或两者的集合,用于归档需要一起执行的测试。
  • test runner:是一个用于执行和输出结果的组件。这个运行器可能使用图形接口、文本接口,或返回一个特定的值表示运行测试的结果。

2. 编写规则

  • 编写单元测试时,我们需要编写一个测试类,从unittest.TestCase继承。
  • test开头的方法就是测试方法,不以test开头的方法不被认为是测试方法,测试的时候不会被执行。
  • 对每一类测试都需要编写一个test_xxx()方法。

3. 简单示例

3.1 目录结构

  1. $ tree .
  2. .
  3. ├── README.md
  4. ├── requirements.txt
  5. └── src
  6. ├── demo
  7. └── calculator.py
  8. └── tests
  9. └── demo
  10. ├── __init__.py
  11. ├── test_calculator_unittest.py
  12. └── test_calculator_unittest_with_fixture.py

3.2 计算器实现代码

  1. class Calculator:
  2. def __init__(self, a, b):
  3. self.a = int(a)
  4. self.b = int(b)
  5. def add(self):
  6. return self.a + self.b
  7. def sub(self):
  8. return self.a - self.b
  9. def mul(self):
  10. return self.a * self.b
  11. def div(self):
  12. return self.a / self.b

3.3 计算器测试代码

  1. import unittest
  2. from src.demo.calculator import Calculator
  3. class TestCalculator(unittest.TestCase):
  4. def test_add(self):
  5. c = Calculator()
  6. result = c.add(3, 5)
  7. self.assertEqual(result, 8)
  8. def test_sub(self):
  9. c = Calculator()
  10. result = c.sub(10, 5)
  11. self.assertEqual(result, 5)
  12. def test_mul(self):
  13. c = Calculator()
  14. result = c.mul(5, 7)
  15. self.assertEqual(result, 35)
  16. def test_div(self):
  17. c = Calculator()
  18. result = c.div(10, 5)
  19. self.assertEqual(result, 2)
  20. if __name__ == '__main__':
  21. unittest.main()

3.4 执行结果

  1. Ran 4 tests in 0.002s
  2. OK

4. 用例前置和后置

基于unittest的四个概念的理解,上述简单用例,可以修改为:

  1. import unittest
  2. from src.demo.calculator import Calculator
  3. class TestCalculatorWithFixture(unittest.TestCase):
  4. # 测试用例前置动作
  5. def setUp(self):
  6. print("test start")
  7. # 测试用例后置动作
  8. def tearDown(self):
  9. print("test end")
  10. def test_add(self):
  11. c = Calculator()
  12. result = c.add(3, 5)
  13. self.assertEqual(result, 8)
  14. def test_sub(self):
  15. c = Calculator()
  16. result = c.sub(10, 5)
  17. self.assertEqual(result, 5)
  18. def test_mul(self):
  19. c = Calculator()
  20. result = c.mul(5, 7)
  21. self.assertEqual(result, 35)
  22. def test_div(self):
  23. c = Calculator()
  24. result = c.div(10, 5)
  25. self.assertEqual(result, 2)
  26. if __name__ == '__main__':
  27. # 创建测试套件
  28. suit = unittest.TestSuite()
  29. suit.addTest(TestCalculatorWithFixture("test_add"))
  30. suit.addTest(TestCalculatorWithFixture("test_sub"))
  31. suit.addTest(TestCalculatorWithFixture("test_mul"))
  32. suit.addTest(TestCalculatorWithFixture("test_div"))
  33. # 创建测试运行器
  34. runner = unittest.TestRunner()
  35. runner.run(suit)

5. 参数化

标准库的unittest自身不支持参数化测试,可以通过第三方库来支持:parameterized和ddt。

其中parameterized只需要一个装饰器@parameterized.expand,ddt需要三个装饰器@ddt、@data、@unpack,它们生成的test分别有一个名字,ddt会携带具体的参数信息。

5.1 parameterized

  1. import unittest
  2. from parameterized import parameterized, param
  3. from src.demo.calculator import Calculator
  4. class TestCalculator(unittest.TestCase):
  5. @parameterized.expand([
  6. param(3, 5, 8),
  7. param(1, 2, 3),
  8. param(2, 2, 4)
  9. ])
  10. def test_add(self, num1, num2, total):
  11. c = Calculator()
  12. result = c.add(num1, num2)
  13. self.assertEqual(result, total)
  14. if __name__ == '__main__':
  15. unittest.main()

执行结果:

  1. test_add_0 (__main__.TestCalculator) ... ok
  2. test_add_1 (__main__.TestCalculator) ... ok
  3. test_add_2 (__main__.TestCalculator) ... ok
  4. ----------------------------------------------------------------------
  5. Ran 3 tests in 0.000s
  6. OK

5.2 ddt

  1. import unittest
  2. from ddt import data, unpack, ddt
  3. from src.demo.calculator import Calculator
  4. @ddt
  5. class TestCalculator(unittest.TestCase):
  6. @data((3, 5, 8),(1, 2, 3),(2, 2, 4))
  7. @unpack
  8. def test_add(self, num1, num2, total):
  9. c = Calculator()
  10. result = c.add(num1, num2)
  11. self.assertEqual(result, total)
  12. if __name__ == '__main__':
  13. unittest.main()

执行结果:

  1. test_add_1__3__5__8_ (__main__.TestCalculator) ... ok
  2. test_add_2__1__2__3_ (__main__.TestCalculator) ... ok
  3. test_add_3__2__2__4_ (__main__.TestCalculator) ... ok
  4. ----------------------------------------------------------------------
  5. Ran 3 tests in 0.000s
  6. OK

6. 断言

unittest提供了丰富的断言,常用的包括:

assertEqual、assertNotEqual、assertTrue、assertFalse、assertIn、assertNotIn等。

具体可以直接看源码提供的方法: 单元测试成神之路——Python篇 - 图1

三、nose

nose已经进入维护模式,从github nose上可以看到,nose最近的一次代码提交还是在2016年5月4日。

继承nose的是nose2,但要注意的是,nose2并不支持nose的全部功能,它们的区别可以看这里。nose2的主要目的是扩展Python的标准单元测试库unittest,因此它的定位是“带插件的unittest”。nose2提供的插件,例如测试用例加载器,覆盖度报告生成器,并行测试等内置插件和第三方插件,让单元测试变得更加完善。

nose2的社区没有pytest的活跃,要使用高级框架,推荐使用pytest,因此下文不做过多详述。

1. 编写规则

  • nose2的测试用例并不限制于类,也可以直接使用函数。
  • 任何函数和类,只要名称匹配一定的条件(例如,以test开头或以test结尾等),都会被自动识别为测试用例;
  • 为了兼容unittest, 所有的基于unitest编写的测试用例,也会被nose自动识别为。

2. 简单示例

2.1 计算器代码

参考unittest的计算器代码部分。

2.2 计算器测试代码

  1. import nose2
  2. from src.demo.calculator import Calculator
  3. def test_add():
  4. c = Calculator()
  5. result = c.add(3, 5)
  6. assert result == 8
  7. def test_sub():
  8. c = Calculator()
  9. result = c.sub(10, 5)
  10. assert result == 5
  11. def test_mul():
  12. c = Calculator()
  13. result = c.mul(5, 7)
  14. assert result == 35
  15. def test_div():
  16. c = Calculator()
  17. result = c.div(10, 5)
  18. assert result == 2
  19. if __name__ == '__main__':
  20. nose2.main()

2.3 执行结果

  1. ....
  2. ----------------------------------------------------------------------
  3. Ran 4 tests in 0.000s
  4. OK

3. 参数化

  1. import nose2
  2. from nose2.tools import params
  3. from src.demo.calculator import Calculator
  4. test_data = [
  5. {"nums": (3, 5), "total": 8},
  6. {"nums": (1, 2), "total": 3},
  7. {"nums": (2, 2), "total": 4}
  8. ]
  9. @params(*test_data)
  10. def test_add(data):
  11. c = Calculator()
  12. result = c.add(*data['nums'])
  13. assert result == data['total']
  14. if __name__ == '__main__':
  15. nose2.main()

四、pytest

1. 编写规则

  • 测试文件以test_开头(以test结尾也可以)
  • 测试类以Test开头,并且不能带有 init 方法
  • 测试函数以test_开头
  • 断言使用基本的assert即可

可以通过下面的命令,查看 Pytest 收集到哪些测试用例:

  1. $ py.test --collect-only

2. 简单示例

2.1 计算器代码

参考unittest的计算器代码部分。

2.2 计算器实现代码

  1. import pytest
  2. from src.demo.calculator import Calculator
  3. class TestCalculator():
  4. def test_add(self):
  5. c = Calculator()
  6. result = c.add(3, 5)
  7. assert result == 8
  8. def test_sub(self):
  9. c = Calculator()
  10. result = c.sub(10, 5)
  11. assert result == 5
  12. def test_mul(self):
  13. c = Calculator()
  14. result = c.mul(5, 7)
  15. assert result == 35
  16. def test_div(self):
  17. c = Calculator()
  18. result = c.div(10, 5)
  19. assert result == 2
  20. if __name__ == '__main__':
  21. pytest.main(['-s', 'test_calculator_pytest.py'])

2.3 执行结果

  1. ============================= test session starts ==============================
  2. platform darwin -- Python 3.8.3, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
  3. rootdir: python-ut/src/tests/demo
  4. plugins: metadata-1.11.0, html-3.1.1
  5. collected 4 items
  6. test_calculator_pytest.py ....
  7. ============================== 4 passed in 0.01s ===============================

3. 用例前置和后置

加上fixture夹具,有几种方式:

  • 将夹具函数名称作为参数传递到测试用例函数当中
  • @pytest.mark.usefixtures(“夹具函数名称”)
  • @pytest.fixture(autouse=True),设置了autouse,就可以不用上述两种手动方式,默认就会使用夹具
  1. import pytest
  2. from src.demo.calculator import Calculator
  3. @pytest.fixture()
  4. def set_up():
  5. print("[pytest with fixture] start")
  6. yield
  7. print("[pytest with fixture] end")
  8. class TestCalculator():
  9. def test_add(self, set_up):
  10. c = Calculator()
  11. result = c.add(3, 5)
  12. assert result == 8
  13. def test_sub(self, set_up):
  14. c = Calculator()
  15. result = c.sub(10, 5)
  16. assert result == 5
  17. @pytest.mark.usefixtures("set_up")
  18. def test_mul(self):
  19. c = Calculator()
  20. result = c.mul(5, 7)
  21. assert result == 35
  22. @pytest.mark.usefixtures("set_up")
  23. def test_div(self):
  24. c = Calculator()
  25. result = c.div(10, 5)
  26. assert result == 2
  27. if __name__ == '__main__':
  28. pytest.main(['-s', 'test_calculator_pytest_with_fixture.py'])

执行结果:

  1. ============================= test session starts ==============================
  2. platform darwin -- Python 3.8.3, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
  3. rootdir: python-ut/src/tests/demo
  4. plugins: metadata-1.11.0, html-3.1.1
  5. collected 4 items
  6. test_calculator_pytest_with_fixture.py [pytest with fixture] start
  7. .[pytest with fixture] end
  8. [pytest with fixture] start
  9. .[pytest with fixture] end
  10. [pytest with fixture] start
  11. .[pytest with fixture] end
  12. [pytest with fixture] start
  13. .[pytest with fixture] end
  14. ============================== 4 passed in 0.01s ===============================

4. 参数化

4.1 基础知识

  • 如果只有一个参数,里面则是值的列表,比如@pytest.mark.parametrize("num1", [3, 5, 8])
  • 如果有多个参数,则需要用元祖来存放值,一个元祖对应一组参数的值,比如@pytest.mark.parametrize("num1, num2, total", [(3, 5, 8), (1, 2, 3), (2, 2, 4)])
  • 当装饰器 @pytest.mark.parametrize 装饰测试类时,会将数据集合传递给类的所有测试用例方法
  • 一个函数或一个类可以装饰多个 @pytest.mark.parametrize,当参数化有多个装饰器时,用例数是N*M…

4.2 参数化测试

  1. import pytest
  2. from src.demo.calculator import Calculator
  3. class TestCalculator():
  4. @pytest.mark.parametrize("num1, num2, total", [(3, 5, 8), (1, 2, 3), (2, 2, 4)])
  5. def test_add(self, num1, num2, total):
  6. c = Calculator()
  7. result = c.add(num1, num2)
  8. assert result == total
  9. if __name__ == '__main__':
  10. pytest.main(['test_calculator_pytest_with_parameterize.py'])

执行结果:

  1. ============================= test session starts ==============================
  2. platform darwin -- Python 3.8.3, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
  3. rootdir: python-ut/src/tests/demo
  4. plugins: metadata-1.11.0, html-3.1.1
  5. collected 3 items
  6. test_calculator_pytest_with_paramtrize.py ...
  7. ============================== 3 passed in 0.01s ===============================

4.3 参数化标记数据

  1. class TestCalculator():
  2. @pytest.mark.parametrize("num1, num2, total", [
  3. pytest.param(5, 1, 4, marks=pytest.mark.passed),
  4. pytest.param(5, 2, 4, marks=pytest.mark.fail),
  5. (5, 4, 1)
  6. ])
  7. def test_sub(self, num1, num2, total):
  8. c = Calculator()
  9. result = c.sub(num1, num2)
  10. assert result == total
  11. if __name__ == '__main__':
  12. pytest.main(['test_calculator_pytest_with_parameterize.py'])

执行结果:

  1. ============================= test session starts ==============================
  2. platform darwin -- Python 3.8.3, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
  3. rootdir: python-ut/src/tests/demo
  4. plugins: metadata-1.11.0, html-3.1.1
  5. collected 3 items
  6. test_calculator_pytest_with_paramtrize.py .F. [100%]
  7. =================================== FAILURES ===================================
  8. ________________________ TestCalculator.test_sub[5-2-4] ________________________
  9. self = <demo.test_calculator_pytest_with_paramtrize.TestCalculator object at 0x110813d00>
  10. num1 = 5, num2 = 2, total = 4
  11. @pytest.mark.parametrize("num1, num2, total", [
  12. pytest.param(5, 1, 4, marks=pytest.mark.passed),
  13. pytest.param(5, 2, 4, marks=pytest.mark.fail),
  14. (5, 4, 1)
  15. ])
  16. def test_sub(self, num1, num2, total):
  17. c = Calculator()
  18. result = c.sub(num1, num2)
  19. > assert result == total
  20. E assert 3 == 4
  21. test_calculator_pytest_with_paramtrize.py:21: AssertionError
  22. =========================== short test summary info ============================
  23. FAILED test_calculator_pytest_with_paramtrize.py::TestCalculator::test_sub[5-2-4]
  24. =================== 1 failed, 2 passed, 2 warnings in 0.04s ====================

5. 断言

在unittest单元测试框架中提供了丰富的断言方法,例如assertEqual()、assertIn()、assertTrue()、assertIs()等,而pytest单元测试框架中并没提供特殊的断言方法,而是直接使用python的assert进行断言。

  • assert可以使用==!=<>>=<=等符号来比较相等、不相等、小于、大于、大于等于和小于等于。
  • 断言包含和不包含,使用assert a in bassert a not in b
  • 断言真假,使用assert conditionassert not condition
  • 断言异常,使用pytest.raise获取信息

    1. # 详细断言异常
    2. def test_zero_division_long():
    3. with pytest.raises(ZeroDivisionError) as excinfo:
    4. 1 / 0
    5. # 断言异常类型 type
    6. assert excinfo.type == ZeroDivisionError
    7. # 断言异常 value 值
    8. assert "division by zero" in str(excinfo.value)

6. 重跑

需要安装额外的插件pytest-rerunfailures

  1. import pytest
  2. @pytest.mark.flaky(reruns=5)
  3. def test_example():
  4. import random
  5. assert random.choice([True, False, False])

执行结果:

  1. collecting ... collected 1 item
  2. 11_reruns.py::test_example RERUN [100%]
  3. 11_reruns.py::test_example PASSED [100%]
  4. ========================= 1 passed, 1 rerun in 0.05s ==========================

五、Mock

1. mock

mock原是python的第三方库,python3以后mock模块已经整合到了unittest测试框架中。

如果使用的是python3.3以后版本,那么不用单独安装,使用的时候在文件开头引入from unittest import mock即可。

如果使用的是python2,需要先pip install mock安装后再import mock即可。

1.1 Mock一个方法

  1. import unittest
  2. from unittest import mock
  3. from src.demo.calculator import Calculator
  4. def multiple(a, b):
  5. return a * b
  6. class TestCalculator(unittest.TestCase):
  7. @mock.patch('test_calculator_mock.multiple')
  8. def test_function_multiple(self, mock_multiple):
  9. mock_return = 1
  10. mock_multiple.return_value = mock_return
  11. result = multiple(3, 5)
  12. self.assertEqual(result, mock_return)
  13. if __name__ == '__main__':
  14. unittest.main()

1.2 Mock一个对象里面的方法

分别给出了普通写法和注解写法,以及side_effect关键参数的效果案例。

  1. import unittest
  2. from unittest import mock
  3. from src.demo.calculator import Calculator
  4. class TestCalculator(unittest.TestCase):
  5. def test_add(self):
  6. c = Calculator()
  7. mock_return = 10
  8. c.add = mock.Mock(return_value=mock_return)
  9. result = c.add(3, 5)
  10. self.assertEqual(result, mock_return)
  11. def test_add_with_side_effect(self):
  12. c = Calculator()
  13. mock_return = 10
  14. # 传递side_effect关键字参数, 会覆盖return_value参数值, 使用真实的add方法测试
  15. c.add = mock.Mock(return_value=mock_return, side_effect=c.add)
  16. result = c.add(3, 5)
  17. self.assertEqual(result, 8)
  18. @mock.patch.object(Calculator, 'add')
  19. def test_add_with_annotation(self, mock_add):
  20. c = Calculator()
  21. mock_return = 10
  22. mock_add.return_value = mock_return
  23. result = c.add(3, 5)
  24. self.assertEqual(result, mock_return)
  25. if __name__ == '__main__':
  26. unittest.main()

1.3 Mock每次调用返回不同的值

  1. import unittest
  2. from unittest import mock
  3. from src.demo.calculator import Calculator
  4. class TestCalculator(unittest.TestCase):
  5. @mock.patch.object(Calculator, 'add')
  6. def test_add_with_different_return(self, mock_add):
  7. c = Calculator()
  8. mock_return = [10, 8]
  9. mock_add.side_effect = mock_return
  10. result1 = c.add(3, 5)
  11. result2 = c.add(3, 5)
  12. self.assertEqual(result1, mock_return[0])
  13. self.assertEqual(result2, mock_return[1])
  14. if __name__ == '__main__':
  15. unittest.main()

1.4 Mock抛出异常的方法

  1. import unittest
  2. from unittest import mock
  3. from src.demo.calculator import Calculator
  4. # 被调用函数
  5. def multiple(a, b):
  6. return a * b
  7. # 实际调用函数
  8. def is_error(a, b):
  9. try:
  10. return multiple(a, b)
  11. except Exception as e:
  12. return -1
  13. class TestCalculator(unittest.TestCase):
  14. @mock.patch('test_calculator_mock.multiple')
  15. def test_function_multiple_exception(self, mock_multiple):
  16. mock_multiple.side_effect = Exception
  17. result = is_error(3, 5)
  18. self.assertEqual(result, -1)
  19. if __name__ == '__main__':
  20. unittest.main()

1.5 Mock多个方法

  1. import unittest
  2. from unittest import mock
  3. from src.demo.calculator import Calculator
  4. def multiple(a, b):
  5. return a * b
  6. class TestCalculator(unittest.TestCase):
  7. # z'h
  8. @mock.patch.object(Calculator, 'add')
  9. @mock.patch('test_calculator_mock.multiple')
  10. def test_both(self, mock_multiple, mock_add):
  11. c = Calculator()
  12. mock_add.return_value = 1
  13. mock_multiple.return_value = 2
  14. self.assertEqual(c.add(3, 5), 1)
  15. self.assertEqual(multiple(3, 5), 2)
  16. if __name__ == '__main__':
  17. unittest.main()

2. pytest-mock

如果项目本身使用的框架是 pytest,则 Mock 更建议使用 pytest-mock 这个插件,它提供了一个名为mocker的fixture,仅在当前测试funciton或method生效,而不用自行包装。

mocker和mock.patch有相同的api,支持相同的参数。

2.1 简单示例

  1. import pytest
  2. from src.demo.calculator import Calculator
  3. class TestCalculator():
  4. def test_add(self, mocker):
  5. c = Calculator()
  6. mock_return = 10
  7. mocker.patch.object(c, 'add', return_value=mock_return)
  8. result = c.add(3, 5)
  9. assert result == mock_return
  10. if __name__ == '__main__':
  11. pytest.main(['-s', 'test_calculator_pytest_mock.py'])

2.2 mock方法和域

  1. class ForTest:
  2. field = 'origin'
  3. def method():
  4. pass
  5. def test_for_test(mocker):
  6. test = ForTest()
  7. # 方法
  8. mock_method = mocker.patch.object(test, 'method')
  9. test.method()
  10. # 检查行为
  11. assert mock_method.called
  12. # 域
  13. assert 'origin' == test.field
  14. mocker.patch.object(test, 'field', 'mocked')
  15. # 检查结果
  16. assert 'mocked' == test.field

3. monkeypatch

monkeypatch是pytest框架内置的固件,有时候,测试用例需要调用某些依赖于全局配置的功能,或者这些功能本身又调用了某些不容易测试的代码(例如:网络接入)。monkeypatch提供了一些方法,用于安全地修补和模拟测试中的功能:

  1. monkeypatch.setattr(obj, name, value, raising=True)
  2. monkeypatch.delattr(obj, name, raising=True)
  3. monkeypatch.setitem(mapping, name, value)
  4. monkeypatch.delitem(obj, name, raising=True)
  5. monkeypatch.setenv(name, value, prepend=False)
  6. monkeypatch.delenv(name, raising=True)
  7. monkeypatch.syspath_prepend(path)
  8. monkeypatch.chdir(path)

主要考虑以下情形:

  • 修改测试的函数行为或类的属性
  • 修改字典的值
  • 修改测试环境的环境变量
  • 在测试期间,用于修改和 更改当前工作目录的上下文。

六、单元测试覆盖率报告

coverage 是 Python 推荐使用的覆盖率统计工具。

pytest-cov 是 pytest 的插件,它可以让你在 pytest 中使用 cpverage.py。

HtmlTestRunner,需要在代码里面写入一点配置,但是报告生成比较美观。

coverage和pytest-cov只需要配置,就可直接使用,不需要测试代码配合。

1. coverage

1.1 安装

  1. pip install coverage

详情可参考:coverage

1.2 运行

  1. coverage run -m unittest discover

运行结束之后,会生成一个覆盖率统计结果文件(data file).coverage文件,在pycharm里可识别为一个数据库: 单元测试成神之路——Python篇 - 图2

1.3 结果

1.3. 1 report

  1. coverage report -m

执行结果如下:

  1. $ coverage report -m
  2. Name Stmts Miss Cover Missing
  3. ---------------------------------------------------------------------------------------------
  4. src/tests/demo/test_calculator_pytest_with_fixture.py 28 16 43% 8-10, 15-17, 20-22, 26-28, 32-34, 38
  5. src/tests/demo/test_calculator_pytest_with_parameterize.py 15 7 53% 9-11, 19-21, 25
  6. src/tests/demo/test_calculator_unittest.py 22 1 95% 31
  7. src/tests/demo/test_calculator_unittest_with_ddt.py 13 1 92% 18

1.3.2 html

会生成htmlcov/index.html文件,在浏览器查看:

  1. coverage html

点击各个py文件,可以查看详细情况。

2. html-testRunner

2.1 安装

  1. pip install html-testRunner

详细说明可参考HtmlTestRunner

2.2 运行

在代码中加上HTMLTestRunner,如下

  1. import HtmlTestRunner
  2. # some tests here
  3. if __name__ == '__main__':
  4. unittest.main(testRunner=HtmlTestRunner.HTMLTestRunner())

如果是在测试套件中运行,换成HTMLTestRunner即可:

  1. # 创建测试运行器
  2. # runner = unittest.TestRunner()
  3. runner = HTMLTestRunner()
  4. runner.run(suit)

2.3 结果

默认会生成reports/ 文件夹,按照时间显示报告: 单元测试成神之路——Python篇 - 图3

3. pytest-cov

3.1 安装

  1. pip install pytest-cov

详细可参考pytest-cov

3.2 运行

  1. pytest --cov --cov-report=html

或者指定目录:

  1. pytest --cov=src --cov-report=html

3.3 结果

会生成htmlcov/index.html文件,在浏览器查看,类似于coverage的报告。

4. 可能的问题

4.1 报告没生成

如果出现不了报告,pycharm运行的时候,记得选择python,而不是Python tests

单元测试成神之路——Python篇 - 图4

4.2 在Pycharm中配置覆盖率展示

可选择unittest和pytest为默认runner

单元测试成神之路——Python篇 - 图5

可显示覆盖率窗口: 单元测试成神之路——Python篇 - 图6

七、情景示例

1. 概览

1.1 项目介绍

一个简单的博客系统,包含:

  • 创建文章
  • 获取文章
  • 获取文章列表

1.2 项目结构

  1. ├── README.md
  2. ├── requirements.txt
  3. └── src
  4. ├── blog
  5. ├── __init__.py
  6. ├── app.py
  7. ├── commands.py
  8. ├── database.db
  9. ├── init_db.py
  10. ├── models.py
  11. └── queries.py
  12. └── tests
  13. └── blog
  14. ├── __init__.py
  15. ├── conftest.py
  16. ├── schemas
  17. ├── Article.json
  18. ├── ArticleList.json
  19. └── __init__.py
  20. ├── test_app.py
  21. ├── test_commands.py
  22. └── test_queries.py

1.3 关键技术

  • Flask,web框架
  • SQLite,轻量级数据库,文件格式
  • pytest,单元测试框架
  • Pydantic,数据校验

2. Service测试

2.1 创建文章

models.py如下:

  1. import os
  2. import sqlite3
  3. import uuid
  4. from typing import List
  5. from pydantic import BaseModel, EmailStr, Field
  6. class NotFound(Exception):
  7. pass
  8. class Article(BaseModel):
  9. id: str = Field(default_factory=lambda: str(uuid.uuid4()))
  10. author: EmailStr
  11. title: str
  12. content: str
  13. @classmethod
  14. def get_by_id(cls, article_id: str):
  15. con = sqlite3.connect(os.getenv('DATABASE_NAME', 'database.db'))
  16. con.row_factory = sqlite3.Row
  17. cur = con.cursor()
  18. cur.execute("SELECT * FROM articles WHERE id=?", (article_id,))
  19. record = cur.fetchone()
  20. if record is None:
  21. raise NotFound
  22. article = cls(**record) # Row can be unpacked as dict
  23. con.close()
  24. return article
  25. @classmethod
  26. def get_by_title(cls, title: str):
  27. con = sqlite3.connect(os.getenv('DATABASE_NAME', 'database.db'))
  28. con.row_factory = sqlite3.Row
  29. cur = con.cursor()
  30. cur.execute("SELECT * FROM articles WHERE title = ?", (title,))
  31. record = cur.fetchone()
  32. if record is None:
  33. raise NotFound
  34. article = cls(**record) # Row can be unpacked as dict
  35. con.close()
  36. return article
  37. @classmethod
  38. def list(cls) -> List['Article']:
  39. con = sqlite3.connect(os.getenv('DATABASE_NAME', 'database.db'))
  40. con.row_factory = sqlite3.Row
  41. cur = con.cursor()
  42. cur.execute("SELECT * FROM articles")
  43. records = cur.fetchall()
  44. articles = [cls(**record) for record in records]
  45. con.close()
  46. return articles
  47. def save(self) -> 'Article':
  48. with sqlite3.connect(os.getenv('DATABASE_NAME', 'database.db')) as con:
  49. cur = con.cursor()
  50. cur.execute(
  51. "INSERT INTO articles (id,author,title,content) VALUES(?, ?, ?, ?)",
  52. (self.id, self.author, self.title, self.content)
  53. )
  54. con.commit()
  55. return self
  56. @classmethod
  57. def create_table(cls, database_name='database.db'):
  58. conn = sqlite3.connect(database_name)
  59. conn.execute(
  60. 'CREATE TABLE IF NOT EXISTS articles (id TEXT, author TEXT, title TEXT, content TEXT)'
  61. )
  62. conn.close()

commands.py如下:

  1. from pydantic import BaseModel, EmailStr
  2. from src.blog.models import Article, NotFound
  3. class AlreadyExists(Exception):
  4. pass
  5. class CreateArticleCommand(BaseModel):
  6. author: EmailStr
  7. title: str
  8. content: str
  9. def execute(self) -> Article:
  10. try:
  11. Article.get_by_title(self.title)
  12. raise AlreadyExists
  13. except NotFound:
  14. pass
  15. article = Article(
  16. author=self.author,
  17. title=self.title,
  18. content=self.title
  19. ).save()
  20. return article

单元测试test_commands.py:

  1. import pytest
  2. from src.blog.commands import CreateArticleCommand, AlreadyExists
  3. from src.blog.models import Article
  4. def test_create_article():
  5. """
  6. GIVEN CreateArticleCommand with a valid properties author, title and content
  7. WHEN the execute method is called
  8. THEN a new Article must exist in the database with the same attributes
  9. """
  10. cmd = CreateArticleCommand(
  11. author='john@doe.com',
  12. title='New Article',
  13. content='Super awesome article'
  14. )
  15. article = cmd.execute()
  16. db_article = Article.get_by_id(article.id)
  17. assert db_article.id == article.id
  18. assert db_article.author == article.author
  19. assert db_article.title == article.title
  20. assert db_article.content == article.content
  21. def test_create_article_with_mock(monkeypatch):
  22. """
  23. GIVEN CreateArticleCommand with valid properties author, title and content
  24. WHEN the execute method is called
  25. THEN a new Article must exist in the database with same attributes
  26. """
  27. article = Article(
  28. author='john@doe.com',
  29. title='New Article',
  30. content='Super awesome article'
  31. )
  32. monkeypatch.setattr(
  33. Article,
  34. 'save',
  35. lambda self: article
  36. )
  37. cmd = CreateArticleCommand(
  38. author='john@doe.com',
  39. title='New Article',
  40. content='Super awesome article'
  41. )
  42. db_article = cmd.execute()
  43. assert db_article.id == article.id
  44. assert db_article.author == article.author
  45. assert db_article.title == article.title
  46. assert db_article.content == article.content
  47. def test_create_article_already_exists():
  48. """
  49. GIVEN CreateArticleCommand with a title of some article in database
  50. WHEN the execute method is called
  51. THEN the AlreadyExists exception must be raised
  52. """
  53. Article(
  54. author='jane@doe.com',
  55. title='New Article',
  56. content='Super extra awesome article'
  57. ).save()
  58. cmd = CreateArticleCommand(
  59. author='john@doe.com',
  60. title='New Article',
  61. content='Super awesome article'
  62. )
  63. with pytest.raises(AlreadyExists):
  64. cmd.execute()

当多次运行时候,需要清理数据库,那么需要使用到用例前置和后置:

confest.py:

  1. import os
  2. import tempfile
  3. import pytest
  4. from src.blog.models import Article
  5. @pytest.fixture(autouse=True)
  6. def database():
  7. _, file_name = tempfile.mkstemp()
  8. os.environ['DATABASE_NAME'] = file_name
  9. Article.create_table(database_name=file_name)
  10. yield
  11. os.unlink(file_name)

再次运行,执行结果:

  1. $ python3 -m pytest src/tests/blog/test_commands.py
  2. =================== test session starts ======================
  3. platform darwin -- Python 3.8.3, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
  4. rootdir: python-ut
  5. plugins: metadata-1.11.0, html-3.1.1, mock-3.5.1
  6. collected 3 items
  7. src/tests/blog/test_commands.py ... [100%]
  8. ===================== 3 passed in 0.02s =======================

2.2 获取文章列表

queries.py:

  1. from typing import List
  2. from pydantic import BaseModel
  3. from src.blog.models import Article
  4. class ListArticlesQuery(BaseModel):
  5. def execute(self) -> List[Article]:
  6. articles = Article.list()
  7. return articles

单元测试test_queries.py:

  1. from src.blog.models import Article
  2. from src.blog.queries import ListArticlesQuery, GetArticleByIDQuery
  3. def test_list_articles():
  4. """
  5. GIVEN 2 articles stored in the database
  6. WHEN the execute method is called
  7. THEN it should return 2 articles
  8. """
  9. Article(
  10. author='jane@doe.com',
  11. title='New Article',
  12. content='Super extra awesome article'
  13. ).save()
  14. Article(
  15. author='jane@doe.com',
  16. title='Another Article',
  17. content='Super awesome article'
  18. ).save()
  19. query = ListArticlesQuery()
  20. assert len(query.execute()) == 2

2.3 获取文章

queries.py里面加入:

  1. class GetArticleByIDQuery(BaseModel):
  2. id: str
  3. def execute(self) -> Article:
  4. article = Article.get_by_id(self.id)
  5. return article

单元测试test_queries.py里加入:

  1. def test_get_article_by_id():
  2. """
  3. GIVEN ID of article stored in the database
  4. WHEN the execute method is called on GetArticleByIDQuery with id set
  5. THEN it should return the article with the same id
  6. """
  7. article = Article(
  8. author='jane@doe.com',
  9. title='New Article',
  10. content='Super extra awesome article'
  11. ).save()
  12. query = GetArticleByIDQuery(
  13. id=article.id
  14. )
  15. assert query.execute().id == article.id

3. 其他功能测试

应用入口app.py:

  1. from flask import Flask, jsonify, request
  2. from src.blog.commands import CreateArticleCommand
  3. from src.blog.queries import GetArticleByIDQuery, ListArticlesQuery
  4. from pydantic import ValidationError
  5. app = Flask(__name__)
  6. @app.route('/articles/', methods=['POST'])
  7. def create_article():
  8. cmd = CreateArticleCommand(
  9. **request.json
  10. )
  11. return jsonify(cmd.execute().dict())
  12. @app.route('/articles/<article_id>/', methods=['GET'])
  13. def get_article(article_id):
  14. query = GetArticleByIDQuery(
  15. id=article_id
  16. )
  17. return jsonify(query.execute().dict())
  18. @app.route('/articles/', methods=['GET'])
  19. def list_articles():
  20. query = ListArticlesQuery()
  21. records = [record.dict() for record in query.execute()]
  22. return jsonify(records)
  23. @app.errorhandler(ValidationError)
  24. def handle_validation_exception(error):
  25. response = jsonify(error.errors())
  26. response.status_code = 400
  27. return response
  28. if __name__ == '__main__':
  29. app.run()

暴露json schema,校验响应payload:

Article.json

  1. {
  2. "$schema": "http://json-schema.org/draft-07/schema#",
  3. "title": "Article",
  4. "type": "object",
  5. "properties": {
  6. "id": {
  7. "type": "string"
  8. },
  9. "author": {
  10. "type": "string"
  11. },
  12. "title": {
  13. "type": "string"
  14. },
  15. "content": {
  16. "type": "string"
  17. }
  18. },
  19. "required": [
  20. "id",
  21. "author",
  22. "title",
  23. "content"
  24. ]
  25. }

ArticleList.json

  1. {
  2. "$schema": "http://json-schema.org/draft-07/schema#",
  3. "title": "ArticleList",
  4. "type": "array",
  5. "items": {
  6. "$ref": "file:Article.json"
  7. }
  8. }

从应用本身,串起来整个流程的测试,测试test_app.py:

  1. import json
  2. import pathlib
  3. import pytest
  4. from jsonschema import validate, RefResolver
  5. from src.blog.app import app
  6. from src.blog.models import Article
  7. @pytest.fixture
  8. def client():
  9. app.config['TESTING'] = True
  10. with app.test_client() as client:
  11. yield client
  12. def validate_payload(payload, schema_name):
  13. """
  14. Validate payload with selected schema
  15. """
  16. schemas_dir = str(
  17. f'{pathlib.Path(__file__).parent.absolute()}/schemas'
  18. )
  19. schema = json.loads(pathlib.Path(f'{schemas_dir}/{schema_name}').read_text())
  20. validate(
  21. payload,
  22. schema,
  23. resolver=RefResolver(
  24. 'file://' + str(pathlib.Path(f'{schemas_dir}/{schema_name}').absolute()),
  25. schema # it's used to resolve file: inside schemas correctly
  26. )
  27. )
  28. def test_create_article(client):
  29. """
  30. GIVEN request data for new article
  31. WHEN endpoint /articles/ is called
  32. THEN it should return Article in json format matching schema
  33. """
  34. data = {
  35. 'author': 'john@doe.com',
  36. 'title': 'New Article',
  37. 'content': 'Some extra awesome content'
  38. }
  39. response = client.post(
  40. '/articles/',
  41. data=json.dumps(
  42. data
  43. ),
  44. content_type='application/json',
  45. )
  46. validate_payload(response.json, 'Article.json')
  47. def test_get_article(client):
  48. """
  49. GIVEN ID of article stored in the database
  50. WHEN endpoint /articles/<id-of-article>/ is called
  51. THEN it should return Article in json format matching schema
  52. """
  53. article = Article(
  54. author='jane@doe.com',
  55. title='New Article',
  56. content='Super extra awesome article'
  57. ).save()
  58. response = client.get(
  59. f'/articles/{article.id}/',
  60. content_type='application/json',
  61. )
  62. validate_payload(response.json, 'Article.json')
  63. def test_list_articles(client):
  64. """
  65. GIVEN articles stored in the database
  66. WHEN endpoint /articles/ is called
  67. THEN it should return list of Article in json format matching schema
  68. """
  69. Article(
  70. author='jane@doe.com',
  71. title='New Article',
  72. content='Super extra awesome article'
  73. ).save()
  74. response = client.get(
  75. '/articles/',
  76. content_type='application/json',
  77. )
  78. validate_payload(response.json, 'ArticleList.json')
  79. @pytest.mark.parametrize(
  80. 'data',
  81. [
  82. {
  83. 'author': 'John Doe',
  84. 'title': 'New Article',
  85. 'content': 'Some extra awesome content'
  86. },
  87. {
  88. 'author': 'John Doe',
  89. 'title': 'New Article',
  90. },
  91. {
  92. 'author': 'John Doe',
  93. 'title': None,
  94. 'content': 'Some extra awesome content'
  95. }
  96. ]
  97. )
  98. def test_create_article_bad_request(client, data):
  99. """
  100. GIVEN request data with invalid values or missing attributes
  101. WHEN endpoint /create-article/ is called
  102. THEN it should return status 400 and JSON body
  103. """
  104. response = client.post(
  105. '/articles/',
  106. data=json.dumps(
  107. data
  108. ),
  109. content_type='application/json',
  110. )
  111. assert response.status_code == 400
  112. assert response.json is not None

4. 小结

自此,上面的web小应用基本可以完成,包含了基本的服务层单元测试、数据库模拟、mock创建文章以及参数化请求验证。

七、结语

1. 小结

Python的单元测试框架中,Python库本身提供了unittest,也有第三方框架进行了封装。原生的库插件少,二次开发非常方便。第三方框架融合了不少插件,上手简单。

Python属于脚本语言,不像编译型语言那样先将程序编译成二进制再运行,而是动态地逐行解释运行,虽然其本身的结构灵活多变,但是仍然不妨碍我们用单元测试保证其质量、权衡其设计、设置其有形和无形的约束,为开发保驾护航。

2. 推荐阅读


测试 Share Tweet +1