第 15 篇:介面的單元測試

削微寒發表於2020-08-14

作者:HelloGitHub-追夢人物

一個完整的專案,無論是個人的還是公司的,自動化的單元測試是必不可少,否則以後任何的功能改動將成為你的災難。

假設你正在維護公司的一個專案,這個專案已經開發了幾十個 API 介面,但是沒有任何的單元測試。現在你的 leader 讓你去修改幾個介面並實現一些新的功能,你接到需求後高效地完成了開發任務,然後手動測試了一遍改動的介面和新實現的功能,確保沒有任何問題後,滿心歡喜地提交了程式碼。

程式碼上線後出了 BUG,分析原因發現原來是新的改動導致某個舊 API 介面出了問題,因為上線前只對改動的介面做了測試,所以未能發現這個問題。你的 leader 批評了你,你因為事故記了過,年終只能拿個 3.25,非常悽慘。

但是如果我們有全面的單元測試,上述情況就有很大概率避免。只需要在程式碼釋出前執行一遍單元測試,受影響的功能立即就會報錯,這樣就能在程式碼部署前發現問題,從而避免線上事故。

當然以上故事純屬虛構,說這麼多隻是希望大家在開發時養成良好的習慣,一是寫優雅的程式碼,二是一定要測試自己寫的程式碼

單元測試回顧

在上一部教程 Django部落格教程(第二版)單元測試:測試 blog 應用單元測試:測試評論應用Coverage.py 統計測試覆蓋率 中,我們詳細講解了 django 單元測試框架的使用方式。這裡我們再對 djnago 的測試框架做一個回顧整體回顧,至於如何編寫和執行測試,後面將會進行詳細的講解,如果想對 django 的單元測試做更基礎的瞭解,推薦回去看看關於測試的 3 篇教程以及 django 的官方文件。

下面是 djnago 單元測試框架的一些要點:

  • django 的單元測試框架基於 Python 的 unittest 測試框架。
  • django 提供了多個 XXTestCase 類,這些類均直接或者間接繼承自 unittest.TestCase 類,因為 django 的單元測試框架是基於 unittest 的,所以編寫的測試用例類也都需要直接或者間接繼承 unittest.TestCase。通常情況我們都是繼承 django 提供的 XXTestCase,因為這些類針對 django 定製了更多的功能特性。
  • 預設情況下,測試程式碼需要放在 django 應用的下的 tests.py 檔案或者 tests 包裡,django 會自動發現 tests 包中以 test 開頭的模組(例如 test_models.py、test_views.py),然後執行測試用例類中命名以 test 開頭的方法。
  • python manage.py test 命令可以執行單元測試。

梳理需要測試的介面

接下來我們就為部落格的 API 介面來編寫單元測試。對 API 介面來說,我們主要關心的就是:對特定的請求返回正確的響應。我們先來梳理一下需要測試的介面和功能點。

部落格主要的介面都集中在 PostViewSetCommentViewSet 兩個檢視集中。

  • CommentViewSet 檢視集的介面比較簡單,就是建立評論。

  • PostViewSet 檢視集的介面則包含了文章列表、文章詳情、評論列表、歸檔日期列表等。對於文章列表介面,還可以通過查詢引數對請求的文章列表資源進行過濾,獲取全部文章的一個子集。

測試 CommentViewSet

CommentViewSet 只有一個介面,功能比較簡單,我們首先以它為例來講解單元測試的編寫方式。

測試介面的一般步驟:

  1. 獲得介面的 URL。
  2. 向介面傳送請求。
  3. 檢查響應的 HTTP 狀態碼、返回的資料等是否符合預期。

我們以測試建立評論的程式碼 test_create_valid_comment 為例:

# filename="comments/tests/test_api.py
from django.apps import apps
from django.contrib.auth.models import User
from rest_framework import status
from rest_framework.reverse import reverse
from rest_framework.test import APITestCase

from blog.models import Category, Post
from comments.models import Comment


class CommentViewSetTestCase(APITestCase):
    def setUp(self):
        self.url = reverse("v1:comment-list")
        # 斷開 haystack 的 signal,測試生成的文章無需生成索引
        apps.get_app_config("haystack").signal_processor.teardown()
        user = User.objects.create_superuser(
            username="admin", email="admin@hellogithub.com", password="admin"
        )
        cate = Category.objects.create(name="測試")
        self.post = Post.objects.create(
            title="測試標題", body="測試內容", category=cate, author=user,
        )

    def test_create_valid_comment(self):
        data = {
            "name": "user",
            "email": "user@example.com",
            "text": "test comment text",
            "post": self.post.pk,
        }
        response = self.client.post(self.url, data)
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

        comment = Comment.objects.first()
        self.assertEqual(comment.name, data["name"])
        self.assertEqual(comment.email, data["email"])
        self.assertEqual(comment.text, data["text"])
        self.assertEqual(comment.post, self.post)

首先,介面的 URL 地址為:reverse("v1:comment-list")reverse 函式通過檢視函式名來解析對應的 URL,檢視函式名的格式為:"<namespace>:<basename>-<action name>"

其中 namespace 是 include 函式指定的 namespace 引數值,例如:

path("api/v1/", include((router.urls, "api"), namespace="v1"))

basename 是 router 在 register 檢視集時指定的引數 basename 的值,例如:

router.register(r"posts", blog.views.PostViewSet, basename="post")

action name 是 action 裝飾器指定的 url_name 引數的值,或者預設的 list、retrieve、create、update、delete 標準 action 名,例如:

# filename="blog/views.py
@action(
	methods=["GET"], detail=False, url_path="archive/dates", url_name="archive-date"
)
def list_archive_dates(self, request, *args, **kwargs):
	pass

因此,reverse("v1:comment-list") 將被解析為 /api/v1/comments/。

接著我們向這個 URL 傳送 POST 請求:response = self.client.post(self.url, data),因為繼承自 django-reset-framework 提供的測試類 APITestCase,因此可以直接通過 self.client 來傳送請求,其中 self.client 是 django-rest-framework 提供的 APIClient 的一個例項,專門用來傳送 HTTP 測試請求。

最後就是對請求的響應結果 response 做檢查。建立評論成功後返回的狀態碼應該是 201,介面返回的資料在 response.data 屬性中,我們對介面返回的狀態碼和部分資料進行了斷言,確保符合預期的結果。

當然以上是評論建立成功的情況,我們測試時不能只測試正常情況,更要關注邊界情況和異常情況,我們再來增加一個評論資料格式不正確導致建立失敗的測試案例:

# filename="comments/tests/test_api.py
def test_create_invalid_comment(self):
    invalid_data = {
        "name": "user",
        "email": "user@example.com",
        "text": "test comment text",
        "post": 999,
    }
    response = self.client.post(self.url, invalid_data)
    self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
    self.assertEqual(Comment.objects.count(), 0)

套路還是一樣的,第一步向介面發請求,然後對預期返回的響應結果進行斷言。這裡由於評論資料不正確(關聯的 id 為 999 的 post 不存在),因此預期返回的狀態碼是 400,同時資料庫中不應該有建立的評論。

測試 PostViewSet

儘管 PostViewSet 包含的介面比較多,但是每個介面測試的套路和上面講的是一樣的,依葫蘆畫瓢就行了。因為 PostViewSet 測試程式碼較多,這裡僅把各個測試案例對應的方法列出來,具體的測試邏輯省略掉。如需瞭解詳細可檢視 GitHub 上專案的原始碼:

# filename="blog/tests/test_api.py
from datetime import datetime

from django.apps import apps
from django.contrib.auth.models import User
from django.core.cache import cache
from django.urls import reverse
from django.utils.timezone import utc
from rest_framework import status
from rest_framework.test import APITestCase

from blog.models import Category, Post, Tag
from blog.serializers import PostListSerializer, PostRetrieveSerializer
from comments.models import Comment
from comments.serializers import CommentSerializer


class PostViewSetTestCase(APITestCase):
    def setUp(self):
        # 斷開 haystack 的 signal,測試生成的文章無需生成索引
        apps.get_app_config("haystack").signal_processor.teardown()
        # 清除快取,防止限流
        cache.clear()

        # 設定部落格資料
        # post3 category2 tag2 2020-08-01 comment1 comment2
        # post2 category1 tag1 2020-07-31
        # post1 category1 tag1 2020-07-10

    def test_list_post(self):
        """
        這個方法測試文章列表介面,預期的響應狀態碼為 200,資料為文章列表序列化後的結果
        """
        url = reverse("v1:post-list")

    def test_list_post_filter_by_category(self):
        """
        這個方法測試獲取某個分類下的文章列表介面,預期的響應狀態碼為 200,資料為文章列表序列化後的結果
        """
        url = reverse("v1:post-list")
        

    def test_list_post_filter_by_tag(self):
        """
        這個方法測試獲取某個標籤下的文章列表介面,預期的響應狀態碼為 200,資料為文章列表序列化後的結果
        """
        url = reverse("v1:post-list")
        

    def test_list_post_filter_by_archive_date(self):
        """
        這個方法測試獲取歸檔日期下的文章列表介面,預期的響應狀態碼為 200,資料為文章列表序列化後的結果
        """
        url = reverse("v1:post-list")
        

    def test_retrieve_post(self):
        """
        這個方法測試獲取單篇文章介面,預期的響應狀態碼為 200,資料為單篇文章序列化後的結果
        """
        url = reverse("v1:post-detail", kwargs={"pk": self.post1.pk})
        

    def test_retrieve_nonexistent_post(self):
        """
        這個方法測試獲取一篇不存在的文章,預期的響應狀態碼為 404
        """
        url = reverse("v1:post-detail", kwargs={"pk": 9999})
        

    def test_list_archive_dates(self):
        """
        這個方法測試獲取文章的歸檔日期列表介面
        """
        url = reverse("v1:post-archive-date")
        

    def test_list_comments(self):
        """
        這個方法測試獲取某篇文章的評論列表介面,預期的響應狀態碼為 200,資料為評論列表序列化後的結果
        """
        url = reverse("v1:post-comment", kwargs={"pk": self.post3.pk})
        

    def test_list_nonexistent_post_comments(self):
        """
        這個方法測試獲取一篇不存在的文章的評論列表,預期的響應狀態碼為 404
        """
        url = reverse("v1:post-comment", kwargs={"pk": 9999})

我們以 test_list_post_filter_by_archive_date 為例做一個講解,其它的測試案例程式碼邏輯大同小異。

# filename="blog/tests/test_api.py
def test_list_post_filter_by_archive_date(self):
    # 解析文章列表介面的 URL
    url = reverse("v1:post-list")
    
    # 傳送請求,我們這裡給 get 方法的第二個引數傳入了一個字典,這個字典代表了 get 請求的查詢引數。
    # 例如最終的請求的 URL 會被編碼成:/posts/?created_year=2020&created_month=7
    response = self.client.get(url, {"created_year": 2020, "created_month": 7})
    self.assertEqual(response.status_code, status.HTTP_200_OK)
    
    # 如何檢查返回的資料是否正確呢?對這個介面的請求,
    # 我們預期返回的結果是 post2 和 post1 這兩篇釋出於2020年7月的文章序列化後的資料。
    # 因此,我們使用 PostListSerializer 對這兩篇文章進行了序列化,
    # 然後和返回的結果 response.data["results"] 進行比較。
    serializer = PostListSerializer(instance=[self.post2, self.post1], many=True)
    self.assertEqual(response.data["results"], serializer.data)

執行測試

接下來執行測試:

"Linux/macOS"
$ pipenv run coverage run manage.py test

"Windows"
...\> pipenv run coverage run manage.py test

大部分測試都通過了,但是也有一個測試失敗了,也就是說我們通過測試發現了一個 BUG:

======================================================================
FAIL: test_list_archive_dates (blog.tests.test_api.PostViewSetTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\Users\user\SpaceLocal\Workspace\G_Courses\HelloDjango\HelloDjango-rest-framework-tutorial\blog\tests\test_api.py", line 123, in test_list_archive_dates
    self.assertEqual(response.data, ["2020-08", "2020-07"])
AssertionError: Lists differ: ['2020-08-01', '2020-07-01'] != ['2020-08', '2020-07']

失敗的是 test_list_archive_dates 這個測試案例,文章歸檔日期介面返回的資料不符合我們的預期,我們預期得到 yyyy-mm 格式的日期列表,但介面返回的是 yyyy-mm-dd,這是我們之前開發時沒有發現的,通過測試將問題暴露了,這也從一定程度上印證了我們之前強調的測試的作用。

既然已經發現了問題,就來修復它。我相信修復這個 bug 對你來說應該已經是輕而易舉的事了,因此留作練習吧,這裡不再講解。

重新執行一遍測試,得到 ok 的狀態。

Ran 55 tests in 8.997s

OK

說明全部測試通過。

檢查測試覆蓋率

以上測試充分了嗎?單憑肉眼自然很難發現,Coverage.py 統計測試覆蓋率 中我們配置了 Coverage.py 並介紹了它的用法,直接執行下面的命令就可以檢視程式碼的測試覆蓋程度:

"Linux/macOS"
$ pipenv run coverage report

"Windows"
...\> pipenv run coverage report

覆蓋結果如下:

Name                  Stmts   Miss Branch BrPart  Cover   Missing
-----------------------------------------------------------------
blog\serializers.py      46      5      0      0    89%   82-86
blog\utils.py            21      2      4      1    88%   29->30, 30-31
blog\views.py           119      5      4      0    94%   191, 200, 218-225
comments\views.py        25      1      2      0    96%   59
-----------------------------------------------------------------
TOTAL                  1009     13     34      1    98%

可以看到測試覆蓋率整體達到了 98%,但是仍有 4 個檔案部分程式碼未被測試,命令列中只給出了未被測試覆蓋的程式碼行號(Missing 列),不是很直觀,執行下面的命令可以生成一個 HTML 報告,視覺化地檢視未被測試覆蓋的程式碼片段:

"Linux/macOS"
$ pipenv run coverage html

"Windows"
...\> pipenv run coverage html

命令執行後會在專案根目錄生成一個 htmlcov 資料夾,用瀏覽器開啟裡面的 index.html 頁面就可以檢視測試覆蓋情況的詳細報告了。

HTML 報告頁面示例:

未覆蓋的程式碼通過紅色高亮背景標出,非常直觀。可以看到 blog/views.py 中 CategoryViewSetTagViewSet 未進行測試,按照上面介紹的測試方法補充測試就可以啦。這兩個檢視集都非常的簡單,測試的任務就留作練習了。

補充測試

blog/serializers.py 中的 HighlightedCharField 未測試,還有 blog/utils.py 中新增的 UpdatedAtKeyBit 未測試,我們編寫相應的測試案例。

測試 UpdatedAtKeyBit

UpdatedAtKeyBit 就只有一個 get_data 方法,這個方法預期的邏輯是:從快取中取得以 self.key 為鍵的快取值(快取被設定時的時間),如果快取未命中,就取當前時間,並將這個時間寫入快取。

將預期的邏輯寫成測試程式碼如下,需要注意的一點是因為這個輔助類不涉及 django 資料庫方面的操作,因此我們直接繼承自更為簡單的 unittest.TestCase,這可以提升測試速度:

# filename="blog/tests/test_utils.py
import unittest
from datetime import datetime

from django.core.cache import cache

from ..utils import Highlighter, UpdatedAtKeyBit

class UpdatedAtKeyBitTestCase(unittest.TestCase):
    def test_get_data(self):
        # 未快取的情況
        key_bit = UpdatedAtKeyBit()
        data = key_bit.get_data()
        self.assertEqual(data, str(cache.get(key_bit.key)))

        # 已快取的情況
        cache.clear()
        now = datetime.utcnow()
        now_str = str(now)
        cache.set(key_bit.key, now)
        self.assertEqual(key_bit.get_data(), now_str)

測試 HighlightedCharField

我們在講解自定義系列化欄位的時候講過,序列化欄位通過呼叫 to_representation 方法,將傳入的值進行序列化。HighlightedCharField 的預期邏輯就是呼叫 to_representation 方法後將傳入的值進行高亮處理。

HighlightedCharField 涉及到一些高階操作,主要是因為 to_representation 方法中涉及到對 HTTP 請求request 的操作。正常的檢視函式呼叫時,檢視函式會接收到傳入的 request 引數,然後 django-rest-framework 會將 request 傳給序列化器(Serializer)的 _context 屬性,序列化器中的任何序列化欄位均可以通過直接訪問 context 屬性而間接訪問到 _context 屬性,從而拿到 request 物件。

但是在單元測試中,可能沒有這樣的檢視函式呼叫,因此 _context 的設定並不會自動進行,需要我們模擬檢視函式呼叫時的行為,手動進行設定。主要包括 2 點:

  1. 構造 HTTP 請求物件 request。
  2. 設定 _context 屬性的值。

具體的程式碼如下,詳細講解請看相關程式碼行的註釋:

# filename="blog/tests/test_serializer.py
import unittest

from blog.serializers import HighlightedCharField
from django.test import RequestFactory
from rest_framework.request import Request


class HighlightedCharFieldTestCase(unittest.TestCase):
    def test_to_representation(self):
        field = HighlightedCharField()
        # RequestFactory 專門用來構造 request 物件。
        # 這個 RequestFactory 生成的 request 代表了一個對 URL / 訪問的 get 請求,
        # 幷包含 URL 引數 text=關鍵詞。
        # 請求訪問的完整 URL 就是 /?text=關鍵詞
        request = RequestFactory().get("/", {"text": "關鍵詞"})
        
        # django-rest-framework 對 django 內建的 request 進行了包裝,
        # 因此這裡要手動使用 drf 提供的 Request 類對 django 的 request 進行一層包裝。
        drf_request = Request(request=request)
        
        # 設定 HighlightedCharField 例項 _context 屬性的值,這樣在其內部就可以通過
        # self.context["request"] 拿到請求物件 request
        setattr(field, "_context", {"request": drf_request})
        document = "無關文字關鍵詞無關文字,其他別的關鍵詞別的無關的詞。"
        result = field.to_representation(document)
        expected = (
            '無關文字<span class="highlighted">關鍵詞</span>無關文字,'
            '其他別的<span class="highlighted">關鍵詞</span>別的無關的詞。'
        )
        self.assertEqual(result, expected)

再次執行一遍測試覆蓋率的檢查命令,這次得到的測試覆蓋率就是 100% 了:

Name    Stmts   Miss Branch BrPart  Cover   Missing
---------------------------------------------------
---------------------------------------------------
TOTAL    1047      0     32      0   100%

當然,需要提醒一點的是,測試覆蓋率 100% 並不能說明程式就沒有 BUG 了。線上可能出現各種奇奇怪怪的問題,這些問題可能並沒有寫成測試案例,所以也就沒有測試到。但無論如何,目前我們已經進行了較為充分的測試,就可以考慮釋出一個版本了。如果以後再線上遇到什麼問題,或者想到了新的測試案例,可以隨時補充進單元測試,以後程式出 BUG 的機率就會越來越低了。


關注公眾號加入我們

相關文章