Django 项目单元测试的几个套路

439 阅读4分钟
原文链接: blog.logflows.top

单元测试,可以说是软件工程当中降低开发成本、提高软件质量的最常用手段之一。不过,相信很多同学在项目当中尝试或者推广单元测试的时候,都会遇到这样那样的实际问题,又无法得到解答。

下面是我遇到的几个常见问题和解决方案,在此整理一下。

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 依赖注册框架。

更多的关于这个问题的讨论,推荐看这篇文章 Inversion of Control Containers and the Dependency Injection pattern

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 版本的统计报告。