Тестирование Trainer Advisor: сетап теста, подготовка хранилищ
April 20, 2024
Введение
Это третий пост о тестировании Trainer Advisor. Перед прочтением этого поста стоит ознакомиться с общими идеями и принципами, описанными в первом посте и с сетапом инфраструктуры и приложения во втором. В этом посте я продолжаю описание работы с самой замороченной частью тестирования - фикстурой.
На самом верхнем уровне этот процесс состоит из следующих шагов:
- Один раз на запуск набора тестов:
- Запустить или сбросить состояние запущенной инфраструктуры (сейчас - Postgres и Minio);
- Запустить приложение;
- Проинициализировать инфраструктуру (схему и бакеты);
- Для каждого тест кейса из набора:
- Подготовить хранилища;
- Привести данные в БД к эталонным;
- Сформировать специфичные данные, которые sut загрузит из хранилищ неявно в процессе испытания (вызова метода в блоке Then);
- Вставить эти данные в хранилища.
- Подготовить тестовые дубли.
- Подготовить хранилища;
Шага сетапа набора тестов я рассмотрел в предыдущем посте, а в этом посте рассмотрю первый шаг сетапа отдельного теста - подготовка хранилищ.
Сброс состояния БД
Для того чтобы интеграционные тесты работали стабильно, они должны работать со стабильным (от запуска к запуску) окружением — состоянием внешних систем.
В случае TA - это состояние Postgres и Minio*.
В общем же случае это могут быть так же очереди сообщений, внешние сервисы с состоянием, почтовые серверы и т.п.
Для сброса состояния PostgreSQL я использую небольшой SQL-скрипт:
Здесь первым шагом удаляются рабочие данные из всех таблиц, а потом заново вставляется пользователь-терапевт. Пользователя терапевта я добавляю в этом скрипте во имя экономии как в скорости запуска, так и в скорости разработки тестов - сейчас терапевт так или иначе требуется практически каждому тесту.
А зачем я добавляю админа - я не знаю. Надо удалить.
Далее, так как я не использую Spring-овую автомагию тестирования (так как она съедает 5-10% временнОго бюджета на запуск теста), этот скрипт я выполняю в @BeforeEach-методе корневого класса теста с помощью небольшой самописной утилитки, поверх Spring-овой утилитки:
Наконец, у меня есть несколько констант, для использования этого представленного терапевта:
После сетапа разделяемой фикстуры, следующим шагом идёт сетап фикстуры, специфичной для теста. В общем случае этот шаг делится на две части — генерация тестовых данных и их вставка в БД.
Генерация тестовых данных
Раньше я для генерации случайных тестовых данных (примитивных значений) писал генераторы руками:
Но недавно наткнулся на библиотеку Datafaker и теперь стараюсь использовать её (пока что использовал только три раза, поэтому не могу написать отзыв на неё).
Генерация тестовых доменных объектов
В генерации тестовых доменных объектов у меня один из двух самых больших бардаков сейчас.
Пока что я могу сформулировать только две общих идеи, которых стараюсь придерживаться:
- Все данные, нерелевантные для данного теста, должны генерироваться случайно;
- Если у объекта есть нуллабельные поля - должно быть два отдельных метода для генерации объектов только с обязательными полями и со всеми полями.
Код генерации объектов раскидан по Object Mother-ам, у которых общего только то, что они содержат методы с кучей параметров, со случайными дефолтными значениями:
Клиенты таких методов (в основном тест кейсы) при вызове передают только релевантные для данного случая параметры:
Этот тест проверяет, что после создания карточки клиента со значением поля "Откуда вы о нас узнали" равным "Другое" и без комментария, комментарий должен сохраниться в БД как null. А не как пустая строка, видимо - я сейчас наверняка уже не помню, но скорее всего, этот тест я написал, когда при ручном тестировании словил какую-то проблему, связанную с сохранением пустого коммента пустой строкой.
Зачастую тесту вообще не важно значение атрибутов сущности:
Тут стоит пояснить, что, по историческим причинам, за названием "страница редактирования клиента" скрывается таб-панель со всей информацией о клиенте, в которой по умолчанию открывается таб с журналом клиента:
И тест выше проверяет, что "страница редактирования клиента" корректно открывается для только что созданного клиента - в данном случае нам не важны конкретные значения атрибутов клиента.
В этом примере в передаче THE_THERAPIST_ID
в метод createClients
также видна одна из моих стратегий работы с референсными данными - ссылки на разделяемую фикстуру.
Референсные данные
Подавляющее большинство доменных объектов ссылаются на другие доменные объекты. У меня ссылки между доменными объектами реализованы в виде свойств типа AggregateReference из Spring Data JDBC, который, по сути, является обёрткой над примитивным идентификатором. Но само значение этого идентификатора надо как-то получить, и я сейчас использую три способа:
- Захардкоженные идентификаторы;
- Внедрённые идентификаторы;
- Фейковые идентификаторы.
Захардкоженные идентификаторы
Сейчас у меня захардкоженный идентификатор только один — THE_THERAPIST_ID
— идентификатор терапевта.
Так сделано потому, что практически каждый тест требует существование терапевта как минимум для того, чтобы аутентифицироваться и иметь возможность выполнять запросы.
Кроме того, большинство других доменных объектов прямо или транзитивно привязаны к терапевту.
И для того, чтобы немного сэкономить на времени сетапа фикстуры, терапевт с этим идентификатором вставляется в БД через SQL.
... печальный опыт Проекта Э показал, что злоупотреблять разделяемой фикстурой нельзя.
Во-первых, это приведёт к титаническим усилиям по обновлению скриптов после рефакторинга схемы БД.
А во-вторых, это снизит наглядность теста - входные данные придётся искать в другом месте и в них невозможно будет отличить атрибуты, которые влияют на результат теста, от тех, что нет.
Поэтому подавляющее большинство референсных данных я генерируют и вставляю с помощью Object Mother-ов и Background-ов.
Внедрённые идентификаторы
В случае если мне нужна просто ссылка, чтобы создать целевой объект, я использую бэкграунды, чтобы сгенерировать случайный доменный объект, вставить его в БД и получить идентификатор для ссылки. При необходимости, проделав эту процедуру рекурсивно:
Это тест на то, что после редактирования минимального приёма с указанием значений опциональных атрибутов, все атрибуты имеют то же значение, при последующих запросах. Приём ссылается на терапевта, клиента, тип приёма и необязательную терапевтическую задачу.
И здесь создаётся произвольный, но минимальный приём, со ссылкой на THE_THERAPIST и произвольными клиентами и типом (1).
Затем, на базе созданного приёма создаётся ДТОшка обновления приёма (2), в которую внедряется сгенерённые с помощью бэкграундов ссылки на тип (3) и задачу приёма (4).
Фейковые идентификаторы
Наконец, в некоторых случаях можно ссылаться на фейковые (или, скорее, transient) доменные объекты.
Первым видом таких случаев является опция "сошлись или создай". Например, в UI при создании приёма можно тип приёма либо прописать новый, либо выбрать существующий из выпадающего списка.
И это активно используется в тестах на создание приёмов. Настолько активно, что сейчас при генерации приёма внедрить его тип невозможно - в генераторе захардкожено использование фейкового типа:
object AppointmentTypesObjectMother {
fun fakeAppointmentType() = AggregateReferenceTarget(
AppointmentType(THE_THERAPIST_REF, "Тренировка", id = -1)
)
}
object AppointmentsObjectMother {
fun randomAppointment(
therapistRef: TherapistRef = THE_THERAPIST_REF,
client: ClientRef = ClientsObjectMother.fakeClientRef,
typeId: AppointmentTypeRef? = null,
typeTitle: String = randomCyrillicWord(),
therapeuticTask: TherapeuticTaskRef? = null,
dateTime: LocalDateTime = randomAppointmentDate(),
timeZone: ZoneId = randomTimeZone(),
duration: Duration = randomAppointmentDuration(),
place: String? = null,
cost: Int? = null,
payed: Boolean? = null,
appointmentStatus: AppointmentStatus = AppointmentStatus.entries.random(),
comment: String? = null
): Appointment {
return Appointment(
therapistRef,
randomEditAppointmentRequest(
client,
typeId,
typeTitle,
therapeuticTask,
dateTime,
timeZone,
duration,
place,
cost,
payed,
appointmentStatus,
comment
),
AppointmentTypesObjectMother.fakeAppointmentType()
)
}
}
Второй случай, когда можно использовать фейковые ссылки на доменные объекты - когда эти ссылки не доберутся до БД.
Это, в свою очередь, также может быть по двум причинам.
Первой причиной является то, что обработка операции может прерваться (из-за ошибки) раньше обращения к БД:
object ClientsObjectMother {
val fakeClientRef: ClientRef = AggregateReferenceTarget(
Client(THE_THERAPIST_ID, createClientCardDtoMinimal())
)
}
class CreateAppointmentPageControllerTest : QYogaAppBaseTest() {
@Test
fun `Creation of appointment, that intersects with an appointment in another time zone should fail`() {
// Given
val existingAppointmentTimeZone = asiaNovosibirsk
val newAppointmentTimeZone = europeMoscow
val existingAppointmentLocalDateTime = aDateTime
backgrounds.appointments.create(
dateTime = existingAppointmentLocalDateTime,
timeZone = existingAppointmentTimeZone
)
val createNewAppointmentRequest = randomEditAppointmentRequest(
client = ClientsObjectMother.fakeClientRef,
dateTime = aDate.atTime(existingAppointmentLocalDateTime.get(ChronoField.HOUR_OF_DAY) - timeZonesDiff, 0),
timeZone = newAppointmentTimeZone
)
// When
val result = controller.createAppointment(
createNewAppointmentRequest,
theTherapistUserDetails
)
// Then
result.shouldBeIntersectionError()
backgrounds.appointments.getDaySchedule(aDate) shouldHaveSize 1
}
}
Этот тест проверяет систему на соответствие требованию "Система должна исключить создание приёмов, пересекающихся по времени, даже если время для них указано в разных часовых поясах". И здесь фейковая ссылка на клиента в приёме до БД не дойдёт - до вставки приёма в БД выполнится проверка на пересечение, которая не пройдёт и выполнение метода прервётся выбросом исключения.
Вторая причина, по которой фейковая может не дойти до БД - она используется в юнит-тесте чистой логики:
object AppointmentsObjectMother {
fun randomAppointment(
therapistRef: TherapistRef = THE_THERAPIST_REF,
client: ClientRef = ClientsObjectMother.fakeClientRef, // по умолчанию используется фейковый клиент
// остальные параметры
): Appointment {
return // ...
}
}
class CalendarPageModelTest {
@Test
fun `Calendar should start at first appointment time`() {
// Given
val firstAppointmentStartTime = LocalTime.of(2, 0)
val today = LocalDate.now()
val appointments = listOf(
randomAppointment( // при вызове не указывается значение параметра client
dateTime = today.atTime(firstAppointmentStartTime), timeZone = asiaNovosibirskTimeZone
),
randomAppointment(
dateTime = today.atTime(8, 0), timeZone = asiaNovosibirskTimeZone
)
)
// When
val calendarPageModel = CalendarPageModel.of(today, appointments)
// Then
calendarPageModel.timeMarks.first().time shouldBe firstAppointmentStartTime
}
}
В коде выше приведён один из двух на данный момент юнит-тестов в проекте. Этот тест проверяет соответствие системы требованию "Календарь должен начинаться со времени первого приёма". Тут также надо немного пояснить устройство системы. Расписание отображается терапевту в виде календаря — сетки с днями в столбцах и временем дня в строках:
По умолчанию отображается время с 7 до 22 часов. И тест выше проверяет, что если есть приём, который начинается раньше 7 утра, то в модели этой страницы сетка времени начинается со времени самого раннего приёма.
Код построения модели страницы, спрятанный за CalendarPageModel.of
— чистый.
Он получает на вход дату и список приёмов и возвращает сетку календаря с приёмами в ячейках.
И клиенты приёмов в данном случае совершенно не важны и не попадут в БД, но требуются для того, чтобы банально создать экземпляр объекта приёма. Поэтому я спокойно использую фейковые объекты (один и тот же, на самом деле).
Бонус трэк - отключение проверки внешних ключей
Сам я так никогда не делал, но теоретически проблему ссылочных данных можно решить кардинально - с помощью отключения контроля целостности внешних ключей.
Сейчас не получается быстро нагуглить пример того, как это делать при тестировании и, воспользовавшись форматом микропоста, я не буду его изобретать сам и описывать.
Но это совершенно точно решаемая задача, при желании.
Генерация рандомных доменных данных
Стоит упомянуть, что при генерации случайных данных я стараюсь, чтоб значения попадали в область реальных значений. Например, время начала примера - это не случайная пара часов и минут, а время в интервале с 8 до 21 часа и минуты кратные 5. И код генерации таких данных хоть и является, по сути, генератором примитивных данных - я его складываю рядом с Object Mother сущности, для генерации значений атрибутов которой он используется.
Генерация коллекций доменных объектов
Благодаря тому, что доменные объекты генерируются случайно — их можно генерировать в любых количествах.
Но сейчас это используется только в тестах просмотра списка объектов.
@Test
fun `Clients list page should render 10 rows when enough clients exists`() {
// Given
// Дублирование этой константы в тесте - спорная техника
// С одной стороны - если поменять её в продовом коде - это потребует
// изменений и в тесте, что, может показаться снижением устойчивости теста к рефакторингу
// С другой стороны, изменение кол-ва записей на странице - это изменение в требованиях,
// и в этом случае я ожидаю, что тест будет изменён
val pageSize = 10
val therapist = TherapistClient.loginAsTheTherapist()
val clients = ClientsObjectMother.createClientCardDtos(pageSize + 1)
// Это может показаться тавтологией — повтором продовой логики в тесте
// но в проде этот же результат (данные отсортированы и ограничены) достигается
// средствами SQL, и если заменить код на явное перечисление записей,
// то, во-первых, это замусорит код тест кейса и, во-вторых, потребует
// комментария, который объяснит суть ожидаемого результата и фактически будет аналогом этого кода на естественном языке
val firstPage = clients.sortedBy { it.lastName.lowercase() }.take(pageSize)
backgrounds.clients.createClients(clients)
// When
val document = therapist.clients.getClientsListPage()
// Then
document shouldBe ClientsListPage
ClientsListPage.clientRows(document) shouldHaveSize pageSize
firstPage.forAll {
document shouldHave ClientsListPage.clientRow(Client(THE_THERAPIST_ID, it))
}
}
Генерация сложных агрегатов
Сейчас самым сложным агрегатом в TA является упражнение. У упражнения есть от 0 до нескольких шагов, у каждого шага есть необязательное изображение, а само изображение, из соображений производительности, вынесено в отдельный физический агрегат.
И, из соображений скорости выполнения тестов, хочется генерировать изображения только в тех тестах, где они нужны. Для решения этой задачи я завёл фабрику изображений:
Затем экземпляр этой фабрики передаётся в функцию генерации упражнения с None в качестве дефолта:
Генерация упражнения. Смотреть на GitHub
fun randomExercise(
stepsCount: Int = 0,
imagesGenerationMode: ImagesGenerationMode = None,
therapistId: Long = THE_THERAPIST_ID,
id: Long = 0
): Pair<Exercise, Map<Int, StoredFile>> {
val exercise = Exercise(
randomCyrillicWord(),
randomSentence(),
randomExerciseDuration(),
ExerciseType.entries.random(),
therapistId,
steps = exerciseSteps(stepsCount),
id = id
)
val images = imagesGenerationMode.generateImages(stepsCount)
return exercise to images
}
Дедупликация фикстуры одной логики, доступной через разные эндпоинты
Иногда бывает так, что одна и та же операция ядра "выставляется" наружу в нескольких операциях системы. И в этом случае в тестах этих операций можно использовать одну и ту же фикстуру. В TA такой операцией является поиск терапевтической задачи по имени, который используется на странице списка терапевтических задач и в компоненте выбора терапевтической задачи на странице карточки записи в журнале.
Эти две штуки покрыты тестами TherapeuticTasksPageTest и SearchTherapeuticTaskTest.
И оба эти теста используют одну и ту же фикстуру и ожидаемый результат и я их вытащил в отдельный объект:
Вставка фикстуры теста
После генерации фикстурных объектов, для интеграционных тестов их необходимо вставить в БД. Для этого я использую бэкграунды.
Единичный объект
Проще всего выполняется вставка единичного объекта:
Это тест на то, что редактирование приёма обновляет все данные приёма, включая ссылку на тип приёма. В данном случае для теста не важно как именно назван тип приёма, важно, что после редактирования приёма он ссылается на верный тип.
Поэтому здесь в методе createAppointmentType
выполняется генерация и вставка в БД произвольного типа приёма (1), затем он используется в объекте запроса на редактирования приёма (2), после чего в блоке верификации в методе shouldMatch
выполняется проверка, что persistedAppointment
ссылается на newAppointmentType
(3).
Следующий же тест проверяет функциональность поиска типа приёма по имени и в нём нам уже важно значение поля имени:
Реализация же метода createAppointmentType
очевидная и тривиальная:
Дерево произвольных объектов
В разделе "Внедрённые идентификаторы" я уже приводил пример использования генерации произвольного дерева объектов (приёма в данном случае), теперь посмотрим как это реализованно.
У приёма есть три различных по характеру ссылки:
- на клиента - обязательная, целевой клиент должен существовать на момент создания приёма;
- на тип - обязательная, целевой тип можно создать одновременно с приёмом;
- на задачу - необязательная, но если указана, то должна существовать, на момент создания приёма.
И двигаясь по пути наименьшего сопротивления, при создании произвольного приёма я:
- использую бэкграунд для генерации клиента;
- использую генератор случайных данных для генерации имени типа приёма, который будет создан одновременно с приёмом;
- не указываю задачу.
В коде это выглядит так:
Коллекции объектов
Периодически тестам требуется вставка нескольких объектов одного типа. Притом бывает так, что тесту важно, контролировать значения свойств этих объектов, а бывает, что нет — нужна просто безликая массовка.
Решаются эти задачи тривиально - коллекция специфичных объектов вставляется простой делегацией в метод создания в продовый код, а массовка генерируется посредством генерации N случайных объектов и делегацией её вставки в метод вставки коллекции специфичных объектов:
Коллекция уникальных объектов
Иногда "массовка" должна быть не совсем безликой - вам всё ещё не важны конкретные значения полей объектов, но важны свойства распределения значений этих полей в коллекции. Самым частым примером является уникальность какого-то поля в БД и, как следствие, уникальность его значений в выборке.
Очень часто (например, в случае телефона клиента) это свойство достаточно надёжно обеспечивается просто случайной генерацией значений - в TA (за несколько тысяч запусков тестов, я думаю) я ещё ни разу не сталкивался с тем, чтобы тест упал из-за генерации клиентов с одинаковыми телефонами.
Однако, область (разумных) значений поля бывает не такой широкой и коллизии уникальных полей могут стать проблемой. В ТА таким полем стала дата записи журнала, которая, по требованиям, должна быть уникальной в рамках одного клиента.
Решил я эту проблему так:
Заключение
Фух, это было долго. Спасибо, что вы ещё со мной.
Суммируя всё вышесказанное:
- Подготовка БД для тестов состоит из двух больших частей - формирование объектов и их вставка в БД;
- За формирование объектов отвечают Object Mother-ы, за их вставку в БД - Background-ы;
- Объекты я формирую преимущественно случайные, задавая определённые значения только тем атрибутам, которые важны для данного теста;
- Формирование ссылочных данных — объектов, у которых нам важны не значения атрибутов, а их идентификаторов — выполняется совместной работой Object Mother-а (генерация произвольного объекта) и Background-а (его вставка в БД);
- Помимо этого для формирования ссылочных данных я использую ещё две стратегии:
- Разделяемая фикстура - вставляется в БД SQL-скриптом перед каждым тестом;
- Фейковые объекты - используется только в том случае, если ссылающийся объект не дойдёт до БД - в юнит-тестах чистой логики или в интеграционных тестах негативных сценариев.
В следующем посте мы ещё разберём подготовку нескольких видов тестовых дублей и наконец можно будет перейти к испытаниям.