Меня зовут Алексей. Я являюсь специалистом по автоматизации тестирования. Пишу как UI тесты на селениуме, так и покрываю тестами серверное REST API. Данная статья является туториалом и будет полезна как начинающим, так и действующим тестировщикам и автоматизаторам. Но также может быть полезна разработчикам и специалистам из смежных направлений. В статье мы пошагово покроем тестами REST API https://restful-api.dev на примере методов GET, POST, PUT, DELETE. Подход, описанный в статье, я сам использую на реальном проекте.
Структура статьи
Используемые библиотеки
Написание тестов:
требования к системе тестов
структура проекта
создание АПИ клиента
написание первого теста
код первого теста
тесты на GET, POST, PUT, DELETE
Заключение
Используемые библиотеки
python 3.11.4
pytest 7.4.0
- написание тестов. Подробнее: https://pytest-docs-ru.readthedocs.io/ru/latest/getting-started.htmlpydantic 2.3.0
- библиотека для валидации структуры ответа. Подробнее: https://docs.pydantic.dev/httpx 0.24.1
- отправка запросов. Подробнее: https://www.python-httpx.org/quickstart/тестовое АПИ - https://restful-api.dev
среда разработки -
PyCharm
.
Написание тестов
Перед тем, как что-то писать, сформируем набор требований, которые мы будем соблюдать в нашей системе тестов, а именно:
идейность (каждый тест имеет четкую идею)
атомарность идеи (в тесте проверяется только одна идея)
независимость от других тестов (действия в одном тесте не влияют на другой, тесты могут идти в любом порядке)
гибкость относительно изменений в системе (тесты можно легко перенести на другую конфигурацию, например другой стенд или быстро изменить в случае изменений в тестируемом приложении)
Соблюдение этих требований позволит нам писать тесты структурно и минимизировать лишний рефакторинг.
Структура проекта
Сформируем каркас проекта с папками, содержащими:
api - классы для взаимодействия с api
logs - логи запросов к серверу
assertions - классы проверок для тестов
models - модели pydantic для проверки схем ответа
test_data - эталонные файлы json для отправки запросов и проверки ответов
tests - классы с тестами
utilities - вспомогательные классы-утилиты (для работы с json и.т.д)
Структура будет выглядеть следующим образом:
При создании проекта также была инициализирована питоновская папкаvenv
(подробнее https://docs.python.org/3/library/venv.html) для всех необходимых библиотек. Добавим в корень проекта файлrequirements.txt
со следующей структурой:
httpx==0.24.1
pytest==7.4.0
pydantic==2.3.0
python-dotenv==1.0.0
Установим перечисленные библиотеки командой pip install -r requirements.txt
Создание АПИ клиента
В проекте для отправки запросов использована библиотека httpx. Из фичей поддерживается сохранение сессии (HTTP connection pooling) и асинхронные запросы. Очень полезна в освоении. httpx предлагает использовать класс Client. Он позволяет открыть TCP соединение и отправить сколько угодно запросов в рамках него одного. Это экономит время при каждом запросе к серверу.
Наш АПИ клиент для удобства дополнительно будет логировать отправленные запросы по принципу: тип запроса, путь конечного эндпоинта.
В папкуapi
добавляем файлapi_client.py
import os
from httpx import Client, Response
from utilities.logger_utils import logger
class ApiClient(Client):
"""
Расширение стандартного клиента httpx.
"""
def __init__(self):
super().__init__(base_url=f"https://{os.getenv('RESOURSE_URL')}")
def request(self, method, url, **kwargs) -> Response:
"""
расширение логики метода httpx request с добавлением логирования типа запроса и его url,
логировать или нет задается в файле .env
:param method: метод, который мы используем (POST, GET и.т.д)
:param url: путь на домене, по которому отправляем запрос
"""
if eval(os.getenv("USE_LOGS")):
logger.info(f'{method} {url}')
return super().request(method, url, **kwargs)
На каждый конечный эндпоинт добавим соответствующие методы в файлobjects_api.py
from api import routes
def get_objects(client, *ids):
return client.get(routes.Routes.OBJECTS, params={'id': ids} if ids else None)
def get_object(client, obj_id):
return client.get(routes.Routes.OBJECTS_ITEM.format(obj_id))
def post_object(client, **kwargs):
return client.post(routes.Routes.OBJECTS, **kwargs)
def put_object(client, obj_id, **kwargs):
return client.put(routes.Routes.OBJECTS_ITEM.format(obj_id), **kwargs)
def delete_object(client, obj_id):
return client.delete(routes.Routes.OBJECTS_ITEM.format(obj_id))
Также добавим класс с путями внутри сервера в файлroutes.py
from enum import Enum
class Routes(str, Enum):
OBJECTS = '/objects'
OBJECTS_ITEM = '/objects/{}'
def __str__(self) -> str:
return self.value
Перед тем, как писать первый тест, добавим в корень проектаconftest.py
, .env
файл иpytest.ini
. Они нужны для установки параметров логирования в нашем клиенте.
Файлconftest.py
import logging
import os
from dotenv import load_dotenv
from utilities.logger_utils import logger
def pytest_configure(config):
# устанавливаем текущую директорию на корень проекта (это позволит прописывать относительные пути к файлам)
os.chdir(os.path.dirname(os.path.abspath(__file__)))
# загружаем переменные-параметры из файла /.env
load_dotenv(dotenv_path=".env")
# задаем параметры логгера
path = "logs/"
os.makedirs(os.path.dirname(path), exist_ok=True)
file_handler = logging.FileHandler(path + "/info.log", "w")
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter("%(lineno)d: %(asctime)s %(message)s"))
# создаем кастомный логгер
custom_logger = logging.getLogger("custom_loger")
custom_logger.setLevel(logging.INFO)
custom_logger.addHandler(file_handler)
def pytest_runtest_setup(item):
logger.info(f"{item.name}:")
Файл.env
RESOURSE_URL=api.restful-api.dev
USE_LOGS=True
Файлpytest.ini
[pytest]
addopts =
--rootdir=.
-p no:logging
Все готово к написанию первого теста. По итогу структура проекта выглядит следующим образом:
Написание первого теста
Сформируем требования к структуре наших тестов. Каждый тест имеет обязательные и необязательные атрибуты (зависит от логики самого теста). Структурно это выглядит так:
Отправка запроса.
Проверка кода ответа (сравнивается ожидаемый и пришедший код).
Проверка схемы ответа (проверяется структура тела ответа и типы полей).
Проверка тела ответа и специфической логики
1 и 2 пункты мы исполняем обязательно т.к любой запрос дает нам ожидаемый код ответа. Третий пункт мы будем проверять, если у ответа есть тело (json). 4 пункт подразумевает проверку корректности значений полей в теле ответа, а также специфической логики, которая соответствует идеи нашего теста. Этот пункт будет обязательным, если у ответа есть тело.
Для проверок 2, 3 и 4 пунктов добавим базовые методы assertion. В папкуassertions
добавим классassertion_base.py
from typing import Type
from pydantic import BaseModel
from utilities.files_utils import read_json_test_data, read_json_common_response_data
from utilities.json_utils import compare_json_left_in_right, remove_ids
class LogMsg:
"""
Базовый класс для построение логов AssertionError. Конструирует сообщение в свое поле _msg.
"""
def __init__(self, where, response):
self._msg = ""
self._response = response
self._where = where
def add_request_url(self):
"""
добавляет данные об отправленном на сервер запросе
"""
self._msg += f"Содержимое отправляемого запроса (url, query params, тело):\n" \
f"\tURL: {self._response.request.url}\n"
self._msg += f"\tmethod: {self._response.request.method}\n"
self._msg += f"\theaders: {dict(self._response.request.headers)}\n"
if hasattr(self._response.request, 'params') and self._response.request.params:
self._msg += f"\tquery params: {self._response.request.params}\n"
else:
self._msg += f"\tquery params:\n"
if hasattr(self._response.request, 'content') and self._response.request.read():
self._msg += f"\tbody: {self._response.request.read()}\n"
else:
self._msg += f"\tbody:\n"
return self
def add_response_info(self):
"""
добавляет информацию о содержимом тела ответа
"""
self._msg += f"Тело ответа:\n\t{self._response.content}\n"
return self
def add_error_info(self, text):
if text:
self._msg += f"\n{text}\n"
else:
self._msg += "\n"
return self
def get_message(self):
return self._msg
class BodyLogMsg(LogMsg):
"""
Добавляет в логи результаты проверок тела ответа.
"""
def __init__(self, response):
super().__init__('В ТЕЛЕ ОТВЕТА', response)
def add_compare_result(self, diff):
"""
добавляет информацию о результате сравнения полученного json с эталоном
:param diff: словарь с данными полей, которые после сравнения имеют разные значения
"""
self._msg += f"{self._where} в json следующие поля не совпали с эталоном:\n"
for key, value in diff.items():
self._msg += f"ключ: {value['path']}\n\t\texpected: {value['expected']} \n\t\tactual: {value['actual']}\n"
return self
class CodeLogMsg(LogMsg):
"""
Добавляет в логи результаты проверки кода ответа.
"""
def __init__(self, response):
super().__init__('В КОДЕ ОТВЕТА', response)
def add_compare_result(self, exp, act):
"""
добавляет информацию об ожидаемом и полученной коде
:param exp: ожидаемый код
:param act: полученный код
"""
self._msg += f"{self._where} \n\tожидался код: {exp}\n\tполученный код: {act}\n"
return self
class BodyValueLogMsg(LogMsg):
def __init__(self, response):
super().__init__('В ТЕЛЕ ОТВЕТА', response)
def add_compare_result(self, exp, act):
"""
добавляет информацию о сравнении значений в теле ответа
:param exp: ожидаемое значение
:param act: полученное значение
"""
self._msg += f"\texptected: {exp}\n\tactual: {act}\n"
return self
def assert_status_code(response, expected_code):
"""
сравнивает код ответа от сервера с ожидаемым
:param response: полученный от сервера ответ
:param expected_code: ожидаемый код ответа
:raises AssertionError: если значения не совпали
"""
assert expected_code == response.status_code, CodeLogMsg(response) \
.add_compare_result(expected_code, response.status_code) \
.add_request_url() \
.add_response_info() \
.get_message()
def assert_schema(response, model: Type[BaseModel]):
"""
проверяет тело ответа на соответствие его схеме механизмами pydantic
:param response: ответ от сервера
:param model: модель, по которой будет проверяться схема json
:raises ValidationError: если тело ответа не соответствует схеме
"""
body = response.json()
if isinstance(body, list):
for item in body:
model.model_validate(item, strict=True)
else:
model.model_validate(body, strict=True)
def assert_left_in_right_json(response, exp_json, actual_json):
"""
проверяет, что все значения полей exp_json равны значениям полей в actual_json
:param response: полученный ответ от сервера
:param exp_json: ожидаемый эталонный json
:param actual_json: полученый json
:raises AssertionError: если в exp_json есть поля со значениями, которые отличаются или которых нет в actual_json
"""
root = 'root:' if isinstance(actual_json, list) else ''
compare_res = compare_json_left_in_right(exp_json, actual_json, key=root, path=root)
assert not compare_res, BodyLogMsg(response) \
.add_compare_result(compare_res) \
.add_request_url() \
.add_response_info() \
.get_message()
def assert_response_body_fields(request, response, exp_obj=None, rmv_ids=True):
"""
проверяет ответ от сервера, сравнивая ожидаемый объект с полученным
:param request: стандартный объект request фреймворка pytest
:param response: ответ от сервера
:param exp_obj: ожидаемый объект
:param rmv_ids: флаг: значение True - удаляет id из тела ответа при проверке, False - не удаляет
"""
exp_json = read_json_test_data(request) if exp_obj is None else exp_obj
act_json = remove_ids(response.json()) if rmv_ids else response.json()
assert_left_in_right_json(response, exp_json, act_json)
def assert_response_body_value(response, exp, act, text=None):
"""
проверяет ответ от сервера, сравнивая полученное значение с ожидаемым для тела запроса
:param response: ответ от сервера
:param exp: ожидаемое значение
:param act: полученное значение
:param text: дополнительный текст, который необходимо вывести при несовпадении exp и act
"""
assert exp == act, BodyValueLogMsg(response) \
.add_error_info(text) \
.add_compare_result(exp, act) \
.add_request_url() \
.add_response_info() \
.get_message()
def assert_empty_list(response):
"""
проверяет, что тело ответа содержит пустой список
:param response: ответ от сервера
"""
assert_left_in_right_json(response, [], response.json())
def assert_bad_request(request, response):
"""
проверяет, что тело ответа содержит данные BAD REQUEST
:param request: стандартный объект request фреймворка pytest
:param response: ответ от сервера
"""
assert_response_body_fields(request, response, exp_obj=read_json_common_response_data("bad_request_response"))
def assert_not_found(request, response, obj_id):
"""
проверяет, что тело ответа содержит данные NOT FOUND
:param request: стандартный объект request фреймворка pytest
:param response: ответ от сервера
:param obj_id: id объекта, который сервер не нашел
"""
exp = read_json_common_response_data("not_found_obj_response")
exp['error'] = exp['error'].format(obj_id)
assert_response_body_fields(request, response, exp_obj=exp)
def assert_not_exist(request, response, obj_id):
"""
проверяет, что тело ответа содержит данные NOT EXIST
:param request: стандартный объект request фреймворка pytest
:param response: ответ от сервера
:param obj_id: id объекта, который сервер не нашел
"""
exp = read_json_test_data(request)
exp['error'] = exp['error'].format(obj_id)
assert_response_body_fields(request, response, exp_obj=exp, rmv_ids=False)
Базовые методыassertion
начинаются сassert
. КлассLogMsg
и его производные используются для вывода понятных логов в stdout в случае несовпадения ожидаемого результата с фактическим.
Для проверки схемы ответа удобно использовать Pydantic. Это многофункциональная либа для сериализации и десериализации json со встроенными механизмами валидации. Для ее работы нам потребуются эталонные модели получаемых от сервера объектов. В папкуmodels
добавим файлobject_models.py
с моделями. Все они наследуются отBaseModel
.
from typing import List
from pydantic import BaseModel, Field
class ObjectData(BaseModel):
year: int
price: float
cpu_model: str = Field(alias="CPU model")
hard_disk_size: str = Field(alias="Hard disk size")
class CustomObj(BaseModel):
name: str
class CustomObjData(BaseModel):
bool: bool
int: int
float: float
string: str
array: List[str]
obj: CustomObj
class ObjectOutSchema(BaseModel):
id: str
name: str
data: ObjectData
class ObjectCreateOutSchema(BaseModel):
id: str
name: str | None
data: ObjectData | None
createdAt: str
class CustomObjCreateOutSchema(BaseModel):
id: str
name: str
data: CustomObjData
createdAt: str
class ObjectUpdateOutSchema(BaseModel):
id: str
name: str | None
data: ObjectData | None
updatedAt: str
class CustomObjUpdateOutSchema(BaseModel):
id: str
name: str
data: CustomObjData
updatedAt: str
Также, чтобы сравнивать полученное тело ответа с эталонным, добавим вспомогательные классы-утилиты для работы с файлами и json.
Файлfiles_utils.py
import json
def get_test_data_path():
return "test_data"
def get_common_response_path():
return f"{get_test_data_path()}/common/responses"
def get_common_requests_path():
return f"{get_test_data_path()}/common/requests"
def read_json_file_data(path):
"""
возвращает содержимое json файла в виде dict
:param path: путь до файла без расширения json
"""
with open(f"{path}.json", "r") as f:
data = json.load(f)
return data
def read_json_test_data(request):
"""
считывает данные для теста в формате json
:param request: стандартный объект request фреймворка pytest
:return: содержимое данных для теста из папки test_data
"""
return read_json_file_data(f"{get_test_data_path()}/{request.node.originalname}")
def read_json_common_response_data(file_name):
"""
считывает данные для теста в формате json из общей папки
:param file_name: имя файла без расширения json
:return: содержимое данных для теста из папки test_data/common/responses
"""
return read_json_file_data(f"{get_common_response_path()}/{file_name}")
def read_json_common_request_data(file_name):
"""
считывает данные для теста в формате json из общей папки
:param file_name: имя файла без расширения json
:return: содержимое данных для теста из папки test_data/common/requests
"""
return read_json_file_data(f"{get_common_requests_path()}/{file_name}")
Файлjson_utils.py
def remove_ids(origin_dict):
"""
удаляет ключи из словаря, в которых есть id
:param origin_dict: исходный словарь
:return: словарь с выпиленными id
"""
def rmv_ids(node):
remove_keys = []
if isinstance(node, dict):
for key, value in node.items():
rmv_ids(value)
if key == 'id':
remove_keys.append(key)
for key in remove_keys:
del node[key]
elif isinstance(node, list):
for item in node:
rmv_ids(item)
res = origin_dict.copy()
rmv_ids(res)
return res
def compare_json_left_in_right(json1, json2, key='', path=''):
"""
сравнивает, что все значения ключей из json1 есть в json2, лишние ключи из левого json - игнорируются
:param json1: эталонный словаро
:param json2: словарь, с которым идет сравнение
:param key: корневое имя ключа
:param path: путь до ключа, в котором произошло несовпадение значений
:return: если в правом словаре есть несовпадения значений со значений из левого словаря, возвращается словарь
формата {"ключ_в_котором_произошло_различие": {"expected": value, "actual": value, "path": полный_путь_до_ключа}}
"""
diff_dict = {}
if isinstance(json1, dict) and isinstance(json2, dict):
for key in json1:
if key not in json2:
diff_dict[key] = {"expected": json1[key], "actual": "key undefined", "path": f"{path}{key}"}
continue
diff_dict.update(compare_json_left_in_right(json1[key], json2[key], key, f"{path}{key}:"))
elif json1 != json2:
diff_dict[key] = {"expected": json1, "actual": json2, "path": path[:-1]}
return diff_dict
С методами проверок закончили. Приступим к написанию тестов. Начнем с написания тестов на GET методы. Для начала сформируем шаблон с принципами и действиями, которыми будем руководствоваться при написании чек-листов.
Есть общие действия для каждого типа метода, исходя из его типа (GET, POST и.т.д). Для каждого типа метода нам надо выполнить 2 пункта: проверить, что метод выполняет свою суть в зависимости от типа и проверить его бизнес-логику. Например суть метода GET - возвращать данные с сервера. Соответственно на каждый GET метод мы должны множеством тестов убедиться, что метод правильно возвращает значения с сервера, и проверить специфическую логику, заложенную в этот метод.
Метод | Пункты, которые необходимо реализовать |
GET | 1 проверка корректности получения объекта: 2 Проверка специфической логики |
Распишем чек-листы, распределив их на позитивные и негативные. В данном АПИ метод GET /objects просто возвращает список объектов с сервера, соответственно мы должны проверить, что объекты возвращаются при валидных и невалидных параметрах.
Позитивные | Негативные |
запрос с параметрами по-умолчанию запрос с 1 сущ. айдишником запрос с 2 сущ. айдишниками | запрос с несущ. айдишником запрос с невалидным айдишником |
Реализуем первый тест
Файлtest_objects.py
from http import HTTPStatus
import pytest
from assertions.objects_assertion import should_be_posted_success, should_be_updated_success, should_be_deleted_success, \
should_be_valid_objects_response
from api.api_client import ApiClient
from api.objects_api import get_objects, get_object, post_object, put_object, delete_object
from assertions.assertion_base import assert_status_code, assert_response_body_fields, assert_bad_request, \
assert_not_found, assert_empty_list, assert_schema, assert_not_exist
from models.object_models import ObjectOutSchema, ObjectCreateOutSchema, CustomObjCreateOutSchema, \
ObjectUpdateOutSchema, CustomObjUpdateOutSchema
from utilities.files_utils import read_json_test_data, read_json_common_request_data
class TestObjects:
"""
Тесты /objects
"""
@pytest.fixture(scope='class')
def client(self):
return ApiClient()
def test_get_objects(self, client, request):
"""
получение заранее заготовленных объектов из базы с параметрами по-умолчанию,
GET /objects
"""
# получаем объекты из базы
response = get_objects(client)
# убеждаемся, что в ответ пришли объекты, которые мы ожидаем
assert_status_code(response, HTTPStatus.OK)
assert_response_body_fields(request, response)
В тесте происходит следующее: мы делаем запрос, проверяем, что код ответа 200, а затем убеждаемся, что полученный объект в ответе соответствует ожидаемому (проверка тела ответа), делая прямое сравнение с проверочным файлом. При сравнении, чтобы не нарушать 4 сформированное требование к тестирующей системе (гибкость), выпиливаем все id из ответа. id - это внутреннее состояние объекта. Выпиливаем мы их, чтобы при каком-либо обновлении состояния БД наши тесты не посыпались, если логика метода и структура объекта при таком обновлении не изменились. В проверочном файле их также нет. Проверочный файл выглядит следующим образом:
Файлtest_get_objects.json
[
{
"name": "Google Pixel 6 Pro",
"data": {
"color": "Cloudy White",
"capacity": "128 GB"
}
},
{
"name": "Apple iPhone 12 Mini, 256GB, Blue",
"data": null
},
{
"name": "Apple iPhone 12 Pro Max",
"data": {
"color": "Cloudy White",
"capacity GB": 512
}
},
{
"name": "Apple iPhone 11, 64GB",
"data": {
"price": 389.99,
"color": "Purple"
}
},
{
"name": "Samsung Galaxy Z Fold2",
"data": {
"price": 689.99,
"color": "Brown"
}
},
{
"name": "Apple AirPods",
"data": {
"generation": "3rd",
"price": 120
}
},
{
"name": "Apple MacBook Pro 16",
"data": {
"year": 2019,
"price": 1849.99,
"CPU model": "Intel Core i9",
"Hard disk size": "1 TB"
}
},
{
"name": "Apple Watch Series 8",
"data": {
"Strap Colour": "Elderberry",
"Case Size": "41mm"
}
},
{
"name": "Beats Studio3 Wireless",
"data": {
"Color": "Red",
"Description": "High-performance wireless noise cancelling headphones"
}
},
{
"name": "Apple iPad Mini 5th Gen",
"data": {
"Capacity": "64 GB",
"Screen size": 7.9
}
},
{
"name": "Apple iPad Mini 5th Gen",
"data": {
"Capacity": "254 GB",
"Screen size": 7.9
}
},
{
"name": "Apple iPad Air",
"data": {
"Generation": "4th",
"Price": "419.99",
"Capacity": "64 GB"
}
},
{
"name": "Apple iPad Air",
"data": {
"Generation": "4th",
"Price": "519.99",
"Capacity": "256 GB"
}
}
]
По итогу структура проекта приобретает следующий вид:
Расширим тестовое покрытие метода GET /objects по нашим чек-листам. В данном случае кейсы “запрос с 2 сущ. айдишниками” и “запрос с 1 сущ. айдишниками” требуют специфических проверок. Для начала добавим класс, который будет хранить такие проверкиobjects_assertion.py
. Методы специфических проверок начинаются с префиксаshould_be
.
Файлobjects_assertion.py
from http import HTTPStatus
from api.objects_api import get_object
from assertions.assertion_base import assert_response_body_fields, assert_status_code, assert_response_body_value
from utilities.files_utils import read_json_test_data
def should_be_valid_objects_response(request, response, param):
# убеждаемся, что в ответе столько объектов, сколько мы ожидаем
exp = read_json_test_data(request)[param['index']]
exp_len, act_len = len(exp), len(response.json())
assert_response_body_value(response, exp_len, act_len,
text="ОЖИДАЕМОЕ КОЛИЧЕСТВО ОБЪЕКТОВ НЕ СОВПАЛО С ФАКТИЧЕСКИМ")
# убеждаемся в корректности значений полей полученных объектов
assert_response_body_fields(request, response, exp)
Добавим тесты в файлtest_objects.py
from http import HTTPStatus
import pytest
from assertions.objects_assertion import should_be_posted_success, should_be_updated_success, should_be_deleted_success, \
should_be_valid_objects_response
from api.api_client import ApiClient
from api.objects_api import get_objects, get_object, post_object, put_object, delete_object
from assertions.assertion_base import assert_status_code, assert_response_body_fields, assert_bad_request, \
assert_not_found, assert_empty_list, assert_schema, assert_not_exist
from models.object_models import ObjectOutSchema, ObjectCreateOutSchema, CustomObjCreateOutSchema, \
ObjectUpdateOutSchema, CustomObjUpdateOutSchema
from utilities.files_utils import read_json_test_data, read_json_common_request_data
class TestObjects:
"""
Тесты /objects
"""
@pytest.fixture(scope='class')
def client(self):
return ApiClient()
def test_get_objects(self, client, request):
"""
получение заранее заготовленных объектов из базы с параметрами по-умолчанию,
GET /objects
"""
# получаем объекты из базы
response = get_objects(client)
# убеждаемся, что в ответ пришли объекты, которые мы ожидаем
assert_status_code(response, HTTPStatus.OK)
assert_response_body_fields(request, response)
@pytest.mark.parametrize("param", [{"index": 0, "ids": [1]}, {"index": 1, "ids": [1, 2]}])
def test_get_objects_id_param(self, client, request, param):
"""
получение заранее заготовленных объектов из базы с параметром ids,
GET /objects
"""
# получаем массив объектов с определенными айдишниками
response = get_objects(client, *param['ids'])
# убеждаемся, что в ответ пришли именно те объекты, id которых мы запросили
assert_status_code(response, HTTPStatus.OK)
should_be_valid_objects_response(request, response, param)
def test_get_objects_not_exist_id(self, client):
"""
попытка получить из базы объект с несуществующим id,
GET /objects
"""
# пытаемся получить объект, несуществующий в системе
response = get_objects(client, 8523697415)
# убеждаемся, что в ответ пришел пустой список
assert_status_code(response, HTTPStatus.OK)
assert_empty_list(response)
def test_get_objects_invalid_id(self, client):
"""
попытка получить из базы объект с невалидным по типу id,
GET /objects
"""
# пытаемся получить объект, отправив невалидный по типу параметр ids
response = get_objects(client, "kjdsf23321")
# убеждаемся, что в ответ пришел пустой список
assert_status_code(response, HTTPStatus.OK)
assert_empty_list(response)
По итогу мы 5 тестами проверили, что метод GET /objects действительно возвращает корректные объекты из базы при разных параметрах и выдает ошибки при неверных id.
Распишем по такому же принципу чек-лист для метода GET /objects/{id}
Позитивные | Негативные |
запрос существующего объекта | запрос несуществующего объекта |
В файлtest_objects.py
добавим
def test_get_object(self, client, request):
"""
получение заранее заготовленного объекта из базы,
GET /objects/{id}
"""
# получаем единичный объект с сервера
response = get_object(client, 7)
# убеждаемся, что получен именно тот объект, который мы запросили
assert_status_code(response, HTTPStatus.OK)
assert_schema(response, ObjectOutSchema)
assert_response_body_fields(request, response)
def test_get_object_not_exist(self, client, request):
"""
попытка получить из базы единичный объект с несуществующим id,
GET /objects/{id}
"""
# запрашиваем единичный объект с сервера с несуществующим id
response = get_object(client, 1593576458)
# убеждаемся, что сервер вернул NOT FOUND ответ
assert_status_code(response, HTTPStatus.NOT_FOUND)
assert_not_exist(request, response, 1593576458)
Как видно в тестеtest_get_object
добавилась проверка схемы ответа (методassert_schema
). Pydantic проверит, что полученный объект содержит все необходимые поля и проверит, что они строго того типа, который мы ожидаем. В случае ошибки, он выведет нам поля, которые не соответствуют схеме по типу или не найдены.
Приступим к методу POST.
Cуть метода POST - сохранять данные на сервере. Как правило, это приводит к порождению нового объекта. Соответственно мы должны множеством тестов убедиться, что метод правильно сохраняет значения на сервере, и проверить специфическую логику, заложенную в этот метод.
Метод | Пункты, которые необходимо реализовать |
POST | 1 проверка корректности сохранения объекта на сервере: 2 Проверка специфической логики |
В данном АПИ метод POST /objects сохраняет объект, у которого всегда есть 2 поля name
и data
. Сохранение данных всегда происходит в эти 2 поля, если они не заполнены, сервер установит их в null. Соответственно наша задача проверить, что данные в эти поля сохраняются в соответствии с заложенной логикой, и сервер не позволяет сохранить в базу невалидный объект. Распишем чек-лист.
Позитивные | Негативные |
запись пустого тела в базу запись name и data с полями всех типов в базу | отправка невалидного json |
В файл test_objects.py
добавим
def test_post_object_empty_body(self, client, request):
"""
запись объекта в базу с пустым телом,
POST /objects
"""
# записываем объект в базу с пустым телом
response = post_object(client, json={})
# убеждаемся, что объект успешно записан в базу
assert_status_code(response, HTTPStatus.OK)
assert_schema(response, ObjectCreateOutSchema)
should_be_posted_success(request, client, response, exp_obj={"data": None, "name": None})
def test_post_object_with_full_body(self, client, request):
"""
запись объекта в базу полностью заполненным телом,
POST /objects
"""
# записываем объект в базу со всеми заполненными полями
exp_obj = read_json_common_request_data("valid_post_object")
response = post_object(client, json=exp_obj)
# убеждаемся, что объект успешно записан в базу
assert_status_code(response, HTTPStatus.OK)
assert_schema(response, CustomObjCreateOutSchema)
should_be_posted_success(request, client, response, exp_obj)
def test_post_object_send_invalid_json(self, client, request):
"""
попытка записать в базу невалидный json,
POST /objects
"""
# отправляем запрос на запись объекта в базу с невалидным json в теле
response = post_object(client, content='{"name",}', headers={"Content-Type": "application/json"})
# убеждаемся, что сервер дал BAD REQUEST ответ
assert_status_code(response, HTTPStatus.BAD_REQUEST)
assert_bad_request(request, response)
По итогу 3 тестами мы убедились, что POST /objects корректно записывает валидные объекты в базу, порождает объекты с пустыми полями name
и data
и, если мы их не отправили, не позволяет записать невалидные объекты.
Приступим к методу PUT. Cуть метода PUT - обновлять данные на сервере. Как правило это полное обновление объекта. Соответственно мы должны убедиться, что метод правильно обновляет значения на сервере, и проверить специфическую логику.
Метод | Пункты, которые необходимо реализовать |
PUT | 1 проверка корректности обновления объекта в базе: 2 Проверка специфической логики |
В данном АПИ метод PUT /objects/{id} обновляет 2 поля объекта: name
и data
. При записи обновления в базу логика такая же как у метода POST. Распишем чек-лист.
Позитивные | Негативные |
обновление name и data с полями всех типов обновление на пустой | обновление несущ. объекта на невалидный объект |
В файл test_objects.py
добавим
def test_put_object_with_empty_body(self, client, request):
"""
обновление объекта в базе на пустой объект,
PUT /objects/{id}
"""
# записываем объект в базу со всеми заполненными полями
post_obj = read_json_common_request_data("valid_post_object")
response = post_object(client, json=post_obj)
assert_status_code(response, HTTPStatus.OK)
# обновляем этот объект на пустой объект
exp_json = {"id": response.json()['id'], "name": None, "data": None}
response = put_object(client, exp_json['id'], json={})
# убеждаемся, что объект был успешно обновлен
assert_status_code(response, HTTPStatus.OK)
assert_schema(response, ObjectUpdateOutSchema)
should_be_updated_success(request, client, response, exp_json)
def test_put_object_with_full_body(self, client, request):
"""
обновление всех полей объекта в базе,
PUT /objects/{id}
"""
# записываем объект в базу со всеми заполненными полями
post_obj = read_json_common_request_data("valid_post_object")
response = post_object(client, json=post_obj)
assert_status_code(response, HTTPStatus.OK)
# обновляем значения всех полей этого объекта на новые
put_obj = read_json_test_data(request)
put_obj_id = response.json()['id']
response = put_object(client, put_obj_id, json=put_obj)
# убеждаемся, что объект был успешно обновлен
assert_status_code(response, HTTPStatus.OK)
assert_schema(response, CustomObjUpdateOutSchema)
put_obj['id'] = put_obj_id
should_be_updated_success(request, client, response, put_obj)
def test_put_object_send_invalid_json(self, client, request):
"""
попытка обновить объект, отправив невалидный json,
PUT /objects/{id}
"""
# записываем объект в базу со всеми заполненными полями
response = post_object(client, json=read_json_common_request_data("valid_post_object"))
assert_status_code(response, HTTPStatus.OK)
# пытаемся обновить этот объект, отправив невалидный json
response = put_object(client, response.json()['id'], content='{"name",}',
headers={"Content-Type": "application/json"})
# убеждаемся, что сервер дал BAD REQUEST ответ
assert_status_code(response, HTTPStatus.BAD_REQUEST)
assert_bad_request(request, response)
def test_put_object_update_non_exist_obj(self, client, request):
"""
попытка обновить несуществующий объект,
PUT /objects/{id}
"""
# пытаемся обновить несуществующие объект
obj_id = "ff8081818a194cb8018a79e7545545ac"
response = put_object(client, obj_id, json={})
# убеждаемся, что сервер дал NOT FOUND ответ
assert_status_code(response, HTTPStatus.NOT_FOUND)
assert_not_found(request, response, obj_id)
По итогу 4 тестами мы убедились, что PUT /objects/{id} обновляет все поля именно того объекта, id которого мы отправили, и не позволяет обновить состояние объекта на невалидное.
Cуть метода DELETE - удалять данные с сервера.
Метод | Пункты, которые необходимо реализовать |
DELETE | 1 проверка удаления объекта с сервера: |
Распишем чек-лист
Позитивные | Негативные |
удаление существующего объекта | удаление несуществующего объекта |
В файлtest_objects.py
добавим
def test_delete_exist_object(self, client, request):
"""
удаление сущестующего объекта,
DELETE /objects/{id}
"""
# записываем объект в базу со всеми заполненными полями
response = post_object(client, json=read_json_common_request_data("valid_post_object"))
assert_status_code(response, HTTPStatus.OK)
# удаляем этот объект
obj_id = response.json()['id']
response = delete_object(client, obj_id)
# убеждаемся, что объект удален
assert_status_code(response, HTTPStatus.OK)
should_be_deleted_success(request, response, obj_id)
def test_delete_not_exist_object(self, client, request):
"""
удаление несущестующего объекта,
DELETE /objects/{id}
"""
# пытаемся удалить несуществующий объект
obj_id = "ff8081818a194cb8018a79e7545545ac"
response = delete_object(client, obj_id)
# убеждаемся, что сервер дал NOT FOUND ответ
assert_status_code(response, HTTPStatus.NOT_FOUND)
assert_not_exist(request, response, obj_id)
На каждом шаге в папкуtest_data
также добавляются соответствующие файлы для проверки ответов от сервера. По итогу получилось 16 тестов. Файл со всеми тестами выглядит следующим образом.
from http import HTTPStatus
import pytest
from api.api_client import ApiClient
from api.objects_api import get_objects, get_object, post_object, put_object, delete_object
from assertions.assertion_base import assert_status_code, assert_response_body_fields, assert_bad_request, \
assert_not_found, assert_empty_list, assert_schema, assert_not_exist
from assertions.objects_assertion import should_be_posted_success, should_be_updated_success, should_be_deleted_success, \
should_be_valid_objects_response
from models.object_models import ObjectOutSchema, ObjectCreateOutSchema, CustomObjCreateOutSchema, \
ObjectUpdateOutSchema, CustomObjUpdateOutSchema
from utilities.files_utils import read_json_test_data, read_json_common_request_data
class TestObjects:
"""
Тесты /objects
"""
@pytest.fixture(scope='class')
def client(self):
return ApiClient()
def test_get_objects(self, client, request):
"""
получение заранее заготовленных объектов из базы с параметрами по-умолчанию,
GET /objects
"""
# получаем объекты из базы
response = get_objects(client)
# убеждаемся, что в ответ пришли объекты, которые мы ожидаем
assert_status_code(response, HTTPStatus.OK)
assert_response_body_fields(request, response)
@pytest.mark.parametrize("param", [{"index": 0, "ids": [1]}, {"index": 1, "ids": [1, 2]}])
def test_get_objects_id_param(self, client, request, param):
"""
получение заранее заготовленных объектов из базы с параметром ids,
GET /objects
"""
# получаем массив объектов с определенными айдишниками
response = get_objects(client, *param['ids'])
# убеждаемся, что в ответ пришли именно те объекты, id которых мы запросили
assert_status_code(response, HTTPStatus.OK)
should_be_valid_objects_response(request, response, param)
def test_get_objects_not_exist_id(self, client):
"""
попытка получить из базы объект с несуществующим id,
GET /objects
"""
# пытаемся получить объект, несуществующий в системе
response = get_objects(client, 8523697415)
# убеждаемся, что в ответ пришел пустой список
assert_status_code(response, HTTPStatus.OK)
assert_empty_list(response)
def test_get_objects_invalid_id(self, client):
"""
попытка получить из базы объект с невалидным по типу id,
GET /objects
"""
# пытаемся получить объект, отправив невалидный по типу параметр ids
response = get_objects(client, "kjdsf23321")
# убеждаемся, что в ответ пришел пустой список
assert_status_code(response, HTTPStatus.OK)
assert_empty_list(response)
def test_get_object(self, client, request):
"""
получение заранее заготовленного объекта из базы,
GET /objects/{id}
"""
# получаем единичный объект с сервера
response = get_object(client, 7)
# убеждаемся, что получен именно тот объект, который мы запросили
assert_status_code(response, HTTPStatus.OK)
assert_schema(response, ObjectOutSchema)
assert_response_body_fields(request, response)
def test_get_object_not_exist(self, client, request):
"""
попытка получить из базы единичный объект с несуществующим id,
GET /objects/{id}
"""
# запрашиваем единичный объект с сервера с несуществующим id
response = get_object(client, 1593576458)
# убеждаемся, что сервер вернул NOT FOUND ответ
assert_status_code(response, HTTPStatus.NOT_FOUND)
assert_not_exist(request, response, 1593576458)
def test_post_object_empty_body(self, client, request):
"""
запись объекта в базу с пустым телом,
POST /objects
"""
# записываем объект в базу с пустым телом
response = post_object(client, json={})
# убеждаемся, что объект успешно записан в базу
assert_status_code(response, HTTPStatus.OK)
assert_schema(response, ObjectCreateOutSchema)
should_be_posted_success(request, client, response, exp_obj={"data": None, "name": None})
def test_post_object_with_full_body(self, client, request):
"""
запись объекта в базу полностью заполненным телом,
POST /objects
"""
# записываем объект в базу со всеми заполненными полями
exp_obj = read_json_common_request_data("valid_post_object")
response = post_object(client, json=exp_obj)
# убеждаемся, что объект успешно записан в базу
assert_status_code(response, HTTPStatus.OK)
assert_schema(response, CustomObjCreateOutSchema)
should_be_posted_success(request, client, response, exp_obj)
def test_post_object_send_invalid_json(self, client, request):
"""
попытка записать в базу невалидный json,
POST /objects
"""
# отправляем запрос на запись объекта в базу с невалидным json в теле
response = post_object(client, content='{"name",}', headers={"Content-Type": "application/json"})
# убеждаемся, что сервер дал BAD REQUEST ответ
assert_status_code(response, HTTPStatus.BAD_REQUEST)
assert_bad_request(request, response)
def test_put_object_with_empty_body(self, client, request):
"""
обновление объекта в базе на пустой объект,
PUT /objects/{id}
"""
# записываем объект в базу со всеми заполненными полями
post_obj = read_json_common_request_data("valid_post_object")
response = post_object(client, json=post_obj)
assert_status_code(response, HTTPStatus.OK)
# обновляем этот объект на пустой объект
exp_json = {"id": response.json()['id'], "name": None, "data": None}
response = put_object(client, exp_json['id'], json={})
# убеждаемся, что объект был успешно обновлен
assert_status_code(response, HTTPStatus.OK)
assert_schema(response, ObjectUpdateOutSchema)
should_be_updated_success(request, client, response, exp_json)
def test_put_object_with_full_body(self, client, request):
"""
обновление всех полей объекта в базе,
PUT /objects/{id}
"""
# записываем объект в базу со всеми заполненными полями
post_obj = read_json_common_request_data("valid_post_object")
response = post_object(client, json=post_obj)
assert_status_code(response, HTTPStatus.OK)
# обновляем значения всех полей этого объекта на новые
put_obj = read_json_test_data(request)
put_obj_id = response.json()['id']
response = put_object(client, put_obj_id, json=put_obj)
# убеждаемся, что объект был успешно обновлен
assert_status_code(response, HTTPStatus.OK)
assert_schema(response, CustomObjUpdateOutSchema)
put_obj['id'] = put_obj_id
should_be_updated_success(request, client, response, put_obj)
def test_put_object_send_invalid_json(self, client, request):
"""
попытка обновить объект, отправив невалидный json,
PUT /objects/{id}
"""
# записываем объект в базу со всеми заполненными полями
response = post_object(client, json=read_json_common_request_data("valid_post_object"))
assert_status_code(response, HTTPStatus.OK)
# пытаемся обновить этот объект, отправив невалидный json
response = put_object(client, response.json()['id'], content='{"name",}',
headers={"Content-Type": "application/json"})
# убеждаемся, что сервер дал BAD REQUEST ответ
assert_status_code(response, HTTPStatus.BAD_REQUEST)
assert_bad_request(request, response)
def test_put_object_update_non_exist_obj(self, client, request):
"""
попытка обновить несуществующий объект,
PUT /objects/{id}
"""
# пытаемся обновить несуществующие объект
obj_id = "ff8081818a194cb8018a79e7545545ac"
response = put_object(client, obj_id, json={})
# убеждаемся, что сервер дал NOT FOUND ответ
assert_status_code(response, HTTPStatus.NOT_FOUND)
assert_not_found(request, response, obj_id)
def test_delete_exist_object(self, client, request):
"""
удаление сущестующего объекта,
DELETE /objects/{id}
"""
# записываем объект в базу со всеми заполненными полями
response = post_object(client, json=read_json_common_request_data("valid_post_object"))
assert_status_code(response, HTTPStatus.OK)
# удаляем этот объект
obj_id = response.json()['id']
response = delete_object(client, obj_id)
# убеждаемся, что объект удален
assert_status_code(response, HTTPStatus.OK)
should_be_deleted_success(request, response, obj_id)
def test_delete_not_exist_object(self, client, request):
"""
удаление несущестующего объекта,
DELETE /objects/{id}
"""
# пытаемся удалить несуществующий объект
obj_id = "ff8081818a194cb8018a79e7545545ac"
response = delete_object(client, obj_id)
# убеждаемся, что сервер дал NOT FOUND ответ
assert_status_code(response, HTTPStatus.NOT_FOUND)
assert_not_exist(request, response, obj_id)
Подводя итог: 16 тестами мы произвели проверку работы 5 АПИ методов. Каждый тест соблюдает 4 сформированных требования. Тесты атомарны и независимы, а значит мы можем без проблем изменить 1 тест, не нарушая логику другого теста. Тесты относительно гибки. Например проверочные файлы можно группировать при одинаковых или схожих ответах, как в методеassert_bad_request
, делая отношения множество тестов к 1 файлу. Тогда в случае изменения ответа на bad request сервером нам достаточно поправить 1 файл, не меняя код самих тестов.
Это моя первая статья. Старался как мог). Надеюсь описанный подход поможет вам в вашей работе, а также надеюсь, что очень поможет новичкам в освоении АПИ тестирования. Жду ваших отзывов и комментариев, предложений по написанному подходу.
Сам проект вы можете скачать из репозитория https://github.com/HardTester/API_testing. После загрузки проекта в терминале переходим в папку API_testing, устанавливаемrequirements.txt
и запускаем тесты командойpytest
из корня проекта.