Как генерация тестовых данных вернула доверие к тестам
Если вы когда-нибудь сталкивались с автотестами, которые ломаются на ровном месте, не дают предсказуемых результатов или отнимают больше времени, чем ручное тестирование, — эта история для вас.
Наша команда столкнулась с похожей проблемой: тесты, которые должны были ускорять разработку, превращались в источник боли и хаоса. Мы больше не доверяли их результатам: красные прогоны стали «фоновым шумом», а зелёные — чем-то из области фантастики.
В этой статье я расскажу, как мы разбирались с нестабильностью, рассмотрев три разных подхода (быструю починку тестов, создание идеальной базы данных и генерацию тестовых данных), и выбрали тот, который позволил нам ускорить CI/CD и вернуть контроль над автотестами.
Контекст проекта
Команда ERM в 2ГИС отвечает за систему, в которой хранится юридическая, производственная, финансовая информация о всех договоренностях при продаже услуг. Это точка входа для менеджера по продажам.
Функционал практически полностью покрыт автотестами. Казалось бы, отличная история: тесты позволяют нам избегать ручного регресса и катить фичи по необходимости. Однако на практике оказалось, что тесты использовались далеко не так эффективно, как хотелось бы.
Проблема: тесты, в которые никто не верит
На первый взгляд, процесс работы с автотестами выглядел понятно и логично↓
Разработчик завершал задачу и передавал её на тестирование.
QA-инженер запускал автотесты.
После завершения он вручную валидировал результаты.
На основании анализа QA-инженер либо возвращал задачу на доработку, либо приступал к ручному тестированию.
Основная боль заключалась в том, что тесты не давали полной гарантии, что всё хорошо. Вот почему автопрогону никто не доверял:
1. Красный прогон стал обыденностью.
Автопрогон никогда не бывал зелёным. Даже на master-ветке тесты всегда завершались с ошибками. В итоге красный прогон стали восприниматься как норма, а не как сигнал о реальных проблемах.
2. Непредсказуемость прогона.
Каждый запуск автотестов давал разные результаты: вчера падало 40 тестов, сегодня — 42. Даже без изменений в коде тесты могли сломаться или починиться сами. Невозможно было определить влияние нового изменения.
3. Долгое время выполнения.
Прогон занимал больше часа, но даже после его завершения нужно было тратить время на ручную проверку. Это время не критично, если запускается ночной прогон, но если хочется сделать что-то современное, модное, то хотелось бы получать обратную связь быстро и без привлечения людей.
Вот, например, 1549 тестов запускались, но 320 тестов были проигнорированы (жёстко выключены из проверки), 50 тестов упали, из них 23 новых (что создает самый хаос). Время выполнения составило 1 час 23 минуты.
Но эти проблемы не существовали изолированно. Они приводили к организационным трудностям, которые ещё сильнее подрывали доверие к автотестам и замедляли весь процесс. Вот как это проявлялось:
Долгая обратная связь. Нестабильность тестов и сложность работы с их результатами приводили к тому, что QA-инженеры тратили много времени на анализ проблем. Если добавить к этому время, которое задача ждет до момента тестирования, то обратная связь по автотестам доходила до разработчика с огромной задержкой. Часто разработчик, получив результаты, уже был занят другой задачей и тратил время на переключение контекста. Если бы тесты были стабильными и предсказуемыми, разработчики могли бы запускать их сами и быстро получать обратную связь.
Зависимость от тестировщика. Только QA мог интерпретировать результаты прогона, так как разработчики не знали, тестовая ли это ошибка или реальный баг. Это создавало узкое место в процессе и замедляло разработку.
Человеческий фактор. Из-за высокой загруженности QA-инженеры могли пропустить запуск автотестов перед ручным тестированием, что увеличивало риск выпуска продукта с незамеченными ошибками.
Откуда растут проблемы
Мы начали разбираться, почему всё пошло не так.
Не хватало ресурсов QA. Нашей системе уже больше 10 лет. За это время над ней работало множество инженеров. Иногда были периоды нехватки ресурсов QA инженеров. Это привело к тому, что автотесты не создавались и/или их поддержка оставалась на минимальном уровне.
Тестовые данные брались из БД. Для наших автотестов использовались реальные данные из базы, что изначально казалось удобным решением. Но на деле это стало причиной множества проблем, так как такой подход не учитывал, что:
Тесты могли не соответствовать новым условиям сценария. Если в сценарии добавлялось новое условие (например, запретить создавать что-то для самозанятых), и задача была выпущена без изменений автотестов, то тесты начинали «мигать».
Тесты становились зависимы от случайности выбора объектов. Тест может быть успешным для объекта А и неуспешным для объекта Б, которые оба подходят по условиям выборки. Соответственно наши тесты то зелёные, то красные в зависимости от выборки.
Системная зависимость. Система включала множество связанных сущностей, где один объект зависел от другого: заказы, договоры, юридические лица и множество других таблиц. Малейшее изменение данных могло вызвать цепную реакцию падений.
И вот так у нас появилась цели
Мы хотели выйти из этой печальной ситуации. И поставили перед собой следующие цели.
Добиться стабильного зелёного прогона. Нужно было сделать так, чтобы зелёный прогон гарантировал отсутствие проблем в системе, а красный сигнализировал об ошибках.
Использовать тесты как шлюз качества. Это значит, что изменения не должны попадать в кодовую базу, пока тесты не пройдут.
Ускорить тесты, чтобы получать обратную связь быстрее и не создавать очереди на pull-requests.
Пути решения
Мы рассмотрели три подхода:
Быстрая починка тестов. «Давайте просто починим все быстренько, и у нас станет прогон зелёным, мы вернём доверие, и всё будет здорово».
Создание идеальной базы данных. А может быть нам создать БД, в которой не будет плохих данных, а будут только хорошие? Тогда мы будем проверять все наши базовые сценарии, не переживая, что подберется что-то не то.
Генерация тестовых данных. То есть генерировать данные для каждого тестового случая самим и радоваться жизни.
Теперь про каждый подход подробнее.
Подход 1: Быстрая починка тестов
Идея заключалась в том, чтобы быстро исправить 50 падающих тестов. На первый взгляд, казалось, что задача по силам: разобрать 12 тестов на каждого из четырёх тестировщиков. Однако реальность оказалась сложнее.
Я попытался схитрить и временно убрал их из прогона, чтобы получить «зеленый» результат. Однако тесты продолжали падать — сначала ещё 50, потом ещё, пока их не накопилось около 200. Стало ясно, что такой подход не работает: никто больше не позволит просто исключать тесты.
Дополнительно осложняло ситуацию то, что при большом количестве условий тесты начали падать по тайм-ауту (выборки из базы занимали больше 30 секунд). Увеличение времени выполнения тестов противоречило нашим целям. Мы поняли, что даже если решим одну проблему, столкнемся с другой.
Нужно было менять подход. Например, уменьшить объем данных в базе, чтобы тесты работали быстрее.
Подход 2: Идеальная база данных
Идеальная база данных — это, конечно, красивая идея:
Нет «плохих» данных.
Операции с базой выполняются быстро, особенно если объём данных небольшой.
Но на деле мы столкнулись со следующими сложностями:
Для работы с такой базой нужно хорошо разбираться в SQL и самой структуре данных. Нужно уметь выбирать именно те данные, которые важны, и правильно их помечать.
Удаление ненужных данных занимало огромное количество времени из-за зависимостей, а создание новых объектов было сложным.
Нельзя один раз создать дамп базы и использовать его долгое время. Данные нужно регулярно обновлять. Сценарии тестов меняются, это тоже требовало постоянного обновления идеальной БД.
Изменение тестов или добавление новых требует корректировки множества скриптов, что усложняло поддержку.
В итоге, даже с «идеальной» базой, вопросов возникло больше, чем ответов. Так как нам всё равно пришлось бы создавать новые объекты в скриптах, то почему бы не начать это делать в более привычной для нас среде — автотестах.
Подход 3: Генерация тестовых данных
Вместо выбора объекта из БД мы начали создавать данные заново для каждого теста.
Преимущества генерации данных:
Независимость тестов. Генератор позволяет создавать данные для каждого теста и тесты не будут влиять друг на друга.
Простота поддержки. Изменение моделей данных сразу поддерживается в генераторе, без необходимости вносить правки в других местах.
Предсказуемость. Если тест проходит сегодня, то его сбой в будущем укажет на баг в системе, инфраструктурные проблемы или устаревание.
Минус — объекты создаются не молниеносно, особенно если нужно построить цепочку зависимостей для полной изолированности. Иногда это занимает секунду или больше.
Раньше подготовка данных выглядела следующим образом:
public void Order_ChangeLegalPersonData_Habr()
{
var order = Steps.OrderReadModel.GetActiveOrderByState(OrderState.OnRegistration)
.Where(o =>
o.Bargain.Type == BargainType.Boiler
&& o.LegalPersonId != null
&& o.OrderType != OrderType.SpecialProject
&& o.Deal.LegalPersonDeals.Count(
lpd => lpd.LegalPerson.IsActive
&& lpd.LegalPerson.LegalPersonProfile
.Any(lpp => lpp.IsActive)) > 1)
.LastEntityOrDefault();
var newLegalPerson = Query.For(NewSpecs.Predicate(
lp => lp.IsActive
&& !lp.IsDeleted
&& lp.Id != order.LegalPersonId
&& lp.LegalPersonDeals.Any(lpd => lpd.Dealld == order.DealId)
&& lp.LegalPersonProfiles(lpp => lpp.IsActive)))
.FirstOrDefault();
var newProfile = Steps.LegalPersonReadModel.GetMainProfile(newLegalPerson);
...
}
Это тест на смену юридического лица. Мы выбираем нужный заказ из запроса, убираем лишние краевые значения, которые не нужны для теста. В процессе подключаемся к нескольким таблицам. Затем выбираем юридическое лицо с нужными нам условиями, а после — его профиль. Для одного теста получилось три запроса, которые хранятся внутри теста.
Теперь вместо сложных запросов мы перешли к генерации:
public void Order_ChangeLegalPersonData_Habr_New()
{
var order = Order.Creator.CreateOrder();
var newLegPers = LegPersCreator.CreateLegPers();
var lpp = LPPCreator.CreateLegPersProf(lpp);
...
}
Я заменил 20 строк на 3 строки, в которых создаётся каждый независимый объект.
Из-за этого добавились ещё пару плюсов.
1. Улучшилась читаемость: вся лишняя логика по выборке данных ушла из теста.
2. Тесты стали проще, что значительно облегчило их поддержку, отладку и дальнейшую работу с ними. Для нового человека процесс станет гораздо удобнее и понятнее.
Как я упоминал ранее, наши объекты зависит друг от друга, поэтому внутри генератора могут быть дополнительные генераторы данных для других сущностей. Далее заполняется модель, например, модель заказа: данные, которые не нужно менять, задаются по умолчанию, а остальные заполняются сгенерированными в этом методе значениями.
CreateOrder()
{
var lp = legalPersonCreator.CreateLegalPerson();
var firm = firmCreator.CreateFirm()
var orderModel = OrderSetter.SetUp(lp,firm...)
EntityCreator.Create(Type = Order, model = orderModel)
}
В дальнейшем мы отправляем всё на создание, которое бывает двух типов: через API и DB.
Если это наш объект, т.е. мы являемся для него мастер-системой, то у нас есть возможность создавать и обновлять его через API. Это легальный и правильный способ, который всегда корректно сохраняет данные в таблицах.
Альтернативный способ — создание через базу данных.
Этот способ подходит, например, для объектов, которые приходят по импорту из других систем. Обычно такие объекты простые, занимают 1–2 таблицы и содержат минимум информации, так как основная информация хранится в мастер-системах. В этом случае мы просто делаем Insert в базу для нескольких объектов.
Чаще всего достаточно стандартной операции «create order», которая покрывает 80% случаев, но если нужно создать особенный объект, то мы явно меняем необходимые поля.
var order = OrderCreator.CreateOrder (
o => {
o.BeginDistributionDate = currentMonthFirstDate;
o.OrderType = OrderType.Approved;
o.SighupDate = currentMonthFirstDate.AddDays(-1);
});
Это создание заказа с разными параметрами (например тип, период размещения и т. п.). Если нужно изменить параметры, это легко делается через лямбда-функцию. Такой подход можно реализовать на любом языке программирования (здесь C#). По сути, мы реализовали паттерн билдер.
Результаты
Наша работа с автотестами доказала, что стабильность и предсказуемость тестирования начинаются с правильной работы с данными. После перехода на генерацию тестовых данных мы добились:
Стабильного прогона. Количество падающих тестов снизилось с 50 до 2–4, а падения по тайм-аутам исчезли полностью. Скоро дойдем до цели!
Ускорения CI/CD. Время выполнения прогона уменьшилось с 1 часа 23 минут до 59 минут. С новым подходом появилась возможность запустить тесты параллельно, что сильно сократит время прогона.
Прозрачности автотестов. Теперь разработчики видят точный статус тестов в pull-request’ах, а тестировщики больше не тратят время на ручную интерпретацию результатов.
Упрощения поддержки тестов. Мы сделали их более читаемыми и понятными даже для новых членов команды.
Сейчас осталось 235 проигнорированных тестов. Из них 30 это те, которые я убрал из прогона, остальные пропускаются из-за отсутствия данных или локализации. Например, при запуске для России тесты для других стран игнорируются. Тесты, которые не находят данные мы планируем тоже перевести на генерацию, чтобы заранее тестировать новые функции, создавая необходимые объекты самостоятельно.
Конечно, наш подход — это не универсальное решение, а лишь один из способов решения проблемы. Если используется исторические данные для автопрогонов, и тесты успешно проходят, то переход на генерацию может только ускорить прохождение прогона, если ваши выборки занимают много времени. А если ваши данные простые и редко меняются, то можно обойтись скриптами и статичной базой.
Но в случае необходимости постоянно актуальных данных, подход с генерацией данных оказался не только самым эффективным, но и самым масштабируемым — теперь мы уверены в том, что наши тесты отражают реальное состояние системы, а не случайные сбои. Надеюсь, наш опыт может пригодиться и вам:)