Python 单元测试
Table of Contents
1. 测试驱动开发
先写单元测试,然后再实现功能,这样做的好处:
- 在长期实施测试驱动开发,可以明显感受到养成了良好的编码风格,比如函数与类的单一职责更是自然行成;
- 修补 bug 和重构代码更加稳固。尤其是在代码变多的情况下,没有单元测试就会经常出现改好这里,又引出新的问题,测试用例可以更好的避免这种情况;
- 在我的实践中,安全相关的关键代码也写入测试用例,保证源头的基本安全性。review 安全相关的测试用例时也能发现哪些安全问题没有覆盖到。举个例子,假如实现一个文件读取函数,我的单元测试会包含正常读取文件、越目录读取的测试用例。
整个实践的过程很简单,反复循环执行三个步骤:
- 测试先行,站在程序员使用的角度,写好功能测试;
- 运行测试用例,失败以后再写功能代码,直到测试用例通过;
- 检查代码是否值得继续重构。
编写测试用例、实现功能、重构代码,满足了这三大块,才叫TDD。
重点:
- 功能逐个实现,切勿一次写多个测试用例,再写代码。
- 重构时,先写测试用例,不要修改测试用例的同时也修改功能代码。
- 单元测试是用来测试流程和代码逻辑的,无关的东西不要测试(比如常量)。
2. 工具
2.1. pytest
官方网址:http://pytest.org/
在项目中,应当有良好的目录结构,通常把所有的单元测试文件放到单独的 test 目录中:
|-- app/ -- app.py |-- test/ -- test_app.py
2.2. 测试覆盖率
Coverage.py 用于统计单元测试的测试覆盖率的工具。
运行单个测试用例脚本:
$ coverage run test/test_app_browser.py
然后生成覆盖率统计报告:
$ coverage report Name Stmts Miss Cover -------------------------------------------------------------------------------------------------------------------------------------- app/__init__.py 47 0 100% app/api.py 195 150 23% app/browser/__init__.py 0 0 100%
输出的各列含义:
Stmts:有效代码总行数(没有算注释、空行)
Miss:未执行的代码总行数(没有算注释、空行)
Cover:覆盖率
如果用的 pytest,建议使用 pytest-cov 这个工具,它封装了 Coverage。使用方式也简单:
$ py.test -v --cov=app 单元测试目录
注意 –cov 参数,它用于过滤所属项目的测试结果,如果没有过滤的话,每个执行过的模块都被打印出来。
3. doctest
doctest 会去执行 docstring 中的 Python 交互式会话中的内容,例如:
# filename: common.py import hashlib def md5(s): """ >>> md5("123") '202cb962ac59075b964b07152d234b70' >>> md5(123) Traceback (most recent call last): ... AssertionError """ assert(s.__class__ == str) return hashlib.md5(s.encode("utf-8")).hexdigest()
执行测试命令:
$ python3 -mdoctest common.py
如果未通过测试,会给出详细的提示。
doctest 更重要的是起调用示例的作用——给别人看的,或是生成自动文档。
4. 基本的断言——assert
用 assert 可以写出最简单的单元测试,比如 common.py 定义了一些项目中公用的函数:
import hashlib def md5(s): assert(s.__class__ == str) return hashlib.md5(s.encode("utf-8")).hexdigest() def flat_list(a_list): result = [] for l in a_list: result.extend(l) return result
现在在 test_common.py 中写一些测试用例:
def test_md5(): from common import md5 assert(md5("123") == "202cb962ac59075b964b07152d234b70") def test_flat_list(): from common import flat_list assert(flat_list([]) == []) assert(flat_list([[1, 2], [3, 4]]) == [1, 2, 3, 4])
然后用 pytest 运行测试用例:
$ pytest test_common.py
不建议在测试用例中直接使用 assert 关键字:
- assert 不会显示出更详细的信息,仅知道触发了 AssertionError 异常。
- 在运行某几个测试用例以前需要做一些环境初始化的工作。
- 需要测试触发的异常。
5. unittest库
Python 标准库自带的单元测试库。
以前面的 common.py 为例,现在改用 unittest 编写单元测试代码:
import unittest class TestCommon(unittest.TestCase): def test_md5(self): from common import md5 self.assertEqual(md5("123"), "202cb962ac59075b964b07152d234b70") self.assertRaises(AssertionError, md5, 123) # 捕获抛出异常,如果为 md5 函数提供数字型就抛出异常 def test_flat_list(self): from common import flat_list self.assertRaises(TypeError, flat_list, [1, 2, 3, 4]) self.assertEqual(flat_list([]), []) self.assertEqual(flat_list([[1, 2], [3, 4]]), [1, 2, 3, 4]) if __name__ == '__main__': unittest.main()
说明:
- 测试用的类以 Test 开头命名
- 测试类继承 unittest.TestCase 类
- 每个测试用例为一个方法,以 test_ 开头
- 执行 unittest.main() 可以启动自动测试
更详细的用法请见官方文档:https://docs.python.org/3/library/unittest.html
5.1. setUp 和 tearDown
有一些测试场景比较复杂,在运行一组测试之前,需要依赖上下文的初始化,比如:
- 需要先初始化数据库
- 测试 API 接口,需要先获得 token
等等。
这时就可以借助自己实现 setUp 和 tearDown 方法。setUp 和 tearDown 跟类的构造函数和析构函数是一样的,setUp 用于测试前做环境初始化,如要测试数据库,则在 setUp 中需要先行连接数据库;tearDown 则是销毁环境作用,如关闭数据库。
如下演示的代码,被测试的接口需要先完成“登录”授权,我把登录授权和销毁状态功能实现在 setUp 和 tearDown 中:
# 注:self.client、self.context 就定义在 TestBase 类中的 class TestManagerBlog(TestBase, unittest.TestCase): def setUp(self): # 先设置登录状态 with self.client.session_transaction() as s: s["username"] = "test" s["islogin"] = True def tearDown(self): # 测试完成后销毁登录状态 with self.client.session_transaction() as s: s.pop("username") s.pop("islogin") def test_post_new_blog(self): """发表文章测试""" with self.context: resp = self.client.post(url_for("blog.new_blog"), data={ "title": "just test", "content": "test", }) # 成功增加博文后,服务端返回 302,跳转到首页 # 模拟 POST 请求后,判断状态码 self.assertTrue(resp.status_code == 302) # 判断是否重定向首页 self.assertTrue(resp.location.endswith("/")) # 查询数据库,看是否新增成功 # 使用的 SQLAlchemy 库 blog = ManagerBlogModel.query.filter( ManagerBlogModel.title == "just test" ).first() self.assertTrue(blog is not None)
6. Stub(桩代码) 测试
比如要测试的有些类,它同时依赖其他类,我们可以自己去实现一个和它依赖的类长得差不多的类。
示例代码:
class Reader(object): def __init__(self, path): self.path = path def read(self): content = None try: with open(self.path) as f: content = f.read().strip() except FileNotFoundError: pass return content class Flag(object): """验证 flag 文件""" def __init__(self): self.reader = Reader("/tmp/flag") def is_flag(self): flag = self.reader.read() if flag is None: raise ValueError("flag 错误") if flag == "49f68a5c8493ec2c0bf489821c21fc3b": return True return False
现在要测试 Flag 这个类,但要测试的对象类依赖 Reader 类,因此我们在测试代码中直接制造假的 Reader 类:
import unittest from test import Flag from test import Reader class StubReader1(Reader): def read(self): return "49f68a5c8493ec2c0bf489821c21fc3b" class StubReader2(Reader): def read(self): return "xxx" class StubReader3(Reader): def read(self): return None class TestFlag(unittest.TestCase): def setUp(self): self.flag = Flag() def test_is_flag(self): # 测试前先修改依赖 self.flag.reader = StubReader1("/tmp/flag") self.assertTrue(self.flag.is_flag()) self.flag.reader = StubReader2("/tmp/flag") self.assertFalse(self.flag.is_flag()) self.flag.reader = StubReader3("/tmp/flag") self.assertRaises(ValueError, self.flag.is_flag)
7. Mock 测试
有些测试是不依赖环境的(比如某个算法类的功能),但在实际项目中,有很功能依赖网络环境、读取外部文件等等,这些外部环境出现问题就会导致单元测试过不了,而用 Mock 测试就可以直接“模拟”出这些“环境”,保证每次测试都是如期的。
Mock 和 Stub 的区别:
Stub 虽然是虚拟出的对象,但这个“虚拟”却要自己去实现一大堆代码了,比如要测试某个类的某个方法,需要先写一个继承类,然后覆盖父类的方法。Stub 有具体实现的方法,所以写测试时,还要关注它的实现逻辑。 对于 Mock,我们在不去重新实现依赖方法的情况下,就可以去做更深层次的依赖模拟,同时还能知道依赖的对象最终是否被调用到。
7.1. mock 库
Python 3.3+ 已经自带 mock 库(unittest.mock),如果使用的低版本 Python,可用 pip 安装:
$ sudo pip install mock
mock 详细文档请见:https://docs.python.org/3/library/unittest.mock.html
示例1:
from unittest import TestCase from unittest.mock import patch class TestCountIP(TestCase): def setUp(self): self.c = CountIP() @patch("config.es.search", lambda **args: { "took" : 26, "timed_out" : False, "_shards" : { "total" : 130, "successful" : 130, "skipped" : 0, "failed" : 0 }, "hits" : { "total" : 27073841, "max_score" : 0.0, "hits" : [ ] }, "aggregations" : { "attack_ip_total" : { "value" : 11210866 } } }) def test_count(self): self.assertEqual(self.c.count(), 11210866)
以上代码中,CountIP 类中有个 count 方法负责调用 Elasticsearch 接口,然后取值。这个测试用例用 patch 装饰器指定好了 config.es.search 的调用结果,最后再做测试。
示例2,如何 mock datetime.datetime 模块
比如有一个获取昨日日期的函数,需要做单元测试:
import datetime def yesterday(): now = datetime.datetime.now() yesterday = now - datetime.timedelta(days=1) return yesterday.strftime("%Y%m%d")
如果单元测试这样写:
import datetime from utils import yesterday from unittest import TestCase, main from unittest.mock import patch class TestUtils(TestCase): @patch("datetime.datetime.now", lambda: datetime.datetime(2019, 11, 11, 11, 19, 26, 469747)) def test_yesterday(self): self.assertEqual(yesterday(), "20191110")
执行测试时会报错:
Traceback (most recent call last): File "/usr/lib64/python3.7/unittest/mock.py", line 1268, in patched patching.__exit__(*exc_info) File "/usr/lib64/python3.7/unittest/mock.py", line 1430, in __exit__ setattr(self.target, self.attribute, self.temp_original) TypeError: can't set attributes of built-in/extension type 'datetime.datetime'
对于这种情况,可以用匿名类来解决:
class TestUtils(TestCase): @patch("datetime.datetime", type("XX", (datetime.datetime,), { "now": lambda: datetime.datetime(2019, 11, 11, 11, 19, 26, 469747)})) def test_yesterday(self): self.assertEqual(yesterday(), "20191110")
7.2. pytest mock
用法可参考文档:http://pytest.org/latest/monkeypatch.html
假如 common.py 中定义了个获取当前日期的函数:
import time def current_date(): return time.strftime('%Y-%m-%d', time.localtime(time.time()))
想写个测试用例来判断它是否以“yyyy-MM-dd”格式返回当前日期,按 pytest mock 的文档写的 mock 测试:
def test_current_date(monkeypatch): import time from common import current_date monkeypatch.setattr(time, "time", lambda: 1490749983.466406) assert(current_date() == "2017-03-29")
运行 pytest:
$ pytest test_common.py
7.3. 其他 mock 库
对于一些特殊的三方库,差不多都可以在网上找到相应的专用 mock 库,在此展示两个。
7.3.1. mockredis
官网:https://github.com/locationlabs/mockredis
在某个 Flask 开发的 Web 应用中,用到了 Redis 做缓存,并实现了两个缓存读写函数,对读写函数的测试用例如下:
"""缓存模块单元测试""" from unittest.mock import patch from unittest import TestCase from mockredis import MockRedis from flask import Flask from flask_redis import Redis from cache import write_cache from cache import read_cache @patch('cache.redis_conn', MockRedis(strict=True)) class TestCache(TestCase): def setUp(self): redis_conn = Redis() self.app = Flask(__name__) redis_conn.init_app(self.app) def test_write_cache(self): write_cache("test_redis", "test", second=30) self.assertTrue(read_cache("test_redis") == b"test") # 如果 second 参数为0,不做缓存 write_cache("test_redis_0", "test", second=0) self.assertIsNone(read_cache("test_redis_0")) def test_read_cache(self): self.assertIsNone(read_cache("test"))
7.3.2. requests-mock
如果被测试的对象用到 Requests 库,就可以到 requests-mock,官网:https://requests-mock.readthedocs.io/
例,测试 API 登录获得 token 功能:
import requests def get_token(username, password): resp = requests.post("http://api.server.com/login", data={"username": username, "password": password}) return resp.text
测试用例如下:
import requests_mock def test_login(): with requests_mock.Mocker() as m: m.post('http://api.server.com/login', text='{"token": "123"}') token = get_token(username="admin", password="admin") assert(token == '{"token": "123"}')
8. 小脚本中的单元测试
工作中经常会遇到只用写一两个文件的程序,不想搞复杂,通常我会在程序中直接定义一个测试函数去测试各项功能。然后为程序顺带提供一个 --test
参数,如:
def self_test(): ... if __name__ == '__main__': ... parser.add_option("--test", help=u"自测", action="store_true") ... (options, args) = parser.parse_args() if options.test: self_test() raise SystemExit()