Django - 寫好單元測試

版權聲明:做者「嚴北(微信shin-devops)」,發佈於「掘金」,未經受權禁止轉載!python

單元測試是軟件開發中一個重要的質量保障手段。git

經過單元測試,你能夠「測試先行」,將 TDD 落地;你也能夠在重構代碼時保證原有的邏輯不受影響。github

在 Django 官方文檔的「測試」一章中,已經比較詳盡地介紹瞭如何完成單元測試,本文的目的在於「以儘可能小的篇幅介紹如何編寫一個單元測試基礎類」,加上一些高級用法(如 Mock )的實踐,讓寫單測變得簡單而非煎熬。shell

官方文檔

基礎用法

單元測試經過調用一個方法(執行),判斷這個方法執行後產生的做用是否與預期相符(斷言),都是執行並判斷結果的過程。數據庫

那麼下面的代碼就比較好理解了:django

from django.test import TestCase
from myapp.models import Animal

class AnimalTestCase(TestCase):
    def test_animals_can_speak(self):
        """Animals that can speak are correctly identified"""
        Animal.objects.create(name="lion", sound="roar")
        Animal.objects.create(name="cat", sound="meow")
        lion = Animal.objects.get(name="lion")
        cat = Animal.objects.get(name="cat")
        self.assertEqual(lion.speak(), 'The lion says "roar"')
        self.assertEqual(cat.speak(), 'The cat says "meow"')
複製代碼

示例來自 Django 官網json

在以 test_ 開頭的測試方法中,經過調用函數,而後使用 assert 方法斷定結果。api

若是還有環境準備以及測試數據回收的過程,那麼就可使用 setUptearDown 方法進行處理:微信

""" @Author: Shin Yang @WeChat: shin-devops """
from django.test import TestCase
from myapp.models import Animal

class AnimalTestCase(TestCase):
    def setUp(self):
        Animal.objects.create(name="lion", sound="roar")
        Animal.objects.create(name="cat", sound="meow")
        
    def test_animals_can_speak(self):
        """Animals that can speak are correctly identified"""
        lion = Animal.objects.get(name="lion")
        cat = Animal.objects.get(name="cat")
        self.assertEqual(lion.speak(), 'The lion says "roar"')
        self.assertEqual(cat.speak(), 'The cat says "meow"')
    
    def tearDown(self):
        Animal.objects.filter(name__in=["lion", "cat"]).delete()
複製代碼

運行測試

python manage.py test
複製代碼

在運行單測時,添加 --keepdb 參數,來避免每次執行單測時須要重建數據庫的問題,提升執行速度:cookie

python manage.py test --keepdb
複製代碼

接口測試

對於接口的測試,一般 Web 框架自身都會集成「測試套件」,經過模擬請求的方式來執行單元測試用例。Django 已經實現了一個 RequestFactory 類,能夠直接用它來發送請求:

""" @Author: Shin Yang @WeChat: shin-devops """
from django.contrib.auth.models import User
from django.test import RequestFactory, TestCase

from .views import MyView

class SimpleTest(TestCase):
    def setUp(self):
        # Every test needs access to the request factory.
        self.factory = RequestFactory()
        self.user = User.objects.create_user(
            username='jacob', email='jacob@…', password='top_secret')

    def test_details(self):
        # Create an instance of a GET request.
        request = self.factory.get('/customer/details')
        # Recall that middleware are not supported. You can simulate a
        # logged-in user by setting request.user manually.
        request.user = self.user
        # Use this syntax for class-based views.
        response = MyView.as_view()(request)
        self.assertEqual(response.status_code, 200)
複製代碼

DRF 中的 APITestCase

能夠看到,構造請求的方法仍是比較麻煩,每一個用例中咱們都須要先初始化一個 RequestFactory 對象,調用的時候(response = MyView.as_view()(request))也不夠直觀。

Djang Rest Framework 中就解決了這個問題,能夠直接經過 APITestCase 中的 self.client 發送請求:

from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from myproject.apps.core.models import Account

class AccountTests(APITestCase):
    def test_create_account(self):
        """ Ensure we can create a new account object. """
        url = reverse('account-list')
        data = {'name': 'DabApps'}
        response = self.client.post(url, data, format='json')
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(Account.objects.count(), 1)
        self.assertEqual(Account.objects.get().name, 'DabApps')
複製代碼

示例來自 DRF 官網

解決與優化問題

那麼咱們須要考慮一下,Django 與 DRF 這些方法是否都知足咱們的需求?是否有更簡單的實現方法?

問題1. 如何模擬一個用戶登陸狀態

咱們的項目有對用戶進行權限控制,那麼首先遇到的問題就是「如何模擬一個用戶登陸狀態」,使得權限相關的邏輯不會出現錯誤。

在 Django 的示例中,因爲先構造 request,再顯式地將 request.user 配置爲建立好的 User 對象來實現,但在 DRF 中,因爲封裝了構造 request 的過程,沒法再使用這種方法,這是須要解決的問題。

解決方法

在解決這個問題時,我在 GitHub 上搜索了 Star 數較多的 Django 項目,學習了不一樣的項目如何優化單元測試。

其中 Sentry 封裝了一個 login_as 方法,經過在當前的 session 中添加用戶信息來繞過用戶登陸。

根據這個思路,我簡化了 Sentry 的實現代碼,獲得下面的方法:

""" @Author: Shin Yang @WeChat: shin-devops """
from django.conf import settings
from django.contrib.auth import login
from django.contrib.auth.models import AnonymousUser
from django.utils.functional import cached_property
from django.http import HttpRequest
from rest_framework.test import APITestCase as BaseAPITestCase

class APITestCase(BaseAPITestCase):
 @staticmethod
    def create_session():
        engine = import_module(settings.SESSION_ENGINE)
        session = engine.SessionStore()
        session.save()
        return session
        
 @cached_property
    def session(self):
        return self.create_session()

    def save_session(self):
        self.session.save()
        self.save_cookie(
            name=settings.SESSION_COOKIE_NAME,
            value=self.session.session_key,
            expires=None
        )

    def save_cookie(self, name, value, **params):
        self.client.cookies[name] = value
        self.client.cookies[name].update({
            k.replace('_', '-'): v
            for k, v in params.items()
        })

    def login(self, user):
        """登陸用戶,用於經過權限校驗"""
        user.backend = settings.AUTHENTICATION_BACKENDS[0]
        request = self.make_request()
        login(request, user)
        request.user = user
        self.save_session()

    def make_request(self, user=None, auth=None, method=None):
        request = HttpRequest()
        if method:
            request.method = method
        request.META['REMOTE_ADDR'] = '127.0.0.1'
        request.META['SERVER_NAME'] = 'testserver'
        request.META['SERVER_PORT'] = 80
        request.REQUEST = {}

        # order matters here, session -> user -> other things
        request.session = self.session
        request.auth = auth
        request.user = user or AnonymousUser()
        request.is_superuser = lambda: request.user.is_superuser
        request.successful_authenticator = None
        return request
複製代碼

在發送請求前,經過調用 login 方法將模擬登陸後的 Session 保存,使得調用 self.client 發送請求時帶上 SessionId,來達到登陸效果:

""" @Author: Shin Yang @WeChat: shin-devops """
class MyViewTest(APITestCase):
    def setUp(self) -> None:
        self.user = self.create_user(is_staff=True)
        self.login(self.user)
        
    def test_get_myview_details(self) -> None:
        # 假設 /api/myview 只有在 is_staff 用戶登陸狀況下才可請求
        response = self.client.get(path=/api/myview)
        # status_code 不爲 401,說明用戶已經登陸
        self.assertEqual(response.status_code, 200)
複製代碼

問題2. 如何便捷地初始化準備數據

接口邏輯代碼一般依賴已有的數據進行讀寫操做,「初始化準備數據」須要變得更靈活,減小在本測試用例下不關心的屬性的初始化,同時還要保持用例執行的操做清晰。

寫一些帶默認值的 create_ 方法

在單元測試中初始化一些數據的時候,咱們一般只是但願對某一些字段進行配置,所以能夠將全部數據模型建立數據的方法封裝至測試套件中,並對全部字段自動添加默認值:

""" @Author: Shin Yang @WeChat: shin-devops """
from django.contrib.auth.models import User
from uuid import uuid4
from rest_framework.test import APITestCase as BaseAPITestCase

class APITestCase(BaseAPITestCase):
 @staticmethod
    def create_user(username=None, **kwargs):
        if username is None:
            username = uuid4().hex

        return User.objects.create_user(username=username, **kwargs)
複製代碼

更好的方式是抽取一個類,專門用於處理初始化數據,這樣的代碼看起來將會更加美觀,也更容易維護:

""" @Author: Shin Yang @WeChat: shin-devops """
from rest_framework.test import APITestCase as BaseAPITestCase

class Factories(object):
 @staticmethod
    def create_user(username=None, **kwargs):
        ...
       
 @staticmethod 
    def create_task(task_name=None, **kwargs):
        ...
        
class APITestCase(Factories, BaseAPITestCase):
    pass
複製代碼

對 URL 的處理

URL 能夠是寫路徑格式,可是萬一路徑更改了,維護起來比較麻煩。

Django 中的路由支持經過 Endpoint 反向查找路徑:

>>>from django.urls import reverse
>>>reverse("app_label.endpoint")
/api/my-endpoint
複製代碼

咱們在 APITestCase 類中添加 app_labelendpoint 兩個屬性,提供 get_url 方便調用:

""" @Author: Shin Yang @WeChat: shin-devops """
class APITestCase(Factories, BaseAPITestCase):
    # django App 名
    app_label = 'my_app'
    # 端點,用於標識 URL
    endpoint = None
    
    def get_url(self, *args, **kwargs):
        return reverse(f"{self.app_label}:{self.endpoint}", args=args, kwargs=kwargs)
複製代碼

在測試用例中:

""" @Author: Shin Yang @WeChat: shin-devops """
class TaskDetailTest(APITestCase):
    endpoint = 'task-detail'

    def setUp(self) -> None:
        self.url = self.get_url(task_id=self.task.pk)
        ...
    
    def test_get_task_details(self):
        result = self.client.get(self.url)
        ...
複製代碼

問題3. 如何在不啓動服務的狀況下執行異步任務

再就是咱們使用celery執行異步任務,「如何在不啓動服務的狀況下執行異步任務」?

解決方法1:異步代碼同步執行

經過修改 Celery 的配置便可:

class MyTest(TestCase):

    def setUP(self):
        celery.conf.update(CELERY_ALWAYS_EAGER=True)
複製代碼

修改後,異步任務將被同步執行,也就不須要啓動 Celery Worker與 RabbitMQ 等服務了。如下二者是等效的:

add.delay(2, 2)
add(2, 2)
複製代碼

可是這種處理方法有個壞處,即測試用例的數量將會變爲 Celery 任務的分支數 * 主任務的條件分支數,發生倍數增加。

所以須要藉助 Mock 來減小交叉覆蓋場景。

解決方法2:Mock

假設代碼以下:

tasks.py

""" @Author: Shin Yang @WeChat: shin-devops """
@celery_task
def add(x, y):
    print(x + y)
    
def main_func():
    x, y = do_something()
    add.delay(x, y)
複製代碼

那麼能夠用 Mock 方法將異步任務的 delay 跳過,達到只測試 main_func 方法中的其餘代碼; 由於跳過了 add 方法,那麼還需額爲對 add 方法進行測試便可:

test.py

""" @Author: Shin Yang @WeChat: shin-devops """
from unittest import mock
from tasks import add, main_func

class MyTest(TestCase):

 @mock.patch("tasks.add.delay")
    def test_main_func(self, mocked_delay):
        mocked_delay.return_value = None
        result = main_func()
        mocked_delay.assert_called_with(1, 2)
        self.assertEqual(result, my_expect1)
        
    def test_add(self):
        result = add()
        self.assertEqual(result, my_expect2)
複製代碼

總結

再回到開頭,「單元測試是軟件開發中一個重要的質量保障手段」,可是不少公司/團隊/開發人員爲了肉眼可見的「產出」而忽視單測。

單元測試屬於長線投資,在開發需求時編寫單元測試可能以爲寫了兩份代碼,可是實際上,對公司而言,有單測的代碼可以下降維護代碼過程當中產生風險的機率,以及爲了解決這些風險而形成的損失;對開發人員而言,質量意識與測試思惟可以讓你在開發時考慮更多代碼的可維護性、可測試性,寫出更優質的代碼。

參考

版權聲明:做者「嚴北(微信shin-devops)」,發佈於「掘金」,未經受權禁止轉載!

相關文章
相關標籤/搜索