1. 环境准备
1、创建项目和应用:
django-admin startproject django_example_untest
cd django_example
python manage.py startapp users
2、添加应用,注释 csrf
:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'users'
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
# 'django.middleware.csrf.CsrfViewMiddleware', # 防止测试时需要验证 csrf
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
3、目录结构如下:
django_example
├── django_example_untest
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── manage.py
└── users
├── __init__.py
├── admin.py
├── apps.py
├── migrations
│ └── __init__.py
├── models.py
├── tests.py
└── views.py
4、添加视图函数 users/views.py
:
import json
from django.contrib.auth import authenticate, login, logout
from django.http import JsonResponse
from django.shortcuts import render
# Create your views here.
from django.views import View
class UserView(View):
"""获取用户信息"""
def get(self, request):
if not request.user.is_authenticated:
return JsonResponse({
'code': 401,
'message': '用户未登录'
})
return JsonResponse({
'code': 200,
'message': 'OK',
'data': {
'username': request.user.username
}
})
class SessionView(View):
def post(self, request):
"""用户登录"""
# 客户端的请求体是 json 格式
content_type = request.content_type
if 'application/json' not in content_type:
return JsonResponse({
'code': 400,
'message': '非 json 格式'
})
else:
data = eval(request.body.decode('utf-8'))
username = data.get('username', '')
password = data.get('password', '')
user = authenticate(username=username,
password=password)
# 检查用户是否存在
if not user:
return JsonResponse({
'code': 400,
'message': '用户名或密码错误'
})
# 执行登录
login(request, user)
return JsonResponse({
'code': 201,
'message': 'OK'
})
def delete(self, request):
"""退出登录"""
logout(request)
return JsonResponse({
'code': 204,
'message': 'OK'
})
执行 python manage.py makemigrations、python manage.py migrate
。
2. 单元测试
Django
的单元测试使用的是 python unittest
模块,在每个应用下面都有一个 tests.py
文件,将测试代码写入其中即可。如果测试的代码量比较多,我们需要将测试的代码分模块,那么可以在当前应用下创建 tests
包。
2.1 常用方法及使用事项
django
提供了 django.test.TestCase
单元测试基础类,它继承自 python
标准库中 unittest.TestCase
。
通常测试代码中自定义的类继承 TestCase
类,里面测试方法必须以 test_
开头,一个类可以包含多个测试方法,如测试登录可以取名为 test_login
。
两个特殊方法
setUp(self)
:在每个测试方法执行之前调用,一般用于做一些准备工作tearDown(self)
:在每个测试方法执行之后被调用,一般用来做一些清理工作
两个特殊类方法
setUpClass(cls)
:用于做类级别的准备工作,会在测试之前被调用,且一个类中,只能被调用一次tearDown(self)
:用于做类级别的准备工作,会在测试之后被调用,且一个类中,只能被调用一次
客户端类 Client
客户端类用于模拟客户端发起 get、post、delet
等请求,且能自动保存 cookie
,Django
的客户端类由 django.test.client.Client
提供。
另外 Client
还提供了 login
方法,可以很方便地进行用户登录。
注意:通过
client
发起请求时,URL
路径不用添加schema://domain
前缀
2.2 如何执行
- 测试所有类(多个应用/多个测试文件):
python manage.py test
- 测试具体应用、文件、类、方法:
python manage.py test [app_name] [.test_file_name] [.class_name] [.test_method_name]
示例:
python manage.py test users
python manage.py test users.tests
python manage.py test users.tests.UserTestCase
python manage.py test users.tests.UserTestCase.test_user
测试时会创建自动创建测试数据库,测试完毕后也会自动销毁:
(MxShop) F:\My Projects\django_example_untest>python manage.py test users.tests.UserTestCase.test_user
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.241s
OK
Destroying test database for alias 'default'...
看到 OK
两字表示测试用例通过,否则测试失败,还需检查程序代码哪里有问题。
2.3 测试代码
users/tests.py
编辑如下:
from django.contrib.auth.models import User
from django.test import TestCase
from django.test.client import Client
# Create your tests here.
class UserTestCase(TestCase):
def setUp(self):
# 创建测试用户
self.username = 'rose'
self.password = 'rose123'
self.user = User.objects.create_user(
username=self.username, password=self.password
)
# 实例化 client 对象
self.client = Client()
# 登录
self.client.login(username=self.username, password=self.password)
def tearDown(self):
"""删除测试用户"""
self.user.delete()
def test_user(self):
"""测试获取用户信息接口"""
path = '/users'
resp = self.client.get(path)
result = resp.json()
self.assertEqual(result['code'], 200, result['message'])
class SessionTestCase(TestCase):
@classmethod
def setUpClass(cls):
"""测试之前被调用,只调用一次"""
# 创建测试用户
cls.username = 'lila'
cls.password = 'lila123'
cls.user = User.objects.create_user(
username=cls.username, password=cls.password
)
# 实例化 client 对象
cls.client = Client()
@classmethod
def tearDownClass(cls):
"""测试之后调用,只调用一次,删除测试用户"""
cls.user.delete()
def test_login(self):
"""测试登录接口"""
path = '/session'
auth_data = {
"username": self.username,
"password": self.password
}
# 将请求体设置为 json
resp = self.client.post(
path, data=auth_data, content_type='application/json'
)
# 转换为 字典
result = resp.json()
# 检查登录结果
self.assertEqual(result['code'], 201, result['message'])
def test_logout(self):
"""测试退出接口"""
path = '/session'
resp = self.client.delete(path)
result = resp.json()
# 断言,测试 result['code'] 是否为 204,若为 204 则表示测试通过
self.assertEqual(result['code'], 204, result['message'])
测试:
(MxShop) F:\My Projects\django_example_untest>python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.579s
OK
Destroying test database for alias 'default'...
以上表示测试用例通过
2.4 RequestFactory 模拟请求
2.4.1 测试代码
上面测试代码,我们都是通过 client
来模拟请求,该请求最终会通过路由找到视图,并调用视图函数。
那么可不可以直接调用视图函数,而不通过 client
,答案是可以的;Django
提供了一个 RequestFactory
对象可以直接调用视图函数 django.test.test.client.RequestFactory
。
users/tests.py
中新增代码:
from django.contrib.auth.models import User
from django.contrib.sessions.middleware import SessionMiddleware
from django.test import TestCase
from django.test.client import Client, RequestFactory
class SessionRequestFactoryTestCase(TestCase):
"""
RequestFactory 直接调用视图函数
"""
@classmethod
def setUpClass(cls):
cls.username = 'john'
cls.password = 'john123'
cls.user = User.objects.create_user(
username=cls.username, password=cls.password
)
@classmethod
def tearDownClass(cls):
"""删除测试用户"""
cls.user.delete()
def test_login(self):
"""测试登录视图函数"""
request_factory = RequestFactory()
path = '/session'
auth_data = {
'username': self.username,
'password': self.password
}
# 构建请求对象
request = request_factory.post(
path, data=auth_data, content_type='application/json'
)
# 登录视图函数
login_function = SessionView().post
# 调用视图函数
resp = login_function(request)
print(resp.content)
def test_logout(self):
"""测试退出函数"""
request_factory = RequestFactory()
path = '/session'
request = request_factory.delete(path)
# 退出视图函数
logout_function = SessionView().delete
# 调用视图
resp = logout_function(request)
print(resp.content)
测试:
python manage.py test
AttributeError: 'WSGIRequest' object has no attribute 'session'
提示没有 session
属性,这是因为 login、logout
两个函数都有利用 session
对用户信息进行操作(如:获取用户个人信息、清除个人信息等),查看相关源码可获知:
def login(request, user, backend=None):
"""
Persist a user id and a backend in the request. This way a user doesn't
have to reauthenticate on every request. Note that data set during
the anonymous session is retained when the user logs in.
"""
session_auth_hash = ''
if user is None:
user = request.user
if hasattr(user, 'get_session_auth_hash'):
session_auth_hash = user.get_session_auth_hash()
if SESSION_KEY in request.session:
if _get_user_session_key(request) != user.pk or (
session_auth_hash and
not constant_time_compare(request.session.get(HASH_SESSION_KEY, ''), session_auth_hash)):
# To avoid reusing another user's session, create a new, empty
# session if the existing session corresponds to a different
# authenticated user.
request.session.flush()
else:
request.session.cycle_key()
try:
backend = backend or user.backend
except AttributeError:
backends = _get_backends(return_tuples=True)
if len(backends) == 1:
_, backend = backends[0]
else:
raise ValueError(
'You have multiple authentication backends configured and '
'therefore must provide the `backend` argument or set the '
'`backend` attribute on the user.'
)
# 将相关信息存储到 session 中
request.session[SESSION_KEY] = user._meta.pk.value_to_string(user)
request.session[BACKEND_SESSION_KEY] = backend
request.session[HASH_SESSION_KEY] = session_auth_hash
if hasattr(request, 'user'):
request.user = user
rotate_token(request)
user_logged_in.send(sender=user.__class__, request=request, user=user)
def logout(request):
"""
Remove the authenticated user's ID from the request and flush their session
data.
"""
# Dispatch the signal before the user is logged out so the receivers have a
# chance to find out *who* logged out.
user = getattr(request, 'user', None)
if hasattr(user, 'is_authenticated') and not user.is_authenticated:
user = None
user_logged_out.send(sender=user.__class__, request=request, user=user)
# remember language choice saved to session
language = request.session.get(LANGUAGE_SESSION_KEY)
# 清除 session
request.session.flush()
if language is not None:
request.session[LANGUAGE_SESSION_KEY] = language
if hasattr(request, 'user'):
from django.contrib.auth.models import AnonymousUser
request.user = AnonymousUser()
2.4.2 SessionMiddleware 源码
Django
是通过 SessionMiddleware
中间件 process_request
方法来实现的,具体可见源码:
# middleware.py
class SessionMiddleware(MiddlewareMixin):
def __init__(self, get_response=None):
self.get_response = get_response
engine = import_module(settings.SESSION_ENGINE)
self.SessionStore = engine.SessionStore
def process_request(self, request):
session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
# 新增 session 属性(request 对象中)
request.session = self.SessionStore(session_key)
2.4.3 解决方法
在上面 SessionRequestFactoryTestCase
测试类中我们只创建了请求对象 request
,但是并没有添加 session
属性,所以导致后面的测试用例无法通过。
为此可以通过调用 SessionMiddleware.process_request
方法来设置 session
属性。
class SessionRequestFactoryTestCase(TestCase):
....
def test_login(self):
"""测试登录视图函数"""
request_factory = RequestFactory()
path = '/session'
auth_data = {
'username': self.username,
'password': self.password
}
# 构建请求对象
request = request_factory.post(
path, data=auth_data, content_type='application/json'
)
# 调用中间件处理
session_middleware = SessionMiddleware()
session_middleware.process_request(request)
# 登录视图函数
login_function = SessionView().post
# 调用视图函数
resp = login_function(request)
print(resp.content)
def test_logout(self):
"""测试退出函数"""
request_factory = RequestFactory()
path = '/session'
request = request_factory.delete(path)
# 调用中间件处理
session_middleware = SessionMiddleware()
session_middleware.process_request(request)
# 退出视图函数
logout_function = SessionView().delete
# 调用视图
resp = logout_function(request)
print(resp.content)
再进行测试:
(MxShop) F:\My Projects\django_example_untest>python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 5 tests in 0.676s
OK
Destroying test database for alias 'default'...
2.5 总结
一个完整的 Django
生命周期:创建 request
对象 —> 执行中间层处理 —> 路由匹配 —> 视图处理 ----> 中间层处理 ----> 返回响应对象。
Django
模拟客户端发送请求的两种方式:
Client
对象:本质也是继承RequestFactory
对象,调用request
方法来发起完整的请求: 创建request
对象–>执行中间层处理–>视图函数处理–>中间层处理–>返回响应对象。RequestFactory
对象:request
对象就只做一件事,就是创建request
对象,因此需要手动实现其他流程
注意:实际工作中一般采用
Client
对象,更为方便,只有遇到请求对象比较特殊或执行流程较复杂时,才通过RequestFactory
对象来实现。
3. 代码测速覆盖度
3.1 快速上手
Coverage
是一个用来测试 python
程序代码覆盖率的功劳,可以识别代码的哪些部分已经被执行,有哪些可以执行但为执行的代码,可以用来衡量测试的有效性和完善性。
1、安装:
pip install coverage
2、配置:
项目根目录新建 .coveragerc
文件:
[run]
branch = True # 是否统计条件语句分支覆盖情况,if 条件语句中的判断通常有 True 和 False,设置为 TRUE 是,会测量这两种情况是否都被测试到
source = . # 指定需统计的源代码目录,这里为当前目录,即项目根目录
[report]
show_missing = True # 在生成的统计报告中显示未被测试覆盖到的代码行号
coverage
遵循 ini
配置语法,上面有两个配置块,每个配置块表示不同的含义。
3、运行:
清除上一次的统计信息:coverage erase
用 coverage run
来代替 python manage.py test
来测试:
(MxShop) F:\My Projects\django_example_untest>coverage run manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 5 tests in 0.745s
OK
Destroying test database for alias 'default'...
生成覆盖率统计报告(查看已覆盖数、未覆盖数及覆盖百分比):
(MxShop) F:\My Projects\django_example_untest>coverage report
Name Stmts Miss Branch BrPart Cover Missing
-------------------------------------------------------------------------------
django_example_untest\__init__.py 0 0 0 0 100%
django_example_untest\settings.py 18 0 0 0 100%
django_example_untest\urls.py 4 0 0 0 100%
django_example_untest\wsgi.py 4 4 0 0 0% 10-16
manage.py 9 2 2 1 73% 5->exit, 9-10
users\__init__.py 0 0 0 0 100%
users\admin.py 1 0 0 0 100%
users\apps.py 3 0 0 0 100%
users\migrations\__init__.py 0 0 0 0 100%
users\models.py 1 0 0 0 100%
users\tests.py 64 0 0 0 100%
users\views.py 26 3 6 3 81% 11->12, 12, 30->31, 31, 42->43, 43
-------------------------------------------------------------------------------
TOTAL 130 9 8 4 91%
注意:倒数第二列为测试覆盖率,倒数第一列为未覆盖的代码行号
获取更详细的信息,会在同级目录生成一个 htmlcov
文件夹,打开 index.html
文件即可查看测试覆盖情况:
生成 xml 格式的报告:
(MxShop) F:\My Projects\django_example_untest>coverage xml
更多命令可使用:coverage --help
查看
3.2 高级用法
查看上述的报告,可看到很多覆盖度 100% 的文件(通常为源码文件),有时我们并不关心这些报告,当文件很多的时候要想快速找到我们需要测试的文件就有点困难,因此我们要将一些不必要的文件排除掉,通过 [run]
配置的 omit
配置项即可实现:
[run]
branch = True
source = .
omit = # 忽略一些非核心的项目文件(具体按照自己项目来配置)
django_example_untest\__init__.py
django_example_untest\settings.py
django_example_untest\urls.py
django_example_untest\wsgi.py
django_example_untest\manage.py
users\__init__.py
users\admin.py
users\apps.py
users\migrations\__init__.py
users\models.py
*\migrations\*
[report]
show_missing = True
skip_covered = True # 指定统计报告中不显示 100%的文件
再重新进行统计:
(MxShop) F:\My Projects\django_example_untest>coverage run manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
b'{"code": 201, "message": "OK"}'
.b'{"code": 204, "message": "OK"}'
....
----------------------------------------------------------------------
Ran 5 tests in 0.687s
OK
Destroying test database for alias 'default'...
(MxShop) F:\My Projects\django_example_untest>coverage report
Name Stmts Miss Branch BrPart Cover Missing
---------------------------------------------------------------------------
django_example_untest\wsgi.py 4 4 0 0 0% 10-16
manage.py 9 2 2 1 73% 5->exit, 9-10
users\views.py 26 3 6 3 81% 11->12, 12, 30->31, 31, 42->43, 43
---------------------------------------------------------------------------
TOTAL 103 9 8 4 88%
注意:除第一次统计外,后面的统计最好按照
coverage erase
-->coverage run manage.py test
—>coverage report/html
的顺序来统计,这样做的目的是避免上一次统计结果影响下一次统计
另外 coverage html
可生成更为详细的统计报告,coverage report
只能查看未覆盖的范围的行号,不够直观。但是 coverage html
生成的 index.html
通过浏览器打开,点击相关文件可以可视化查看具体的范围来查看代码的覆盖情况,更为直观。
参考文章
Django
单元测试:http://www.itheima.com/news/20200807/182220.html
- 统计
Django
项目的测试覆盖率:https://blog.csdn.net/a419240016/article/details/104708147