關閉

                  接口的單元測試

                  發表于:2020-8-26 10:36

                  字體: | 上一篇 | 下一篇 | 我要投稿

                   作者:HelloGitHub    來源:今日頭條

                    一個完整的項目,無論是個人的還是公司的,自動化的單元測試是必不可少,否則以后任何的功能改動將成為你的災難。
                    假設你正在維護公司的一個項目,這個項目已經開發了幾十個 API 接口,但是沒有任何的單元測試,F在你的 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 接口來說,我們主要關心的就是:對特定的請求返回正確的響應。我們先來梳理一下需要測試的接口和功能點。
                    博客主要的接口都集中在 PostViewSet 和 CommentViewSet 兩個視圖集中。
                    ·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 中 CategoryViewSet 和 TagViewSet 未進行測試,按照上面介紹的測試方法補充測試就可以啦。這兩個視圖集都非常的簡單,測試的任務就留作練習了。
                    補充測試
                    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 點:
                    構造 HTTP 請求對象 request。
                    設置 _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 的幾率就會越來越低了。

                    本文內容不用于商業目的,如涉及知識產權問題,請權利人聯系51Testing小編(021-64471599-8017),我們將立即處理
                  《2023軟件測試行業現狀調查報告》獨家發布~

                  關注51Testing

                  聯系我們

                  快捷面板 站點地圖 聯系我們 廣告服務 關于我們 站長統計 發展歷程

                  法律顧問:上海蘭迪律師事務所 項棋律師
                  版權所有 上海博為峰軟件技術股份有限公司 Copyright©51testing.com 2003-2024
                  投訴及意見反饋:webmaster@51testing.com; 業務聯系:service@51testing.com 021-64471599-8017

                  滬ICP備05003035號

                  滬公網安備 31010102002173號

                  亚洲欧洲自拍图片专区123_久久久精品人妻无码专区不卡_青青精品视频国产色天使_A免看的日黄亚洲