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

Но, как я уже упоминал в одной из статей, есть у него один пробел по сравнению с pytest — это отсутствие адекватной параметризации тестов. Справедливости ради, у Robot Framework есть надстройка, которая позволяет генерировать тесты на основе внешней таблицы. Но это не совсем то, что нам подходит.

После прошлой статьи многие спрашивали меня, как именно делается параметрическая генерация автотестов. В этой статье отвечу на вопрос.


Проект-иллюстрация

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

  Горох может иметь цвет (Color) и тип (Kind):

from colors import Color
from kinds import Kind
 

class Peas:
 
    color: Color
    kind: Kind
 
    def __init__(self, color: Color, kind: Kind):
        self.color = color
        self.kind = kind
 
    def __str__(self):
        return f"{self.color.name().capitalize()} {self.kind.name()} горошек."

Не будем усложнять: класс переопределяет метод __str__() и выводит полную информацию о свойствах этого сорта гороха. Для иллюстрации этого будет вполне достаточно.

Добавим базовый класс «Цвет» (Color) и унаследуем от него основные — Зеленый и Желтый:

class Color:
    color_name: str = "неизвестный цвет"
 
    def name(self) -> str:
        return self.color_name
 
 
class Green(Color):
    color_name = "зелёный"
 
 
class Yellow(Color):
    color_name = "жёлтый"

Теперь опишем базовый тип и унаследуем от него основные — Гладкий и Сморщенный (он же — Мозговой):

class Kind:
    kind_name: str = "неизвестная форма"
 
    def name(self) -> str:
        return self.kind_name
 
 
class Smooth(Kind):
    kind_name = "гладкий"
 
 
class Brain(Kind):
    kind_name = "мозговой"

Базовый тест

Сфокусируемся на методе вывода информации о сорте. Чтобы покрыть все варианты, мы можем написать четыре простых теста:

from colors import Green, Yellow
from kinds import Smooth, Brain
from peas import Peas
 

def test_green_smooth_peas():
    peas = Peas(Green(), Smooth())
    assert str(peas) == "Зелёный гладкий горошек."
 
 
def test_yellow_smooth_peas():
    peas = Peas(Yellow(), Smooth())
    assert str(peas) == "Жёлтый гладкий горошек."
 
 
def test_green_brain_peas():
    peas = Peas(Green(), Brain())
    assert str(peas) == "Зелёный мозговой горошек."
 
 
def test_yellow_brain_peas():
    peas = Peas(Yellow(), Brain())
    assert str(peas) == "Жёлтый мозговой горошек."

Это все замечательно работает, но я живу по принципу: «Если пишешь что-то дважды — значит, делаешь что-то не так!»

Параметризация тестов

Вынесем цвет и тип в параметры и напишем генерацию параметров через умножение (product()) списков:

from itertools import product
from typing import Tuple
 
from pytest import mark
 
from colors import Color, Yellow, Green
from kinds import Kind, Smooth, Brain
from peas import Peas
 
colors = [(Yellow(), "Жёлтый"), (Green(), "Зелёный")]
kind = [(Smooth(), "гладкий"), (Brain(), "мозговой")]
peas_word = "горошек"
 
sets = list(product(colors, kind))
 

@mark.parametrize("color_info,kind_info", sets)
def test_peas_str(color_info: Tuple[Color, str], kind_info: Tuple[Kind, str]):
    color, color_str = color_info
    kind, kind_str = kind_info
    peas = Peas(color, kind)
    assert str(peas) == f"{color_str} {kind_str} {peas_word}."

Теперь мы ничего не дублируем и имеем те же четыре теста.

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

Добавим черный цвет:

class Black(Color):
    color_name = "чёрный"

Теперь, чтобы тестов стало шесть, достаточно просто изменить список цветов в модуле тестов:

colors = [(Yellow(), "Жёлтый"), (Green(), "Зелёный"), (Black(), "Чёрный")]

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

Названия кейсов

Возможно, первое, что бросилось в глаза при запуске таких тестов, — это не совсем приятное описание кейсов:

Не самое удобное описание кейсов.
Не самое удобное описание кейсов.

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

test_names = [
  f"{params[0][0].__class__.__qualname__} - {params[1][0].__class__.__qualname__}" 
  for params in sets
]

Теперь пропишем их имена в параметризацию:

@mark.parametrize("color_info,kind_info", sets, ids=test_names)

Совсем другое дело:

Теперь всё понятно.
Теперь всё понятно.

Расширенная генерация тестов

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

 Сейчас мы избавимся от кортежей в параметрах самого теста и вынесем строки в отдельные параметры.

 Есть в pytest зарезервированное название метода — pytest_generate_tests. Этот метод будет вызываться для каждого теста в модуле:

def pytest_generate_tests(metafunc):
    args = []
    names = []
    for color_info, kind_info in product(colors, kinds):
        color, color_str = color_info
        kind, kind_str = kind_info
        args.append([color, color_str, kind, kind_str])
        names.append(f"{color.__class__.__qualname__} - {kind.__class__.__qualname__}")
    metafunc.parametrize("color,color_str,kind,kind_str", args, ids=names)
 
 
def test_peas_str(color: Color, color_str: str, kind: Kind, kind_str: str):
    peas = Peas(color, kind)
    assert str(peas) == f"{color_str} {kind_str} {peas_word}."

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

Когда расширенная генерация тестов необходима

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

Откровенно говоря, это не самая правильная практика и не стоит перенимать такой опыт. Но это хороший повод показать, почему иногда подход «расширенной» генерации незаменим и не может быть выполнен обычным @mark.parametrize.

Если вы еще не сталкивались с тем, как сделать тест при падении серым вместо красного (это не валит билд, но отмечает проблему), то это очень просто. Нужно использовать @mark.xfail. При необходимости в качестве параметра reason можно передать идентификатор issue в вашем баг-трекере.

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

Если тесты упали -- билда не будет.
Если тесты упали -- билда не будет.

Нужно лишь добавить условие, по которому будет определяться, нужно ли сделать кейс серым. Изменим код генерации аргументов:

params = [color, color_str, kind, kind_str]
args.append(
  params if not isinstance(color, Purple) else pytest.param(*params, marks=pytest.mark.xfail(
    reason="Not implemented yet."))
)

Весь метод будет выглядеть так:

def pytest_generate_tests(metafunc):
    args = []
    names = []
    for color_info, kind_info in product(colors, kinds):
        color, color_str = color_info
        kind, kind_str = kind_info
        params = [color, color_str, kind, kind_str]
        args.append(params if not isinstance(color, Purple) else pytest.param(*params, marks=pytest.mark.xfail(
            reason="Not implemented yet.")))
        names.append(f"{color.__class__.__qualname__} - {kind.__class__.__qualname__}")
    metafunc.parametrize("color,color_str,kind,kind_str", args, ids=names)

А результаты выполнения будут отмечены серым и не будут валить билд в CI:

Теперь в отчёте видно какие тесты падают, но на сборку это не повлияет.
Теперь в отчёте видно какие тесты падают, но на сборку это не повлияет.

Когда еще необходима расширенная генерация тестов

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

Привычный всем подход @mark.parametrize("param", self.params) не будет работать, потому что на момент параметризации он еще не знает, что такое self. И тут мы снова возвращаемся к pytest_generate_tests, добавив его в класс:

def pytest_generate_tests(self, metafunc):
    metafunc.parametrize("param", self.params)

Такой подход уже будет работать.

И еще небольшая хитрость. Если вам нужно выполнить генерацию только для конкретного теста или по-разному организовать генерацию для разных тестов внутри pytest_generate_tests (напомню, он выполняется для каждого метода, начинающегося с test_ отдельно), можно обратиться к его параметру metafunc и получить свойство metafunc.function.__name__ — там и будет имя теста. Например,

class TestExample:
    params: List[str] = [“I”, “like”, “python”]
 
    def pytest_generate_tests(self, metafunc):
        if metafunc.function.__name__ == “test_for_generate”:
            metafunc.parametrize("param", self.params)
    
    def test_for_generate(self, param: str):
        print(param)
 
    def test_not_for_generate(self, param: str):
        print(param)

...сгенерирует тесты для test_for_generate, но не сделает этого для test_not_for_generate.

Заключение

На моем текущем проекте есть сервис, который сочетает разные фильтры и сегменты, каждый из которых является отдельным классом, и использование pair-wise может дать размытый результат. Вследствие чего не будет понятно, в каком именно классе ошибка. Генерируя тесты, я могу добиться 100%-го покрытия тестами, которые будут давать четкие результаты.

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

 Всем зеленых тестов и стопроцентного покрытия!

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


  1. amarao
    24.11.2021 15:35

    Почему вы их называете "автотесты"? Звучит как "самодвижущийся мотоцикл".


    1. bp3 Автор
      24.11.2021 15:36

      Тесты вполне себе бывают и ручными...


      1. amarao
        24.11.2021 15:55

        Эм... Не более, чем закатывание мотоцикла вручную. Теоретически можно, на практике никто так не делает.

        Т.е. я реально не знаю ни одного продакшен метода ручного тестирования функций в коде. Для ряда языков программирования это вообще не возможно в силу отсутствия repl'а и довольно агрессивной компиляции после мономорфизации дженериков.


        1. bp3 Автор
          24.11.2021 15:59

          Такой пример тестируемого продукта, с вызовом методов, показался мне самым простым для иллюстрации.

          На практике, этот подход, лично я, применяю для тестирования backend, которое вполне может быть ручным (с применением каких-то приложений, конечно).


  1. Xop
    24.11.2021 16:46

    А property-based подход не пробовали? (тут бессовестная ссылка на мою же старую статью: https://habr.com/ru/post/434008/)