Кто я?

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

Удачи!

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


  1. conopus
    07.06.2022 00:16
    +2

    У вас в примере упоминается Swagger, который позволяет генерировать клиента в том числе и на Python. Идея не писать весь этот бойлер-плейт руками выглядит довольно привлекательно. Что бы вы сказали "за" и "против" такого подхода?


    1. ivansibaha
      07.06.2022 01:57

      Интересно глянуть, что он генерирует. Вообще с нормальным клиентом не вижу смысла использовать что-то генерёное. Накидать несколько ендпоинтов - не то, чтобы самое трудозатратное в автоматизации. Боди надо будет или самому вносить или проверять, хэдеры тоже. Как оно будет подтягивать изменения - непонятно, отдаём стабильность тестов на откуп команде бэкенда. Я бы не стал на серьёзном проекте таким заниматься, особенно если есть CI/CD


    1. Litovsky83 Автор
      07.06.2022 04:03

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


      1. funca
        07.06.2022 08:07
        +2

        Я использую https://github.com/p1c2u/openapi-core на бекенде для конфигурации routes, парсинга запросов и валидации ответов. Это вынуждает поддерживать swagger в актуальном состоянии, иначе приложение просто не заработает. Вообще swagger служит контрактом между командами, поэтому должен быть всегда актуален.


        1. Litovsky83 Автор
          07.06.2022 08:35
          +1

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


        1. gigimon
          07.06.2022 11:21

          к сожалению генерировать api клиент из сваггера для тестов не очень получается, т.к. зачастую в тестах необходимо что-то специфическое (добавить/удалить поле и т.п.), поэтому проще писать своего клиент, но вот использовать сваггер для валидации ответа прям отлично


      1. conopus
        07.06.2022 11:16

        Это уже офтопик, но хотелось бы позицию до конца прояснить: а к использованию ORM в автотестах вы как относитесь?


        1. Litovsky83 Автор
          07.06.2022 11:21

          Если внутри команды есть договоренность, что этот уровень может использоваться в тестах (например, что бы чистить базу полбе себя или делать дополнительные проверки), то норм)


  1. funca
    07.06.2022 00:35
    +1

    response.response.get

    Зачем два раза response? Такие тавтологии обычно являются признаком наличия проблем с дизайном (возможно где-то появился лишний слой абстракции).

    вся архитектура стала гибкой и легко поддается рефакторингу

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

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


    1. ReadOnlySadUser
      07.06.2022 00:46
      +1

      Скажем, первый шаг с валидацией через схему мне показался оправданным. А вот все последующие - лютый оверкилл какой-то.

      Тут забавный парадокс. Поддерживать эти обертки над обертками над обертками более или менее удобно, когда всё более или менее стабильно в плане используемых фреймворков. Однако зачем писать обертки над тем, что и так стабильно? А если на проекте всё постоянно меняется, то при наличии этих абстракций, количество мест где надо что-то где-то поправить только увеличится.

      В общем, уверен на каких-то больших масштабах указанный подход имеет место быть, но в маленьком проекте - это прям паттерны головного мозга.


      1. Litovsky83 Автор
        07.06.2022 04:07
        +2

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

        А если на проекте всё постоянно меняется, то при наличии этих абстракций, количество мест где надо что-то где-то поправить только увеличится.

        Ровно наоборот и происходит)


        1. Pavel1114
          07.06.2022 07:00

          за условную цифру в 100 тестов

          Я бы увеличил эту условную цифру хотя бы до 1000. Иначе, как правильно заметили выше, все эти абстракции над абстракциями будут только мешать ещё не стабилизировавшемуся приложению. А скрывать requests, из за страха его будущей замены, я бы не стал ещё дольше — бессмысленная трата ресурсов.
          Ровно наоборот и происходит)

          Ровно наоборот происходит только при удачно выбранных абстракциях. Но на такое способен только очень скиллованный специалист. Но тогда непонятно на кого рассчитана ваша статья, потому как тут же вы поясняете что такое Faker и как сложно настроить логгирование в python/


          1. Litovsky83 Автор
            07.06.2022 07:16

            Иначе, как правильно заметили выше, все эти абстракции над абстракциями будут только мешать ещё не стабилизировавшемуся 

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

             А скрывать requests, из за страха его будущей замены, я бы не стал ещё дольше — бессмысленная трата ресурсов. 

            requests скрывается не из за "страха его будущей замены", условная будущая замена это пример, для чего мы это делаем, а не "молча выделим новую абстракцию". Вынести запросы в отдельный файл (кстати, почему это бессмысленная трата ресурсов?) это инверсия зависимостей.


            1. Pavel1114
              07.06.2022 07:31
              +1

              код в лоб практически сразу становится сложно поддерживать

              Такие абстракции каждый пишет по своему и каждый раз это как новый фреймворк(может быть хороший), который надо изучить, но по которому нет документации
              условная будущая замена это пример, для чего мы это делаем, а не «молча выделим новую абстракцию»

              Так requests надо скрывать или нет? И зачем?


              1. Litovsky83 Автор
                07.06.2022 07:46

                Такие абстракции каждый пишет по своему и каждый раз это как новый фреймворк(может быть хороший), который надо изучить, но по которому нет документации

                Из моей практики происходит наоборот. За счет абстракций код хорошо читается и новый человек в команда достаточно быстро начинает писать тесты, смотря на верхнеуровневые примеры, постепенно погружаясь ниже.
                П.C. документация у нас есть и она обязательна)

                Так requests надо скрывать или нет? И зачем?

                Задам встречный вопрос - для чего в PageObject используют base_page? Какую проблему это решает? Или можно в пейджах оставить методы селениума/плейврайта?


                1. Pavel1114
                  07.06.2022 08:05
                  +1

                  Из моей практики происходит наоборот. За счет абстракций код хорошо читается и новый человек в команда достаточно быстро начинает писать тесты

                  Всё равно не пойму зачем выносить стабильный и всем понятный requests куда то и заменять его каким то custom_client.
                  По итоговому тесту есть и другие замечания. Во первых именования — уже отмеченный response.response и почему body получается из объекта RegisterUser — магия сплошная, которой новый человек ещё должен будет овладеть. Ну и проверка message на мой взгяд лишняя — лучше http код проверять.


                  1. Litovsky83 Автор
                    07.06.2022 08:21

                    Всё равно не пойму зачем выносить стабильный и всем понятный requests куда то и заменять его каким то custom_client

                    На самом деле можно и не выносить и оставить как есть. И во многих примерах/документациях так. Но если по правильному - то выносить. Опять же сошлюсь на PO, так как он более менее знаком большинству - base_page для чего он? Ведь есть понятный и стабильный selenium. И когда вы ответите на этот вопрос, то будет понятен финт с requests.

                    Во первых именования — уже отмеченный response.response

                    Да, соглашусь, можно было более четче разделить именование, что бы не было путаницы

                    и почему body получается из объекта RegisterUser — магия сплошная

                    Не совсем понял ваш вопрос, класс RegisterUser отвечает за рандом данных для отправки регистрации пользователя. Да, его можно и нужно переписать и улучшить, но статья не про это.

                    Ну и проверка message на мой взгяд лишняя — лучше http код проверять.

                    Тут с вами сильно не соглашусь, проверка респонса обязательна


                    1. Pavel1114
                      07.06.2022 08:33

                      Тут с вами сильно не соглашусь, проверка респонса обязательна

                      вы message на строгое соответствие проверяете. Например в мультиязычном приложении, такие сообщения иногда выносятся в отдельные файлы, доступные для правки не только программистами. Не думаю что тесты должны падать, только из за того что переводчик решил изменить формулировку сообщения.
                      У вас там на selenium в статье ссылка битая. И отвечать вопросом на вопрос не только не очень вежливо, но наталкивает на мысль о том «знаете ли вы ответ». Пока что я понял что так нужно делать потому что нужно


                      1. Litovsky83 Автор
                        07.06.2022 08:42

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

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

                        А проверять нужно и падать обязательно, так как одно дело, когда разработчик поменяет текстовику, а другое, когда вместо текста у вас пролезет стектрейс Java/scala/js и вы сможете отловить ошибку заранее.


                1. funca
                  07.06.2022 08:53
                  +1

                  для чего в PageObject используют base_page?

                  https://wiki.c2.com/?GodClass

                  Какую проблему это решает?

                  Предположу, что даёт возможность команде AQA поиграть в программистов-архитекторов, а за одно помогает раздувать сроки, штат и бюджет на ровном месте.

                  Как тиражировать такое решение на разные команды/проекты, неужели вы предлагаете каждой писать свои обёртки поверх requests?


                  1. Litovsky83 Автор
                    07.06.2022 09:11
                    +1

                    Я не думаю, что 10 строчек кода раздует проект )

                    Разработчики, при написании кода , используют паттерны, подходы, mvc и так далее. И делают они это не для того, что бы раздуть сроки, а для читаемого, гибкого кода и возможности быстро и безболезненно делать рефакторинг (да, это не гарантия, но всё же). Чем aqa “хуже”? Почему автотестер должен писать код в лоб, не думаю наперёд ?

                    Ничем не хуже, это такой же разработчик. Правильный построенный код (фронт, бэк, тесты) упрощает поддержку кода. Обычно команды тестирования, которые не могут это сделать/не хватает опыта и пишут «как есть», приводят проект к раздуванию и удорожанию (так как такие тесты легче выкинуть, чем поддерживать).


                    1. dopusteam
                      07.06.2022 10:03
                      +1

                      Проблема в том, что нужно писать тесты на тесты тогда. Чем больше наворочено в тестах, тем сложнее их читать и тем больше возникает нюансов при из написании/поддержке.

                      Ну т.е. это достаточно холиварная тема.


                      1. Litovsky83 Автор
                        07.06.2022 10:20

                        ну... если уйти от стереотипов, то aqa это разработчик. Разработчик обязан тестировать свой код. Если необходимо, то автотестер пишет "тесты на тесты" (на функции), просто это не так часто встречается


  1. ivansibaha
    07.06.2022 02:02

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


    1. Litovsky83 Автор
      07.06.2022 04:14
      +1

      По поводу переиспользования тестов с помощью фейкера - ну частично решает проблему, но гарантий уникальности не даёт. 

      Если нужна уникальность, то обычно делают так

      def random_email() -> str:
          """
          Get random email
          """
          timestamp = pendulum.now().int_timestamp
          return f"{timestamp}_{fake.email()}"

      А для переиспользования тестов существуют фикстуры, которые сначала создают для теста нужные сущности а после себя чистят. После теста должен остаться только артефакт - отчёт или лог. А у вас база данных будет в куче пользователей.

      В самом начале я написал, что пишем без фикстур) Так как это бы только бы отвлекало.

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


  1. ivansibaha
    07.06.2022 02:29
    +2

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

    Тестовый класс и его метод без докстринги ( то что там урл написан ясности что в нём проверяется не даёт). Чтобы понять, что делает тест, надо читать код, лезть под капот, тратить время.

    Инициализация клиента внутри тестового класса - не знаю, может и есть в этом что-то, но что если мы напишем другой клиент? Или у нас есть экземпляры клиента для разных случаев? И зачем вообще каждому тестовому классу свой клиент?

    Файл с клиентом requests.py - зачем эта путаница? ну пусть бы был custom_client или ещё какой, но как основную либу для запросов то зачем называть?

    Тестовая функция не принимает аргументов, значит нельзя её параметризировать, значит отпадает здоровый кусок функционала, который нам предоставляет PyTest. Если мы захотим сделать отрицательный тест, нам надо будет либо копипастить тестовую функцию, либо выносить параметры, чтобы их можно было перебирать через pytest.mark.parametrize

    Когда дело дойдёт до запуска, эти тесты надо будет либо списком вызывать, либо по файлам распихивать, что одно что другое неудобно. Поэтому существую маркеры.

     Так как у нас есть положительные и негативные тесты ...

    Не увидел отрицательных тестов

    Знаю, что мои замечания больше про большой проект, но всё же когда показываешь пример, надо или говорить что это автотест на один эндпоинт или уже показывать как масштабировать. Этот код не подходит для серьёзного проекта ( хотя бы потому что не используется питест)

    Хоть автор и пишет, что он абстрагируется, толку от выноса импорта особо нет, поменять импорт при переходе на другую библиотеку - самое малое, что нужно будет сделать. Хочешь сделать на асинхронной либе - ну сделай сразу (хотя зачем, если есть xdist - непонятно).

    Сразу извиняюсь если слишком токсично, у меня нет 17 лет опыта автоматизации, но если бы статья называлась "как улучшить плохой автотест" или вроде того, не было бы замечаний, а так не удержался.


    1. Litovsky83 Автор
      07.06.2022 04:43

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

      Основная цель статьи это вопрос архитектуры

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

      Да, лучше дописывать строку, что бы коллеге было больше информации, если упадем, то мы в любом случае словим "питоновские ошибки", но с пайтестам нам подсветят дифф

      E       AssertionError: assert 'User created successfully.' == 'User created successfully.1'
      E         - User created successfully.1
      E         ?                           -
      E         + User created successfully.

      Инициализация клиента внутри тестового класса - не знаю, может и есть в этом что-то, но что если мы напишем другой клиент? Или у нас есть экземпляры клиента для разных случаев? И зачем вообще каждому тестовому классу свой клиент?

      Где инициализировать клиента это второй вопрос, это не было целью статьи

      Тестовый класс и его метод без докстринги ( то что там урл написан ясности что в нём проверяется не даёт). Чтобы понять, что делает тест, надо читать код, лезть под капот, тратить время.

      Можно сделать так

          def test_registration(self):
              """
              Steps
              1. Try to register new user
              2. Check, that status code is 201
              3. Check response
              """
              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')

      В статье рассматривался один тест, сценарий его оговаривался, поэтому код был дан без докстрингов, но в следующий раз учту, спасибо

      Тестовая функция не принимает аргументов, значит нельзя её параметризировать, значит отпадает здоровый кусок функционала, который нам предоставляет PyTest. Если мы захотим сделать отрицательный тест, нам надо будет либо копипастить тестовую функцию, либо выносить параметры, чтобы их можно было перебирать через pytest.mark.parametrize

      не совем понял вашего замечания. Параметризация в pytest работает не так, как вы описали, аргументы добавляются после использования декоратора @pytest.mark.parametrize(...)

      Когда дело дойдёт до запуска, эти тесты надо будет либо списком вызывать, либо по файлам распихивать, что одно что другое неудобно. Поэтому существую маркеры.

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

      Знаю, что мои замечания больше про большой проект, но всё же когда показываешь пример, надо или говорить что это автотест на один эндпоинт или уже показывать как масштабировать. Этот код не подходит для серьёзного проекта ( хотя бы потому что не используется питест)

      Повторюсь, что эта статья не про pytest, pytest здесь использован только как ранер

      Хоть автор и пишет, что он абстрагируется, толку от выноса импорта особо нет, поменять импорт при переходе на другую библиотеку - самое малое, что нужно будет сделать. Хочешь сделать на асинхронной либе - ну сделай сразу (хотя зачем, если есть xdist - непонятно).

      xdist это не решение проблемы. Все зависит от проекта, но при "тысячах" тестах впихнуть xdist та еще задача.
      Попробуйте мыслить категориями "у меня здесь 10 000 тестов и как мне с этим жить" )


      1. gigimon
        07.06.2022 11:14

        А что не так с xdist? Я делал запуск xdist на сьюте из 5к API тестов, из которых 2-3к генерировались динамически при старте сьюта (тесты на различные версии апи). Надо учесть пару мелочей загрузки тестов и все.


        1. Litovsky83 Автор
          07.06.2022 11:24

          Как и написал выше, есть зависимость от проекта. На своем проекте мне пришлось выключить его, придётся переписывать фикстуры, изменился бэк. В соседней репе все работает прекрасно.


  1. Pavel1114
    07.06.2022 06:42
    +3

    Что изменилось? Код стал более читаемый

    3 раза пришлось возвращаться чтобы понять какой Register за что отвечает


  1. gigimon
    07.06.2022 11:11

    Было очень обнадеживающее вступление, но прочитал и расстроился. Первоначальный тест читаемее в 10 раз итогового варианта.

    1. У вас получился какой-то java подход с какими-то не нужными абстракциями (фабриками)

    2. Зачем выделять в отдельную сущность регистрацию, если в 99% случаев мы напишем на этот кусочек кода 5 тестов и никогда больше к нему не вернемся? (в API тестах, как по мне, page object подход не работает абсолютно, он только создает кучу ненужного кода, раскиданого по всему проекту)

    3. Зачем делать custom_request, но при этом все равно явно передавать ему url? У вас URL сервера не меняется на протяжении тестов, передайте его при инициализации.

    4. Зачем каждой сущности типа Register, создавать своего клиента? http клиент должен быть 1 на весь прогон тестов (также, как и коннекты к базе и т.п.)

    5. Зачем вообще нужен custom_request и свой клиент, если они под собой используют все равно requests? Если хочется свои обработки, то лучше у requests кастомную сессию определить

    6. Зачем делать свою ResponseModel, если она не модель и повторяет ответы от requests? Если назвали моделью, то хоть минимальную валидацию данных в ней реализуйте

    7. Вы упомянули swagger, но никак его не используете, зачем упомянули? По хорошему, на основе схемы надо реализовывать валидацию структуры и типов данных


    1. Litovsky83 Автор
      07.06.2022 11:44

      Первоначальный тест читаемее в 10 раз итогового варианта.

      У вас получился какой-то java подход с какими-то не нужными абстракциями (фабриками)

      Как я писал выше, попробуйте мыслить не 1 тест, а "у нас тут много тестов и это все надо поддерживать"

      Зачем выделять в отдельную сущность регистрацию, если в 99% случаев мы напишем на этот кусочек кода 5 тестов и никогда больше к нему не вернемся? (в API тестах, как по мне, page object подход не работает абсолютно, он только создает кучу ненужного кода, раскиданого по всему проекту)

      А как вы предлагаете решить проблему? Писать в лоб? Где та тонкая грань, когда тестов "много" (больше 5?) и стоит писать нужный код и раскидывать по всему проекту

      Зачем делать custom_request, но при этом все равно явно передавать ему url? У вас URL сервера не меняется на протяжении тестов, передайте его при инициализации.

      Так как в будущем вы будете использовать эти тесты в CI и вам надо будет прокидывать url через командную строку и/или переменную. Поэтому хардкодить не вариант

      Зачем каждой сущности типа Register, создавать своего клиента? http клиент должен быть 1 на весь прогон тестов (также, как и коннекты к базе и т.п.)

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

      Здесь реализация через фикстуру и "должен быть 1 на весь прогон тестов", как пример

      Зачем вообще нужен custom_request и свой клиент, если они под собой используют все равно requests? Если хочется свои обработки, то лучше у requests кастомную сессию определить

      инверсия зависимости

      Зачем делать свою ResponseModel, если она не модель и повторяет ответы от requests? Если назвали моделью, то хоть минимальную валидацию данных в ней реализуйте

      Эта модель не повторяет requests, эта наше собственная модель. В тестах мы независим от нижней реализации - requests. Где делать валидацию это вопрос для дальнейшего обсуждения, к сожалению, мне не хватит бумаги что бы за один заход рассказать как писать api тесты

      Вы упомянули swagger, но никак его не используете, зачем упомянули? По хорошему, на основе схемы надо реализовывать валидацию структуры и типов данных

      Используем, сваггер это "книга данных", к в которую автотестор постоянно заглядывает и смотрит. В идеальной компании такой подход, когда мы доверимся полностью сваггеру и будем на основе его писать модели, сработал бы. Но я никогда не встречал "идеальный" сваггер, который 100% отражает действительность. Сваггер для меня это внешняя зависимость, а такие зависимости стараются убирать.


      1. gigimon
        07.06.2022 11:58

        Как я писал выше, попробуйте мыслить не 1 тест, а "у нас тут много тестов и это все надо поддерживать"

        Я выше писал, поддерживал ~5к апи тестов с различными версиями. Сначала тоже делал абстракции на абстракцию, потом все сильно упрощал, т.к. чем больше кода, тем сложнее стало в нем разбираться и метод "в лоб", сильно упростил написание и сопровождение тестов.

        А как вы предлагаете решить проблему? Писать в лоб? Где та тонкая грань, когда тестов "много" (больше 5?) и стоит писать нужный код и раскидывать по всему проекту

        Вы сами писали в комментариях, что разделяете тесты по логическим папкам. Зачем для этого тащить регистрацию куда-то дальше модуля проверяющего регистрацию (или всю логическую сущность в виду системы аутентификации)? Определение много/мало это лежит на коллективе, разрабатывающего эти тесты.

        Так как в будущем вы будете использовать эти тесты в CI и вам надо будет прокидывать url через командную строку и/или переменную. Поэтому хардкодить не вариант

        Предлагалось, как вы ниже и написали, использовать задавание урла в 1 месте. В вашем же примере данной статьи, вы показываете о задании в каждом тесте/инициализации клиента.

        инверсия зависимости

        Чтобы что? Боитесь, что requests надо будет заменять?

        Эта модель не повторяет requests, эта наше собственная модель. В тестах мы независим от нижней реализации - requests. Где делать валидацию это вопрос для дальнейшего обсуждения, к сожалению, мне не хватит бумаги что бы за один заход рассказать как писать api тесты

        Видимо не хватило этой статьи, чтобы объяснить для чего это, пока что, как и предыдущий вопрос, видится, что это для будущей замены requests.

        Используем, сваггер это "книга данных", к в которую автотестор постоянно заглядывает и смотрит. В идеальной компании такой подход, когда мы доверимся полностью сваггеру и будем на основе его писать модели, сработал бы. Но я никогда не встречал "идеальный" сваггер, который 100% отражает действительность. Сваггер для меня это внешняя зависимость, а такие зависимости стараются убирать.

        Но вы ведь его повторяете в своих тестах (в репозитории) описывая свои модели и у вас появляется теперь 2 вещи, где описана структура ответов от API и в случае проблемы, какой из реализаций доверять больше?

        Я посмотрел ваш репозиторий, который вы указали, и там часть вопросов отпадает, реализовано (на мой взгляд) лучше.


        1. Litovsky83 Автор
          07.06.2022 12:16

          Я выше писал, поддерживал ~5к апи тестов с различными версиями. Сначала тоже делал абстракции на абстракцию, потом все сильно упрощал, т.к. чем больше кода, тем сложнее стало в нем разбираться и метод "в лоб", сильно упростил написание и сопровождение тестов.

          Мне трудно представить, что бы тот же фронт, например, писали в лоб, без использования MVC (не берем в расчет библиотеки) - на выходе будет неподдерживаемая каша. Но у вас в фирме приняли такое решение, это работает, ок)

          Предлагалось, как вы ниже и написали, использовать задавание урла в 1 месте. В вашем же примере данной статьи, вы показываете о задании в каждом тесте/инициализации клиента.

          Статья не про "куда нам деть url" ) Код в статье можно и нужно улучшать, но я бы начал с архитектуры, а потом думал об  url

          Чтобы что? Боитесь, что requests надо будет заменять?

          Я не хочу показаться грубым или надменным и заранее извиняюсь, но когда я писал эту статью, мне с одной стороны казалось, что я буду писать про очевидные вещи, даже несмотря на то, сколько кода я видел в проде автотестеров. Но нет, я получил хороший, полезный и интересный отклик)

          Но вы ведь его повторяете в своих тестах (в репозитории) описывая свои модели и у вас появляется теперь 2 вещи, где описана структура ответов от API и в случае проблемы, какой из реализаций доверять больше?

          Например, в сваггере могут не описать 400 ошибки (и их модели) или копипастнуть. Если бы мой код лежал в одной репе с бэком(на питоне), то я просто бы забрал модели и переиспользовал бы их.