habrahabr

Как правильно писать API авто тесты на Python

  • понедельник, 9 января 2023 г. в 00:49:03
https://habr.com/ru/post/709380/
  • Python
  • API
  • Тестирование веб-сервисов


Вступление

Эта статья как продолжение статьи Как правильно писать UI авто тесты на Python. Если мы говорим про UI автотесты, то тут хотя бы есть паттерны Page Object, Pagefactory; для API автотестов таких паттернов нет. Да, существуют общие паттерны, по типу Decorator, SIngletone, Facade, Abstract Factory, но это не то, что поможет протестировать бизнес логику. Когда мы пишем API автотесты, то нам хотелось бы, чтобы они отвечали требованиям:

  1. Проверки должны быть полными, то есть мы должны проверить статус код ответа, данные в теле ответа, провалидировать JSON схему;

  2. Автотесты должны быть документированными и поддерживаемыми. Чтобы автотесты мог читать и писать не только QA Automation, но и разработчик;

  3. Хотелось бы, чтобы JSON схема и тестовые данные генерировались автоматически на основе документации;

  4. Отчет должен быть читабельным, содержав в себе информацию о ссылках, заголовках, параметрах, с возможностью прикреплять какие-то логи.

Для меня требования выше являются базой, ваши же требования могут быть другие в зависимости от продукта.

Также очень важно отметить, что если при написании автотестов вы выберете неправильный подход, то проблемы появляются не сразу, а примерно через 100-150 написанных тестов. Тогда фиксы автотестов превратятся в ад, добавление новых автотестов будет все сложнее и сложнее, а читать такие автотесты никто кроме вас не сможет, что плохо. В практике встречаются случаи, когда компания просит переписать их автотесты и очень часто мотивом является: “Наш QA Automation ушел, поэтому теперь мы не можем даже запустить автотесты и непонятно, что в них происходит”. Это означает, что человек, написавший автотесты, писал их костыльно, как бы повышая свою ценность (в плохом смысле, что никто, кроме него, не сможет понять автотесты в будущем после его ухода или банального ухода на больничный), как сотрудника, что очень плохо для компании. В итоге время потрачено, деньги потрачено.

Еще один распространенный кейс - это когда новый QA Automation приходит на проект и сразу же хочет все переписать. Окай, переписывает, суть не меняется, автоматизация также страдает. По "правильному" мнению человека, который все переписал, виноват продукт, разработчики, но не он сам. Компания в данном случае выступает тренажером/плейграундом для неопытного QA Automation. В итоге время потрачено, деньги потрачено.

Requirements

Для примера написания API автотестов мы будем использовать:

  • pytest - pip install pytest;

  • httpx - pip install httpx, - для работы с HTTP протоколом;

  • allure - pip install allure-pytest, - необязательная зависимость. Вы можете использовать любой другой репортер;

  • jsonschema - pip install jsonschema, - для валидации JSON схемы;

  • pydantic, python-dotenv - pip install pydantic python-dotenv, - для генерации тестовых данных, для управления настройками, для автогенерации JSON схемы;

Почему не requests? Мне нравится httpx, потому что он умеет работать асинхронно и у него есть AsyncClient. Также документация httpx в стиле Material Design мне больше нравится, чем у requests. В остальном requests замечательная библиотека, можно использовать ее и разницы никакой нет.

Библиотека pydantic служит для реализации “строгой типизации” в python. Она нам нужна для автогенерации JSON схемы, для описания моделей данных, для генерации тестовых данных. У этой библиотеки есть много плюсов по сравнению с обычными dataclass-сами в python. Если приводить пример из жизни, то pydantic - это как ехать на автомобиле, а dataclass'ы - это идти пешком. 

В качестве альтернативы pydantic можно взять библиотеку models-manager, которая делает все тоже самое, что и pydantic, т.е. умеет работать с базой данных из коробки, генерировать рандомные негативные тестовые данные на основе модели. Эта библиотека больше подойдет для тестирования валидации входных данных вашего API. Документацию по models-manager можно найти тут. Мы не будем использовать models-manager, так как нам не нужна база данных и мы не будем тестировать валидацию.

Но у pydantic тоже есть библиотека SQLModel для работы с базой данных. Если вам для автотестов нужна база данных, то вы можете использовать: SQLAlchemy + pydantic, SQLModel, models-manager. В нашем же случае работа с базой данных не потребуется.

Тесты будем писать на публичный API https://sampleapis.com/api-list/futurama. Данный API всего лишь пример. На реальных проектах API может быть гораздо сложнее, но суть написания автотестов остается та же.

Settings

Опишем настройки проекта. Для этого будем использовать класс BaseSettings из pydantic, потому что он максимально удобный, умеет читать настройки из .env файла, умеет читать настройки из переменных окружения, умеет читать настройки из .txt файла, умеет управлять ссылками на редис или базу данных и много чего еще, можно почитать тут https://docs.pydantic.dev/usage/settings/. Это очень удобно для использования на CI/CD, или когда у вас есть много настроек, которые разбросаны по всему проекту + с BaseSettings все настройки можно собрать в один объект.

from pydantic import BaseModel, BaseSettings, Field


class TestUser(BaseModel):
    email: str
    password: str


class Settings(BaseSettings):
    base_url: str = Field(..., env='BASE_URL')
    user_email: str = Field(..., env='TEST_USER_EMAIL')
    user_password: str = Field(..., env='TEST_USER_PASSWORD')

    class Config:
        env_file = '.env'
        env_file_encoding = 'utf-8'

    @property
    def api_url(self) -> str:
        return f'{self.base_url}/futurama'

    @property
    def user(self) -> TestUser:
        return TestUser(
            email=self.user_email,
            password=self.user_password
        )


base_settings = Settings()

Мы будем читать настройки из .env файла.

Models

Теперь опишем модели, используя pydantic, перед этим переопределим базовую модель из pydantic. Нам это нужно, чтобы закрыть некоторые лимитации и баги, которые есть в pydantic.

utils\models\base_model.py

from pydantic import BaseModel as PydanticBaseModel


class BaseModel(PydanticBaseModel):
    class Config:
        @staticmethod
        def schema_extra(schema: dict, model: PydanticBaseModel):
            """
            https://github.com/pydantic/pydantic/issues/1270#issuecomment-729555558
            """
            for prop, value in schema.get('properties', {}).items():
                field = [
                    model_field for model_field in model.__fields__.values()
                    if model_field.alias == prop
                ][0]

                if field.allow_none:
                    if 'type' in value:
                        value['anyOf'] = [{'type': value.pop('type')}]

                    elif '$ref' in value:
                        if issubclass(field.type_, BaseModel):
                            value['title'] = field.type_.__config__.title or field.type_.__name__
                        value['anyOf'] = [{'$ref': value.pop('$ref')}]
                    value['anyOf'].append({'type': 'null'})

    def __hash__(self):
        """
        https://github.com/pydantic/pydantic/issues/1303#issuecomment-599712964
        """
        return hash((type(self),) + tuple(self.__dict__.values()))

Я прикрепил ссылки на github issue, где вы можете почитать подробнее, какая именно проблема закрывается. Если будете использовать pydantic, то вам это пригодится. Ну или же вы можете использовать models-manager, ибо там нет этих проблем.

Теперь опишем модель для аутентификации:

models\authentication.py

from pydantic import Field

from settings import base_settings
from utils.models.base_model import BaseModel


class AuthUser(BaseModel):
    email: str = Field(default=base_settings.user.email)
    password: str = Field(default=base_settings.user.password)


class Authentication(BaseModel):
    auth_token: str | None
    user: AuthUser | None = AuthUser()

Напишем модель для объекта question из API https://sampleapis.com/api-list/futurama. Сам объект выглядит примерно так:

{
  "id": 1,
  "question": "What is Fry's first name?",
  "possibleAnswers": [
    "Fred",
    "Philip",
    "Will",
    "John"
  ],
  "correctAnswer": "Philip"
}

models\questions.py

from typing import TypedDict

from pydantic import BaseModel, Field

from utils.fakers import random_list_of_strings, random_number, random_string


class UpdateQuestion(BaseModel):
    question: str | None = Field(default_factory=random_string)
    possible_answers: list[str] | None = Field(
        alias='possibleAnswers',
        default_factory=random_list_of_strings
    )
    correct_answer: str | None = Field(
        alias='correctAnswer',
        default_factory=random_string
    )


class DefaultQuestion(BaseModel):
    id: int = Field(default_factory=random_number)
    question: str = Field(default_factory=random_string)
    possible_answers: list[str] = Field(
        alias='possibleAnswers',
        default_factory=random_list_of_strings
    )
    correct_answer: str = Field(
        alias='correctAnswer',
        default_factory=random_string
    )


class DefaultQuestionsList(BaseModel):
    __root__: list[DefaultQuestion]


class QuestionDict(TypedDict):
    id: int
    question: str
    possibleAnswers: list[str]
    correct_answer: str

Обратите внимание на аргумент alias в функции Field. Он служит для того, чтобы мы могли работать со snake_case в python и с любым другим форматом извне. Например, в python нам бы не хотелось писать название атрибута таким образом - possibleAnswers, т.к. это нарушает PEP8, поэтому мы используем alias. Pydantic сам разберется, как обработать JSON объект и разобрать его по нужным атрибутам в модели. Так же в функции Field есть очень много крутых фич по типу: max_length, min_length, gt, ge, lt, le и можно писать регулярки. Есть куча полезных настроек для ваших моделей и есть возможность использовать встроенные типы или писать свои. Короче, пользуйтесь.

Данные функции: random_list_of_strings, random_number, random_string используются, чтобы сгенерировать какие-то рандомные данные. Мы не будем усложнять и напишем эти функции, используя стандартные средства python, в своих же проектах вы можете использовать faker.

utils\fakers.py

from random import choice, randint
from string import ascii_letters, digits


def random_number(start: int = 100, end: int = 1000) -> int:
    return randint(start, end)


def random_string(start: int = 9, end: int = 15) -> str:
    return ''.join(choice(ascii_letters + digits) for _ in range(randint(start, end)))


def random_list_of_strings(start: int = 9, end: int = 15) -> list[str]:
    return [random_string() for _ in range(randint(start, end))]

Готово, мы описали нужные нам модели. С помощью них можно будет генерировать тестовые данные:

DefaultQuestion().dict(by_alias=True)

{
  'id': 859, 
  'question': 'a5mii6xsAmxZ', 
  'possibleAnswers': ['3HW4gA0HW', 'dcp07Wm2EHM9X4', '4oSm5xSIF', 'SSQXoUrYc', 'xeCV3GGduHjI', '9ScfUI2pF', 'b5ezRFJ8m8', '9fY1nKTNlp', '4BbKZUamwJjDnG', 'PRdHxVgH0lmSL', 'b4budMBfz', 'Oe62YMnC7wRb', 'BI6DUSsct4aCE', 'WIxX0efx6t5IPxd', 'x3ZKlXXTGEd'], 
  'correctAnswer': 'fX7nXClR6nS'
}

JSON схема генерируется автоматически на основе модели. В практике встречал людей, которые писали JSON схему руками, при этом считали это единственным верным подходом, но не нужно так. Ведь если объект состоит из 4-х полей, как в нашем случае, то еще можно написать JSON схему руками, а что если объект состоит их 30-ти полей? Тут уже могут быть сложности и куча потраченного времени. Поэтому мы полностью скидываем эту задачу на pydantic:

DefaultQuestion().schema()

{
  'title': 'DefaultQuestion', 
  'type': 'object', 
  'properties': {
    'id': {'title': 'Id', 'type': 'integer'}, 
    'question': {'title': 'Question', 'type': 'string'}, 
    'possibleAnswers': {'title': 'Possibleanswers', 'type': 'array', 'items': {'type': 'string'}}, 
    'correctAnswer': {'title': 'Correctanswer', 'type': 'string'}
  }
}

API Client

Теперь опишем базовый API httpx клиент, который будем использовать для выполнения HTTP запросов:

base\client.py

import typing
from functools import lru_cache

import allure
from httpx import Client as HttpxClient
from httpx import Response
from httpx._client import UseClientDefault
from httpx._types import (AuthTypes, CookieTypes, HeaderTypes, QueryParamTypes,
                          RequestContent, RequestData, RequestExtensions,
                          RequestFiles, TimeoutTypes, URLTypes)

from base.api.authentication_api import get_auth_token
from models.authentication import Authentication
from settings import base_settings


class Client(HttpxClient):
    @allure.step('Making GET request to "{url}"')
    def get(
        self,
        url: URLTypes,
        *,
        params: typing.Optional[QueryParamTypes] = None,
        headers: typing.Optional[HeaderTypes] = None,
        cookies: typing.Optional[CookieTypes] = None,
        auth: typing.Union[AuthTypes, UseClientDefault] = None,
        follow_redirects: typing.Union[bool, UseClientDefault] = None,
        timeout: typing.Union[TimeoutTypes, UseClientDefault] = None,
        extensions: typing.Optional[RequestExtensions] = None
    ) -> Response:
        return super().get(
            url=url,
            params=params,
            headers=headers,
            cookies=cookies,
            auth=auth,
            follow_redirects=follow_redirects,
            timeout=timeout,
            extensions=extensions
        )

    @allure.step('Making POST request to "{url}"')
    def post(
        self,
        url: URLTypes,
        *,
        content: typing.Optional[RequestContent] = None,
        data: typing.Optional[RequestData] = None,
        files: typing.Optional[RequestFiles] = None,
        json: typing.Optional[typing.Any] = None,
        params: typing.Optional[QueryParamTypes] = None,
        headers: typing.Optional[HeaderTypes] = None,
        cookies: typing.Optional[CookieTypes] = None,
        auth: typing.Union[AuthTypes, UseClientDefault] = None,
        follow_redirects: typing.Union[bool, UseClientDefault] = None,
        timeout: typing.Union[TimeoutTypes, UseClientDefault] = None,
        extensions: typing.Optional[RequestExtensions] = None
    ) -> Response:
        return super().post(
            url=url,
            content=content,
            data=data,
            files=files,
            json=json,
            params=params,
            headers=headers,
            cookies=cookies,
            auth=auth,
            follow_redirects=follow_redirects,
            timeout=timeout,
            extensions=extensions
        )

    @allure.step('Making PATCH request to "{url}"')
    def patch(
        self,
        url: URLTypes,
        *,
        content: typing.Optional[RequestContent] = None,
        data: typing.Optional[RequestData] = None,
        files: typing.Optional[RequestFiles] = None,
        json: typing.Optional[typing.Any] = None,
        params: typing.Optional[QueryParamTypes] = None,
        headers: typing.Optional[HeaderTypes] = None,
        cookies: typing.Optional[CookieTypes] = None,
        auth: typing.Union[AuthTypes, UseClientDefault] = None,
        follow_redirects: typing.Union[bool, UseClientDefault] = None,
        timeout: typing.Union[TimeoutTypes, UseClientDefault] = None,
        extensions: typing.Optional[RequestExtensions] = None
    ) -> Response:
        return super().patch(
            url=url,
            content=content,
            data=data,
            files=files,
            json=json,
            params=params,
            headers=headers,
            cookies=cookies,
            auth=auth,
            follow_redirects=follow_redirects,
            timeout=timeout,
            extensions=extensions
        )

    @allure.step('Making DELETE request to "{url}"')
    def delete(
        self,
        url: URLTypes,
        *,
        params: typing.Optional[QueryParamTypes] = None,
        headers: typing.Optional[HeaderTypes] = None,
        cookies: typing.Optional[CookieTypes] = None,
        auth: typing.Union[AuthTypes, UseClientDefault] = None,
        follow_redirects: typing.Union[bool, UseClientDefault] = None,
        timeout: typing.Union[TimeoutTypes, UseClientDefault] = None,
        extensions: typing.Optional[RequestExtensions] = None
    ) -> Response:
        return super().delete(
            url=url,
            params=params,
            headers=headers,
            cookies=cookies,
            auth=auth,
            follow_redirects=follow_redirects,
            timeout=timeout,
            extensions=extensions
        )


@lru_cache(maxsize=None)
def get_client(
    auth: Authentication | None = None,
    base_url: str = base_settings.api_url
) -> Client:
    headers: dict[str, str] = {}

    if auth is None:
        return Client(base_url=base_url, trust_env=True)

    if (not auth.auth_token) and (not auth.user):
        raise NotImplementedError(
            'Please provide "username" and "password" or "auth_token"'
        )

    if (not auth.auth_token) and auth.user:
        token = get_auth_token(auth.user)
        headers = {**headers, 'Authorization': f'Token {token}'}

    if auth.auth_token and (not auth.user):
        headers = {**headers, 'Authorization': f'Token {auth.auth_token}'}

    return Client(base_url=base_url, headers=headers, trust_env=True)

Мы создали свой класс Client, который унаследовали от httpx.Client и переопределили необходимые нам методы, добавив к ним allure.step. Теперь при http-запросе через Client в отчете у нас будут отображаться те запросы, которые мы выполняли. Мы специально использовали allure.step, как декоратор, чтобы в отчет также попали параметры, которые мы передаем внутрь функции метода. Позже посмотрим, как это все будет выглядеть в отчете. Внутрь Client мы также можем добавить запись логов или логирование в консоль, но в данном примере обойдемся только allure.step, на своем проекте вы можете добавить логирование.

Также мы создали функцию get_client, которая будет конструировать и возвращать объект Client. Эта функция будет добавлять базовые атрибуты, заголовки, base_url от которого будем строить ссылки на запросы к API. В этом API https://sampleapis.com/api-list/futurama нет аутентификации, я указал заголовок для аутентификации по API Key ради примера. Скорее всего на вашем проекте у вас будет другой заголовок для аутентификации.

Обратите внимание, что мы использовали декоратор lru_cache для кеширования клиента, чтобы не конструировать его для каждого запроса.

API endpoints

Теперь опишем методы для взаимодействия с API.

Для примера опишем методы, которые будут работать с аутентификацией. Для https://sampleapis.com/api-list/futurama аутентификация не требуется, но в своем проекте вы можете указать ваши методы для получения токена.

base\api\authentication_api.py

from functools import lru_cache

from httpx import Client, Response

from models.authentication import AuthUser
from settings import base_settings
from utils.constants.routes import APIRoutes


def get_auth_token_api(payload: AuthUser) -> Response:
    client = Client(base_url=base_settings.api_url)
    return client.post(f'{APIRoutes.AUTH}/token', json=payload.dict())


@lru_cache(maxsize=None)
def get_auth_token(payload: AuthUser) -> str:
    """
    Should be used like this:

    response = get_auth_token_api(payload)
    json_response = response.json()

    assert response.status_code == HTTPStatus.OK
    assert json_response.get('token')

    return json_response['token']
    """
    return 'token'

Теперь опишем методы работы с questions:

import allure
from httpx import Response

from base.client import get_client
from models.authentication import Authentication
from models.questions import DefaultQuestion, UpdateQuestion
from utils.constants.routes import APIRoutes


@allure.step(f'Getting all questions')
def get_questions_api(auth: Authentication = Authentication()) -> Response:
    client = get_client(auth=auth)
    return client.get(APIRoutes.QUESTIONS)


@allure.step('Getting question with id "{question_id}"')
def get_question_api(
    question_id: int,
    auth: Authentication = Authentication()
) -> Response:
    client = get_client(auth=auth)
    return client.get(f'{APIRoutes.QUESTIONS}/{question_id}')


@allure.step('Creating question')
def create_question_api(
    payload: DefaultQuestion,
    auth: Authentication = Authentication()
) -> Response:
    client = get_client(auth=auth)
    return client.post(APIRoutes.QUESTIONS, json=payload.dict(by_alias=True))


@allure.step('Updating question with id "{question_id}"')
def update_question_api(
    question_id: int,
    payload: UpdateQuestion,
    auth: Authentication = Authentication()
) -> Response:
    client = get_client(auth=auth)
    return client.patch(
        f'{APIRoutes.QUESTIONS}/{question_id}',
        json=payload.dict(by_alias=True)
    )


@allure.step('Deleting question with id "{question_id}"')
def delete_question_api(
    question_id: int,
    auth: Authentication = Authentication()
) -> Response:
    client = get_client(auth=auth)
    return client.delete(f'{APIRoutes.QUESTIONS}/{question_id}')


def create_question(auth: Authentication = Authentication()) -> DefaultQuestion:
    payload = DefaultQuestion()

    response = create_question_api(payload=payload, auth=auth)
    return DefaultQuestion(**response.json())

С помощью методов выше сможем выполнять простые CRUD запросы к API.

Utils

Добавим необходимые утилитки, которые помогут сделать тесты лучше:

utils\constants\routes.py

from enum import Enum


class APIRoutes(str, Enum):
    AUTH = '/auth'
    INFO = '/info'
    CAST = '/cast'
    EPISODES = '/episodes'
    QUESTIONS = '/questions'
    INVENTORY = '/inventory'
    CHARACTERS = '/characters'

    def __str__(self) -> str:
        return self.value

Лучше хранить роутинги в enum, чтобы не дублировать код и наглядно видеть, какие роутинги используются:

utils\fixtures\questions.py

import pytest

from base.api.questions_api import create_question, delete_question_api
from models.questions import DefaultQuestion


@pytest.fixture(scope='function')
def function_question() -> DefaultQuestion:
    question = create_question()
    yield question

    delete_question_api(question.id)

Для некоторых тестов, например, на удаление или изменение, нам понадобится фикстура, которая будет создавать question. После создания мы будем возвращать объект DefaultQuestion и когда тест завершится, то удалим его delete_question_api(question.id).

conftest.py

pytest_plugins = (
    'utils.fixtures.questions',
)

Поэтому не забудем добавить нашу API фикстуру в pytest_plugins. В принципе вы можете писать API фикстуры прямо рядом с вашими тестами, но, как показывает моя практика, это не долгоиграющая история. На реальных проектах бизнес логика может быть гораздо сложнее, где фикстуры могут наследоваться друг от друга. Поэтому сразу выносим эту фикстуру в pytest_plugins

utils\assertions\schema.py

import allure
from jsonschema import validate


@allure.step('Validating schema')
def validate_schema(instance: dict, schema: dict) -> None:
    validate(instance=instance, schema=schema)

Функция validate_schema будет использоваться для валидации схемы. Можно было бы использовать validate из jsonschema, но тогда мы потеряем allure.step.

Для проверок вы можете использовать обычный assert в python, либо же одну из библиотек: assertpy, pytest-assertions. Но мы будем использовать кастомную реализацию expect, которая будет включать в себя allure.step или другой удобный для вас репортер. Стоит отметить, что в библиотеке pytest-assertions также есть встроенные allure.step.

Реализацию expect вы можете посмотреть тут https://github.com/Nikita-Filonov/sample_api_testing/tree/main/utils/assertions/base. По этой ссылке код достаточно объемный, поэтому я не буду разбирать его в статье.

Также добавим функцию, которая будет проверять корректность объекта question, который вернуло на API.

utils\assertions\api\questions.py

from models.questions import DefaultQuestion, QuestionDict, UpdateQuestion
from utils.assertions.base.expect import expect


def assert_question(
    expected_question: QuestionDict,
    actual_question: DefaultQuestion | UpdateQuestion
):
    if isinstance(actual_question, DefaultQuestion):
        expect(expected_question['id']) \
            .set_description('Question "id"')\
            .to_be_equal(actual_question.id)

    expect(expected_question['question']) \
        .set_description('Question "question"') \
        .to_be_equal(actual_question.question)

    expect(expected_question['possibleAnswers']) \
        .set_description('Question "possibleAnswers"') \
        .to_be_equal(actual_question.possible_answers)

    expect(expected_question['correctAnswer']) \
        .set_description('Question "correctAnswer"') \
        .to_be_equal(actual_question.correct_answer)

Эта функция служит для того, чтобы нам не приходилось в каждом тесте писать заново все проверки для объекта question и достаточно будет использовать функцию assert_question. Если у вас объект состоит из множества ключей (например, 20), то рекомендую писать такие обертки, чтобы использовать их повторно в будущем.

Также обратите внимание на QuestionDict - это не модель, это TypedDict и он служит для аннотации dict в python. Лучше стараться писать более конкретные типы вместо абстрактного dict, учитывая, что аннотации в python - это просто документация и не более. Ибо в будущем абстрактные аннотации будут только затруднять понимание кода. Даже если вы пишете просто тип int, то лучше писать что-то конкретное по типу MyScoreInt = int.

Testing

Мы подготовили всю базу для написания тестов. Осталось только написать сами тесты:

tests\test_futurama_questions.py

from http import HTTPStatus

import allure
import pytest

from base.api.questions_api import (create_question_api, delete_question_api,
                                    get_question_api, get_questions_api,
                                    update_question_api)
from models.questions import (DefaultQuestion, DefaultQuestionsList,
                              QuestionDict, UpdateQuestion)
from utils.assertions.api.questions import assert_question
from utils.assertions.base.solutions import assert_status_code
from utils.assertions.schema import validate_schema


@pytest.mark.questions
@allure.feature('Questions')
@allure.story('Questions API')
class TestQuestions:
    @allure.title('Get questions')
    def test_get_questions(self):
        response = get_questions_api()
        json_response: list[QuestionDict] = response.json()

        assert_status_code(response.status_code, HTTPStatus.OK)

        validate_schema(json_response, DefaultQuestionsList.schema())

    @allure.title('Create question')
    def test_create_question(self):
        payload = DefaultQuestion()

        response = create_question_api(payload)
        json_response: QuestionDict = response.json()

        assert_status_code(response.status_code, HTTPStatus.CREATED)
        assert_question(
            expected_question=json_response,
            actual_question=payload
        )

        validate_schema(json_response, DefaultQuestion.schema())

    @allure.title('Get question')
    def test_get_question(self, function_question: DefaultQuestion):
        response = get_question_api(function_question.id)
        json_response: QuestionDict = response.json()

        assert_status_code(response.status_code, HTTPStatus.OK)
        assert_question(
            expected_question=json_response,
            actual_question=function_question
        )

        validate_schema(json_response, DefaultQuestion.schema())

    @allure.title('Update question')
    def test_update_question(self, function_question: DefaultQuestion):
        payload = UpdateQuestion()

        response = update_question_api(function_question.id, payload)
        json_response: QuestionDict = response.json()

        assert_status_code(response.status_code, HTTPStatus.OK)
        assert_question(
            expected_question=json_response,
            actual_question=payload
        )

        validate_schema(json_response, DefaultQuestion.schema())

    @allure.title('Delete question')
    def test_delete_question(self, function_question: DefaultQuestion):
        delete_question_response = delete_question_api(function_question.id)
        get_question_response = get_question_api(function_question.id)

        assert_status_code(delete_question_response.status_code, HTTPStatus.OK)
        assert_status_code(
            get_question_response.status_code, HTTPStatus.NOT_FOUND
        )

Тут 5-ть тестов на стандартные CRUD операции для questions API https://api.sampleapis.com/futurama/questions.

Возвращаясь к нашим требованиям:

  1. Проверяем статус код ответа, тело ответа, JSON схему;

  2. При создании объекта внутри метода create_question у нас происходит автоматическая валидация на основе модели pydantic DefaultQuestion(**response.json()). Это автоматически избавляет нас от необходимости писать проверки для ответа API;

  3. Автотесты документированы и легко читаются. Теперь другой QA Automation или разработчик, когда посмотрит на наши тесты, сможет увидеть аннотацию в виде моделей. Посмотрев на модели, он сможет легко разобраться с какими именно объектами мы работаем. В pydantic имеется возможность добавлять description к функции Field, поэтому при желании вы сможете описать каждое поле вашей модели;

  4. JSON схема генерируется автоматически, рандомные тестовые данные тоже генерируются автоматически на основе модели. При большой мотивации вы можете взять ваш Swagger и вытащить из него JSON схему с помощью https://github.com/instrumenta/openapi2jsonschema. Далее y pydantic есть убойная фича https://docs.pydantic.dev/datamodel_code_generator/ и на основе JSON схемы pydantic сам сделает нужные модели. Этот процесс можно сделать автоматическим.

Report

Запустим тесты и посмотрим на отчет:

python -m pytest --alluredir=./allure-results

Теперь запустим отчет:

allure serve

Либо можете собрать отчет и в папке allure-reports открыть файл index.html:

allure generate

Получаем прекрасный отчет, в котором отображается вся нужная нам информация. Вы можете модифицировать шаги под свои требования или добавить логирование для каждого HTTP запроса.

Полную версию отчета посмотрите тут https://nikita-filonov.github.io/sample_api_testing/

Заключение

Весь исходный код проекта расположен на моем github: https://github.com/Nikita-Filonov/sample_api_testing

Всегда старайтесь писать автотесты так, чтобы после вас их смог прочитать любой другой QA Automation или разработчик; желательно не только прочитать и понять, но и легко починить, если потребуется. Не повышайте свою ценность для компании через "магический код" понятный только вам.