Вступление

Эта статья как продолжение статьи Как правильно писать 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 файла.

.env

BASE_URL="https://api.sampleapis.com" # API endpoint
TEST_USER_EMAIL="some@gmail.com" # Some random user just for example
TEST_USER_PASSWORD="some" # Some random password just for example

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 запроса.

Полную версию отчета посмотрите тут.

Заключение

Весь исходный код проекта расположен на моем github.

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

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


  1. shpaker
    08.01.2023 16:31
    +8

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

    Заголовок с амбициями конечно :) Что такое "правильно", а что такое нет - это тот еще вопрос.

    Когда мы пишем API автотесты, то нам хотелось бы, чтобы они отвечали требованиям…

    И ниже перечислен список требований не к тестам, а к позитивному случаю конкретного теста. Вообще, требования к автотестам на АПИ это тема, которая может вытянуть на отдельную статью (я бы такую почитал с удовольствием), и должна затрагивать не так канкретные шаги в тест-кейсе как методологию выбора тест-кейсов и проверки в том или ином случае.

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

    Вывод явно не правильный. Если процессы построены так, что автотесты завязаны на одном человеке результат работы которого никто не ревьюит, то беда с процессами, а не с человеком. Ну и в написании кода очень много субъективного и результат может казаться одним правильным, а другим костыльным.

    Еще один распространенный кейс - это когда новый QA Automation приходит на проект и сразу же хочет все переписать.

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

    Requirements

    Мне кажется, что в современном мире ставить зависимости пипом и без указания версий это моветон. Я бы поэтри использовал или какую другую альтернативу даже в автотестах.

    Если конкретно по зависимостям, то не очень понял, почему бы джейсонки не валидировать пайдантиоком. Кстати, дотенв можно отдельно не ставить, а поставить его сразу с пайдантиком (pip install pydantic[dotenv]). За httpx лайкосик. А кстати, какие генераоторы отчетов бывают кроме алюра? Я когда-то его юзал и меня напрягало, что он не умеет статику генерить чтоб прилеплять её как артефакт (хотя может я просто сам себе злой буратино и просто не понял как это делать). Ну и в целом я не ощутил восторга от его использования.

    Библиотека pydantic служит для реализации “строгой типизации” в python.

    Питон и так язык со строгой типизацией. Можно здесь почитать, что такое строгая типизация и понять при чем тут питон https://habr.com/ru/post/161205/. Пайдантик, строго говоря, в современном мире используется для каста значений к указанным типам и валидации.

    В качестве альтернативы pydantic можно взять библиотеку models-manager

    Теплое и мягкое. Либа для моделей и ORM. В качестве альтернативы я бы рассматривал только мармелоу, но это вкусовщина и “ну такое”. Вообще, если уж коснуться темы походов из автотестов в базу, то я бы наверное использовал “сырые” запросы, но тут тоже добрая доля вкусовщины.

    class Settings(BaseSettings):

    Три точки не обязатально писать. Для работы с префиксами в переменных окружения в конфиг можно указать специальный параметр. И удивительно, что вы объявляете переменную в глобальном скоупе, а не засовываете её сразу в фикстуру. Кстати, в доке пайдантика настройки получают из кешированной функции и код из-за этого чуть чище кмк.

    models\questions.py

    Напрашивается наследование одной модели от другой и непонятно что делает в названии класса слово default. То есть:

    class DefaultQuestion(UpdateQuestion)

    И я бы все-таки убрал default_factory из моделей и написал простую функцию фабрику (ну что-то в духе def make_question_model_with_random_data()) для генерации моделей с рандомными данными. Из разряда вкусовщины: люблю страшно фикстуры в пайтесте и генераторы рандомных данных в них бы упаковал завернум в алюровский декоратор с шагом, но это ну такое. Вообще, рандомные данные в тесты я не любитель пихать, но иногда без этого, конечно же не обойтись.

    JSON схема генерируется автоматически на основе модели.

    Валидируйте без схемы, а сразу моделью пайдантика.

    base\client.py

    По поводу if (not auth.auth_token) and (not auth.user): непонятно почему бы это не валидировать в самой модели. И в тестах прям напрашивается assert. NotImplementedError явно не к месту. Ну или на крайняк ValueError.

    Поэтому не забудем добавить нашу API фикстуру в pytest_plugins

    Прочитал на пару раз и так и не понял зачем мы её добавляем в pytest_plugins

    ps: Далее по диагонали читал. За старание пять, а по содержимому натянутая 4ка с минусом, как по мне, и не в последнюю очередь из-за того, что заголовок намекает на вычищенность подхода, а на мой взгляд есть еще над чем работать. Готов подискутировать по своему комменту если считаете, что где-то я не прав. Про упоминание своих реп - надо бы четко обозначать, что вот сомтрите так и эдак вот моя репа и в ней то-то и то-то, а лучше даже отдельным постом с описанием того, что в либе есть и обзором плюсов и минусов своего решения.


    1. sound_right Автор
      08.01.2023 19:19

      1. Без кометариев

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

      3. Да, в этом и проблема. Люди учатся по материалам, которые заведомо учат их неправильному. Далее компания становится пелйграундом для такого человека, пока он не попадет в команду, где его чему-то научат. У некоторых это не случается никогда

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

      5. На github есть все зависимости и версии. Я не стал заморачиваться с poetry, моя главная задача это авто тесты. Статья и так получилась очень большая. Есть много репортеров allure, pytest-html, report-portal, junit, но тут даже речь не о репортере, сейчас почти все +- не бедные компании используют Allure TestOPS, в нем есть все, от коллаборации Manual + Automation инженеров, до аналитики и статистики, короче топовая штука.

      6. В python нет строгой типизации. У python динамическая строгая типизация https://ru.wikipedia.org/wiki/Python, это означает, что одна и так же переменная может иметь разный тип в процессе выполнения программы. Где используется Pydantic тоже субъективно, где только не используется, некоторые даже умудряются пихать его в Django.

      7. Без комментариев

      8. Если положить настройки в фикстуру, то потом фикстура будет доступна только в скоупе тестов. То есть если вы захотите добавить какой-либо скрипт, который например чистит стенд или создает окружение или еще что-то, то фикстура тут будет не доступна. Это не гибкое решение. И даже если положить настройки в фикстуру, то потом придется эти настройки передавать в каждую функцию/класс и получится огромная вложенность. По поводу елипсов, их даже в документации используют https://docs.pydantic.dev/usage/settings/, но я знаю, что не обязательно

      9. Наследовать тут не вариант т.к. типы у объектов будут разные. Как в pydantic сделать все поля опциональными я не нашел, по крайней мере официального решения, есть только костыли и хаки. Да, можно через функцию сделать, например get_random, но я не вижу проблем с default_factory.

      10. Не совсем понял, в чем тут проблема

      11. Не люблю использовать root_validator, но можно валидировать и в нем. В тестах используется expect, дефолтный assert хоть и хорош с pytest-ом, но он не даст нам allure.step


      1. Niccolum
        09.01.2023 21:08

        2

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

        Если вы хотите валидировать жсон схему - используйте отдельную схему от той, что вы используете в коде явно. Иначе вы не отловите кейсы изменения схемы, которые, как я понимаю, должны падать. Иначе тест сводится к "в этом эндпоинте используется схема с этим именем"

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

        Документирование автотестов выглядит как что-то ненужное. Максимум, что можно сказать о документированности - из имени метода должно быть понятно что делает этот тест или к чему призван, из за чего написан.

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

        Посмотрите в сторону faker и подобных инструментов. Наверняка есть что то, что генерирует данные на основе пидантик схемы

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

        Выглядит как что то совсем излишнее. Если это, конечно, не касается end2end тестов

        3 Это всё ещё означает, что процессы плохо выстроены именно у вас

        6 Вы сначала говорите, что в python нет строгой типизации и сразу же себя опровергаете. Не надо так :)

        8 Если я правильно понял комментатора выше, имелось ввиду не настройки в фикстуру, а сделать функцию с кэшем получения настроек. Как в доке фастапи. Если мы говорим опять же про фастапи, то можно настройки подменять через Depends. Фикстуру, к слову, можно прикинуть во все тесты, явно её не передавая из за магии autouse=True.

        10 Модель Authentication вполне может в валидацию того, что вы написали в клиенте. Мне кажется, комментатор выше именно это имел ввиду.


        1. sound_right Автор
          09.01.2023 22:40

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

          2. Под документацией имелось ввиду, типизация объектов по максимуму

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

          4. Мне вот интересно, как такие умники дебажат авто тесты? Если в отчете только абстрактная информация, без какой-то конкретики

          5. Еще немного кринжа. Строгая типизация и динамическая строгая типизация это разные вещи, советую изучить этот вопрос

          6. Еще немножко кринжа, оставлю это без комментариев

          7. Да, что-то можно было бы вынести в модели

            Эта статья была написана для людей, которые хотят найти верный подход для написания автотестов. Все выше почему-то воспринимаю это, как туториал, что ошибочно. Если вас не устраивают какие-то моменты, то это ваши проблемы, задача статьи только в том, чтобы описать концепцию. Описать все подходы, использовать все супер модные либы, отполировать все до идеала, не входило в мои планы


          1. Niccolum
            09.01.2023 22:47

            1 В этом и суть. Иначе такие тесты кроме имени схемы ничего не валидируют.

            4 Строить тесты таким образом, чтобы они документировали сами себя? Прям как код? Естественно, такое возможно не всегда, но очень часто этого достаточно.

            5 Есть строгая/нестрогая типизация. Также есть статическая/динамическая. То, что вы, говоря о строгой типизации, имеете ввиду строгую статическую, желательно упоминать явно.


            1. sound_right Автор
              09.01.2023 23:08

              1. Мне кажется мы с вами про разное говорим

              2. Я бы сказал так, что документации в виде типов будет достаточно для понимания. Но еще бы хотелось, чтобы на основе авто тестов генерировалась тестовая документация, то есть тест кейсы. Но тут уже зависит от процессов, у кого-то совсем нет документации, только маленькие чек-листы, сначала пишутся авто тесты, потом автотесты генерируют документацию, при этом документация всегда актуальна т.к. мы чиним тесты. Кто-то на оборот, сначала пишет тест кейсы, потом на основе них делает автотесты. Лично мне нравится первый подход

              3. Я думаю, все поняли о чем речь, в python все же нет такой типизации, как например, в C# или Java. Даже если мы указываем явно тип переменной int, в python ничего не мешает нам потом сделать эту переменную строкой. Я как раз это имел ввиду, и поэтому сказал, что pydantic якобы реализует "строгую типизацию", т.к. он будет ругаться, на все, что не подходит под нужный тип, специально даже выделил кавычками, думал меня поймут, но нет. Короче кому надо, тот будет использовать и не так важно, как это называется


  1. irony_iron
    08.01.2023 23:43
    +1

    Вы сделали прекрасный базис для тестирования rest-api сервисов, к сожалению api бывают еще и всякие разнообразные RPC, мир живет не только на http, думаю поэтому статью минусят.
    По моему опыту большая часть проблем с авто-тестами немножко все ж не на уровне базового слоя, голый pytest + request достаточно читабельный, а вот уровень тест-дизайна страдает по максимуму - даже при помощи вашего фреймворка можно будет понаписать лапши, которая не бьется ни по одной из техник https://highload.today/blogs/8-tehnik-test-dizajna-s-primerami/#8
    Если честно, мне кажется ваш цикл статей пытается решить организационные вопросы менеджмента разработки небольших компаний с помощью кода вместо использования софтскилов.
    Проблема подхода в том, что без оглядки изобретается велосипед, который уже проработан у корпоратов, я помню очень похожий слой авто-тестов предлагал оператор МТС, но их авто-тесты жили в парадигме TDD и писались до создания сервисов - на зафейленный тест тут же заводился автоматом баг и разрабы писали код сервиса под тесты


    1. sound_right Автор
      09.01.2023 22:50

      Мне кажется это каким-то бредом, приходить читать статью где тестируют rest api и минусовать ее за то, что нет ничего кроме http протокола

      Это равно тому, если бы вы пришли в магазин "одежа" и сказали, а какого фига тут нет капусты?

      Я описал лишь концепцию, далее есть множество путей развития

      Мне кажется, более реальная причина хейта кроется в российском менталитете. Благодарный читатель будет извелекать 150% информации из работы любого автора, инфантил же, скажет, что одна вода и вообще автор дурак