Как написать тысячу автотестов за пару дней

Kate

Administrator
Команда форума

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

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

Горох может иметь цвет (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 тысяч тестов. Не представляю, как бы я это все покрывал без такой генерации.

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

 
Сверху