
Меня зовут Алексей. Я являюсь специалистом по автоматизации тестирования. Пишу как UI тесты на селениуме, так и покрываю тестами серверное REST API. Данная статья является туториалом и будет полезна как начинающим, так и действующим тестировщикам и автоматизаторам. Но также может быть полезна разработчикам и специалистам из смежных направлений. В статье мы пошагово покроем тестами REST API https://restful-api.dev на примере методов GET, POST, PUT, DELETE. Подход, описанный в статье, я сам использую на реальном проекте.
Структура статьи
Используемые библиотеки
Написание тестов:
требования к системе тестов
структура проекта
создание АПИ клиента
написание первого теста
код первого теста
тесты на GET, POST, PUT, DELETE
Заключение
Используемые библиотеки
python 3.11.4pytest 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из корня проекта.
