单元测试,可以说是软件工程当中降低开发成本、提高软件质量的最常用手段之一。不过,相信很多同学在项目当中尝试或者推广单元测试的时候,都会遇到这样那样的实际问题,又无法得到解答。
下面是我遇到的几个常见问题和解决方案,在此整理一下。
mock http 请求
很多项目当中都需要做系统集成,或者说软件工程很大程度上就是个做集成的活儿。很多模块势必需要调用其他系统的接口,最常见的例子就是发送短信通知,比如下面这个例子:
import requests
def sendCode(phone, code):
data = {'phone': phone, 'code': code, 'apiKey': API_KEY}
res = requests.post('https://message.api.com/api/v1/send-code', data=data)
# error handling code...
saveCode(phone, code)
如果不考虑 http 请求的问题,单元测试一般会写成下面这个样子:
class SendCodeTestCase(AuditTestCase)
def test_send_code(self):
sendCode('18888888888', '123456')
code = self.getSavedCode('18888888888')
self.assertEqual(code, '123456')
但是,这个测试用例是有问题的,至少是有副作用的,每次执行都得发 http 请求。
解决方案其实很简单,可以使用 responses 模块来 mock http 请求。使用方式如下:
+import responses
class SendCodeTestCase(AuditTestCase)
+ @responses.activate def test_send_code(self):
+ responses.add(responses.GET, 'https://message.api.com/api/v1/send-code',+ json={'error': 'not found'}, status=404)
sendCode('18888888888', '123456')
code = self.getSavedCode('18888888888')
self.assertEqual(code, '123456')
仔细看,你还能发现,使用 responses 库 mock http 请求的另一大好处是,能够模拟 http 请求出错的情况。
fake time
有很多时候,我们需要测试和时间有关的逻辑。举一个常见的例子是检查 token 是否超时:
def validateToken(token):
now = timezone.now()
data = getDataByToken(token)
if now > data.expiredAt:
return 'token-expired'
# some other validation rules...
其中的 now = timezone.now() 语句每次执行测试的时候都会边,而且都是系统当前时间。解决方案是使用 freegun 这个模块:
from freezegun import freeze_time
class TokenTestCase(AuditTestCase)
def test_validate_token(self):
token = None
with freeze_time(lambda: datetime.datetime(2012, 1, 14)):
token = fakeToken(userId='jack')
with freeze_time(lambda: datetime.datetime(2012, 1, 22)):
error = validateToken(token)
self.assertEqual(error, 'token-expired')
假设 token 有效期是 7 天,使用 freegun 就可以模拟 token 生成后时间超过七天 token 失效的场景。
依赖注册/注入
有的时候,我们要对接的系统不是通过 http 接口来集成的,而是对方提供了 client 库,比如七牛,参考官方文档提供的例子:
import qiniu
def updateFile(path):
q = qiniu.Auth(ACCESS_KEY, SECRET_KEY)
key = 'hello'
data = 'hello qiniu!'
token = q.upload_token(bucket_name)
ret, info = qiniu.put_data(token, key, data)
if ret is not None:
print('All is OK')
else:
print(info) # error message in info
处理这类问题一般要是依赖注册/注入的方法来解决了,我推荐优先使用 依赖注册 的方法解决这个问题。具体方法是实现一个全局模块,用来注册像七牛这类的服务:
class ServiceRegistry()
def __init__(self):
self.services = {}
def register(self, id, service):
self.services[id] = service
def get(self, id):
return self.services[id]
serviceRegistry = ServiceRegistry()
上传文件的代码要做一些调整:
import qiniu
+from xxx import serviceRegistry
def updateFile(path):
q serviceRegistry.get('qiniu)
- q = qiniu.Auth(ACCESS_KEY, SECRET_KEY) key = 'hello'
data = 'hello qiniu!'
token = q.upload_token(bucket_name)
ret, info = qiniu.put_data(token, key, data)
if ret is not None:
print('All is OK')
else:
print(info) # error message in info
简单来说,就是使用 qiniu 接口的时候,不再自己创建,而是通过 serviceRegistry 对象来查询 qiniu 的接口实现。
在测试代码中调用 uploadFile 函数之前,先通过 serviceRegistry 注册一个 mock 的 qiniu 接口:
from xxx import serviceRegistry
class MockQiniu:
def __init__(self):
pass
def upload_token(self):
return 'foobar'
def put_token(self, key, data)
return None
class UploadTestCase(AuditTestCase)
def test_upload(self):
serviceRegistry.register('qiniu', MockQiniu())
uploadFile('/var/lib/avatar.png')
依赖注册 vs 依赖注入
之所以推荐优先使用依赖注册,主要的原因是 Django 项目一般都是应用层项目,代码逻辑不会很复杂,使用依赖注册很容易让人理解,对现有代码的破坏程度也不大。
在几乎都是各种搬砖逻辑的项目里面用依赖注册,就显得有点大材小用了,而且还没有看到有比较好的 Django 依赖注册框架。
mock 缓存、数据库
mock cache 和 database 在 Django 项目当中是非常容易的,因为启动 Django 服务是可以设置不同的配置文件。
可以先创建一个测试环境配置文件 test_settings.py:
import logging
from backend.settings import *
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3')
}
}
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'unique-snowflake',
}
}
然后运行测试的时候,让 Django 读取这个配置文件即可。
$ manage.py test --settings=backend.test_settings
这样,执行 Django 单元测试的时候,就可以使用内存版本缓存以及本地临时创建的 sqlite3 数据库来测试了。
统计代码覆盖率
统计代码覆盖率就用 coverage 模块好了,还没找到其他合适的工具,使用方式如下:
$ coverage run \ --source='.' \ --omit='venv/*,core/migrations/*' \ manage.py test \ --no-logs \ --failfast \ --settings=backend.test_settings
$ coverage html
$ coverage report -m
执行完测试以后,可以打开 htmlcov/index.html 文件来看 html 版本的统计报告。
更多内容可以查看我的博客