Переписываем API тесты
Давайте сначала познакомимся. Меня зовут Александр, и я 17 лет работаю в тестировании. В основном я занимаюсь unit/api/ui/e2e/load тестами. Мой основной стек это JS/TS/Python. Так же я преподаю в университете курс автоматизации тестирования, и меня привлекают для оценки/помощи внедрения автотестов в отделах/компаниях.
И моя сегодняшняя тема касается архитектуры api тестов. Язык, на котором они написаны не важен, +/- на всех языках одинаково. Свои примеры я буду показывать на Python. Возможно, для опытных коллег я буду рассказывать очевидные вещи, но, как я написал выше, иногда я участвую в консультациях в сторонних организациях и вижу довольно много кода api тестов, проблемного кода, который был написан от мидлов до лидов. Так же я посмотрел репозитории на GitHub различных школ и . я бы переписал).
Я не ставлю целей самоутвердиться за счет других, весь код ниже будет моим.
Перед тем, как начнем, давайте вспомним об одном замечательном паттерне PageObject. Его довольно успешно освоили и применяют автотестеры, но на вопрос «А про что этот паттерн и какую проблему он решает?» ,к сожалению, смогут ответить не все. Так про что этот паттерн? Про абстракции и инкапсуляцию (главные друзья). Мы выделяем в отдельные слои тесты и работу со страницами, инкапсулируем работу с драйвером. Знания PO очень помогут нам в написании API тестов. Можно освежить свои знания здесь.
Так же хотелось бы отметить, что код ниже про архитектуру, некоторые вещи я намерено упрощаю, что бы не затягивать.
Часть первая. Начнем!
Давайте попробуем написать самые простые api тесты на Python. Создадим новый проект и виртуальное окружение. Добавим библиотеку requests для возможности отправлять наши http запросы. Так же установим ранер тестов — pytest (pytest я буду использовать только как раннер тестов, без фикстур).
mkdir python_api_tests cd python_api_tests python3 -m venv env source env/bin/activate pip install requests pip install pytest
Напишем самые простые тесты на регистрацию пользователя:
import requests class TestRegistration: def test_registration(self): body = response = requests.post("https://stores-tests-api.herokuapp.com/register", json=body) assert response.status_code == 201 assert response.json().get('message') == 'User created successfully.' assert response.json().get('uuid') assert isinstance(response.json().get('uuid'), int) print(response.text)
Что делает этот код? Формируем боди и с помощью библиотеки requests посылаем запрос, проверяем статус код и респонс. На всякий случай проверяем, что в респонсе uuid типа int (isinstance(. )). И принтуем полученный респонс.
Стандартный код, который можно найти на ютубе, в первых строчках гугла и на курсах. И в проде автотестеров. Хороший ли этот код? Как пример нормальный, как код автотестов в вашей компании не самые лучшее решение. Давайте попробуем разобраться почему.
В тестах мы используем библиотеку requests (эта библиотека отвечает за запросы). Чем это плохо? Наши тесты знают, кто их тестирует. Если завтра нам надо будет заменить requests, то его необходимо будет переписывать во всех тестах. Вспоминаем идеи PO, нам необходимо разделить тесты и вызовы api — таким образом мы уберем requests и наши тесты будет легче изменять.
response = requests.post("https://stores-tests-api.herokuapp.com/register", json=body)
Тут очевидно url «https://stores-tests-api.herokuapp.com» можно вынести в константу.
Для того, что бы мы моли переиспользовать наши тесты (если запустить тесты два раза, то получим 400 ошибку, такой пользователь существует). Для этого будем использовать библиотеку faker.
assert response.json().get('message') == 'User created successfully.' assert response.json().get('uuid') assert isinstance(response.json().get('uuid'), int)
Здесь мы проверяем респонс, какого типа данные нам вернулись (функция isinstance)
print хорош для примеров, но в больших проектах не используйте его. Для этого есть logger . Он гибче и у него больше настроек.
Начинаем править. Вынесем обращения к сервису из тестов в отдельный класс. Создадим пакет register, внутри файл api.py
# register/api.py import requests class Register: def __init__(self, url): self.url = url POST_REGISTER_USER = '/register' def register_user(self, body: dict): """ https://app.swaggerhub.com/apis-docs/berpress/flask-rest-api/1.0.0#/register/regUser """ return requests.post(f"", json=body)
У нас появился класс Register, который принимает в конструкторе класса url тестируемого приложения и функция register_user, которая регистрирует новых пользователей. Обратите внимание, что появился импорт requests, в тестах его больше не будет, они теперь не знаю, кто их тестирует!
Далее предлагаю добавить рандом в наши тесты и прикрутить faker. Создадим файл models.py
# register/models from faker import Faker fake = Faker() class RegisterUser: @staticmethod def random(): username = fake.email() password = fake.password() return
Здесь у нас есть класс RegisterUser и функция random, которая генерирует каждый раз рандомные данные, согласно сваггеру.
Четвертый пункт можно решить множественными способами и библиотеками. Я предпочитаю пользоваться attrs/cattrs (ниже буду ссылки на примеры), но возьмем библиотеку jsonschema . Наша задача заключается в том, что бы валидировать наш респонс. Если поля/типы не совпадают с нашей схемой, то наши тесты будут выкидывать ошибку, например
> raise error E jsonschema.exceptions.ValidationError: 9 is not of type 'string' E E Failed validating 'type' in schema['properties']['uuid']: E E E On instance['uuid']: E 9
Создадим пакет schemas, а внутри файл registration.py
# schemas/registration.py valid_schema = < "type": "object", "properties": < "message": , "uuid": , >, "required": ["message", "uuid"] >
Здесь мы описали, как будет выглядеть наш респонс, какие поля обязательные и какого типа они будут. Согласно справке jsonschema для валидация нам понадобится функция validate. Предлагаю ее добавить в api
# register/api.py . def register_user(self, body: dict, schema: dict): """ https://app.swaggerhub.com/apis-docs/berpress/flask-rest-api/1.0.0#/register/regUser """ response = requests.post(f"", json=body) validate(instance=response.json(), schema=schema) return response
Если валидация не пройдет, то мы упадем на 9 строчке. Так как у нас есть положительные и негативные тесты, то для этих сценариев необходимо будет дописывать «свои» схемы в папке schemas. Обратите внимание, что функция register_user теперь принимает аргумент schema, как раз для случаев, которые я описал ранее.
Теперь перейдем к пятому пункту — логгирование. Само логгирование в питоне не совсем простое для понимания, но свой логгер нам писать не нужно будет, за нас это реализовано в pytest, нам необходимо его только настроить. Создадим в корне каталога файл pytest.ini:
[pytest] log_format = %(asctime)s %(levelname)s %(message)s log_date_format = %Y-%m-%d %H:%M:%S log_cli=true log_level=INFO
Само логгирование будет в файле api.py:
# register/api.py import requests import logging from jsonschema import validate logger = logging.getLogger("api") class Register: def __init__(self, url): self.url = url POST_REGISTER_USER = '/register' def register_user(self, body: dict, schema: dict): """ https://app.swaggerhub.com/apis-docs/berpress/flask-rest-api/1.0.0#/register/regUser """ response = requests.post(f"", json=body) validate(instance=response.json(), schema=schema) logger.info(response.text) return response
Теперь посмотрим, как изменились наш тест после рефакторинга:
from register_1_step.api import Register from register_1_step.models import RegisterUser from schemas.registration import valid_schema URL = "https://stores-tests-api.herokuapp.com" class TestRegistration: def test_registration(self): body = RegisterUser.random() response = Register(url=URL).register_user(body=body, schema=valid_schema) assert response.status_code == 201 assert response.json().get('message') == 'User created successfully.' assert response.json().get('uuid')
Что изменилось? Код стал более читаемый, мы разделили тесты от запросов и добавили валидацию ответов. Достаточно ли этого? Нет, продолжим.
Часть вторая. Еще глубже!
Обратим внимание на api.py:
# api.py import requests . class Register: def __init__(self, url): self.url = url POST_REGISTER_USER = '/register' def register_user(self, body: dict, schema: dict): """ https://app.swaggerhub.com/apis-docs/berpress/flask-rest-api/1.0.0#/register/regUser """ response = requests.post(f"", json=body) . return response
Мы используем внутри библиотеку requests. Да, правильно, что мы ее вынесли из тестов, но должна ли она быть в api? А что если у нас таких api файлов будет 1000 и завтра библиотеку requests будем менять на асинхронную aiohttp ? Напрашивается выделить работу с запросами в отдельный слой (и да, это очень похоже на то, что мы делаем в PO, когда прячем работы с selenium в самый подвал). Создадим файл requests.py, который спрячет работу с библиотеками, которые отвечают за запросы:
import requests from requests import Response class Client: @staticmethod def custom_request(method: str, url: str, **kwargs) -> Response: """ Request method method: method for the new Request object: GET, OPTIONS, HEAD, POST, PUT, PATCH, or DELETE. # noqa url – URL for the new Request object. **kwargs: params – (optional) Dictionary, list of tuples or bytes to send in the query string for the Request. # noqa json – (optional) A JSON serializable Python object to send in the body of the Request. # noqa headers – (optional) Dictionary of HTTP Headers to send with the Request. """ return requests.request(method, url, **kwargs)
Я специально назвал метод запроса custom_request, что бы не путаться с библиотекой requests. Именно здесь мы будем отправлять запросы, изолировав выполнение от тестов и api. Теперь перепишем api:
# api.py import logging from jsonschema import validate from register_2_step.requests import Client logger = logging.getLogger("api") class Register: def __init__(self, url): self.url = url self.client = Client() POST_REGISTER_USER = '/register' def register_user(self, body: dict, schema: dict): """ https://app.swaggerhub.com/apis-docs/berpress/flask-rest-api/1.0.0#/register/regUser """ response = self.client.custom_request("POST", f"", json=body) validate(instance=response.json(), schema=schema) logger.info(response.text) return response
Обратите внимание, что «пропал» импорт библиотеки requests.
Что изменилось? Мы изолировали запросы, наш код стал более гибким. Достаточно ли этого? Нет, продолжим.
Часть третья. Течет!
Давайте внимательно посмотрим на наш тест:
def test_registration(self): body = RegisterUser.random() response = Register(url=URL).register_user(body=body, schema=valid_schema) assert response.status_code == 201 assert response.json().get('message') == 'User created successfully.' assert response.json().get('uuid')
Если его запустить под дебагом с точкой остановы на 4 строке, то выяснится, что объект response типа Response (а сам Response принадлежит библиотеке requests). У нас получилось ситуация, при который тесты знают, кто их тестирует — requests. То есть «упоминание» requests попало с самого нижнего уровня абстракции наверх в тесты. В программировании это называется «протекающие абстракции» . Чем это плохо в данном случае? Если мы поменяем requests, то нам необходимо будет менять все тесты, так как новая библиотека может не иметь атрибут status_code и метод json(), которые принадлежат библиотеке requests. Будем править, добавим в models:
class ResponseModel: def __init__(self, status: int, response: dict = None): self.status = status self.response = response
Этот объект мы и будем возвращать в api:
# api.py import logging from jsonschema import validate from register_3_step.requests import Client from register_3_step.models import ResponseModel logger = logging.getLogger("api") class Register: def __init__(self, url): self.url = url self.client = Client() POST_REGISTER_USER = '/register' def register_user(self, body: dict, schema: dict): """ https://app.swaggerhub.com/apis-docs/berpress/flask-rest-api/1.0.0#/register/regUser """ response = self.client.custom_request("POST", f"", json=body) validate(instance=response.json(), schema=schema) logger.info(response.text) return ResponseModel(status=response.status_code, response=response.json())
А так теперь будут выглядеть наши тесты:
from register_3_step.api import Register from register_3_step.models import RegisterUser from schemas.registration import valid_schema URL = "https://stores-tests-api.herokuapp.com" class TestRegistration: def test_registration(self): body = RegisterUser.random() response = Register(url=URL).register_user(body=body, schema=valid_schema) assert response.status == 201 assert response.response.get('message') == 'User created successfully.' assert response.response.get('uuid')
Итог
- тесты не зависят от реализации, тесты не знают, кто тестируют и кто посылает запросы;
- легко создавать фейковые данные и логгировать результат тестов;
- вся архитектура стала гибкой и легко поддается рефакторингу, тесты легко и быстро поддерживать.