首頁常見問題正文

怎樣執(zhí)行單元測試?

更新時(shí)間:2023-11-28 來源:黑馬程序員 瀏覽量:

IT培訓(xùn)班

在 Django 項(xiàng)目中,我們開發(fā)完一些功能模塊之后,通常需要去寫單元測試來檢測代碼的 bug。Django 框架內(nèi)部提供比較方便的單元測試工具,接下來我們主要來學(xué)習(xí)如何寫 Django 的單元測試,以及測試 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(selfrequest):

        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(selfrequest):
        """用戶登錄"""
        # 客戶端的請求體是 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(selfrequest):
        """退出登錄"""
        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)境準(zhǔn)備中我們寫了 2 個(gè)類視圖,SessionView 提供了用戶登錄、退出接口,UserView 提供了獲取用戶信息接口。接下來我們主要來看如何針對這些接口寫單元測試。

在哪兒里寫單元測試

Django中每一個(gè)應(yīng)用下面都會(huì)有一個(gè) tests.py 文件,我們將當(dāng)前應(yīng)用測試代碼寫在這個(gè)文件中。如果測試的代碼量比較多,我們需要將測試的代碼分模塊,那么可以在當(dāng)前應(yīng)用下創(chuàng)建 tests 包。

單元測試代碼如何寫

django 提供了 django.test.TestCase 單元測試基礎(chǔ)類,它繼承自 python 標(biāo)準(zhǔn)庫中 unittest.TestCase 。

我們通常定義類繼承自 django.test.TestCase ,在類中我們定義 test_ 開頭的方法,在方法中寫具體的測試邏輯,一個(gè)類中可以包含多個(gè) 測試方法。

2個(gè)特殊的方法:

·def setUp(self) 這個(gè)方法會(huì)在每一個(gè)測試方法執(zhí)行之前被調(diào)用,通常用來做一些準(zhǔn)備工作

·def tearDown(self) 這個(gè)方法會(huì)在每一個(gè)測試用法執(zhí)行之后被被調(diào)用,通常用來做一些清理工作

2 個(gè)特殊的類方法


@classmethod
def setUpClass(cls
# 這個(gè)方法用于做類級別的準(zhǔn)備工作,他會(huì)在測試執(zhí)行之前被調(diào)用,且一個(gè)類中,只被調(diào)用一次
  
@classmthod
def tearDownClass(cls):
# 這個(gè)方法用于做類級別的準(zhǔn)備工作,他會(huì)在測試執(zhí)行結(jié)束后被調(diào)用,且一個(gè)類中,只被調(diào)用一次

Django 還是提供了 django.test.client.Client 客戶端類,用于模擬客戶端發(fā)起 [get|post|delete...] 請求,并且能夠自動(dòng)保存 cookie。

Client 還包含了 login 方法方便進(jìn)行用戶登錄。

通過 client 發(fā)起請求的時(shí)候 url 是路徑,不需要 schema://domain 這個(gè)前綴

如何執(zhí)行單元測試

./manage.py test

如果想值測試具體的 app,或者 app 下的某個(gè)測試文件,測試類,測試方法,也是可以的,命令參數(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)
        # 實(shí)例化 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)
        # 實(shí)例化 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
        }
        # 這里我們設(shè)置請求體格式為 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):
        """測試退出接口"""
        path = '/session'
        resp = self.client.delete(path)

        result = resp.json()
        self.assertEqual(result['code'], 204, result['message'])

測試結(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'...


測試視圖函數(shù)的方式

上面的代碼是我們測試視圖函數(shù)最簡便的方式,我們是通過 client 對象模擬請求,該請求最終會(huì)路由到視圖函數(shù),并調(diào)用視圖函數(shù)。

下面我們看看不通過 client,在測試方法中直接調(diào)用視圖函數(shù)。

利用 RequestFactory 直接調(diào)用視圖函數(shù)

大家知道每個(gè)視圖函數(shù)都有一個(gè)固定參數(shù) request,這個(gè)參數(shù)是客戶端請求對象。如果我們需要直接測試視圖函數(shù),那么必須模擬這個(gè)請求對象,然后傳遞給視圖函數(shù)。

django 提供了模擬請求對象的類 `django.test.client.RequestFactory` 我們通過 RequestFactory 對象的` [get|post|delete|...]` 方法來模擬請求對象,將該對象傳遞給視圖函數(shù),來實(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ù)"""
        # 實(shí)例化 RequestFactory
        request_factory = RequestFactory()
        path = '/session'
        auth_data = {
            'username'self.username,
            'password'self.password
        }
        # 構(gòu)建請求對象
        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)對象的 content,也就是響應(yīng)體
        print(resp.content)
       

    def test_logout(self):
        """測試退出視圖函數(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)對象的 content,也就是響應(yīng)體
        print(resp.content)

如果此時(shí)我們執(zhí)行測試的話,會(huì)拋出異常信息 AttributeError: 'WSGIRequest' object has no attribute 'session' 。

原因分析

session 視圖函數(shù) get,post 會(huì)調(diào)用login 和 logout 函數(shù),我們來看下這兩個(gè)函數(shù)的源碼


def login(requestuserbackend=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è)方法中需要對 request 對象的 session 屬性進(jìn)行相關(guān)操作。

而 django 中 session 是通過 django.contrib.sessions.middleware.SessionMiddleware 這個(gè)中間件來完成,源碼如下:


class SessionMiddleware(MiddlewareMixin):
    def __init__(selfget_response=None):
        self.get_response = get_response
        engine = import_module(settings.SESSION_ENGINE)
        self.SessionStore = engine.SessionStore

    def process_request(selfrequest):
        session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
        # 設(shè)置了 session 屬性
        request.session = self.SessionStore(session_key)
# ......

我們可以看出 session 屬性是在 SessionMiddleware.process_request 中設(shè)置的。

我們通過 RequestFactory 只是創(chuàng)建了請求對象,沒有被中間件處理過,所以也就請求對象中也就沒有了 session 屬性。

解決辦法

既然某些請求對象需要經(jīng)過中間件處理,那么我們是否可以手動(dòng)調(diào)用中間件處理一下呢?答案是肯定的。我們在調(diào)用視圖函數(shù)前先讓中間件處理一下請求對象。


from django.contrib.sessions.middleware import SessionMiddleware

# .....
    def test_login(self):
        """測試登錄視圖函數(shù)"""
        # 實(shí)例化 RequestFactory
        request_factory = RequestFactory()
        path = '/session'
        auth_data = {
            'username'self.username,
            'password'self.password
        }
        # 構(gòu)建請求對象
        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)對象的 content,也就是響應(yīng)體
        print(resp.content)
       

    def test_logout(self):
        """測試退出視圖函數(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)對象的 content,也就是響應(yīng)體
        print(resp.content)

總結(jié)

我們通過 RequestFactory 模擬的請求對象,然后傳遞給視圖函數(shù),來完成視圖函數(shù)的直接調(diào)用測試,如果需要經(jīng)過中間件的處理,我們需要手動(dòng)調(diào)用中間件。

django 視圖函數(shù)測試的兩種方法對比和原理淺析

django 請求的處理流程的大致如下: 創(chuàng)建 request 對象-->執(zhí)行中間層處理-->視圖函數(shù)處理-->中間層處理-->返回鄉(xiāng)響應(yīng)對象。

我們可以從源碼中看出來:


class WSGIHandler(base.BaseHandler):
    request_class = WSGIRequest

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # 初始化時(shí),加載中間件
        self.load_middleware()

    def __call__(selfenvironstart_response):# 按照 WSGI 協(xié)議接受參數(shù)
        set_script_prefix(get_script_name(environ))
        signals.request_started.send(sender=self.__class__environ=environ)
        # 創(chuàng)建請求對象,默認(rèn)是 WSGIRequest 對象
        request = self.request_class(environ)
        # 獲取響應(yīng)對象,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'Noneis 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__(selfenviron):
        # 加載中間件
        if self._middleware_chain is None:
            self.load_middleware()

                # ...
        # 構(gòu)建 WSGIRequest 請求對象
        request = WSGIRequest(environ)
        
        # 調(diào)用中間件-->視圖函數(shù)-->中間件
        response = self.get_response(request)

        # ....
                # 返回響應(yīng)對象
        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)對象添加額外的屬性和方法,例如 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': (10),
            '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è)請求對象
        return WSGIRequest(self._base_environ(**request))

從源碼中大家可以看出 Client 集成自 RequestFactory 類。

Client 對象通過調(diào)用 request 方法來發(fā)起完整的請求: 創(chuàng)建 request 對象-->執(zhí)行中間層處理-->視圖函數(shù)處理-->中間層處理-->返回鄉(xiāng)響應(yīng)對象。

RequestFactory 對象的 request 方法只做了一件事 :創(chuàng)建 request 對象 ,所以我們要手動(dòng)實(shí)現(xiàn)后面的完整過程。

總結(jié)

本章主要介紹了如何寫 django 的單元測試,測試視圖函數(shù)的兩種方式和部分源碼剖析,在實(shí)際工作中,我們通常使用 Client 來方便測試,遇到請求對象比較特殊或者執(zhí)行流程復(fù)雜的時(shí)候,就需要通過 RequestFactory 這種方式。

分享到:
在線咨詢 我要報(bào)名
和我們在線交談!