TL;DR在本教程中,我们将学习如何在AWS Chalice应用程序中使用Pytest编写单元测试和集成测试。我们还将学习如何衡量测试覆盖率。
简介
AWS Chalice是一个基于Python的Web微框架,利用AWS Lambda和API网关服务。它被用来创建无服务器应用程序。通过语义和语法等功能,Chalice的体验类似于Flask。关于创建和部署Chalice应用程序的更多细节,你可以通过关于如何用AWS Chalice创建CRUD REST API的文章。
在构建应用程序时,有必要对代码进行测试,以避免运送错误和不稳定的代码。这也为人们节省了大量的调试时间,使部署工作不那么紧张。
软件开发中常见的测试形式包括。
- 单元测试。这是对代码中某一特定功能、组件或逻辑的测试。这样,边缘案例可以很容易地被识别、隔离和修复。单元测试通常涉及检查一个函数的输出与一个已知或预期的输出。
- 集成测试:以端到端的方式检查多个部分或整个应用程序。它考虑每个功能或组件如何与其他功能或组件一起工作。
然而,Chalice目前提供的测试客户端只是用于单元测试。因此,集成测试的编写方式类似于单元测试,除了集成测试由多个单元测试组成外,没有大的区别。
在哪里写测试
在基于Python的应用程序中,测试通常被放置在 test.py文件中。这些是测试文件,将导入要测试的应用逻辑。
让我们假设我们有一个简单的Chalice应用程序,它的文件夹结构如下所示。
├── app.py
├── .chalice
├── requirements.txt
└── test.py
然而,当应用程序变得更大时,一个单一的 test.py文件会变得笨重,并且难以处理。因此,有必要创建一个名为tests 的测试文件夹,然后在测试文件夹内将测试分成多个测试文件。
现在,我们需要创建一个名为tests 的新文件夹,并在该文件夹内创建一个空的 __init__.py文件夹中。该 __init__.py将允许 Python 将测试目录识别为一个可以运行的包。
mkdir tests && cd tests
touch __init__.py
现在,让我们在测试文件夹中为我们的应用程序创建一个测试文件。
touch test_unit.py
然后,文件夹的结构将看起来像这样。
├── app.py
├── .chalice
├── requirements.txt
└── tests
├── __init__.py
└── test_unit.py
然后,我们可以根据需要在tests 文件夹中创建任意多的测试文件。
如何进行单元测试
自从1.17.0版本发布以来,AWS Chalice已经提供了一个测试客户端,作为测试运行器,在Chalice应用程序中编写测试。我们不再需要为测试设置锅炉板和逻辑。我们只需要将测试客户端导入我们的测试文件中。
让我们假设我们的 Chalice 应用程序有一个 app.py文件,它看起来像下面这样。
from chalice import Chalice
app = Chalice(app_name='chalice-api-sample')
@app.route('/')
def index():
return {'hello': 'world'}
现在,我们将修改这个 test_unit.py文件,如下所示。
import app
from chalice.test import Client
在上面的代码中,我们刚刚导入了 app.py和chalice测试客户端。让我们添加下面的测试代码。
...
def test_index():
with Client(app.app) as client:
response = client.http.get('/')
assert response.status_code == 200
assert response.json_body == {'hello': 'world'}
在上面的测试代码中。
- 我们实例化了测试客户端,在特定的测试功能的背景下使用。这意味着每当我们运行测试时,一个带有资源和环境变量的测试环境将被建立,然后在运行测试后被清理掉。
- 我们通过HTTP提出了一个
GET通过HTTP请求,使用client.http属性提出请求。 - 我们断言,一个
200``{'hello': 'world'}.
我们将安装Pytestrunner来运行我们的测试。
pip install pytest
然后,我们将用以下命令运行。
py.test tests/test_unit.py
我们应该得到一个像下面这样的响应。
======================================== test session starts ========================================
platform win32 -- Python 3.7.7, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: C:\aws-chalice-api-sample
collected 1 item
tests\test_unit.py . [100%]
========================================= 1 passed in 0.11s =========================================
创建模拟
我们可以尝试模拟一个外部API,以便学习如何使用模拟变量进行测试。我们将测试从我们的应用程序到JSONPlaceholderAPI数据服务的一个端点的请求,以获得一个假的帖子列表。首先,让我们安装Python requests模块。
pip install requests
然后,让我们在文件中添加一个函数 app.py文件中添加一个函数,向 GET请求到 /post的端点发出请求,并返回一个帖子列表。
...
import requests
@app.route('/post')
def get_post():
response = requests.get("https://jsonplaceholder.typicode.com/posts/1")
if response.ok:
return response.json()
else:
return None
...
在上面的代码中,我们写了一个叫做get_post 的函数,向DummyAPI服务器发出HTTP请求,并以JSON形式返回请求的响应。
因此,我们将在文件中添加一个模拟的 test_unit.py文件。
...
from unittest.mock import patch
...
@patch('app.requests.get')
def test_get_post(mock_get):
"""Mocking with the patch decorator to get a post from an External API"""
mock_get.return_value.ok = True
response = app.get_post()
assert response.ok
在上面的代码中,我们导入了mock模块的补丁函数。然后,我们把这个补丁函数定义为一个装饰器,引用了项目的 request.get.然后,我们创建了一个名为test_get_post 的函数,其参数名为mock_get ,用来测试文件中的get_post 函数。 app.py文件中的函数。如果mock_get 的返回状态是ok ,那么就会向JSONPlaceholder发出一个假的请求,之后对请求的响应状态代码进行断言。我们确保模拟的行为就像它在向JSONPlaceholder发出一个真正的 request.get请求,而这是一个假的请求。这使得我们可以在不依赖JSONPlaceholder外部API服务器的情况下测试我们的代码。
如果我们用下面的bash命令再次运行我们的测试。
py.test tests/test_unit.py
我们应该得到一个类似于下面的输出。
========================================= test session starts =======================================
platform win32 -- Python 3.7.7, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: C:\aws-chalice-api-sample
plugins: cov-2.12.1
collected 2 items
tests\unit\test_unit.py .. [100%]
========================================= 2 passed in 0.85s =========================================
当所需的响应没有返回时,我们如何测试?这就是为什么我们有一个 else语句在 get_post()函数中的 app.py文件中的语句,对吗?为了适应当我们从JSONPlaceholder请求一个帖子时没有返回的情况。因此,我们将添加一个测试,在没有帖子返回时进行检查。让我们在文件中添加以下代码 test_unit.py文件中。
...
@patch('app.requests.get')
def test_no_get_post(mock_get):
"""Mock testing to check when no post is returned"""
mock_get.return_value.ok = False
response = app.get_post()
assert response == None
在上面的文件中,我们要求测试来检查模拟请求的返回值是否是 GET请求的返回值不是ok ,在这一行 mock_get.return_value.ok = False.我们还断言,返回的是None 响应。因此,我们已经能够处理没有返回帖子的情况。
然后,我们可以再次运行 test_unit.py文件,就像这样。
py.test tests/test_unit.py
我们将得到以下输出。
========================================= test session starts =======================================
platform win32 -- Python 3.7.7, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: \aws-chalice-api-sample
plugins: cov-2.12.1
collected 3 items
tests\unit\test_unit.py ... [100%]
========================================= 3 passed in 0.17s =========================================
很好!
该文件的完整代码是 test_unit.py文件的完整代码是这样的。
from unittest.mock import patch
import app
from chalice.test import Client
import json
def test_index():
with Client(app.app) as client:
response = client.http.get('/')
assert response.status_code == 200
assert response.json_body == {'hello': 'world'}
@patch('app.requests.get')
def test_get_post(mock_get):
"""Mocking with the patch decorator to get a post from an External API"""
mock_get.return_value.ok = True
response = app.get_post()
assert response.ok
@patch('app.requests.get')
def test_no_get_post(mock_get):
"""Mock testing to check when no post is returned"""
mock_get.return_value.ok = False
response = app.get_post()
assert response == None
如何编写集成测试
集成测试检查多个组件,看它们是否一起工作。这些测试通常写得像单元测试,但它们涉及到一次验证应用程序的多个部分。一个集成测试可能需要建立一个网络连接,设置一个数据库,等等。这些都可以被配置为固定程序。固定装置是设置初始状态/环境的函数,你可以在测试中创建一次并多次使用。
把单元测试和集成测试放在不同的文件夹中,这是一个好习惯。因此,我们将在test 目录中创建两个文件夹,分别称为unit 和integration 。然后。
- 我们将把该
test_unit.py文件到unit文件夹中。 - 同时,我们将在 文件夹中创建一个新的
test_integration.py文件,同时在integration文件夹中创建一个新的 - 接下来,我们将在 文件夹中创建一个
conftest.py在test文件夹中创建一个文件来放置我们的固定装置。
因此,文件夹的结构将如下所示。
├── app.py
├── .chalice
├── requirements.txt
└── tests
├── __init__.py
├── conftest.py
├── unit/
│ ├── __init__.py
│ └── test_unit.py
│
└── integration/
├── __init__.py
└── test_integration.py
首先,我们将创建一个名为app 的夹具。这个夹具将是我们Chalice应用程序的实例。让我们进入该 conftest.py文件,并将其改为这样。
import pytest
from chalice import Chalice
import app as chalice_app
from chalice.test import Client
@pytest.fixture
def app() -> Chalice:
return chalice_app
@pytest.fixture
def test_client():
with Client(chalice_app.app) as client:
yield client
在上面的代码中,我们将Chalice应用程序的一个实例抽象为一个夹具。我们还为我们的测试客户端创建了一个夹具。
再往前走,让我们假设我们已经为一个书架应用程序建立了一个REST API,在 app.py的CRUD端点。
...
# POST endpoint to add books to the bookshelf
@app.route('/book', methods=['POST'])
def create_book():
book_as_json = app.current_request.json_body
try:
Item = {
'id': book_as_json['id'],
"title": book_as_json['title'],
"author": book_as_json['author']
}
return {"id": book_as_json['id'], "title": book_as_json['title'], "author": book_as_json['author']}
except Exception as e:
return {'message': str(e)}
# PUT endpoint to update a book item based on the given ID
@app.route('/book/{id}', methods=['PUT'])
def update_book(id):
book_as_json = app.current_request.json_body
try:
Item = {
"id": book_as_json['id'],
"title": book_as_json['title'],
}
return {'message': 'ok - UPDATED', 'status': 201}
except Exception as e:
return {'message': str(e)}
# DELETE endpoint to delete a particular book based on the given ID
@app.route('/book/{id}', methods=['DELETE'])
def delete_book(id):
book_as_json = app.current_request.json_body
try:
Item = {
"id": book_as_json['id'],
"author": book_as_json['author']
}
return {'message': 'ok - DELETED', 'status': 201}
except Exception as e:
return {'message': str(e)}
上面的代码包括:。
add_book():函数为POST方法,将书籍添加到目录中update_book(id:使用UPDATE方法,用新的标题更新指定的图书条目。delete_book(id):从目录中删除一个特定的图书条目
现在,我们可以使用ChaliceTestHTTPClient类在文件中为它们编写测试。 test_integration.py文件中的测试,如下所示。
import json
# test for the create_book endpoint
def test_add_book(test_client):
response = test_client.http.post(
'/book',
headers={'Content-Type': 'application/json'},
body=json.dumps(
{
"id": "123",
"title": "Javascript Know It All",
"author": "Chukwuma Obinna",
})
)
assert response.json_body == {
"id": "123",
"title": "Javascript Know It All",
"author": "Chukwuma Obinna"
}
# test for the update_book endpoint
def test_update_book(test_client):
response = test_client.http.put(
'/book/{id}',
headers={'Content-Type': 'application/json'},
body=json.dumps(
{
"id": "123",
"title": "Chalice Book",
})
)
assert response.json_body == {
"message": "ok - UPDATED",
"status": 201
}
# test for the delete_book endpoint
def test_delete_book(test_client):
response = test_client.http.delete('/book/{id}',
headers={'Content-Type': 'application/json'},
body=json.dumps(
{
"id": "123",
"author": "Chukwuma Obinna",
})
)
assert response.json_body == {
"message": "ok - DELETED",
"status": 201
}
在上面的代码中。
- 我们为每个CRUD端点编写了测试。
- 在每个测试函数中,我们都使用了
test_client,我们在前面的文件中把它定义为一个固定程序。conftest.py文件中定义的夹具。 - 定义了每个测试请求中要传递的头和正文
- 我们断言了对测试请求的已知响应
注意:为了简化这个例子,所用的API中不包括数据库功能。否则,我们将不得不写一个固定程序来为集成测试设置一个模拟数据库。
为了运行上述测试,我们将使用以下命令。
py.test tests/test_integration.py
我们应该得到一个类似于下面的响应。
========================================= test session starts ======================================
platform win32 -- Python 3.7.7, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
cachedir: .pytest_cache
rootdir: C:\aws-chalice-api-sample
collected 4 items
tests/integration/test_integration.py::test_index PASSED [ 25%]
tests/integration/test_integration.py::test_add_book PASSED [ 50%]
tests/integration/test_integration.py::test_update_book PASSED [ 75%]
tests/integration/test_integration.py::test_delete_book PASSED [100%]
========================================= 4 passed in 1.01s =======================================
一般来说,集成测试通常比单元测试需要更长的时间来运行。因此,建议不要每次都运行它们,而是在需要部署的时候运行。
该文件的全部代码如下 app.py文件的完整代码是这样的。
from requests.models import Response
from chalice import Chalice
import requests
import json
app = Chalice(app_name='aws-chalice-api-sample')
@app.route('/')
def index():
return {'hello': 'world'}
# Function to make External API Call
@app.route('/post')
def get_post():
response = requests.get("https://jsonplaceholder.typicode.com/posts/1")
if response.ok:
return response.json()
else:
return None
# Function to make POST request to create a book
@ app.route('/book', methods=['POST'])
def create_book():
book_as_json = app.current_request.json_body
try:
Item = {
'id': book_as_json['id'],
"title": book_as_json['title'],
"author": book_as_json['author']
}
return {"id": book_as_json['id'], "title": book_as_json['title'], "author": book_as_json['author']}
except Exception as e:
return {'message': str(e)}
# Function to make POST request to update a book
@app.route('/book/{id}', methods=['PUT'])
def update_book(id):
book_as_json = app.current_request.json_body
try:
Item = {
"id": book_as_json['id'],
"title": book_as_json['title'],
}
return {'message': 'ok - UPDATED', 'status': 201}
except Exception as e:
return {'message': str(e)}
# Function to make POST request to delete a particular book a book
@app.route('/book/{id}', methods=['DELETE'])
def delete_book(id):
book_as_json = app.current_request.json_body
try:
Item = {
"id": book_as_json['id'],
"author": book_as_json['author']
}
return {'message': 'ok - DELETED', 'status': 201}
except Exception as e:
return {'message': str(e)}
测量代码覆盖率
代码覆盖率是量化我们的代码被测试的程度的一种简单手段。在本教程中,我们将使用pytest-cov包来测量测试覆盖率。它是一个建立在coverage.py工具之上的工具,用于测量Python代码的覆盖率。幸运的是,pytest-cov与pytest配合得很好。
让我们来安装pytest-cov。
pip install pytest-cov
让我们通过使用 -cov参数来测量测试覆盖率。我们将测量我们的源代码的覆盖率,在 app.py文件中。
pytest --cov=app --cov-report term-missing
我们用 --cov-report``term-missing命令来指定我们希望我们的覆盖率报告能够指出未被测试覆盖的代码行。
我们将得到一个终端输出,看起来像这样。
======================================= test session starts =======================================
platform win32 -- Python 3.7.7, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: \aws-chalice-api-sample
plugins: cov-2.12.1
collected 7 items
tests\integration\test_integration.py .... [ 57%]
tests\unit\test_unit.py ... [100%]
----------- coverage: platform win32, python 3.7.7-final-0 -----------
Name Stmts Miss Cover Missing
--------------------------------------
app.py 33 6 82% 46-47, 59-60, 72-73
--------------------------------------
TOTAL 33 6 82%
======================================== 7 passed in 2.16s ========================================
注意:上面第46-47、59-60和72-73行中的6条遗漏的异常语句在 app.py文件中。遗漏似乎是在运行时由于pytest错误而发生的。
结论
在这篇文章中,我们已经考虑了如何在Chalice应用程序和API中运行单元和集成测试。我们还学习了如何使用Pytest进行测试和Pytest-cov来测量代码覆盖率。现在我们可以利用所学到的知识继续构建测试驱动的Chalice应用程序。谢谢你的关注。我们很高兴在评论区得到你的想法和建议。谢谢。