更新時(shí)間:2020-08-07 來(lái)源:黑馬程序員 瀏覽量:
在 Django 項(xiàng)目中,我們開(kāi)發(fā)完一些功能模塊之后,通常需要去寫(xiě)單元測(cè)試來(lái)檢測(cè)代碼的 bug。Django 框架內(nèi)部提供比較方便的單元測(cè)試工具,接下來(lái)我們主要來(lái)學(xué)習(xí)如何寫(xiě) Django 的單元測(cè)試,以及測(cè)試 Django 視圖函數(shù)的方式和原理淺析。
環(huán)境準(zhǔn)備
新建項(xiàng)目和應(yīng)用
$ # 新建 django_example 項(xiàng)目 $ django-admin startproject django_example $ # 進(jìn)入 django_example $ cd django_example $ # 新建 users 應(yīng)用 $ ./manage.py startapp users 更新 django_example 項(xiàng)目的配置文件,添加 users 應(yīng)用添加到 INSTALLED_APPS 中,關(guān)閉 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', ]
項(xiàng)目目錄結(jié)構(gòu)如下:
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): """用戶登錄""" # 客戶端的請(qǐng)求體是 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': '用戶名或密碼錯(cuò)誤' }) # 執(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ù)庫(kù) $ ./manage.py makemigrations $ ./manage.py migrate
Django 單元測(cè)試介紹
上面的環(huán)境準(zhǔn)備中我們寫(xiě)了 2 個(gè)類視圖,SessionView 提供了用戶登錄、退出接口,UserView 提供了獲取用戶信息接口。接下來(lái)我們主要來(lái)看如何針對(duì)這些接口寫(xiě)單元測(cè)試。
在哪兒里寫(xiě)單元測(cè)試
Django中每一個(gè)應(yīng)用下面都會(huì)有一個(gè) tests.py 文件,我們將當(dāng)前應(yīng)用測(cè)試代碼寫(xiě)在這個(gè)文件中。如果測(cè)試的代碼量比較多,我們需要將測(cè)試的代碼分模塊,那么可以在當(dāng)前應(yīng)用下創(chuàng)建 tests 包。
單元測(cè)試代碼如何寫(xiě)
django 提供了 django.test.TestCase 單元測(cè)試基礎(chǔ)類,它繼承自 python 標(biāo)準(zhǔn)庫(kù)中 unittest.TestCase 。
我們通常定義類繼承自 django.test.TestCase ,在類中我們定義 test_ 開(kāi)頭的方法,在方法中寫(xiě)具體的測(cè)試邏輯,一個(gè)類中可以包含多個(gè) 測(cè)試方法。
2個(gè)特殊的方法:
·def setUp(self) 這個(gè)方法會(huì)在每一個(gè)測(cè)試方法執(zhí)行之前被調(diào)用,通常用來(lái)做一些準(zhǔn)備工作
·def tearDown(self) 這個(gè)方法會(huì)在每一個(gè)測(cè)試用法執(zhí)行之后被被調(diào)用,通常用來(lái)做一些清理工作
2 個(gè)特殊的類方法
@classmethod def setUpClass(cls) # 這個(gè)方法用于做類級(jí)別的準(zhǔn)備工作,他會(huì)在測(cè)試執(zhí)行之前被調(diào)用,且一個(gè)類中,只被調(diào)用一次 @classmthod def tearDownClass(cls): # 這個(gè)方法用于做類級(jí)別的準(zhǔn)備工作,他會(huì)在測(cè)試執(zhí)行結(jié)束后被調(diào)用,且一個(gè)類中,只被調(diào)用一次
Django 還是提供了 django.test.client.Client 客戶端類,用于模擬客戶端發(fā)起 [get|post|delete...] 請(qǐng)求,并且能夠自動(dòng)保存 cookie。
Client 還包含了 login 方法方便進(jìn)行用戶登錄。
通過(guò) client 發(fā)起請(qǐng)求的時(shí)候 url 是路徑,不需要 schema://domain 這個(gè)前綴
如何執(zhí)行單元測(cè)試
./manage.py test
如果想值測(cè)試具體的 app,或者 app 下的某個(gè)測(cè)試文件,測(cè)試類,測(cè)試方法,也是可以的,命令參數(shù)如下([] 表示可選):
./manage.py test [app_name][.test_file_name][.class_name][.test_method_name]
測(cè)試代碼
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)建測(cè)試用戶 self.username = 'zhangsan' self.password = 'zhangsan12345' self.user = User.objects.create_user( username=self.username, password=self.password) # 實(shí)例化 client 對(duì)象 self.client = Client() # 登錄 self.client.login(username=self.username, password=self.password) def tearDown(self): # 刪除測(cè)試用戶 self.user.delete() def test_user(self): """測(cè)試獲取用戶信息接口""" 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)建測(cè)試用戶 cls.username = 'lisi' cls.password = 'lisi' cls.user = User.objects.create_user( username=cls.username, password=cls.password) # 實(shí)例化 client 對(duì)象 cls.client = Client() @classmethod def tearDownClass(cls): # 刪除測(cè)試用戶 cls.user.delete() def test_login(self): """測(cè)試登錄接口""" path = '/session' auth_data = { 'username': self.username, 'password': self.password } # 這里我們?cè)O(shè)置請(qǐng)求體格式為 json resp = self.client.post(path, data=auth_data, content_type='application/json') # 將相應(yīng)體轉(zhuǎn)化為python 字典 result = resp.json() # 檢查登錄結(jié)果 self.assertEqual(result['code'], 201, result['message']) def test_logout(self): """測(cè)試退出接口""" path = '/session' resp = self.client.delete(path) result = resp.json() self.assertEqual(result['code'], 204, result['message']) 測(cè)試結(jié)果 $ ./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'...
測(cè)試視圖函數(shù)的方式
上面的代碼是我們測(cè)試視圖函數(shù)最簡(jiǎn)便的方式,我們是通過(guò) client 對(duì)象模擬請(qǐng)求,該請(qǐng)求最終會(huì)路由到視圖函數(shù),并調(diào)用視圖函數(shù)。
下面我們看看不通過(guò) client,在測(cè)試方法中直接調(diào)用視圖函數(shù)。
利用 RequestFactory 直接調(diào)用視圖函數(shù)
大家知道每個(gè)視圖函數(shù)都有一個(gè)固定參數(shù) request,這個(gè)參數(shù)是客戶端請(qǐng)求對(duì)象。如果我們需要直接測(cè)試視圖函數(shù),那么必須模擬這個(gè)請(qǐng)求對(duì)象,然后傳遞給視圖函數(shù)。
django 提供了模擬請(qǐng)求對(duì)象的類 `django.test.client.RequestFactory` 我們通過(guò) RequestFactory 對(duì)象的` [get|post|delete|...]` 方法來(lái)模擬請(qǐng)求對(duì)象,將該對(duì)象傳遞給視圖函數(shù),來(lái)實(shí)現(xiàn)視圖函數(shù)的直接調(diào)用測(cè)試。
演示代碼:
class SessionRequestFactoryTestCase(TestCase): @classmethod def setUpClass(cls): # 創(chuàng)建測(cè)試用戶 cls.username = 'wangwu' cls.password = 'wangwu1234' cls.user = User.objects.create_user( username=cls.username, password=cls.password) @classmethod def tearDownClass(cls): # 刪除測(cè)試用戶 cls.user.delete() def test_login(self): """測(cè)試登錄視圖函數(shù)""" # 實(shí)例化 RequestFactory request_factory = RequestFactory() path = '/session' auth_data = { 'username': self.username, 'password': self.password } # 構(gòu)建請(qǐng)求對(duì)象 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ù)返回的響應(yīng)對(duì)象的 content,也就是響應(yīng)體 print(resp.content) def test_logout(self): """測(cè)試退出視圖函數(shù)""" # 實(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ù)返回的響應(yīng)對(duì)象的 content,也就是響應(yīng)體 print(resp.content)
如果此時(shí)我們執(zhí)行測(cè)試的話,會(huì)拋出異常信息 AttributeError: 'WSGIRequest' object has no attribute 'session' 。
原因分析
session 視圖函數(shù) get,post 會(huì)調(diào)用login 和 logout 函數(shù),我們來(lái)看下這兩個(gè)函數(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() # ...... 從代碼中我們可以看出這兩個(gè)方法中需要對(duì) request 對(duì)象的 session 屬性進(jìn)行相關(guān)操作。 而 django 中 session 是通過(guò) django.contrib.sessions.middleware.SessionMiddleware 這個(gè)中間件來(lái)完成,源碼如下: 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) # 設(shè)置了 session 屬性 request.session = self.SessionStore(session_key) # ......
我們可以看出 session 屬性是在 SessionMiddleware.process_request 中設(shè)置的。
我們通過(guò) RequestFactory 只是創(chuàng)建了請(qǐng)求對(duì)象,沒(méi)有被中間件處理過(guò),所以也就請(qǐng)求對(duì)象中也就沒(méi)有了 session 屬性。
解決辦法
既然某些請(qǐng)求對(duì)象需要經(jīng)過(guò)中間件處理,那么我們是否可以手動(dòng)調(diào)用中間件處理一下呢?答案是肯定的。我們?cè)谡{(diào)用視圖函數(shù)前先讓中間件處理一下請(qǐng)求對(duì)象。
from django.contrib.sessions.middleware import SessionMiddleware # ..... def test_login(self): """測(cè)試登錄視圖函數(shù)""" # 實(shí)例化 RequestFactory request_factory = RequestFactory() path = '/session' auth_data = { 'username': self.username, 'password': self.password } # 構(gòu)建請(qǐng)求對(duì)象 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ù)返回的響應(yīng)對(duì)象的 content,也就是響應(yīng)體 print(resp.content) def test_logout(self): """測(cè)試退出視圖函數(shù)""" # 實(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ù)返回的響應(yīng)對(duì)象的 content,也就是響應(yīng)體 print(resp.content)
總結(jié)
我們通過(guò) RequestFactory 模擬的請(qǐng)求對(duì)象,然后傳遞給視圖函數(shù),來(lái)完成視圖函數(shù)的直接調(diào)用測(cè)試,如果需要經(jīng)過(guò)中間件的處理,我們需要手動(dòng)調(diào)用中間件。
django 視圖函數(shù)測(cè)試的兩種方法對(duì)比和原理淺析
django 請(qǐng)求的處理流程的大致如下: 創(chuàng)建 request 對(duì)象-->執(zhí)行中間層處理-->視圖函數(shù)處理-->中間層處理-->返回鄉(xiāng)響應(yīng)對(duì)象。
我們可以從源碼中看出來(lái):
class WSGIHandler(base.BaseHandler): request_class = WSGIRequest def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # 初始化時(shí),加載中間件 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)建請(qǐng)求對(duì)象,默認(rèn)是 WSGIRequest 對(duì)象 request = self.request_class(environ) # 獲取響應(yīng)對(duì)象,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é)議返回?cái)?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
我們來(lái)看下 client 的核心源碼:
# django/test/client.py class ClientHandler(BaseHandler): def __call__(self, environ): # 加載中間件 if self._middleware_chain is None: self.load_middleware() # ... # 構(gòu)建 WSGIRequest 請(qǐng)求對(duì)象 request = WSGIRequest(environ) # 調(diào)用中間件-->視圖函數(shù)-->中間件 response = self.get_response(request) # .... # 返回響應(yīng)對(duì)象 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: # .... # .... # 給響應(yīng)對(duì)象添加額外的屬性和方法,例如 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 我們來(lái)看下 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." # 這里只是返回了一個(gè)請(qǐng)求對(duì)象 return WSGIRequest(self._base_environ(**request))
從源碼中大家可以看出 Client 集成自 RequestFactory 類。
Client 對(duì)象通過(guò)調(diào)用 request 方法來(lái)發(fā)起完整的請(qǐng)求: 創(chuàng)建 request 對(duì)象-->執(zhí)行中間層處理-->視圖函數(shù)處理-->中間層處理-->返回鄉(xiāng)響應(yīng)對(duì)象。
RequestFactory 對(duì)象的 request 方法只做了一件事 :創(chuàng)建 request 對(duì)象 ,所以我們要手動(dòng)實(shí)現(xiàn)后面的完整過(guò)程。
總結(jié)
本章主要介紹了如何寫(xiě) django 的單元測(cè)試,測(cè)試視圖函數(shù)的兩種方式和部分源碼剖析,在實(shí)際工作中,我們通常使用 Client 來(lái)方便測(cè)試,遇到請(qǐng)求對(duì)象比較特殊或者執(zhí)行流程復(fù)雜的時(shí)候,就需要通過(guò) RequestFactory 這種方式。
猜你喜歡:
如何配置Django+HTTPS開(kāi)發(fā)環(huán)境?