在 Django 項目中,我們開發(fā)完一些功能模塊之后,通常需要去寫單元測試來檢測代碼的 bug。Django 框架內(nèi)部提供比較方便的單元測試工具,接下來我們主要來學習如何寫 Django 的單元測試,以及測試 Django 視圖函數(shù)的方式和原理淺析。
環(huán)境準備
新建項目和應用
$ # 新建 django_example 項目
$ django-admin startproject django_example
$ # 進入 django_example
$ cd django_example
$ # 新建 users 應用
$ ./manage.py startapp users
更新 django_example 項目的配置文件,添加 users 應用添加到 INSTALLED_APPS 中,關閉 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',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
項目目錄結構如下:
django_example
├── django_example
│ ├── __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
將 users/views.py 改為如下內(nèi)容
import json
from django.contrib.auth import login, authenticate, logout
from django.shortcuts import render
from django.views import View
from django.http.response import JsonResponse
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.headers.get('Content-Type', '')
if 'application/json' in content_type:
data = json.loads(request.body)
else:
return JsonResponse({
'code': 400,
'message': '非 json 格式'
})
data = json.loads(request.body)
username = data.get('username', '')
password = data.get('password', '')
user = authenticate(username=username,
password=password)
# 檢查用戶是否存在
if not user:
return JsonResponse({
'code': 400,
'message': '用戶名或密碼錯誤'
})
# 執(zhí)行登錄
login(request, user)
return JsonResponse({
'code': 201,
'message': 'OK'
})
def delete(self, request):
"""退出登錄"""
logout(request)
return JsonResponse({
'code': 204,
'message': 'OK'
})
在 django_example/urls.py 綁定接口
from django.contrib import admin
from django.urls import path
from users.views import UserView, SessionView
urlpatterns = [
path('admin/', admin.site.urls),
path('users', UserView.as_view()),
path('session', SessionView.as_view())
]
初始化數(shù)據(jù)庫
$ ./manage.py makemigrations
$ ./manage.py migrate
Django 單元測試介紹
上面的環(huán)境準備中我們寫了 2 個類視圖,SessionView 提供了用戶登錄、退出接口,UserView 提供了獲取用戶信息接口。接下來我們主要來看如何針對這些接口寫單元測試。
在哪兒里寫單元測試
Django中每一個應用下面都會有一個 tests.py 文件,我們將當前應用測試代碼寫在這個文件中。如果測試的代碼量比較多,我們需要將測試的代碼分模塊,那么可以在當前應用下創(chuàng)建 tests 包。
單元測試代碼如何寫
django 提供了 django.test.TestCase 單元測試基礎類,它繼承自 python 標準庫中 unittest.TestCase 。
我們通常定義類繼承自 django.test.TestCase ,在類中我們定義 test_ 開頭的方法,在方法中寫具體的測試邏輯,一個類中可以包含多個 測試方法。
2個特殊的方法:
·def setUp(self) 這個方法會在每一個測試方法執(zhí)行之前被調(diào)用,通常用來做一些準備工作
·def tearDown(self) 這個方法會在每一個測試用法執(zhí)行之后被被調(diào)用,通常用來做一些清理工作
2 個特殊的類方法
@classmethod
def setUpClass(cls)
# 這個方法用于做類級別的準備工作,他會在測試執(zhí)行之前被調(diào)用,且一個類中,只被調(diào)用一次
@classmthod
def tearDownClass(cls):
# 這個方法用于做類級別的準備工作,他會在測試執(zhí)行結束后被調(diào)用,且一個類中,只被調(diào)用一次
Django 還是提供了 django.test.client.Client 客戶端類,用于模擬客戶端發(fā)起 [get|post|delete...] 請求,并且能夠自動保存 cookie。
Client 還包含了 login 方法方便進行用戶登錄。
通過 client 發(fā)起請求的時候 url 是路徑,不需要 schema://domain 這個前綴
如何執(zhí)行單元測試
./manage.py test
如果想值測試具體的 app,或者 app 下的某個測試文件,測試類,測試方法,也是可以的,命令參數(shù)如下([] 表示可選):
./manage.py test [app_name][.test_file_name][.class_name][.test_method_name]
測試代碼
users/tests.py
from django.test import TestCase
from django.test.client import Client
from django.contrib.auth.models import User
class UserTestCase(TestCase):
def setUp(self):
# 創(chuàng)建測試用戶
self.username = 'zhangsan'
self.password = 'zhangsan12345'
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):
# 創(chuàng)建測試用戶
cls.username = 'lisi'
cls.password = 'lisi'
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')
# 將相應體轉(zhuǎn)化為python 字典
result = resp.json()
# 檢查登錄結果
self.assertEqual(result['code'], 201, result['message'])
def test_logout(self):
"""測試退出接口"""
path = '/session'
resp = self.client.delete(path)
result = resp.json()
self.assertEqual(result['code'], 204, result['message'])
測試結果
$ ./manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.309s
OK
Destroying test database for alias 'default'...
測試視圖函數(shù)的方式
上面的代碼是我們測試視圖函數(shù)最簡便的方式,我們是通過 client 對象模擬請求,該請求最終會路由到視圖函數(shù),并調(diào)用視圖函數(shù)。
下面我們看看不通過 client,在測試方法中直接調(diào)用視圖函數(shù)。
利用 RequestFactory 直接調(diào)用視圖函數(shù)
大家知道每個視圖函數(shù)都有一個固定參數(shù) request,這個參數(shù)是客戶端請求對象。如果我們需要直接測試視圖函數(shù),那么必須模擬這個請求對象,然后傳遞給視圖函數(shù)。
django 提供了模擬請求對象的類 `django.test.client.RequestFactory` 我們通過 RequestFactory 對象的` [get|post|delete|...]` 方法來模擬請求對象,將該對象傳遞給視圖函數(shù),來實現(xiàn)視圖函數(shù)的直接調(diào)用測試。
演示代碼:
class SessionRequestFactoryTestCase(TestCase):
@classmethod
def setUpClass(cls):
# 創(chuàng)建測試用戶
cls.username = 'wangwu'
cls.password = 'wangwu1234'
cls.user = User.objects.create_user(
username=cls.username, password=cls.password)
@classmethod
def tearDownClass(cls):
# 刪除測試用戶
cls.user.delete()
def test_login(self):
"""測試登錄視圖函數(shù)"""
# 實例化 RequestFactory
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')
# 登錄的視圖函數(shù)
login_funciton = SessionView().post
# 調(diào)用視圖函數(shù)
resp = login_funciton(request)
# 打印視圖函數(shù)返回的響應對象的 content,也就是響應體
print(resp.content)
def test_logout(self):
"""測試退出視圖函數(shù)"""
# 實例化 RequestFactory
request_factory = RequestFactory()
path = '/session'
request = request_factory.delete(path)
# 退出的視圖函數(shù)
logout_funciton = SessionView().delete
# 調(diào)用視圖函數(shù)
resp = logout_funciton(request)
# 打印視圖函數(shù)返回的響應對象的 content,也就是響應體
print(resp.content)
如果此時我們執(zhí)行測試的話,會拋出異常信息 AttributeError: 'WSGIRequest' object has no attribute 'session' 。
原因分析
session 視圖函數(shù) get,post 會調(diào)用login 和 logout 函數(shù),我們來看下這兩個函數(shù)的源碼
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.'
)
else:
if not isinstance(backend, str):
raise TypeError('backend must be a dotted import path string (got %r).' % backend)
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):
# .....
# remember language choice saved to session
language = request.session.get(LANGUAGE_SESSION_KEY)
request.session.flush()
# ......
從代碼中我們可以看出這兩個方法中需要對 request 對象的 session 屬性進行相關操作。
而 django 中 session 是通過 django.contrib.sessions.middleware.SessionMiddleware 這個中間件來完成,源碼如下:
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.session = self.SessionStore(session_key)
# ......
我們可以看出 session 屬性是在 SessionMiddleware.process_request 中設置的。
我們通過 RequestFactory 只是創(chuàng)建了請求對象,沒有被中間件處理過,所以也就請求對象中也就沒有了 session 屬性。
解決辦法
既然某些請求對象需要經(jīng)過中間件處理,那么我們是否可以手動調(diào)用中間件處理一下呢?答案是肯定的。我們在調(diào)用視圖函數(shù)前先讓中間件處理一下請求對象。
from django.contrib.sessions.middleware import SessionMiddleware
# .....
def test_login(self):
"""測試登錄視圖函數(shù)"""
# 實例化 RequestFactory
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')
# 調(diào)用中間件處理
session_middleware = SessionMiddleware()
session_middleware.process_request(request)
# 登錄的視圖函數(shù)
login_funciton = SessionView().post
# 調(diào)用視圖函數(shù)
resp = login_funciton(request)
# 打印視圖函數(shù)返回的響應對象的 content,也就是響應體
print(resp.content)
def test_logout(self):
"""測試退出視圖函數(shù)"""
# 實例化 RequestFactory
request_factory = RequestFactory()
path = '/session'
request = request_factory.delete(path)
# 調(diào)用中間件處理
session_middleware = SessionMiddleware()
session_middleware.process_request(request)
# 退出的視圖函數(shù)
logout_funciton = SessionView().delete
# 調(diào)用視圖函數(shù)
resp = logout_funciton(request)
# 打印視圖函數(shù)返回的響應對象的 content,也就是響應體
print(resp.content)
總結
我們通過 RequestFactory 模擬的請求對象,然后傳遞給視圖函數(shù),來完成視圖函數(shù)的直接調(diào)用測試,如果需要經(jīng)過中間件的處理,我們需要手動調(diào)用中間件。
django 視圖函數(shù)測試的兩種方法對比和原理淺析
django 請求的處理流程的大致如下: 創(chuàng)建 request 對象-->執(zhí)行中間層處理-->視圖函數(shù)處理-->中間層處理-->返回鄉(xiāng)響應對象。
我們可以從源碼中看出來:
class WSGIHandler(base.BaseHandler):
request_class = WSGIRequest
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 初始化時,加載中間件
self.load_middleware()
def __call__(self, environ, start_response):# 按照 WSGI 協(xié)議接受參數(shù)
set_script_prefix(get_script_name(environ))
signals.request_started.send(sender=self.__class__, environ=environ)
# 創(chuàng)建請求對象,默認是 WSGIRequest 對象
request = self.request_class(environ)
# 獲取響應對象,get_response 執(zhí)行 中間件-->視圖函數(shù)-->中間件
response = self.get_response(request)
response._handler_class = self.__class__
status = '%d %s' % (response.status_code, response.reason_phrase)
response_headers = [
*response.items(),
*(('Set-Cookie', c.output(header='')) for c in response.cookies.values()),
]
# 按照 wsgi 協(xié)議返回數(shù)據(jù)
start_response(status, response_headers)
if getattr(response, 'file_to_stream', None) is not None and environ.get('wsgi.file_wrapper'):
response = environ['wsgi.file_wrapper'](response.file_to_stream)
return response
我們來看下 client 的核心源碼:
# django/test/client.py
class ClientHandler(BaseHandler):
def __call__(self, environ):
# 加載中間件
if self._middleware_chain is None:
self.load_middleware()
# ...
# 構建 WSGIRequest 請求對象
request = WSGIRequest(environ)
# 調(diào)用中間件-->視圖函數(shù)-->中間件
response = self.get_response(request)
# ....
# 返回響應對象
return response
class Client(RequestFactory):
def request(self, **request):
# ...
# 模擬 wsgi 協(xié)議的 environ 參數(shù)
environ = self._base_environ(**request)
# ....
try:
# 調(diào)用 ClientHandler
response = self.handler(environ)
except TemplateDoesNotExist as e:
# ....
# ....
# 給響應對象添加額外的屬性和方法,例如 json 方法
response.client = self
response.request = request
# Add any rendered template detail to the response.
response.templates = data.get("templates", [])
response.context = data.get("context")
response.json = partial(self._parse_json, response)
return response
我們來看下 RequestFactory 的核心源碼
# django/test/client.py
class RequestFactory:
def _base_environ(self, **request):
"""
The base environment for a request.
"""
# This is a minimal valid WSGI environ dictionary, plus:
# - HTTP_COOKIE: for cookie support,
# - REMOTE_ADDR: often useful, see #8551.
# See https://www.python.org/dev/peps/pep-3333/#environ-variables
return {
'HTTP_COOKIE': '; '.join(sorted(
'%s=%s' % (morsel.key, morsel.coded_value)
for morsel in self.cookies.values()
)),
'PATH_INFO': '/',
'REMOTE_ADDR': '127.0.0.1',
'REQUEST_METHOD': 'GET',
'SCRIPT_NAME': '',
'SERVER_NAME': 'testserver',
'SERVER_PORT': '80',
'SERVER_PROTOCOL': 'HTTP/1.1',
'wsgi.version': (1, 0),
'wsgi.url_scheme': 'http',
'wsgi.input': FakePayload(b''),
'wsgi.errors': self.errors,
'wsgi.multiprocess': True,
'wsgi.multithread': False,
'wsgi.run_once': False,
**self.defaults,
**request,
}
def request(self, **request):
"Construct a generic request object."
# 這里只是返回了一個請求對象
return WSGIRequest(self._base_environ(**request))
從源碼中大家可以看出 Client 集成自 RequestFactory 類。
Client 對象通過調(diào)用 request 方法來發(fā)起完整的請求: 創(chuàng)建 request 對象-->執(zhí)行中間層處理-->視圖函數(shù)處理-->中間層處理-->返回鄉(xiāng)響應對象。
RequestFactory 對象的 request 方法只做了一件事 :創(chuàng)建 request 對象 ,所以我們要手動實現(xiàn)后面的完整過程。
總結
本章主要介紹了如何寫 django 的單元測試,測試視圖函數(shù)的兩種方式和部分源碼剖析,在實際工作中,我們通常使用 Client 來方便測試,遇到請求對象比較特殊或者執(zhí)行流程復雜的時候,就需要通過 RequestFactory 這種方式。