Django使用心得(二) 使用TestCase測試介面

elfgzp發表於2018-12-18
Django使用心得(二) 使用TestCase測試介面

在接觸開源社群Github之後,發現特別多的開源專案都會有單元測試TestCase。但是在步入工作後,從業了兩個創業公司,發現大多數程式設計師都沒有養成寫單元測試的習慣。

在目前的公司面試了一些程式設計師,他們的工作經驗平均都有三年以上,但是都沒有編寫單元測試的習慣。 問到"為什麼不去編寫單元測試呢?",無非就是回答"沒有時間""寫的都是介面,直接用客戶端工具測試一下就可以了"

在筆者使用了Django框架自帶的TestCase之後,發現用TestCase測試介面不僅比一些客戶端工具方便,而且還能降低在對程式碼進行修改之後出現BUG的機率, 特別是一些對程式碼有嚴重的潔癖喜歡優化程式碼的程式設計師來說真的非常有用。

而且運用框架的TestCase編寫單元測試,還能結合一些CI工具來實現自動化測試,這個我也會專門寫一篇文章來介紹我利用Gitlab CI結合DjangoTestCase實現自動化測試的一些心得。

TestCase 類的結構

為了方便沒用用過TestCase的讀者,先簡單介紹一下TestCase的類結構。

常見的TestCasesetUp函式、tearDown函式和test_func組成。

這裡test_func是指你編寫了測試邏輯的函式,而setUp函式則是在test_func函式之前執行的函式,tearDown函式則是在test_func執行之後執行的函式。

development_of_test_habits/tests/test_demo.py view raw
from django.test import TestCase


class Demo(TestCase):
    def setUp(self):
        print('setUp')

    def tearDown(self):
        print('tearDown')

    def test_demo(self):
        print('test_demo')

    def test_demo_2(self):
        print('test_demo2')複製程式碼

我們可以通過在Django專案的根目錄執行以下命令來執行這個單元測試

python manage.py test development_of_test_habits.tests.test_demo.Demo
複製程式碼

如果使用Pycharm來執行的話可以直接點選類左側的執行箭頭,更加方便地執行或者Debug這個單元測試。
img

可以從執行後的結果清晰的看到這個單元測試的執行順序。

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
setUp
test_demo
tearDown
.setUp
test_demo2
tearDown
.
----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK
Destroying test database for alias 'default'...
複製程式碼

此外還可以從執行結果看到,在測試之前單元測試建立了一個測試資料庫。

Creating test database for alias ‘default’…

然後在測試結束將資料庫摧毀。

Destroying test database for alias ‘default’…

這個也就是在繼承了Django框架中的TestCase,它已經幫你實現的一些邏輯方便用於測試,所以我們不需要在setUptearDown函式中實現這些邏輯。

利用TestCase測試介面

接下來講一下我們如何使用TestCase來測試介面的,首先我們編寫一個簡單的介面,這裡筆者是用Django Rest FrameworkAPIView來編寫的,讀者也可以使用自己管用的方法來編寫。

development_of_test_habits/views/hello_test_case.py view raw
from rest_framework.views import APIView
from rest_framework.response import Response


class HelloTestCase(APIView):
    def get(self, request, *args, **kwargs):
        return Response({
            'msg': 'Hello %s I am a test Case' % request.query_params.get('name', ',')
        })複製程式碼

然後這個介面類加到我們的路由中。

development_of_test_habits/urls.py view raw
from django.urls import path
from development_of_test_habits import views

urlpatterns = [
    path('hello_test_case', views.HelloTestCase.as_view(), name='hello_test_case'),
]複製程式碼

接下來我們編寫一個HelloTestCase的單元測試類來測試我們的測試用例。

development_of_test_habits/tests/test_hello_test_case.py view raw
from django.urls import resolve, reverse
from django.test import TestCase


class HelloTestCase(TestCase):
    def setUp(self):
        self.name = 'Django'

    def test_hello_test_case(self):
        url = '/test_case/hello_test_case'
        # url = reverse('hello_test_case')
        # Input: print(resolve(url))
        # Output: ResolverMatch(func=development_of_test_habits.views.hello_test_case.HelloTestCase, args=(), kwargs={}, url_name=hello_test_case, app_names=[], namespaces=[])
        response = self.client.get(url)
        self.assertEqual(response.status_code, 200)  # 期望的Http相應碼為200
        data = response.json()
        self.assertEqual(data['msg'], 'Hello , I am a test Case')  # 期望的msg返回結果為'Hello , I am a test Case'

        response = self.client.get(url, {'name': self.name})
        self.assertEqual(response.status_code, 200)  # 期望的Http相應碼為200
        data = response.json()
        self.assertEqual(data['msg'], 'Hello Django I am a test Case')  # 期望的msg返回結果為'Hello Django I am a test Case'複製程式碼

setUp函式中,我定義了一個name屬性並且賦值為Django便於後面使用。

單元測試測試介面主要分為下面幾個重要的內容。

請求的路由地址

在測試介面時無非就是發起請求,檢查返回的狀態嘛和響應內容是否正確。發請求肯定少不了url地址,這裡有兩種方式來配置請求地址。
1.直接設定請求地址

url = '/test_case/hello_test_case'
複製程式碼

2.透過django.urls.reverse函式和在路由設定的name來得到請求的地址

url =  reverse('hello_test_case')
複製程式碼

這裡在介紹以下我們還可以通過django.urls.resolveurl得到對應的介面類或者介面函式`。

請求的客戶端

發起請求我們除了需要路由外,我們還需要一個發起請求的客戶端。python的requests庫就是很好的客戶端工具,只不過Django在它的TestCase類 中已經整合了一個客戶端工具,我們只需要呼叫TestCaseclient屬性就可以得到一個客戶端。

client = self.client
複製程式碼

發起請求

發起請求非常簡單隻需要一行程式碼,我們就可以通過請求得到它的響應體。

response = self.client.get(url)
複製程式碼

如果需要攜帶引數只需要傳入data引數。

response = self.client.get(url, {'name': self.name})
複製程式碼

驗證響應體

在單元測試中,TestCaseassertEqual有點類似pythonassert函式,除了assertEqual外還有assertNotEqualassertGreaterassertIn等等。 這裡筆者主要做了兩個檢查,一個是檢查status_code是否等於200

self.assertEqual(response.status_code, 200)  # 期望的Http相應碼為200
複製程式碼

另一個是檢查響應內容是否正確。

data = response.json()
self.assertEqual(data['msg'], 'Hello , I am a test Case')  # 期望的msg返回結果為'Hello , I am a test Case'
複製程式碼

這個就是最簡單的測試請求的單元測試,但是在實際的介面中,我們是需要資料的,所以我們還需要生成測試資料。
這裡介紹一個非常方便的庫mixer,可以方便在我們的單元測試中生成測試資料。

利用mixer在TestCase中生成測試資料

首先我們定一個場景,比如說我們記錄了學校班級的學生的作業,需要一個介面來返回學生的作業列表,並且這個介面是需要使用者登陸後才可以請求的,定義的models和介面類如下。

development_of_test_habits/models.py view raw
from django.db import models


class School(models.Model):
    name = models.CharField(max_length=32)


class Class(models.Model):
    school_id = models.ForeignKey(to=School, on_delete=models.PROTECT)
    name = models.CharField(max_length=32)


class Student(models.Model):
    class_id = models.ForeignKey(to=Class, on_delete=models.PROTECT)
    name = models.CharField(max_length=32)


class HomeWork(models.Model):
    student_id = models.ForeignKey(to=Student, on_delete=Student)
    name = models.CharField(max_length=32)複製程式碼

介面筆者用的是Django rest frameworkReadOnlyModelViewSet檢視類實現的,實現的功能就是返回一個json結果集, 並且json中有HomeWorkSchool NameClass NameStudent Name,檢視類程式碼和序列化程式碼如下。

development_of_test_habits/views/api/home_work.py view raw
from rest_framework.viewsets import ReadOnlyModelViewSet
from rest_framework.permissions import IsAuthenticated

from development_of_test_habits.models import HomeWork
from development_of_test_habits.serializers import HomeWorkSerializer


class HomeWorkViewSet(ReadOnlyModelViewSet):
    queryset = HomeWork.objects.all()
    serializer_class = HomeWorkSerializer
    permission_classes = (IsAuthenticated, )複製程式碼
development_of_test_habits/serializers.py view raw
from rest_framework import serializers

from development_of_test_habits.models import HomeWork


class HomeWorkSerializer(serializers.ModelSerializer):
    class Meta:
        model = HomeWork
        fields = ('school_name', 'class_name', 'student_name', 'name')

    school_name = serializers.CharField(source='student_id.class_id.school_id.name', read_only=True)
    class_name = serializers.CharField(source='student_id.class_id.name', read_only=True)
    student_name = serializers.CharField(source='student_id.name', read_only=True)複製程式碼

最後把我們的介面類新增到路由中。

development_of_test_habits/serializers.py view raw
urlpatterns = [
    path('hello_test_case', views.HelloTestCase.as_view(), name='hello_test_case'),
    path('api/home_works', views.HomeWorkViewSet.as_view({'get': 'list'}), name='home_works_list')
]複製程式碼

完成介面的編寫,可以開始寫單元測試了,定義HomeWorkAPITestCase測試類並且在setUp中生成測試資料。

development_of_test_habits/tests/test_home_works_api.py viewraw
from django.test import TestCase
from django.urls import reverse

from django.contrib.auth.models import User

from mixer.backend.django import mixer

from development_of_test_habits import models


class HomeWorkAPITestCase(TestCase):
    def setUp(self):
        self.user = mixer.blend(User)

        self.random_home_works = [
            mixer.blend(models.HomeWork)
            for _ in range(11)
        ]複製程式碼

這裡介紹一下mixer這個模組,這個模組會根據你定義的模型和模型的欄位來隨機生成測試資料,包括這個資料的外來鍵資料。 這樣在我們這種層級非常多的關係型資料就非常的方便,否則需要一層一層的去生成資料。 程式碼中就利用mixer生成了一個隨機的使用者和11個隨機的HomeWork資料。

接下來編寫測試的邏輯程式碼。

development_of_test_habits/tests/test_home_works_api.py viewraw
class HomeWorkAPITestCase(TestCase):
    def setUp(self):
        self.user = mixer.blend(User)

        self.random_home_works = [
            mixer.blend(models.HomeWork)
            for _ in range(11)
        ]

    def test_home_works_list_api(self):
        url = reverse('home_works_list')

        response = self.client.get(url)
        self.assertEqual(response.status_code, 403)

        self.client.force_login(self.user)
        response = self.client.get(url)
        self.assertEqual(response.status_code, 200)
        data = response.json()
        self.assertEqual(len(data), len(self.random_home_works))

        data_fields = [key for key in data[0].keys()]

        self.assertIn('school_name', data_fields)
        self.assertIn('class_name', data_fields)
        self.assertIn('student_name', data_fields)
        self.assertIn('name', data_fields)複製程式碼

首先通過django.urls.reverse函式和介面的路由名稱獲得url,第一步先測試使用者在沒有登陸的情況下請求介面,這裡期望的請求響應碼為403

response = self.client.get(url)
self.assertEqual(response.status_code, 403)
複製程式碼

我們通過client的一個登陸函式force_login來登陸我們隨機生成的使用者,再次請求介面,這次的期望的請求相應碼就為200

self.client.force_login(self.user)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
複製程式碼

最後驗證返回的結果數量是和結果中定義的欄位是否正確。

data = response.json()
self.assertEqual(len(data), len(self.random_home_works))

data_fields = [key for key in data[0].keys()]

self.assertIn('school_name', data_fields)
self.assertIn('class_name', data_fields)
self.assertIn('student_name', data_fields)
self.assertIn('name', data_fields)
複製程式碼

以上就是在專案中測試介面的最常見的流程。

TestCase在使用中需要注意的一些問題

假設我們要在介面中增加請求頭,以HelloTestCase介面為例,我們要增加一個TEST_HEADER的請求頭,則在介面的邏輯處理中,就需要給這個請求頭 加上HTTP_字首。

development_of_test_habits/views/hello_test_case.py view raw
class HelloTestCase(APIView):
def get(self, request, *args, **kwargs):
    data = {
        'msg': 'Hello %s I am a test Case' % request.query_params.get('name', ',')
    }
    test_header = request.META.get('HTTP_TEST_HEADER')
    if test_header:
        data['test_header'] = test_header
    return Response(data)複製程式碼

如果我們用客戶端工具類似Post ManRestFul Client等等,請求時只要在請求頭中加上TEST_HEADER即可。 但是在單元測試中,我們也需要把HTTP_這個字首加上,否則介面邏輯是無法獲取的。

development_of_test_habits/tests/test_hello_test_case.py view raw
def test_hello_test_case(self):
    url = '/test_case/hello_test_case'
    # url = reverse('hello_test_case')
    # Input: print(resolve(url))
    # Output: ResolverMatch(func=development_of_test_habits.views.hello_test_case.HelloTestCase, args=(), kwargs={}, url_name=hello_test_case, app_names=[], namespaces=[])
    response = self.client.get(url)
    self.assertEqual(response.status_code, 200)  # 期望的Http相應碼為200
    data = response.json()
    self.assertEqual(data['msg'], 'Hello , I am a test Case')  # 期望的msg返回結果為'Hello , I am a test Case'

    response = self.client.get(url, {'name': self.name})
    self.assertEqual(response.status_code, 200)  # 期望的Http相應碼為200
    data = response.json()
    self.assertEqual(data['msg'], 'Hello Django I am a test Case')  # 期望的msg返回結果為'Hello Django I am a test Case'

    # 假設我們要在介面中增加請求頭'TEST_HEADER'
    # 則在測試時需要加上字首'HTTP_'最終的結果為'HTTP_TEST_HEADER'
    response = self.client.get(url, **{'HTTP_TEST_HEADER': 'This is a test header.'})
    data = response.json()
    self.assertEqual(data['test_header'], 'This is a test header.')複製程式碼

總結

在用測試用例來測試介面後,筆者已經開始養成寫完介面直接用單元測試來測試的習慣,這樣不單是在給別人說明自己的介面的功能,還是減少線上環境的BUG都有明顯的幫助, 希望讀者也能用這種方式不斷的養成寫單元測試的好習慣。

本人部落格原文地址:elfgzp.cn/2018/12/07/…


相關文章