Pull to refresh

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

Reading time19 min
Views46K

Вступление

Эта статья как продолжение статьи Как правильно писать 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_settings, потому что он максимально удобный, умеет читать настройки из .env файла, умеет читать настройки из переменных окружения, умеет читать настройки из .txt файла, умеет управлять ссылками на редис или базу данных и много чего еще, можно почитать тут https://docs.pydantic.dev/latest/concepts/pydantic_settings/. Это очень удобно для использования на CI/CD, или когда у вас есть много настроек, которые разбросаны по всему проекту + с BaseSettings все настройки можно собрать в один объект.

settings.py

from pydantic_settings import BaseSettings, SettingsConfigDict


class TestUser(BaseSettings):
    email: str = ""
    password: str = ""


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file='.env',
        env_file_encoding='utf-8',
        env_nested_delimiter='.'
    )

    base_url: str
    test_user: TestUser = TestUser()

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


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

Обратите внимание на то, как записаны переменные окружения TEST_USER.EMAIL и TEST_USER.PASSWORD. Это сделано специально, чтобы "упаковать" значения во вложенную модель TestUser. В данном случае, в качестве разделителя используется точка, но это можно настроить с помощью параметра env_nested_delimiter='.'

Models

Теперь опишем модели, используя pydantic

Модель для аутентификации:

models\authentication.py

from pydantic import BaseModel, Field, model_validator

from settings import base_settings


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


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

    @model_validator(mode='after')
    def validate_root(self) -> 'Authentication':
        if (not self.auth_token) and (not self.user):
            raise ValueError(
                'Please provide "username" and "password" or "auth_token"'
            )

        return self

Внутри метода validate_root мы проверяем, был ли передан токен или пользователь при инициализации объекта Authentication. Если не было передано ни того, ни другого, то мы выбрасываем ошибку

Напишем модель для объекта 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, RootModel

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 | int = Field(
        alias='correctAnswer',
        default_factory=random_string
    )


class DefaultQuestionsList(RootModel):
    root: list[DefaultQuestion]


class QuestionDict(TypedDict):
    id: int
    question: str
    correctAnswer: str
    possibleAnswers: list[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().model_dump(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().model_json_schema()

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

API Client, HTTP Client

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

utils/clients/http/client.py

import typing

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


class HTTPClient(Client):
    @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
        )


class APIClient:
    def __init__(self, client: HTTPClient) -> None:
        self._client = client

    @property
    def client(self) -> HTTPClient:
        return self._client

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

Класс APIClient является базовым для взаимодействия с API системы. В нашем случае мы создаем классы AuthenticationClient и QuestionsClient, которые наследуются от APIClient. Важно подчеркнуть, что класс APIClient предназначен исключительно для взаимодействия с API и не имеет информации о среде, к которой он обращается, а также не знает о токенах и заголовках запросов. Все эти настройки определяются на уровне класса HTTPClient

Напишем билдер, который будет инициализировать класс HTTPClient:

utils/clients/http/builder.py

from base.api.authentication_api import AuthenticationClient
from models.authentication import Authentication
from settings import base_settings
from utils.clients.http.client import HTTPClient


def get_http_client(
    auth: Authentication | None = None,
    base_url: str = base_settings.api_url
) -> HTTPClient:
    if auth is None:
        return HTTPClient(base_url=base_url, trust_env=True)

    headers: dict[str, str] = {}

    client = HTTPClient(base_url=base_settings.api_url)
    authentication_client = AuthenticationClient(client=client)

    if (not auth.auth_token) and auth.user:
        token = authentication_client.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 HTTPClient(base_url=base_url, headers=headers, trust_env=True)

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

API clients

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

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

base\api\authentication_api.py

from httpx import Response

from models.authentication import AuthUser
from utils.clients.http.client import APIClient
from utils.constants.routes import APIRoutes


class AuthenticationClient(APIClient):
    def get_auth_token_api(self, payload: AuthUser) -> Response:
        return self.client.post(f'{APIRoutes.AUTH}/token', json=payload.model_dump())

    def get_auth_token(self, payload: AuthUser) -> str:
        """
        Should be used like this:

        response = self.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'

Теперь опишем клиент для работы с API questions:

import allure
from httpx import Response

from models.questions import DefaultQuestion, UpdateQuestion
from utils.clients.http.client import APIClient
from utils.constants.routes import APIRoutes


class QuestionsClient(APIClient):
    @allure.step(f'Getting all questions')
    def get_questions_api(self) -> Response:
        return self.client.get(APIRoutes.QUESTIONS)

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

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

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

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

    def create_question(self) -> DefaultQuestion:
        payload = DefaultQuestion()

        response = self.create_question_api(payload)
        return DefaultQuestion(**response.json())

С помощью клиента QuestionsClient сможем выполнять простые 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 QuestionsClient
from models.authentication import Authentication
from models.questions import DefaultQuestion
from utils.clients.http.builder import get_http_client


@pytest.fixture(scope="class")
def class_questions_client() -> QuestionsClient:
    client = get_http_client(auth=Authentication())

    return QuestionsClient(client=client)


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

    function_questions_client.delete_question_api(question.id)

Фикстура class_questions_client используется для инициализации клиента QuestionsClient. Скоуп специально выбран на класс, потому что не имеет смысла инициализировать данный клиент на каждый тест, возможно из-за специфики тестирования вашей системы, скоуп может быть другой

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

Лайфхак. В названиях фикстур используется приставка class_<имя фикстуры> и function_<имя фикстуры>. Это не просто так – приставка соответствует скоупу действия фикстуры, который устанавливается с помощью параметра scope в pytest.fixture(scope="class"). Таким образом, мы включаем информацию о скоупе фикстуры прямо в её название. Это может быть полезно, когда вам нужны одни и те же фикстуры с разными скоупами в разных частях вашего кода.

Например, в некоторых случаях нам может потребоваться создавать нового пользователя для каждого теста, а в других – иметь одного пользователя для всего тестового класса. Используя такое разделение в названиях фикстур, мы делаем код более читабельным и избегаем неявных обозначений скоупов, которые могут ухудшить понимание кода

conftest.py

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

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

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

Чтобы избежать таких проблем и упростить жизнь себе и другим автоматизаторам, которые будут писать тесты вместе с вами или после вас, рекомендую использовать плагины pytest. Это позволяет более гибко и эффективно использовать фикстуры и организовывать их наследование

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 QuestionsClient
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, class_questions_client: QuestionsClient):
        response = class_questions_client.get_questions_api()
        json_response: list[QuestionDict] = response.json()

        assert_status_code(response.status_code, HTTPStatus.OK)

        validate_schema(
            json_response, DefaultQuestionsList.model_json_schema())

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

        response = class_questions_client.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.model_json_schema())

    @allure.title('Get question')
    def test_get_question(
        self,
        function_question: DefaultQuestion,
        class_questions_client: QuestionsClient
    ):
        response = class_questions_client.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.model_json_schema())

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

        response = class_questions_client.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.model_json_schema())

    @allure.title('Delete question')
    def test_delete_question(
        self,
        function_question: DefaultQuestion,
        class_questions_client: QuestionsClient
    ):
        delete_question_response = class_questions_client.delete_question_api(
            function_question.id
        )
        get_question_response = class_questions_client.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 или разработчик; желательно не только прочитать и понять, но и легко починить, если потребуется. Не повышайте свою ценность для компании через "магический код" понятный только вам.

UPD. Статья была обновлена, поскольку код из неё морально устарел. Кроме того, версия pydnatic была обновлена до версии 2.4, и в данной версии были устранены некоторые баги, которые ранее требовали использования костылей. Если вам интересно посмотреть на старую версию автотестов, вы можете найти её здесь

Tags:
Hubs:
Total votes 14: ↑9 and ↓5+5
Comments17

Articles