Search
Write a publication
Pull to refresh

Анатомия проекта по автоматизации на Python: как не запутаться в тестах

Level of difficultyEasy
Reading time9 min
Views5.5K

В этой статье постараемся разобраться, как удобно и гибко структурировать проект по автоматизации тестирования API с использованием Pytest + Request.

Привет, Хабр!
Меня зовут Кирилл, я QA-инженер, который решил пойти по пути автоматизации тестирования. Немного лирики. Наверное, в жизни каждого "ручного" QA рано или поздно наступает момент, когда появляется мысль: "Пора расти и двигаться дальше. Но куда?". Я не стал исключением и выбирая между Perfomance Testing (в просторечии "нагрузочное тестирование") и Automation Testing, пришел к выводу, что мне интереснее второе. С направлением определились, теперь нужен инструмент. Упуская лишние подробности скажу, что выбор пал на ЯП Python. Просто потому что больше по душе, "по-кайфу" как говорится. Дальше просмотры видекурсов, гайдов, литературы - вобщем все как у всех, наверное. Однако все мы знаем, что настоящий и самый ценный опыт приходит на практике. Я начал писать тесты. Много тестов. Когда в консоли IDE появляются первые зелененькие "PASSED" ощущения невероятные: "Я крут! Пойду закину резюме в Google" :)


Сначала все шло хорошо, но потом, с ростом количества тестов и, соответственно, количеством написанного кода, появились проблемы:
- Запутанность. Становилось все сложнее ориентироваться в тестах
- Хрупкость. Выросла сложность поддержания работоспособности. Стоило разработчиками поменять в эндпоинте v1 на v2, приходилось бегать по всем тестам и исправлять везде, где нашел.
- Смешанная ответственность. Логика отправки запросов была переплетена с ассертами (проверками). Тесты становились трудночитаемы.

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

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

Шаг № 1. Выбор инструментов

Тут все достаточно тривиально. Мы с вами будем использовать проверенный временем и тысячами автоматизаторов инструментарий:

- pytest. Самый популярный, мощный и гибкий фреймворк для автоматизации тестирования. Удобная система фикстур, простой синтаксис - вобщем, то, что нам нужно.

- requests. Стандартная библиотека для выполнения HTTP - запросов.

- python - dotenv. Эта библиотека нам понадобится для того, чтобы хранить переменные окружения (базовые URL'лы, логины, пароли) отдельно от кода, который будет попадать в репозиторий GIT.

Создадим файл requirements.txt в корне нашего проекта со следующим содержимым:

pytest
requests
python-dotenv

Отлично! Файл добавлен, теперь мы можем установить все это одной командой:

pip install -r requirements.txt

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

Шаг № 2. Создание "скелета"

Базовая структура проекта по автоматизации API
Базовая структура проекта по автоматизации API

Для простоты восприятия, я с нуля набросал этот проект, исключая усложненную логику. Постараюсь кратко изложить, что тут у нас есть.

- Директория api_framework. Сердце нашего фреймворка. Здесь мы будем описывать, как взаимодействовать с различными частями нашего API. Каждая сущность (регистрация нового пользователя, авторизация) получит свой класс. Файлы __init__.py, которые вы видите на скриншоте в каждой директории, чисто технические. Они пустые и нужны для того, чтобы Python понимал, что эта директория - пакет, из которого можно импортить модули.
--- base_api.py. Это "фундамент" для всех наших API - клиентов. Здесь будет содержаться общая логика для отпрвки любых запросов (GET, POST, PUT и т.д.), обработка базового URL и заголовков.
--- login_api.py и registration_api.py - это конкретные сущности (классы - клиенты), которые будут наследоваться от base_api и содержать методы для работы с определенными эндпоинтами (из названия, думаю, понятно какими именно).
- Директория helpers. Это директория для различных вспомогательных элементов, которые также имеют отношение к нашим тестам, но для красоты и чистоты самого файла с тестами мы поместим эти сущности сюда.
--- assertions.py. Этот файл содержит ассерты (проверки) результатов наших тестов: проверка того, что вернулся код 200, проверка того, что в теле ответа есть то, что мы ожидаем и т.д. Собственно, ассерты - это элемент, без которого наши тесты по сути бесполезны.
- Директория tests. Наконец та самая директория, где мы будем хранить и откуда будут непосредственно запускаться наши тесты.
--- conftest.py. Категорически необходимый файлик. Все, что мы определим в этом файле (фикстуры), будет доступно во всех тестах. Именно тут удобнее всего создавать объекты API - клиентов, которые будут "подтягиваться" тестами, именно сюда удобнее всего подгружать переменные окружения.
--- test_login.py и test_registration.py - непосредственно файлы с тестами. Тут, думаю, добавить нечего.
- .env. Файл для хранения секретных данных (логины, пароли, URL'ы). ВАЖНО! Никогда не отправляйте его в GIT, друзья! Запомните, этот файл только для вас и мы не хотим, чтобы посторонние узнали ваши креды. Именно поэтому, сразу после создания данного файла добавьте его в .gitignore (о нем чуть ниже). Тогда ваши "секретные коды " не утекут в удаленный репозиторий.
- .gitignore. Стандартный файл для GIT. Помогает игнорировать определенные файлы при загрузке проекта в удаленный репозиторий (такие как .env, pycache и т.д).
- pytest.ini. Конфигурационный файл для pytest. Здесь можно прописать различные маркеры (позволяют помечать определенные тесты как вам удобно, чтобы впоследствии более наглядно и красиво все отображалось в отчете), пути к тестам и т.д.

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

Шаг № 3. Наполняем "скелет" жизнью (начинаем шКОДИТЬ)
Как вы уже, наверное, поняли - в нашем примере будет два эндпоинта для проверки: регистрация и логин. Что нам для этого нужно обязательно? Конечно, тестовые данные: имя, почта и пароль. Это как раз те самые "секретные" креды, которыми мы с посторонними делиться не будем. Поэтому идем в файл .env и пишем там:

BASE_URL=http://examplehost:5000/api/v1
TEST_NAME=TestUser
TEST_EMAIL=testemail@mail.com
TEST_PASSWORD=Password123

Далее зададим маркеры в pytest.ini:

[pytest]
markers =
    login: login testing                # таким маркером пометим тесты для входа в систему
    registration: registration testing  # таким - для регистрации нового пользователя

Переходим к нашему "фундаменту" - api_framework/base_api.py. В нем определим шаблон для отправки любого HTTP - запроса, создание requests.Session (полезно для cookies и заголовков), логику обработки базового URL и конкретных эндпоинтов. Другими словами, этот класс будет отвечать на вопрос "Как отправлять?", а на вопрос "Что отправлять?" будет отвечать другой класс (Принцип единственной ответственности):

import requests


class BaseApi():
    def __init__(self, base_url):
        self.base_url = base_url
        self.session = requests.session()

    def _send_request(self, method, endpoint, **kwargs):
        url = f'{self.base_url}{endpoint}'
        try:
            response = self.session.request(method, url, **kwargs)
            response.raise_for_status()
            return response
        except requests.exceptions.HTTPError as e:
            print(f'Http error: {e.response.status_code} - {e.response.text}')
            raise
            

    def get_request(self, endpoint, **kwargs):
        return self._send_request('GET', endpoint, **kwargs)
      

    def post_request(self, endpoint, **kwargs):
        return self._send_request('POST', endpoint, **kwargs)
      

    def put_request(self, endpoint, **kwargs):
        return self._send_request('PUT', endpoint, **kwargs)

Кратко о том, что мы сделали.
1. class BaseApi(). Объявляем класс, который принимает в качестве аргумента переменную base_url и при создании объекта создает сессию для тестирования.
2. def _send_request(). Создаем своего рода универсальный шаблон для отправки любого запроса. На вход принимает любой метод (GET, POST, PUT и т.д.), специализированный эндпоинт и другие аргументы. Берет базовый URL (http://examplehost:5000/api/v1) и добавляет к нему входящий эндпоинт (к примеру эндпоинт регистрации /tasks/rest/doregister). В итоге получаем конечную "ручку" для регистрации: http://examplehost:5000/api/v1tasks/rest/doregister. После чего, формирует и отправляет запрос в рамках сессии, попутно обрабатывая exception, если вдруг что-то пошло не так. Если все ок и ошибок при выполнении запроса не возникло, возвращает ответ.
3. def get_request, def post_request, def put_request - по сути своей просто обертки для конкретных методов запроса (GET, POST, PUT). Далее, когда они будут вызываться в тестах, они с уже "вшитым" в них конкретным методом идут к _send_request() и там уже выполняется основная логика.
Таким образом, мы подготовили фундамент для всего нашего фреймворка. По сути, мы можем отправить любой нужный нам запрос, не прописывая в самих тестах весь стандартный синтаксис, а просто вызвав нужный нам метод из этого класса. Тесты будут чище и легче к восприятию, поверьте:)

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

from api_framework.base_api import BaseApi

class RegistrationApi(BaseApi):
    REG_ENDPOINT = '/tasks/rest/doregister'

    def register(self, email, name, password):
        payload = {"email" : email, "name" : name, "password" : password}
        response = self.post_request(self.REG_ENDPOINT, json = payload)
        return response

В кратце о том, что мы накодили в этом классе:
1. from api_framework.base_api import BaseApi. Импортируем в текущий класс наш фундамент для последующего его использования.
2. class RegistrationApi(BaseApi). Наследуемся от нашего родительского класса - фундамента, чтобы иметь доступ к его конструктору и его функциям. Объявляем конкретный специфичный эндпоинт для регистарции пользователя. Именно этот эндпоинт будет передан при вызове POST запроса в тестах на регистрацию сначала в def post_request, а оттуда в _send_request().
3. def register(). Функция, которая содержит в себе логику исключительно о регистрации нового пользователя. Внутри нее формируется тело запроса и вызывается тот самый post_request из родительского класса с передачей ему эндпоинта и тела запроса. Возвращает ответ.

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

Итак, половина работы уже выполнена. Планомерно двигаемся далее. На очереди файл conftest.py. Здесь мы создадим фикстуры, которые будут использоваться непосредственно нашими тестами.

import pytest
import os
from dotenv import load_dotenv
from api_framework.login_api import LoginApi
from api_framework.registration_api import RegistrationApi

load_dotenv()

@pytest.fixture(scope="session")
def get_base_url():
    return os.getenv('BASE_URL')

@pytest.fixture()
def get_data_for_registration(get_base_url):
    return RegistrationApi(get_base_url)

@pytest.fixture()
def get_data_for_login(get_base_url):
    return LoginApi(get_base_url)

Здесь мы снова импортируем все необходимые для работы модули, включая специализированные классы - клиенты: LoginApi и RegistrationApi. Сюда же мы подгрузим переменные окружения, которые мы прописали в файле .env, с помощью одной простой команды: load_dotenv(). Теперь нам доступна информация из .env. Далее напишем ряд фикстур:
1. Для получения BASE_URL из переменных
2. Для получения готового объекта RegistrationApi
3. Для получения готового объекта LoginApi

На этом в данном примере с файлом conftest.py покончено) Переходим к самим тестам. Для примера покажу здесь тест для кейса "успешной" регистрации.

import os
import pytest
from helpers.assertions import assert_status_code, assert_json_has_key

@pytest.mark.registration
def test_successful_registration(get_data_for_registration):
    email = os.getenv('TEST_EMAIL')
    name = os.getenv('TEST_NAME')
    password = os.getenv('TEST_PASSWORD')

    reg_response = get_data_for_registration.register(email, name, password)
    assert_status_code(reg_response, 200)
    assert_json_has_key(reg_response, 'name')

И снова импорт необходимых модулей: os - для получения тестовых данных из "секретных" данных (из файла .env), pytest - в данном случае для использования маркировок (указанных нами в pytest.ini), helpers - для ассертов. Но о них чуть позднее.
Итак, что тут у нас:
1. Маркируем тест
2. Объявляем тестовую функцию
3. Подтягиваем тестовые данные
4. Отправляем запрос (вся логика которого выполняется в соответствующих классах: RegistrationApi и BaseApi)
5. Делаем ассерты.

Таким образом:
- наш тест выглядит весьма лаконично
- глазам не мешает "ёлочка", которая была бы, расписывая мы всю логику в самом тесте
- мы придерживаемся базового принципа ООП - принципа единственной ответственности (Single Responsibility Principle), так как каждый класс отвечает только за частичку конкретной, возложенной на него логики.

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

Итак, заключительный аккорд. Файл assertions.py

from requests import Response

def assert_status_code(response: Response, expected_code: int):
    actual_code = response.status_code
    assert actual_code == expected_code, \
    f'Ожидался статус код "{expected_code}", но получен {actual_code}. Тело ответа {response.text}'

def assert_json_has_key(response: Response, key: str):
    response_json = response.json()
    assert key in response_json, \
    f'Ключ "{key}" отсутствует в JSON - ответе'

Для примера тут как раз два ассерта: на статус код и на то, что в теле ответа есть определенный "ключ".

Резюмируя, соберем в кучку этот паравозик и посмотрим, как он работает:
1. Мы запускаем pytest, он находит наш тест (в данном случае на успешную регистрацию) и видит, что ему нужна фикстура get_data_for_registration
2. pytest идет за этой фикстурой в файл conftest.py и находит ее. Он видит, что для того, чтобы ее "выполнить", ему нужна другая фикстура - get_base_url. Он выполняет сначала get_base_url и получает оттуда базовый URL (http://examplehost:5000/api/v1).
3. Затем pytest возвращается с базовым URL к фикстуре get_data_for_registration.
4. Внутри get_data_for_registration создается и возвращается готовый объект RegistrationApi. Соответственно, наш тест на успешную регистрацию получает все ему необходимое - все нужные параметры и методы базового класса и класса - клиента RegistrationApi.
5. Тест выполняется и, поскольку мы вызвали два ассерта из модуля helpers.assertions, выполняет две проверки - на статус код и на искомый ключ в теле ответа.
6. Мы видим зелененькие PASSED в окне терминала и радуемся.

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

Tags:
Hubs:
+8
Comments6

Articles