Привет, я Алексей, QA Automation Engineer в команде «Интеграции» в Петрович-ТЕХ. Занимаюсь разработкой фреймворка автоматизированного тестирования сервисов интеграции, для REST и SOAP. 

Наблюдение: когда приходишь на собеседование на должность Junior QA Automation, то обязательно просят разработать автотесты для API. Звучит логично, но не так уж и просто: когда только начинаешь свой путь в автотестировании, тебе не всегда очевидно, как должен выглядеть рабочий тестовый фреймворк, из чего он должен состоять, как правильно написать тесты, а к ним тестовые данные. «Сырые» тесты, которые описывают в книгах и разных источниках – не всегда выручают.

В этой статье расскажу о разработке типового фреймворка для тестирования API – на Python, с нуля, шаг за шагом. В итоге получим полностью готовый тестовый фреймворк – надеюсь, с его помощью вы сможете сделать тестовое задание для собеседования или просто улучшить ваш уже действующий тестовый фреймворк.

Надеюсь, статья будет интересна начинающим авто-тестировщикам и тем, кто уже разрабатывает автотесты для API.


Постановка задачи 

Для наших целей воспользуемся открытым API – ReqRes.

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

Методы, для которых будем писать тесты: Get, Post, Put, Delete.

Репозиторий проекта: https://github.com/ScaLseR/petrovich_test.

С вводными условиями определились, давайте приступать.

Реализуем основной класс API

В корне проекта создадим директорию «api», а в ней – файл «api.py». Опишем там основной класс для работы с API – там будет реализована логика отправки запросов и будет обрабатываться полученный ответ. Класс так и назовем – «Api».

class Api:
    """Основной класс для работы с API"""
    _HEADERS = {'Content-Type': 'application/json; charset=utf-8'}
    _TIMEOUT = 10
    base_url = {}

    def __init__(self):
        self.response = None

В корне проекта добавлен файл requirements.txt, в котором будем хранить список необходимых библиотек. 

Библиотеки, с которыми будем работать:

import allure
import requests
from jsonschema import validate

Requests – поможет нам с отправкой запросов и получением ответов.

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

Jsonschema – отсюда импортируем функцию validate, для реализации проверки на соответствие схеме.

В нашем классе Api реализуем функциональность отправки запросов и получения ответов. Для POST-запроса код будет выглядеть следующим образом:

    def post(self, url: str, endpoint: str, params: dict = None,
             json_body: dict = None):        
            self.response = requests.post(url=f"{url}{endpoint}",
                                          headers=self._HEADERS,
                                          params=params,
                                          json=json_body,
                                          timeout=self._TIMEOUT)
        return self

Добавим "@allure.step” – будем передавать шаги в наш Allure-отчёт. 

@allure.step("Отправить POST-запрос")
def post(self, url: str, endpoint: str, params: dict = None,
         json_body: dict = None):
    with allure.step(f"POST-запрос на url: {url}{endpoint}"
                     f"\n тело запроса: \n {json_body}"):
        self.response = requests.post(url=f"{url}{endpoint}",
                                      headers=self._HEADERS,
                                      params=params,
                                      json=json_body,
                                      timeout=self._TIMEOUT)    
    return self

Дополнительно будем логировать requests и responses. Для этого добавим в проект директорию «helper» – там будут содержаться все наши дополнительные модули. Напишем первый модуль для логирования – logger.py.

"""Модуль логирования"""
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def log(response, request_body=None):
    logger.info(f"REQUEST METHOD: {response.request.method}")
    logger.info(f"REQUEST URL: {response.url}")
    logger.info(f"REQUEST HEADERS: {response.request.headers}")
    logger.info(f"REQUEST BODY: {request_body}\n")
    logger.info(f"STATUS CODE: {response.status_code}")
    logger.info(f"RESPONSE TIME: {response.elapsed.total_seconds() * 1000:.0f} ms\n")
    logger.info(f"RESPONSE HEADERS: {response.headers}")
    logger.info(f"RESPONSE BODY: {response.text}\n.\n.")

Модуль готов, добавим его использование в наш класс Api:

from helper.logger import log

Добавим логирование для наших методов; код метода POST будет иметь следующий вид:

@allure.step("Отправить POST-запрос")
def post(self, url: str, endpoint: str, params: dict = None,
         json_body: dict = None):
    with allure.step(f"POST-запрос на url: {url}{endpoint}"
                     f"\n тело запроса: \n {json_body}"):
        self.response = requests.post(url=f"{url}{endpoint}",
                                      headers=self._HEADERS,
                                      params=params,
                                      json=json_body,
                                      timeout=self._TIMEOUT)
    log(response=self.response, request_body=json_body)
    return self

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

Для проверки полученных респонсов в тестах нам понадобятся ассерты, их также добавим в основной класс Api. Добавим несколько самых основных.  

  • На соответствие статус-кода

@allure.step("Статус-код ответа равен {expected_code}")
def status_code_should_be(self, expected_code: int):
    """Проверяем статус-код ответа actual_code на соответствие expected_code"""
    actual_code = self.response.status_code
    assert expected_code == actual_code, f"\nОжидаемый результат: {expected_code} " \
                                         f"\nФактический результат: {actual_code}"
    return self
  • На соответствие ответа json-схеме

@allure.step("ОР: Cхема ответа json валидна")
def json_schema_should_be_valid(self, path_json_schema: str, name_json_schema: str = 'schema'):
    """Проверяем полученный ответ на соответствие json-схеме"""
    json_schema = load_json_schema(path_json_schema, name_json_schema)
    validate(self.response.json(), json_schema)
    return self

Для реализации проверки ответа на соответствие схеме необходимо добавить в наш «helper» ещё один модуль – load.py. В нём добавим функцию load_json_schema – для подгрузки нужной json-схемы из файла. Модуль будет иметь вид:

"""Модуль для работы с файлами"""
from importlib import import_module

def load_json_schema(path: str, json_schema: str = 'schema'):
    """Подгрузка json-схемы из файла"""
    module = import_module(f"schema.{path}")
    return getattr(module, json_schema)

Не забываем добавить новый модуль из «helper» в класс Api.

from helper.load import load_json_schema
  • На соответствие объектов 

Будем десериализировать полученный ответ в объект и сравнивать с эталонным.

@allure.step("ОР: Объекты равны")
def objects_should_be(self, expected_object, actual_object):
    """Сравниваем два объекта"""
    assert expected_object == actual_object, f"\nОжидаемый результат: {expected_object} " \
                                             f"\nФактический результат: {actual_object}"
    return self
  • На соответствие значения для определенного параметра

@allure.step("ОР: В поле ответа содержится искомое значение")
def have_value_in_response_parameter(self, keys: list, value: str):
    """Сравниваем значение необходимого параметра"""
    payload = self.get_payload(keys)
    assert value == payload, f"\nОжидаемый результат: {value} " \
                                 f"\nФактический результат: {payload}"
    return self

Для получения значения нужного параметра из респонса – добавим ещё один метод класса Api.

def get_payload(self, keys: list):
    """Получаем payload, переходя по ключам,
    возвращаем полученный payload"""
    response = self.response.json()
    payload = self.json_parser.find_json_vertex(response, keys)       
    return payload

Для корректной работы метода get_payload добавим в наш “helper” модуль parser.py:

"""Модуль для парсинга данных"""
from typing import Union

def get_data(keys: Union[list, str], data: Union[dict, list]):
    """Получение полезной нагрузки по ключам,
    если нагрузки нет, возвращаем пустой dict"""
    body = data
    for key in keys:
        try:
            body = body[key]
            if body is None:
                return {}
        except KeyError:
            raise KeyError(f'Отсутствуют данные для ключа {key}')
    return body

Не забываем добавить новый модуль из «helper» в класс «Api»:

from helper.parser import get_data

Вы скорее всего уже заметили, что каждый метод класса «Api» возвращает self; чуть ниже увидим, почему так и насколько это удобно. 

Основной класс готов, что дальше 

Можно сказать, что самая большая и сложная работа к этому моменту уже проведена; «скелет» фреймворка сформирован. Осталось нарастить «мясо»:

  • класс-коннектор для тестируемого API с описанием методов;

  • файлы моделей – дата-классы для реквеста и респонса;

  • json-схемы респонса;

  • фикстуры;

И финальное – нужно будет написать сами тесты.

Приступим к класс-коннектору. Создадим в директории «api» директорию «reqres». В ней создадим файл «reqres_api.py» – собственно, наш коннектор к тестируемому API. Пропишем URL, Endpoint и методы взаимодействия с API. 

Код нашего класса, на примере с post-запросом:

class ReqresApi(Api):

    """URl"""
    _URL = 'https://reqres.in'

    """Endpoint"""
    _ENDPOINT = '/api/users/'

@allure.step('Обращение к create')
def reqres_create(self, param_request_body: RequestCreateUserModel):
    return self.post(url=self._URL,
                     endpoint=self._ENDPOINT,
                     json_body=param_request_body.to_dict())

Теперь нужно создать дата-классы, в которых будет содержаться модель данных.

Сделаем новую директорию «model», внутри директории – файлы с моделями для наших данных.

Пример моделей данных для метода create:

"""Модели для create user"""
from dataclasses import dataclass, asdict

@dataclass
class RequestCreateUserModel:
    """Класс для параметров request"""
    name: str
    job: str

    def to_dict(self):
        """преобразование в dict для отправки body"""
        return asdict(self)

@dataclass
class ResponseCreateUserModel:
    """Класс для параметров респонса"""
    name: str
    job: str
    last_name: str
    id: str
    created_at: str

Добавим использование моделей в класс «ReqresApi»:

from model.reqres.create_model import RequestCreateUserModel, 
ResponseCreateUserModel

Модели готовы и добавлены. Теперь самое время сделать десериализацию полученного респонса в объект данных, для использования в тестах. 

Например, код метода десериализации для “single user” будет иметь следующий вид:

"""Собираем респонс в объект для последующего использования"""
def deserialize_single_user(self):
    """для метода get (single user)"""
    payload = self.get_payload([])
    return ResponseSingleUserModel(id=payload['data']['id'],
                                   email=payload['data']['email'],
                                   first_name=payload['data']['first_name'],
                                   last_name=payload['data']['last_name'],
                                   avatar=payload['data']['avatar'],
                                   url=payload['support']['url'],
                                   text=payload['support']['text'])

Если потребуется, сделаем по аналогии для остальных методов.

Следующим нашим шагом будет создание json-схем для проверки респонса. В корне проекта создаем директорию «schema», где будут находиться схемы ответов.

Схема для «single user»:

"""Схема для ReqRes API, single user"""

schema = {
  "type": "object",
  "properties": {
    "data": {
      "type": "object",
      "properties": {
        "id": {
          "type": "integer"
        },
        "email": {
          "type": "string"
        },
        "first_name": {
          "type": "string"
        },
        "last_name": {
          "type": "string"
        },
        "avatar": {
          "type": "string"
        }
      },
      "required": [
        "id",
        "email",
        "first_name",
        "last_name",
        "avatar"
      ]
    },
    "support": {
      "type": "object",
      "properties": {
        "url": {
          "type": "string"
        },
        "text": {
          "type": "string"
        }
      },
      "required": [
        "url",
        "text"
      ]
    }
  },
  "required": [
    "data",
    "support"
  ]
}

Следующий шаг – написать фикстуру, которая будет передавать в тесты экземпляр класс-коннектора «ReqresApi». В корне проекта создаем директорию «fixture».

Код фикстуры:

"""Фикстуры ReqRes API"""
import pytest
from api.reqres.reqres_api import ReqresApi

@pytest.fixture(scope="function")
def reqres_api() -> ReqresApi:
    """Коннект к ReqRes API"""
    return ReqresApi()

Всё готово – можно переходить к тестам!

Пишем тесты 

В наш модуль «load.py» добавим метод для подгрузки данных непосредственно в тесты; будем параметризировать.

def load_data(path: str, test_data: str = 'data'):
    """Подгрузка из файла тестовых данных для параметризации тестов"""
    module = import_module(f"data.{path}")
    return getattr(module, test_data)

Добавим в корень проекта директорию «test», внутри – файл «test_single_user.py». Пример кода файла:

"""Тест кейс для ReqRes API, single user"""
import allure
import pytest
from helper.load import load_data

pytest_plugins = ["fixture.reqres_api"]
pytestmark = [allure.parent_suite("reqres"),
              allure.suite("single_user")]

@allure.title('Запрос получения данных пользователя с невалидным значением')
@pytest.mark.parametrize(('user_id', 'expected_data'),
                         load_data('single_user_data', 'not_valid_data'))
def test_single_user_wo_parameters(reqres_api, user_id, expected_data):
    reqres_api.reqres_single_user(user_id).status_code_should_be(404).\
        have_value_in_response_parameter([], expected_data)

@allure.title('Запрос получения данных пользователя с валидным значением')
@pytest.mark.parametrize(('user_id', 'expected_data'),
                         load_data('single_user_data'))
def test_single_user_valid_parameters(reqres_api, user_id, expected_data):
    reqres_api.reqres_single_user(user_id).status_code_should_be(200).\
        json_schema_should_be_valid('single_user_schema').\
        objects_should_be(expected_data, reqres_api.deserialize_single_user())

У нас получились два универсальных теста: 

  1. для невалидных параметров, с проверкой кода ответа и тела ответа;

  2. для валидных значений: проверяем код ответа на соответствие json-схеме, десериализуем результат в объект, сравниваем его с эталонным (код остальных тестов можно посмотреть в репозитории).

Надеюсь, по коду тестов понятно, почему методы класса «Api» возвращают объект. Тут всё дело в том, что это позволяет довольно красиво и лаконично писать код теста, вызывая последовательно нужные методы класса и выполняя проверки. 

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

Создадим файл данных для наших тестов. Добавим в корень проекта директорию «data»; внутри – файл «test_single_user.py».

Пример кода файла:

"""Дата-файл для тестирования КуйКуы API, single user"""
# -*- coding: utf-8 -*-
from model.reqres.single_user_model import ResponseSingleUserModel

# эталонные модели данных для проверки в тестах
# user_id = 2
user_id_2 = ResponseSingleUserModel(id=2, email='janet.weaver@reqres.in', first_name='Janet',
                                    last_name='Weaver', avatar='https://reqres.in/img/faces/2-image.jpg',
                                    url='https://reqres.in/#support-heading',
                                    text='To keep ReqRes free, contributions towards server costs are appreciated!')

# user_id = 3
user_id_3 = ResponseSingleUserModel(id=3, email='emma.wong@reqres.in', first_name='Emma',
                                    last_name='Wong', avatar='https://reqres.in/img/faces/3-image.jpg',
                                    url='https://reqres.in/#support-heading',
                                    text='To keep ReqRes free, contributions towards server costs are appreciated!')

# Валидные данные для тестов ('user_id', 'expected_data')
data = ((2, user_id_2), (3, user_id_3))

# пустое тело ответа
empty_data = {}

# Невалидные данные для тестов ('user_id', 'expected_data')
not_valid_data = ((129398274923874, empty_data),
                  ('test', empty_data),
                  ('роывора', empty_data))

Прогоняем тесты 

Запустим наши тесты в консоли и посмотрим на полученный результат:

Тесты прошли, но получили не такой красивый отчёт, как можно было ожидать. Запустим тестовый прогон с формированием Allure-отчёта.

Тесты прошли, отчёт сформирован в папке allure_report. Откроем отчёт в локальном Allur.

Видим: было 17 тестов, все из них имеют статус “passed”.

На странице Suites наши тесты красиво разложены.

Плюс, для каждого теста имеем довольно информативный лог:

Сохранить: чеклист по созданию фреймворка для тестирования API

Итого, на пути создания нашего фреймворка мы прошли такие шаги:

  • завели директорию «api» и файл «api.py» – для работы с классом “Api”;

  • сделали файл requirements.txt – хранить список необходимых библиотек;

  • написали код отправки запросов и получения ответов;

  • добавили логирование

    • реквестов и респонсов

    • методов 

  • завели ассерты

    • статус-код

    • json-схема 

    • объекты

    • значения для определенного параметра

  • сделали класс для получения значения нужного параметра из респонса

  • в дополнение к основному классу завели

    • класс-коннектор для тестируемого API с описанием методов;

    • файлы моделей – дата-классы для реквеста и респонса;

    • json-схемы респонса;

    • фикстуры

  • написали тесты

    • для невалидных параметров, с проверкой кода ответа и тела ответа;

    • для валидных значений

  • прогнали тесты 

    • по умолчанию

    • с формированием Allure-отчёта

Иметь под рукой чеклист – отправная точка. Кроме этого и остальных чеклистов, на пути начинающего автотестировщика будет ещё много всего: книги, статьи, видео, возможно какие-нибудь обучающие курсы; вопросы коллегам, обсуждения в тематических чатах и пабликах.

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

Чеклист сработает как ожидалось – отлично, значит, статья была не зря. Получится найти или придумать более оптимальный чеклист – ещё лучше! Главное – пробовать. 

Успехов вам в автотестировании!

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


  1. maxim_zaitsev
    08.06.2023 06:41
    +1

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

    https://github.com/ScaLseR/petrovich_framework/blob/main/api/api.py#L2

    2. А зачем хранить урлу в словаре? + эта переменная класса вообще не используется в коде

    https://github.com/ScaLseR/petrovich_framework/blob/main/api/api.py#L16

    3. А зачем жестко задавать заголовки и пихать их в каждый метод. Сомневаюсь, что для методов GET и DELETE нужен {'Content-Type': 'application/json; charset=utf-8'}.
    https://github.com/ScaLseR/petrovich_framework/blob/main/api/api.py#L14

    requests из коробки удачно работает с заголовками исходя из переданных параметров: params, json, data. Обычно не требуется их явно указывать.


    4. Велосипед с десериализацией впечатляет. Но удобнее использовать готовые решения. Например, pydantic. Заодно, может и от валидации json-схем отказаться. Ну как минимум, тип данных в ответе pydantic проверит

    5. Для примера взят слишком простой функционал. Банально даже авторизации нет. А если несколько "апи-коннекторов" нужно в тесте. Как будешь с сессией вопрос решать?