Меня зовут Алексей. Я являюсь специалистом по автоматизации тестирования. Пишу как 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
из корня проекта.
Tvorec_molodec
А почему в вашем фреймворке нет методов по очистке созданных сущностей? Это ж базовая необходимость каждого автотеста - вернуть систему к исходному состоянию после завершения прогона
hard_tester Автор
Очистка системы – хорошая практика. В данной тестовой апишке с точки зрения самих тестов, без очистки от запуска, отсутсвие самой очистки не создает проблем для самого процесса прогона тестов т.к в этих методах на нас не влияет состояние системы. Тут нет пользователей, состояние которых от запуска к запуску как-то бы влияло на тесты. Но повторюсь это актуально только в этой учебной АПИ. В реальных проектах хорошая практика производить очистку.
Есть еще один момент, когда мы производим очистку, мы каждый раз запускаем тесты, как правило, на идеально чистой базе, что немного оторвано от реальности т.к в боевых условиях как правило база нагружена и забита. Так что в каком-то аспекте прогнать тесты на нагруженной базе тоже неплохая практика. У меня на работе был такой случай, что на чистой базе тесты проходят хорошо, а на нагруженной была задержка транзакций, и сервер выдавал в ответе POST методов устаревшие состояния объектов.
Я соберу фидбэк по этой статье и думаю выпущу продолжение. Там мы рассмотрим аспекты очестки и другие моменты.
�
TITnet
Позвольте немного побыть занудой.
Это не совсем верно делать после прогона.
Готовить систему к тестированию нужно до, а не после прогона.
После — может не наступить по разным причинам.
Тест после себя может (и это нормально) сломать систему, например.