不少人对自动化测试理解比较混乱,本文试图简化理解,并提供实际清单使用。
什么是自动化测试
一个项目都有很多操作,就像这个在线商城有商品、分类、订单、购物车等模型,每个模型又有很多操作,比如创建、更新、删除、获取列表等,而每个操作也有不少规则,比如只有管理员才能创建商品。为了确认项目已达到预期的功能,我们有两种测试方式。
一是手动在浏览器上测试,但当项目越来越复杂,测试工作量会呈指数级的上升,而且随着时间的推移,我们会忘掉隐藏在某个访问端点的规则,除非我们写下各个场景的规则文档,这样测试员才能测试各种场景。但是,一遍又一遍测试各个场景极其浪费时间。所以需要自动化测试来挽救。
自动化测试利用代码来测试各个端点及业务规则。所以我们只需写一遍代码就能一遍又一遍地测试了。每当我们修改软件或准备部署时,我们只需运行测试代码就能知道有没有错误,几秒钟就知道结果。
所以自动化测试可以使我们写出更好的代码以及对发布软件更有信心。
很多程序员会说:写测试代码就是在浪费时间。这是一把双刃剑,你正确使用,它极有价值 ;但要是你错误地使用,那就是个麻烦,它会阻碍前进道路令人沮丧。
现实情况是,自动化测试有太多错误的信息和不好的实践,我们必须要正确理解才能用好自动化测试,避免写无用的测试代码。下节细讲。
测试行为而不是实现
很多人使用自动化测试失败,一个重要原因是他们测试了实现而不是测试行为。
打个比方,我们测试一个微波炉。我们按下开始按钮,观察显示屏,希望看到加热时间,而不是打开微波炉看看里面的电气元件如何工作。测试软件同样道理。
测试行为而不是测试实现!!
有些人分别测试具体的实现,比如具体的views,serializers,models,routers等,实现随着时间的推移,实现是会变的,比如views改成genericview,或将某个模块分离到其他模块,如果你是测试实现的,你的测试将支离破碎,可能要花大量时间修复测试代码。所以再强调:
你要测试的是API的行为,而不是它的实现!
以创建 collection为例。我们期望测试什么行为呢?很可能是这样的:
- 当匿名用户访问时,我们给出未认证401提示
- 当普通用户访问时,我们要给出未授权403
- 当管理员访问,但数据无效时,我们提示400
- 当管理员访问且数据是有效的,我们提示200
你可以手动打开浏览器测试,或用代码自动化测试。显然,自动化测试只需几秒钟你就知道是否通过。
再强调:测试行为而不是测试实现!
工具
就像编写代码有很多框架一样,自动化测试也有很多框架,主流的是pytest和unittest,其中unittest是自带的,而pytest需要另外安装。很多人的意见是pytest更优秀,它写出的代码更短、更简洁、更清晰。
可以并排比较一下同样的测试,unittest和pytest的差异。
我们安装pytest及插件pytest-django,加上 –dev 表示这个依赖只在开发环境中安装,这样我们在生产环境中就不会安装pytest
pipenv install –dev pytest
pipenv install –dev pytest-django
我们查看Pipfile,能看到这两个依赖在 dev-packages 这一节里。
…
[dev-packages]
autopep8 = “*”
pytest = “*”
pytest-django = “*”
…
第一个测试
我们在store下创建 tests文件夹,必须是复数的tests,否则pytest不会发现它,然后再建test_collections.py文件,同样也必须以test开头。
测试方法也必须以test开头,取名也要清楚简洁,一看就知道测试什么,如这个 test_if_user_is_anonymous_returns_401。由于我们还要测试delete,update等场景,最好以用例来组织测试,比如我们用TestCreateCollection类 将相关创建collection的场景组织在一起,注意类也必须Test开头,否则pytest同样找不到。
每个测试一般由三部分组成,可以用3个A表示(AAA):Arrange,Act,Assert。
Arrange是为测试准备相关环境,比如创建对象,创建数据记录,初始化工作等,在匿名用户新建collection中,Arrange为空。
Act,在新建collection这个测试里,就是发送服务器一个请求。我们需要导入APIClient类,并通该类发送post请求,当然APIClient也可以发送其他如delete,put等请求。我们在请求体内用 {‘title’:’a’},一般用a就够了,因为这是测试,没必要用具体真实的collection名称来测试,这样只会增加干扰。
Assert,用于检查我们期望的结果是否发生了。这里我们希望返回的是401状态,assert 跟着boolean表达式。
from rest_framework import status
from rest_framework.test import APIClient
class TestCreateCollection:
def test_if_user_is_anonymous_returns_401(self):
# Arrange
# ACT
client = APIClient()
response = client.post(‘/store/collections/’, {‘title’: ‘a’})
# ASSERT
assert response.status_code == status.HTTP_401_UNAUTHORIZED
运行测试
有了测试代码,我们运行它。首先要告诉pytest我们项目的settings.py文件。我们在项目根目录新建文件 pytest.ini。
[pytest]
DJANGO_SETTINGS_MODULE=storefront.settings
然后运行:pytest
能看到我们前面建的那条测试已通过。
测试非常简单,但我们要做正确的测试,反映真实情况。我们如何做呢?
我们可以尝试注释掉能让测试通过的代码!我们查看测试代码,它是要返回401状态码。那在CollectionViewSet中,哪行代码是会产生401呢?应该是这行:
permission_classes = [IsAdminOrReadOnly]
这行表示必须是管理员才有权更改Collection,如果我们将该行注释,则是允许未认证用户更改的。如果这样也能测试通过的话,就说明该测试未正确反映实际情况。注释该行后我们再运行:pytest
系统提示:
FAILED store/tests/test_collections.py::TestCreateCollection::test_if_user_is_anonymous_returns_401 – RuntimeError: Database access not allowed, use the “django_db” mark, or the “db” or “transactional_db” fixtures to enable it.
默认情况下,pytest的测试是不允许修改数据库的,这里的提示是测试代码需要修改数据库,我们需要加上@django_db的装饰符。这个装饰符也可以加在方法 test_if_user_is_anonymous_returns_401上,但为不重复,我们加在在类TestCreateCollection上,这样该类下的所有方法都继续了该装饰符。
…
import pytest
@pytest.mark.django_db
class TestCreateCollection:
def test_if_user_is_anonymous_returns_401(self):
…
我们再运行测试:pytest
我们收到了测试失败提示:
FAILED store/tests/test_collections.py::TestCreateCollection::test_if_user_is_anonymous_returns_401 – assert 201 == 401
这样我们就确认了该测试是正确的。
该测试不知道我们的实现,它不清楚我们的views,models,serializers,urls等。明天我们或许因为某个原因修改views为基于函数的views,只要行为不变,该测试就还是有效的。用这种方法写测试,我们就能确保软件的行为是符合我们的需求的,重要的是,我们不需要记住相关规则或更新过时的文档,只依靠测试代码。每当我们修改软件或准备发布时,只需运行一下测试代码就好。
我们再来看一下pytest的其他用法。
当我们运行 pytest 时,默认是运行所有测试。当我们有成百上千个测试时,如果其中某些测试有问题,测试就会中断。我们可以指定只测试某个文件夹、某个模块、某个类或仅是某个测试。
pytest store/tests
pytest store/tests/test_collections.py
pytest store/tests/test_collections.py::TestCreateCollection
pytest store/tests/test_collections.py::TestCreateCollection::test_if_user_is_anonymous_returns_401
注意模块与类与方法之间的分隔是用双冒号 :: 。
我们也可以指定模式,比如我们想只运行名字包含 anonymous 的测试,就可以:
pytest -k anonymous
跳过测试
有时一个测试失败了,我们修复可能需要一点时间,或者我们不想被这个失败的测试干扰,我们可以跳过它。方法很简单,可以用装饰符@pytest.mark.skip即可。
…
@pytest.mark.django_db
class TestCreateCollection:
@pytest.mark.skip
def test_if_user_is_anonymous_returns_401(self):
…
加了装饰符后,我们再运行 pytest 将会跳过该测试。
连续测试
有两种方法运行测试,一种是前面我们用的,在软件修改或发布前运行一下;另一种是始终运行测试,也叫连续测试。有些人喜欢连续测试,有些人讨厌,说它在配置低的机器上会拖慢工作。
有双显示器情况下,可以将连续测试放在另一个显示器,这样一边编码,一边就可以实时看到是否能通过测试,非常方便。
使用连续测试,需要安装pytest插件。
pipenv install –dev pytest-watch
安装之后,可以运行
ptw
这样,当我们修改代码时,它就会测试,我们就会实时看到测试结果。比如注释之前的 permission_classes = [IsAdminOrReadOnly] 或 将assert response.status_code == status.HTTP_401_UNAUTHORIZED 改成 assert response.status_code != status.HTTP_401_UNAUTHORIZED等等。
看看会不会实时看到测试结果。
不管使不使用连续测试,作为最佳实践,在修改软件发布前必须要全部运行一次测试。
在VSCODE上运行和调试测试
我们可以在VSCODE上配置测试,方便操作。
左侧点测试图标–Configure Python Tests –选择 pytest — 再选择根目目录。这样Vscode就配置好了调试和测试。
左侧按层次显示测试的结构,小图标按钮分别表示 运行测试、调试、跳到测试代码。
我们可以分别点各个按钮测试一下,其中调试跟其他都是一样的,就是设置断点,然后可以在断点开始,逐步推进、查看变量等,具体就不细介绍了。
认证用户
我们在TestCreateCollection类增加一个测试,用于测试非管理员用户Post的场景。相对前一个测试,我们只需做些少量修改即可,首先是方法名改成test_if_user_is_not_admin_returns_403,以及返回的http状态是403,认证用户只需调用APIClient的force_authenticate方法并传给参数user一个空对象即可。
@pytest.mark.django_db
class TestCreateCollection:
…
def test_if_user_is_not_admin_returns_403(self):
client = APIClient()
client.force_authenticate(user={})
response = client.post(‘/store/collections/’, {‘title’: ‘a’})
assert response.status_code == status.HTTP_403_FORBIDDEN
编写完成后,我们运行:pytest
确认测试通过。
单个或多个断言
我们添加另一个测试,用于测试管理员用户,但post的是无效的数据。同样,我们可以复制原来的测试,并作一些修改:
from django.contrib.auth.models import User
…
@pytest.mark.django_db
class TestCreateCollection:
…
def test_if_data_is_invalid_returns_400(self):
client = APIClient()
client.force_authenticate(user=User(is_staff=True))
response = client.post(‘/store/collections/’, {‘title’: ”})
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data[‘title’] is not None
我们新建了测试 test_if_data_is_invalid_returns_400 ,我们设置该用户是管理员,user的属性设置is_staff=True,注意这个user是在内存里,不是真正的管理员。然后我们传递键值对是无效的 {‘title’:”},值是空的。
然后我们用了两个断言,一个判断返回400状态码,另一个断言title不为空而是会收到错误提示。
有人会说,一般一个测试只允许一个断言,因为要遵守单一责任原则。单一责任原则没错,但这两个断言是互相关联的,并不违反单一责任原则。如果一定要教条式遵守这个原则,就要再建一个测试,而新建的测试绝大部分代码都一样,只是断言不一样,这也没什么意义。
我们再创建一个测试,用于测试管理员post有效数据的场景。
def test_if_data_is_valid_returns_201(self):
client = APIClient()
client.force_authenticate(user=User(is_staff=True))
response = client.post(‘/store/collections/’, {‘title’: ‘a’})
assert response.status_code == status.HTTP_201_CREATED
assert response.data[‘id’] > 0
对于这个测试的第2个断言 assert response.data[‘id’] > 0 ,有人会有不同方法,他可能从数据库获取创建的collection ,然后再进行判断。这是不建议的,因为这与实现进行了耦合。当用户修改实现后该断言可能就失效了。总之,与实现的细节耦合越少,测试代码越可靠。
还有人可能考虑调用 get方法看看能否获取这个新建的collection。听上去是个合理的方法,但实际也是有问题的,问题就在这个get的api要是崩溃了,我们的测试就会失败,即便已经成功地创建了collection。
简单地判断 id >0 基本可以确定,所以作为判断条件是非常恰当的。
固件(Fixtures)
pytest有个强大的特性叫Fixtures。利用Fixtures,我们可以去除重复的代码。
我们看一下到目前我们建立的测试代码,我们需要导入APIClient,再在每个测试中生成一个APIClient对象,我们可以利用Fixture去除这个重复。我们在tests文件夹新建 conftest.py文件,这个文件是pytest的一个特殊文件,我们可以将重复的代码写在这里,pytest会自动加载这个模块,而不用明确地导入。在 store — tests –conftest.py 文件中,我们定义了api_client方法,返回APIClient对象,同时加上装饰符@pytest.fixture,这样每个测试都可以将这个fixture作为参数传入。
from rest_framework.test import APIClient
import pytest
@pytest.fixture
def api_client():
return APIClient()
定义好fixture后,我们到具体的test中修改。我们在每个测试添加api_client参数,当pytest执行测试时,会寻找conftest.py中的fixture函数,找到的话,就执行fixture函数并返回值传给测试。这样我们的目前的4个测试都改成类似这样:
def test_if_user_is_anonymous_returns_401(self, api_client):
response = api_client.post(‘/store/collections/’, {‘title’: ‘a’})
…
修改后,test_collections.py中也不再需要APIClient了,可以删掉这条import语句。
我们再来看,每个测试都有post(‘/store/collections’,{‘title’:’a’})这样的语句,区别就在于post有些数据是有效的,有些是无效的。我们也来添加fixture来消除重复。
这个API端点不适合将fixture建在 conftest.py中,因为它只针对于collections的创建,如果放在conftest中,所有测试都看得到。所以我们就建在test_collections.py中。
@pytest.fixture
def create_collection(api_client):
api_client.post(‘/store/collections/’, {‘title’: ‘a’})
如上所示,我们不能硬编码数据 {‘title’:’a’},一种考虑是将之变成参数传递,将函数变成 create_collection(api_client, collection), 但在测试代码中是不行的,因为pytest看到这个参数 collection时,会寻找fixture相关的函数,找不到会报错,我们该如何做呢?
用内联函数!
我们在create_collection函数里创建一个内联函数,命名都可以,这里命名为do_create_collection,带一个参数 collection。该内联函数发送请求给创建collection的API端点,并返回response。而外面的函数create_collection返回是内联函数,这样就可以达到传递参数的功能。
然后我们修改测试方法,添加create_collection这个fixture参数,具体如下。
@pytest.fixture
def create_collection(api_client):
def do_create_collection(collection):
return api_client.post(‘/store/collections/’, collection)
return do_create_collection
@pytest.mark.django_db
class TestCreateCollection:
…
def test_if_user_is_not_admin_returns_403(self, api_client, create_collection):
api_client.force_authenticate(user={})
response = create_collection({‘title’: ‘a’})
assert response.status_code == status.HTTP_403_FORBIDDEN
…
现在我们看到还有一个force_authenticate有重复,该方法用于模拟用户身份,会应用于其他测试场景,我们应该写在conftest.py中。authenticate的fixture 类似于前面的create_collection,也是使用内联函数。
@pytest.fixture
def authenticate(api_client):
def do_authenticate(is_staff=False):
api_client.force_authenticate(user=User(is_staff=is_staff))
return do_authenticate
再到测试代码中做相应修改,类似下面这样:
…
def test_if_user_is_not_admin_returns_403(self, authenticate, create_collection):
authenticate()
response = create_collection({‘title’: ‘a’})
assert response.status_code == status.HTTP_403_FORBIDDEN
deftest_if_data_is_invalid_returns_400(self, authenticate, create_collection):
authenticate(is_staff=True)
…
经过这样去重后,我们能很清楚地看出AAA结构,比如:
authenticate() 是 Arrange;
response = create_collection({‘title’:’a’}) 是 ACT;
assert response.status_code == status.HTTP_403_FORBIDDEN是 Assert.
可读性非常强!
创建模型实例
我们完成了创建collection的测试代码,现在来实现获取单个collection的测试,这是有些不同的,我们要注意下。
获取单个collection有两种情况,一是找不到collection而返回404,二是找到collection并返回collection的内容。但这里就要注意了,必须先有collection才能返回内容,每一个测试不能依赖其他测试,我们必须把每一个测试当成是唯一的测试。显然在 Arrange 部分,我们要先创建一个collection。
我们有两个选择。一是先通过 post 到 /store/collections 一个collection,但这个问题我们之前提到过,如果创建 collection的API故障的话会影响到我们获取collection的测试,不可取。二是通过 Collcetion的objects.create方法创建一个,但这个方法是实现,违反了只测试行为不测试实现的原则。我们该如何做呢?
软件工程不是非黑即白,在没有其他选项时,我们可以打破规则选择妥协,这里我们就用objects.create。问题还有,一个models创建一个对象,需要提供不少必备的字段的,比较麻烦,这里我们就可以使用一个极棒的库:model_bakery,它为自动根据Model的各属性类型填充随机数。
pipenv install –dev model_bakery
然后到在 store/test/test_collections.py 添加:
@pytest.mark.django_db
class TestRetrieveCollection:
def test_if_collection_exists_return_200(self, api_client):
collection = baker.make(Collection)
# 代替 Collection.objects.create({‘title’:’a’})
print(collection.__dict__)
# 查看一下实际填充的内容,__dict__输出格式是键值对
assert False
# 为了能查看上一句print的输出,故意设置assert失败
我们看到title填充是非常随机的内容:
{‘_state’: <django.db.models.base.ModelState object at 0x7f50c0bb2b20>, ‘id’: 2, ‘title’: ‘tJHXBTrbUhCkwUAGpKYijQuVSdmKPVsxHmcJohPVvDecHAlNhJtcelgFSVcOzBbnMlKHqdQhiyEhFyzOtkZzwLszOaootQKCxjEdEEdqIWeGfShnwLoqqIfsQmeHGhqKZtmiGCJnDAyONeJXaEilANjwpNhuxQZTGBvXsciCsntyrxjVJNQzVWxwUijrWHIxODeDzbxyIieokABBpzSnLQkKFhTmrnPbwJQOdEGyUCNhxryOlRdKqDYUdcOoqeP’, ‘featured_product_id’: None}
我们看到title填充是非常随机的很长的文本,如果我们还有其他类型的字段,model_bakery也会自动处理。
model_bakery也会处理关系,比如有一句baker.make(Product),由于Product需要有外键Collection,我们无需先建一条Collection,他就会自动先建立Collection,并与Product关联。
有时候我们可能需要控制生成的对象,比如我们要生成10条Product记录,而且每条记录都属于同一个Collection.我们可以这样。
collection = baker.make(Collection)
baker.make(Product, collection=collection, _quantity=10)
_quantity=10 表示要创建的Product记录为10条,而且指定这10条记录的collection均为前一步创建collection。
言归正传,我们将该测试写完整。
@pytest.mark.django_db
class TestRetrieveCollection:
def test_if_collection_exists_return_200(self, api_client):
collection = baker.make(Collection)
response = api_client.get(f’/store/collections/{collection.id}/’)
assert response.status_code == status.HTTP_200_OK
assert response.data == {
‘id’: collection.id,
‘title’: collection.title,
‘products_count’: 0
}
注意,API端点必须是斜杠 / 结尾,否则出错。
最后一件事需要知道的是,当我们开始测试时,pytest会创建一个测试数据库,比如我们的数据库是storefront3,创建的测试数据库就是test_storefront3。当所有测试结束时,再删除测试数据库,这样就不会与我们实际在用的数据库搞混。
小结
本文详细介绍Django的自动化测试。下一篇计划介绍Django的性能测试。