Привет, я Алексей, 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())
У нас получились два универсальных теста:
для невалидных параметров, с проверкой кода ответа и тела ответа;
для валидных значений: проверяем код ответа на соответствие 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-отчёта
Иметь под рукой чеклист – отправная точка. Кроме этого и остальных чеклистов, на пути начинающего автотестировщика будет ещё много всего: книги, статьи, видео, возможно какие-нибудь обучающие курсы; вопросы коллегам, обсуждения в тематических чатах и пабликах.
На мой взгляд залог успеха тут заключается в том, чтобы пробовать. Даже если не всё понятно, даже если не на все вопросы есть ответы – чем раньше начнёшь делать руками, тем раньше разберёшься. Пусть по шаблону прямым копированием шагов, пусть без глубокого понимания методологии – но делать руками как можно раньше.
Чеклист сработает как ожидалось – отлично, значит, статья была не зря. Получится найти или придумать более оптимальный чеклист – ещё лучше! Главное – пробовать.
Успехов вам в автотестировании!
maxim_zaitsev
Какой замечательный фреймворк ...
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. Для примера взят слишком простой функционал. Банально даже авторизации нет. А если несколько "апи-коннекторов" нужно в тесте. Как будешь с сессией вопрос решать?