版權聲明:做者「嚴北(微信
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
若是還有環境準備以及測試數據回收的過程,那麼就可使用 setUp
和 tearDown
方法進行處理:微信
""" @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)
複製代碼
能夠看到,構造請求的方法仍是比較麻煩,每一個用例中咱們都須要先初始化一個 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 這些方法是否都知足咱們的需求?是否有更簡單的實現方法?
咱們的項目有對用戶進行權限控制,那麼首先遇到的問題就是「如何模擬一個用戶登陸狀態」,使得權限相關的邏輯不會出現錯誤。
在 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)
複製代碼
接口邏輯代碼一般依賴已有的數據進行讀寫操做,「初始化準備數據」須要變得更靈活,減小在本測試用例下不關心的屬性的初始化,同時還要保持用例執行的操做清晰。
在單元測試中初始化一些數據的時候,咱們一般只是但願對某一些字段進行配置,所以能夠將全部數據模型建立數據的方法封裝至測試套件中,並對全部字段自動添加默認值:
""" @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 能夠是寫路徑格式,可是萬一路徑更改了,維護起來比較麻煩。
Django 中的路由支持經過 Endpoint 反向查找路徑:
>>>from django.urls import reverse
>>>reverse("app_label.endpoint")
/api/my-endpoint
複製代碼
咱們在 APITestCase
類中添加 app_label
與 endpoint
兩個屬性,提供 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)
...
複製代碼
再就是咱們使用celery執行異步任務,「如何在不啓動服務的狀況下執行異步任務」?
經過修改 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 來減小交叉覆蓋場景。
假設代碼以下:
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
)」,發佈於「掘金」,未經受權禁止轉載!