本章中包含如下小节:
- 使用mock库测试视图
- 使用Selenium测试用户界面
- 测试Django REST framework所创建的API
- 保障测试覆盖范围
要保证代码的质量及准确性,可以使用自动化软件测试。Django提供了编写网站测试套装的工具。测试套装自动检测网站及其组件来保障所有部分正常运行。在修改代码时,可以运行测试来检测所做修改是否对应用产生了负面影响。
自动化软件测试有很多分支和词汇。本书中我们将测试划分为如下分类:
- 单元测试指严格定向单片代码或代码单元的测试。最常见的单元指代单个文件或模块,单元测试会尽力验证其逻辑与行为是否与预期一致。
- 集成测试更进了一步,处理两个或多个单元彼此协作的情况。这类测试的粒度不像单元测试那么细,通常在编写时假设在进行集成验证时均已通过了单元测试。因此,集成测试仅应用于单元测试各项均为正常的相互协作的情况。
- 组件接口测试是集成测试更为高阶的一种测试,其中对单个组件进行了端到端验证。这类测试的编写不考虑用于提供组件行为的底层逻辑,因此只要不改变行为,修改逻辑不会影响测试的通过。
- 系统测试验证所有组件的端到端集成并组成一个系统,通常对应完整的用户流。
- 实用验收测试(OAT)检测所有系统的非功能性的方面是否正常。验收测试检测业务逻辑来查看项目从终端用户的角度看是否按假定的方式运行。
运行本章的代码要求安装最新稳定版的Python 3、MySQL或PostgreSQL数据库以及通过虚拟环境创建的Django项目。
可在GitHub仓库的Chapter11目录中查看本章的代码。
本节中我们学习如何编写单元测试。单元测试是一种检测单个函数或方法是否返回正确结果 的测试。我们会使用likes应用并编写测试来测试使用非登录用户向json_set_like()视图信息提交信息是否返回失败响应,而登录用户是否返回成功的结果。我们会使用Mock对象来模拟HttpRequest和AnonymousUser对象。
我们使用�第4章 模板和JavaScript中实现Like微件一节中的locations和likes应用。
我使用的是mock库,自Python 3.3开始它内置于unittest.mock之中。
通过如下步骤来使用mock测试点赞的动作:
-
在likes应用中创建tests模块
-
在该模块中使用如下内容创建文件test_views.py:
# myproject/apps/likes/tests/test_views.py import json from unittest import mock from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.test import TestCase from myproject.apps.locations.models import Location class JSSetLikeViewTest(TestCase): @classmethod def setUpClass(cls): super(JSSetLikeViewTest, cls).setUpClass() cls.location = Location.objects.create( name="Park Güell", description="If you want to see something spectacular, come to Barcelona, Catalonia, Spain and visit Park Güell. Located on a hill, Park Güell is a public park with beautiful gardens and organic architectural elements.", picture="locations/2020/01/20200101012345.jpg", # dummy path ) cls.content_type = ContentType.objects.get_for_model(Location) cls.superuser = User.objects.create_superuser( username="admin", password="admin", email="admin@example.com" ) @classmethod def tearDownClass(cls): super(JSSetLikeViewTest, cls).tearDownClass() cls.location.delete() cls.superuser.delete() def test_authenticated_json_set_like(self): from ..views import json_set_like mock_request = mock.Mock() mock_request.user = self.superuser mock_request.method = "POST" response = json_set_like(mock_request, self.content_type.pk, self.location.pk) expected_result = json.dumps( {"success": True, "action": "add", "count": Location.objects.count()} ) self.assertJSONEqual(response.content, expected_result) @mock.patch("django.contrib.auth.models.User") def test_anonymous_json_set_like(self, MockUser): from ..views import json_set_like anonymous_user = MockUser() anonymous_user.is_authenticated = False mock_request = mock.Mock() mock_request.user = anonymous_user mock_request.method = "POST" response = json_set_like(mock_request, self.content_type.pk, self.location.pk) expected_result = json.dumps({"success": False}) self.assertJSONEqual(response.content, expected_result)
-
对likes应用运行测试如下:
(env)$ python manage.py test myproject.apps.likes --settings=myproject.settings.test Creating test database for alias 'default'... System check identified no issues (0 silenced). .. ------------------------------------------------------------------- --- Ran 2 tests in 0.268s OK Destroying test database for alias 'default'...
在对likes应用运行测试时,首先会创建一个临时测试数据库。然后,调用 setUpClass()方法。再后,名称以test起始的方法会进行执行,最后会调用tearDownClass()方法,会通过一项测试,在命令行中都出现一个点号(.),每项失败的测试则会出现字母,,而测试中的每个错误则会打印出字母 E。最后,我们可以看到失败和报错测试的一些提示信息。因为当前我们对likes应用的测试套装中仅有两个测试,所以在结果中会出现两个点号。
在setUpClass()中,我们创建一个地点和一个超级用户。财时,查找出Location模型的ContentType对象。我们需要在为其它对象设置或删除点赞的json_set_like()视图中使用到。提醒一下,视图类似下面这样,并会返回一个JSON字符串结果:
def json_set_like(request, content_type_id, object_id):
# all the view logic goes here...
return JsonResponse(result)
在test_authenticated_json_set_like() 和 test_anonymous_json_set_like()方法中,我们使用了Mock对象。这些对象可拥有任意属性或方法。Mock对象的每个未定义属性或方法是另一个Mock对象。因此,在shell中,可以试着链式访问属性,如下:
>>> from unittest import mock
>>> m = mock.Mock()
>>> m.whatever.anything().whatsoever
<Mock name='mock.whatever.anything().whatsoever' id='4320988368'>
在测试中,我们合适Mock对象模拟HttpRequest对象。对于匿名用户,MockUser通过@mock.patch()装饰器生成为一个标准Django User对象的补丁。对于已登录用户,我们仍需要真实的User对象,因为视图对Like对象使用用户ID。
因此,我们调用了json_set_like()函数,并检测所返回的JSON响应是否正确:
- 如果访客未进行登录在响应中返回{"success": false}
- 对于登录用户返回{"action": "add", "count": 1, "success": true}这样的结果
最后会调用tearDownClass()类方法、从测试数据库中删除地点和超级用户。
要测试使用了HttpRequest对象的代码,还可以使用Django请求工厂。详情请参阅https://docs.djangoproject.com/en/3.0/topics/testing/advanced/#the-request-factory。
- 第4章 模板和JavaScript中实现Like微件一节
- 使用Selenium测试用户界面一节
- 测试Django REST framework所创建的API一节
- 保障测试覆盖范围一节
实用验收测试检测业务逻辑来了解项目有没有按假定那样运作。本节中,我们将学习如何使用Selenium编写验收测试,它让我们可以在前台模拟活动,如填写表单或在浏览器中点击具体的DOM元素。
我们使用�第4章 模板和JavaScript中实现Like微件一节中的locations和likes应用进行下面的学习。
本节我们使用Selenium库和Chrome浏览器以及ChromeDriver来对其进行控制。进行如下准备:
-
从https://www.google.com/chrome/下载 Chrome 浏览器。
-
在Django项目中创建drivers目录。从https://sites.google.com/a/chromium.org/chromedriver/下载稳定版ChromeDriver,解压缩并将其放入刚刚创建的drivers目录中。
-
在虚拟环境中安装Selenium如下:
(env)$ pip install selenium
执行如下步骤来通过Selenium测试基于Ajax的点赞功能:
-
在项目配置文件中,添加TESTS_SHOW_BROWSER的配置:
# myproject/settings/_base.py TESTS_SHOW_BROWSER = True
-
在locations应用中创建tests模型并使用如下内容添加一个test_frontend.py文件:
# myproject/apps/locations/tests/test_frontend.py import os from io import BytesIO from time import sleep from django.core.files.storage import default_storage from django.test import LiveServerTestCase from django.contrib.contenttypes.models import ContentType from django.contrib.auth.models import User from django.conf import settings from django.test import override_settings from django.urls import reverse from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.support.ui import WebDriverWait from myproject.apps.likes.models import Like from ..models import Location SHOW_BROWSER = getattr(settings, "TESTS_SHOW_BROWSER", False) @override_settings(DEBUG=True) class LiveLocationTest(LiveServerTestCase): @classmethod def setUpClass(cls): super(LiveLocationTest, cls).setUpClass() driver_path = os.path.join(settings.BASE_DIR, "drivers", "chromedriver") chrome_options = Options() if not SHOW_BROWSER: chrome_options.add_argument("--headless") chrome_options.add_argument("--window-size=1200,800") cls.browser = webdriver.Chrome( executable_path=driver_path, options=chrome_options ) cls.browser.delete_all_cookies() image_path = cls.save_test_image("test.jpg") cls.location = Location.objects.create( name="Park Güell", description="If you want to see something spectacular, come to Barcelona, Catalonia, Spain and visit Park Güell. Located on a hill, Park Güell is a public park with beautiful gardens and organic architectural elements.", picture=image_path, # dummy path ) cls.username = "admin" cls.password = "admin" cls.superuser = User.objects.create_superuser( username=cls.username, password=cls.password, email="admin@example.com" ) @classmethod def tearDownClass(cls): super(LiveLocationTest, cls).tearDownClass() cls.browser.quit() cls.location.delete() cls.superuser.delete() @classmethod def save_test_image(cls, filename): from PIL import Image image = Image.new("RGB", (1, 1), 0) image_buffer = BytesIO() image.save(image_buffer, format="JPEG") path = f"tests/{filename}" default_storage.save(path, image_buffer) return path def wait_a_little(self): if SHOW_BROWSER: sleep(2) def test_login_and_like(self): # login login_path = reverse("admin:login") self.browser.get( f"{self.live_server_url}{login_path}?next={self.location.get_url_path()}" ) username_field = self.browser.find_element_by_id("id_username") username_field.send_keys(self.username) password_field = self.browser.find_element_by_id("id_password") password_field.send_keys(self.password) self.browser.find_element_by_css_selector ('input[type="submit"]').click() WebDriverWait(self.browser, timeout=10).until( lambda x: self.browser.find_element_by_css_selector(".like-button") ) # click on the "like" button like_button = self.browser.find_element_by_css_selector(".like-button") is_initially_active = "active" in like_button.get_attribute("class") initial_likes = int( self.browser.find_element_by_css_selector(".like-badge").text ) self.assertFalse(is_initially_active) self.assertEqual(initial_likes, 0) self.wait_a_little() like_button.click() WebDriverWait(self.browser, timeout=10).until( lambda x: int(self.browser.find_element_by_css_selector(".like-badge").text) != initial_likes ) likes_in_html = int( self.browser.find_element_by_css_selector(".like-badge").text ) likes_in_db = Like.objects.filter( content_type=ContentType.objects.get_for_model(Location), object_id=self.location.pk, ).count() self.assertEqual(likes_in_html, 1) self.assertEqual(likes_in_html, likes_in_db) self.wait_a_little() self.assertGreater(likes_in_html, initial_likes) # click on the "like" button again to switch back to the # previous state like_button.click() WebDriverWait(self.browser, timeout=10).until( lambda x: int(self.browser.find_element_by_css_selector(".like-badge").text) == initial_likes ) self.wait_a_little()
-
对locations应用运行测试如下:
(env)$ python manage.py test myproject.apps.locations --settings=myproject.settings.test Creating test database for alias 'default'... System check identified no issues (0 silenced). . ---------------------------------------------------------------------- Ran 1 test in 4.284s OK Destroying test database for alias 'default'...
在运行这些测试时,在URL下看到打开的一个后台登录页面Chrome窗口,例如http://localhost:63807/en/admin/login/?next=/en/locations/176255a9-9c07-4542-8324-83ac0d21b7c3/。
用户名和密码字段将会填入admin,我们会被重定向到地点Park Güell的详情页,链接类似于http://localhost:63807/en/locations/176255a9-9c07-4542-8324-83ac0d21b7c3/。在该页面会看到两次点击Like按钮,进行点赞和取消点赞的操作。
如果将TESTS_SHOW_BROWSER的配置修改为False(或删除),再次运行测试,测试会在后台以最小等待时间运行,不会打开浏览器窗口。
我们来看下测试套装是如何运作的。定义了一个继承LiveServerTestCase的类。这会创建一个测试套装,随机使用一个未占用的端口运行本地服务,如63807。默认LiveServerTestCase以非调试模式运行服务。但使用override_settings()装饰器将其切换到DEBUG模式会在无需转存的情况下即可访问静态文件,并在任何页面中发生错误时显示错误回溯信息。setUpClass()类方法会在所有测试的一开始执行,tearDownClass() 类方法会在运行了测试之后执行。在这中间,会执行测试套件中所有以test开头的方法的测试。
在开始测试时,新建测试数据库。在setUpClass()中,创建一个浏览器对象、一个地点及一个超级用户。然后执行test_login_and_like()方法,打开后台登录页面,查找用户名字段,输入管理员用户名,查找密码字段,输入管理员密码,查找提交按钮进行点击。然后查找页面中具有.like-button CSS类的DOM元素,等待最长10秒。
你可能记得在第4章 模板和JavaScript中实现Like微件一节中,我们的微件由两个元素组成:
- 一个Like按钮
- 一个显示点赞总数的数标
如果进行按钮的点击,会通过Ajax调用添加或删除Like实例。此外,数标上的记数会根据数据库中的点赞数进行更新。
在进一步的测试中,我们查看按钮的初始状态(是否有.active CSS 类),查看点赞的初始数并模拟按钮的点击。等待最多10秒直至数标发生更改。然后检查数标上的计数与数据库中的地点的总点赞数是否一致。我们还会对数据中数字发生的变化(增加)进行检测。并会再次模拟按钮的点击来将其切换到之前的状态。
最后调用tearDownClass() 方法,它关闭浏览器并从测试数据库中删除地点和超级用户。
- 第4章 模板和JavaScript中实现Like微件一节
- 使用mock库测试视图一节
- 测试Django REST framework所创建的API一节
- 保障测试覆盖范围一节
读者应当已经理解了如何编写单元测试和实用验收测试。本节中我们将对本书此前创建的RESTful API进行组件接口测试。
如果尚不熟悉RESTful API是什么以及如何使用API,请访问https://www.restapitutorial.com做进一步的学习。
我们使用第9章 导入、导出数据中使用Django REST framework创建API一节中的music进行下面的学习。
执行如下步骤来测试RESTful API:
-
在music应用中创建tests模块。在tests模块中,创建一个带有SongTests类的test_api.py文件。该类中有setUpClass() 和 tearDownClass()方法,如下:
# myproject/apps/music/tests/test_api.py from django.contrib.auth.models import User from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase from ..models import Song class SongTests(APITestCase): @classmethod def setUpClass(cls): super().setUpClass() cls.superuser = User.objects.create_superuser( username="admin", password="admin", email="admin@example.com" ) cls.song = Song.objects.create( artist="Lana Del Rey", title="Video Games - Remastered", url="https://open.spotify.com/track/5UOo694cVvjcPFqLFiNWGU?si=maZ7JCJ7Rb6WzESLXg1Gdw", ) cls.song_to_delete = Song.objects.create( artist="Milky Chance", title="Stolen Dance", url="https://open.spotify.com/track/3miMZ2IlJiaeSWo1DohXlN?si=g-xMM4m9S_yScOm02C2MLQ", ) @classmethod def tearDownClass(cls): super().tearDownClass() cls.song.delete() cls.superuser.delete()
-
添加API测试检测歌曲列表:
def test_list_songs(self): url = reverse("rest_song_list") data = {} response = self.client.get(url, data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["count"], Song.objects.count())
-
添加API测试检测单曲详情:
def test_get_song(self): url = reverse("rest_song_detail", kwargs={"pk": self.song.pk}) data = {} response = self.client.get(url, data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["uuid"], str(self.song.pk)) self.assertEqual(response.data["artist"], self.song.artist) self.assertEqual(response.data["title"], self.song.title) self.assertEqual(response.data["url"], self.song.url)
-
添加API测试检测新歌的成功创建:
def test_create_song_allowed(self): # login self.client.force_authenticate(user=self.superuser) url = reverse("rest_song_list") data = { "artist": "Capital Cities", "title": "Safe And Sound", "url": "https://open.spotify.com/track/40Fs0YrUGuwLNQSaHGVfqT?si=2OUawusIT-evyZKonT5GgQ", } response = self.client.post(url, data, format="json") self.assertEqual(response.status_code, status.HTTP_201_CREATED) song = Song.objects.filter(pk=response.data["uuid"]) self.assertEqual(song.count(), 1) # logout self.client.force_authenticate(user=None)
-
添加测试尝试在不登录或失败的情况下创建歌曲,如:
def test_create_song_restricted(self): # make sure the user is logged out self.client.force_authenticate(user=None) url = reverse("rest_song_list") data = { "artist": "Men I Trust", "title": "Tailwhip", "url": "https://open.spotify.com/track/2DoO0sn4SbUrz7Uay9ACTM?si=SC_MixNKSnuxNvQMf3yBBg", } response = self.client.post(url, data, format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
-
添加测试检测歌曲的成功修改:
def test_change_song_allowed(self): # login self.client.force_authenticate(user=self.superuser) url = reverse("rest_song_detail", kwargs= {"pk": self.song.pk}) # change only title data = { "artist": "Men I Trust", "title": "Tailwhip", "url": "https://open.spotify.com/track/2DoO0sn4SbUrz7Uay9ACTM?si=SC_MixNKSnuxNvQMf3yBBg", } response = self.client.put(url, data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["uuid"], str(self.song.pk)) self.assertEqual(response.data["artist"], data["artist"]) self.assertEqual(response.data["title"], data["title"]) self.assertEqual(response.data["url"], data["url"]) # logout self.client.force_authenticate(user=None)
-
添加测试检测因未登录导致的无法修改:
def test_change_song_restricted(self): # make sure the user is logged out self.client.force_authenticate(user=None) url = reverse("rest_song_detail", kwargs= {"pk": self.song.pk}) # change only title data = { "artist": "Capital Cities", "title": "Safe And Sound", "url": "https://open.spotify.com/track/40Fs0YrUGuwLNQSaHGVfqT?si=2OUawusIT-evyZKonT5GgQ", } response = self.client.put(url, data, format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
-
添加测试检测歌曲删除的失败情况:
def test_delete_song_restricted(self): # make sure the user is logged out self.client.force_authenticate(user=None) url = reverse("rest_song_detail", kwargs= {"pk": self.song_to_delete.pk}) data = {} response = self.client.delete(url, data, format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
-
添加测试检测歌曲的成功删除:
def test_delete_song_allowed(self): # login self.client.force_authenticate(user=self.superuser) url = reverse("rest_song_detail", kwargs= {"pk": self.song_to_delete.pk}) data = {} response = self.client.delete(url, data, format="json") self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) # logout self.client.force_authenticate(user=None)
-
对music应用运行测试,如下所示:
(env)$ python manage.py test myproject.apps.music --settings=myproject.settings.test Creating test database for alias 'default'... System check identified no issues (0 silenced). ........ ---------------------------------------------------------------------- Ran 8 tests in 0.370s OK Destroying test database for alias 'default'...
RESTful API测试套装继承APITestCase类。同样,在各测试之前及之后会执行类方法setUpClass() 和 tearDownClass()。测试套件还有一个APIClient类型客户端属性,可用于模拟API调用。客户端为所有标准HTTP请求提供方法:有get()、post()、put()、patch()、delete()、head()和 options()。
在我们测试中,使用了GET、POST和DELETE请求。同时客户端有基于登录认证信息、token或User对象强制用户登录的方法。在我们测试中,我们以第三种方式进行登录:直接将用户传递给force_authenticate()方法。
其它部分的代码根据字面即可理解。
- 第9章 导入、导出数据中的使用Django REST framework创建API一节
- 使用mock库测试视图一节
- 使用Selenium测试用户界面一节
- 保障测试覆盖范围一节
Django让我们可以进行快速原型开发,并且快速地将项目由创意阶段并为实现阶段。但要让项目稳定并可用于生产环境,应当对尽可能多的功能进行测试。借助于测试范围,可以检测项目代码的测试量。我们来一起学习如何实现。
在项目中准备一些测试。
在虚拟环境中安装coverage工具:
(env)$ pip install coverage~=5.0.1
以下是如何在项目中进行测试覆盖量的检测:
-
使用如下代码为coverage工具创建setup.cfg配置文件:
# setup.cfg [coverage:run] source = . omit = media/* static/* tmp/* drivers/* locale/* myproject/site_static/* myprojext/templates/*
-
如果使用了Git版本控制确保在 .gitignore文件中添加如下代码:
# .gitignore htmlcov/ .coverage .coverage.* coverage.xml *.cover
-
创建一个shell脚本run_tests_with_coverage.sh,包含通过coverage运行测试及报告结果的命令:
# run_tests_with_coverage.sh #!/usr/bin/env bash coverage erase coverage run manage.py test --settings=myproject.settings.test coverage report
-
为该脚本添加执行权限:
(env)$ chmod +x run_tests_with_coverage.sh
-
运行该脚本:
(env)$ ./run_tests_with_coverage.sh Creating test database for alias 'default'... System check identified no issues (0 silenced). ........... ---------------------------------------------------------------------- Ran 11 tests in 12.940s OK Destroying test database for alias 'default'... Name Stmts Miss Cover ----------------------------------------------------------------------------------------------- manage.py 12 2 83% myproject/__init__.py 0 0 100% myproject/apps/__init__.py 0 0 100% myproject/apps/core/__init__.py 0 0 100% myproject/apps/core/admin.py 16 10 38% myproject/apps/core/context_processors.py 3 0 100% myproject/apps/core/model_fields.py 48 48 0% myproject/apps/core/models.py 87 29 67% myproject/apps/core/templatetags/__init__.py 0 0 100% myproject/apps/core/templatetags/utility_tags.py 171 135 21% the statistics go on... myproject/settings/test.py 5 0 100% myproject/urls.py 10 0 100% myproject/wsgi.py 4 4 0% ----------------------------------------------------------------------------------------------- TOTAL 1363 712 48%
coverage工具运行测试并检测测试覆盖了多少行代码。在本例中,我们所编写的测试覆盖了48%的代码。如果代码稳定性对你很重要,在有时间时尽量让其接近100%。
在覆盖量配置中,我们跳过了静态资源、模板以及其它非Python文件。
- 使用mock库测试视图一节
- 使用Selenium测试用户界面一节
- 测试Django REST framework所创建的API一节