python

Переписываем API тесты

  • вторник, 7 июня 2022 г. в 00:39:51
https://habr.com/ru/post/669880/
  • Тестирование IT-систем
  • Python


Кто я?

Давайте сначала познакомимся. Меня зовут Александр, и я 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

Тестировать мы будем магазин https://stores-tests-api.herokuapp.com, вот его сваггер - https://github.com/berpress/flask-restful-api.

Напишем самые простые тесты на регистрацию пользователя:

import requests


class TestRegistration:
    def test_registration(self):
        body = {"username": "test@test.com", "password": "Password"}
        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)

Результат:

PASSED       [100%]{"message": "User created successfully.", "uuid": 1}

Что делает этот код? Формируем боди и с помощью библиотеки requests посылаем запрос, проверяем статус код и респонс. На всякий случай проверяем, что в респонсе uuid типа int (isinstance(...)). И принтуем полученный респонс.

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

Первое:

import requests

В тестах мы используем библиотеку requests (эта библиотека отвечает за запросы). Чем это плохо? Наши тесты знают, кто их тестирует. Если завтра нам надо будет заменить requests, то его необходимо будет переписывать во всех тестах. Вспоминаем идеи PO, нам необходимо разделить тесты и вызовы api - таким образом мы уберем requests и наши тесты будет легче изменять.

Второе:

response = requests.post("https://stores-tests-api.herokuapp.com/register", json=body)

Тут очевидно url "https://stores-tests-api.herokuapp.com" можно вынести в константу.

Третье:

body = {"username": "test@test.com", "password": "Password"}

Для того, что бы мы моли переиспользовать наши тесты (если запустить тесты два раза, то получим 400 ошибку, такой пользователь существует). Для этого будем использовать библиотеку faker.

Четвертое:

assert response.json().get('message') == 'User created successfully.'
assert response.json().get('uuid')
assert isinstance(response.json().get('uuid'), int)

Здесь мы проверяем респонс, какого типа данные нам вернулись (функция isinstance)

Пятое:

print(response.text)

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"{self.url}{self.POST_REGISTER_USER}", 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 {"username": username, "password": password}

Здесь у нас есть класс 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               {'type': 'string'}
E           
E           On instance['uuid']:
E               9

Создадим пакет schemas, а внутри файл registration.py

# schemas/registration.py
valid_schema = {
    "type": "object",
    "properties": {
        "message": {"type": "string"},
        "uuid": {"type": "string"},
    },
    "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"{self.url}{self.POST_REGISTER_USER}", 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"{self.url}{self.POST_REGISTER_USER}", 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"{self.url}{self.POST_REGISTER_USER}", 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"{self.url}{self.POST_REGISTER_USER}", 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"{self.url}{self.POST_REGISTER_USER}", 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')

И вот теперь все! Вот файлы в гитхабе.

Итог

Мы пришли к результату:

  • тесты не зависят от реализации, тесты не знают, кто тестируют и кто посылает запросы;

  • легко создавать фейковые данные и логгировать результат тестов;

  • вся архитектура стала гибкой и легко поддается рефакторингу, тесты легко и быстро поддерживать.

Ссылки

Cервис для тренировки по написанию api тестов https://stores-tests-api.herokuapp.com и сваггер к нему https://app.swaggerhub.com/apis-docs/berpress/flask-rest-api/1.0.0

Удачи!