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

Структура статьи

  1. Используемые библиотеки

  2. Написание тестов:

    1. требования к системе тестов

    2. структура проекта

    3. создание АПИ клиента

    4. написание первого теста

    5. код первого теста

    6. тесты на GET, POST, PUT, DELETE

  3. Заключение

Используемые библиотеки

  1. python 3.11.4

  2. pytest 7.4.0 - написание тестов. Подробнее: https://pytest-docs-ru.readthedocs.io/ru/latest/getting-started.html

  3. pydantic 2.3.0 - библиотека для валидации структуры ответа. Подробнее: https://docs.pydantic.dev/

  4. httpx 0.24.1 - отправка запросов. Подробнее: https://www.python-httpx.org/quickstart/

  5. тестовое АПИ - https://restful-api.dev

  6. среда разработки - PyCharm.

Написание тестов

Перед тем, как что-то писать, сформируем набор требований, которые мы будем соблюдать в нашей системе тестов, а именно:

  1. идейность (каждый тест имеет четкую идею)

  2. атомарность идеи (в тесте проверяется только одна идея)

  3. независимость от других тестов (действия в одном тесте не влияют на другой, тесты могут идти в любом порядке)

  4. гибкость относительно изменений в системе (тесты можно легко перенести на другую конфигурацию, например другой стенд или быстро изменить в случае изменений в тестируемом приложении)

Соблюдение этих требований позволит нам писать тесты структурно и минимизировать лишний рефакторинг.

Структура проекта

Сформируем каркас проекта с папками, содержащими:

  • 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. Проверка кода ответа (сравнивается ожидаемый и пришедший код).

  3. Проверка схемы ответа (проверяется структура тела ответа и типы полей).

  4. Проверка тела ответа и специфической логики

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 проверка корректности получения объекта:
- в тестах заранее заготовлен файл с данными для проверки на эталонность без айдишников (файл одинаков при каждом запуске), в базе заранее заготовлен объект, который никогда не изменяется
- делаем get запрос с нужным параметром
- делаем полное сравнение ответа с эталонным файлом

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 проверка корректности обновления объекта в базе:
- получаем id обновляемого объекта
- создаем объект с другими значениями
- запоминаем состояние объекта до обновления его в базе
- обновляем в базе объект
- убеждаемся, что объект в ответе корректно обновлен
- убеждаемся, что в базе этот объект корректно сохранен

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 проверка удаления объекта с сервера:
- получаем id удаляемого объекта
- удаляем его
- убеждаемся, что он удален (404 ошибка)

2 Проверка специфической логики

Распишем чек-лист

Позитивные

Негативные

удаление существующего объекта

удаление несуществующего объекта

В файл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из корня проекта.

Комментарии (3)


  1. Tvorec_molodec
    06.10.2023 05:53

    А почему в вашем фреймворке нет методов по очистке созданных сущностей? Это ж базовая необходимость каждого автотеста - вернуть систему к исходному состоянию после завершения прогона


    1. hard_tester Автор
      06.10.2023 05:53

      Очистка системы – хорошая практика. В данной тестовой апишке с точки зрения самих тестов, без очистки от запуска, отсутсвие самой очистки не создает проблем для самого процесса прогона тестов т.к в этих методах на нас не влияет состояние системы. Тут нет пользователей, состояние которых от запуска к запуску как-то бы влияло на тесты. Но повторюсь это актуально только в этой учебной АПИ. В реальных проектах хорошая практика производить очистку.

      Есть еще один момент, когда мы производим очистку, мы каждый раз запускаем тесты, как правило, на идеально чистой базе, что немного оторвано от реальности т.к в боевых условиях как правило база нагружена и забита. Так что в каком-то аспекте прогнать тесты на нагруженной базе тоже неплохая практика. У меня на работе был такой случай, что на чистой базе тесты проходят хорошо, а на нагруженной была задержка транзакций, и сервер выдавал в ответе POST методов устаревшие состояния объектов.

      Я соберу фидбэк по этой статье и думаю выпущу продолжение. Там мы рассмотрим аспекты очестки и другие моменты.


    1. TITnet
      06.10.2023 05:53

      Позвольте немного побыть занудой.

      базовая необходимость каждого автотеста - вернуть систему к исходному состоянию после завершения прогона

      Это не совсем верно делать после прогона.

      Готовить систему к тестированию нужно до, а не после прогона.

      После — может не наступить по разным причинам.

      Тест после себя может (и это нормально) сломать систему, например.