Тестирование Trainer Advisor: теория

March 23, 2024

Введение

Это первый пост об устройстве тестирования Trainer Advisor. В этом посте я расскажу какие принципы и модели лежат в основе тестирования TA, а в последующих постах подробно разберу все ключевые виды тестового кода в проекте.

Но начать надо с того, чтобы решить куда мы хотим прийти - какими должны быть тесты?

Что такое хороший тест?

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

Для того чтобы тесты были показательными, я считаю, что надо [стремится] придерживаться трёх основных правил:

  1. Тестировать систему в конфигурации максимально приближенной к боевой. То есть минимизировать использование тестовых дублей (моков, стабов, фейков, спаев).
  2. Взаимодействовать с системой через публичное АПИ. То есть минимизировать взаимодействие с системой через код её реализации и, в особенности, напрямую с базой данных системы, а так же через тестовые дубли;
  3. Тестировать систему так, как её будет тестировать пользователь. То есть кодировать в тест кейсах, те действия и проверки, которые вы сами или ваши QA выполняют в рамках тестирования. Например, в тесте операции регистрации, проверить не только то, что в ответ на запрос вернулся 200-ый статус (его можно и захардкодить), но и то, что после этого можно залогиниться с теми же логином и паролем.

Все эти правила, не являются догмами и в реальной практике их приходится нарушать:

  1. Зачастую тесты на обработку ошибок инфраструктуры намного проще написать с помощью моков, чем ломать реальную инфраструктуру из тестов;
  2. Иногда спровоцировать то или иное поведение глубоко запрятанного кода или верифицировать результат его выполнения намного проще прямым вызовом, чем через публичное АПИ.
  3. Как правило писать тесты на API ядра/домена/бизнес-логики намного проще, чем писать тесты на любой вид UI. В Trainer Advisor это значит, что вызывать в тестах методы контроллеров напрямую и верифицировать модельные объекты (даже, если они заточены под определённую страницу) намного проще, чем отправлять HTTP-запросы и парсить и верифицировать HTML.

Поэтому у меня есть только одно жёсткое правило, контролируемое автоматически - код методов HTTP эндпоинтов должен быть покрыт на 100% тестами, работающим через HTTP API с приложением без моков.

Так же, так как у меня нет отдельной команды QA, я бы хотел ещё одно жёсткое автоматически контролируемое правило - все ключевые элементы вёрстки (самое главные - поля ввода, формы и ссылки, а так же - динамические строки, включая сообщения об ошибках) должны быть покрыты тестами на 100% . Но я не вижу способа как это можно сделать, поэтому к следованию этого правила я просто стремлюсь.

Практика Trainer Advisor, Проекта Э и других проектов сделанных по Эргономичному подходу такова, что одни только такие тесты (покрывающие 100% эндпоинтов и ключевых элементов вёрстки) обеспечивают примерно 90% покрытия кода, и необходимость в тестах на более низких уровнях абстракции возникает довольно редко.

Это хорошо видно по цифрам распределения видов тестов в Trainer Advisor:

  1. Общее кол-во тестов (тест кейсов, тестовых методов): 151
  2. Кол-во e2e (Selenium) тестов: 2
  3. Кол-во внешних (выполняющих HTTP-запросы и парсящих HTML) тестов: 122
  4. Кол-во внутренних (вызывающих методы контроллеров напрямую) тестов: 15
  5. Кол-во интеграционных тестов сервисов/компонентов: 6
  6. Кол-во юнит тестов: 3
  7. Кол-во тестов с моками: 2
  8. Кол-во архитектурных (ArchUnit) тестов: 1

При том 149 не-e2e тестов на моей машине (i7-8700, 32GB Ram, SSD) проходят за 9.5 секунд, в среднем - ~64мс на тест, если вас беспокоит время выполнения внешних тестов. Для сравнения, два теста с моками занимают порядка 600-700 на первый и 150-200 на второй тест.

Виды тестового кода и их общая структура

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

В цифрах это:

  1. Кол-во строк тест кейсов: 3654
  2. Кол-во строк фикстурного кода: 5299
  3. Кол-во строк продового кода: 5627

Как видно, тестового кода у меня больше чем продового и к его организации надо подходить не менее тщательно.

Я выделяю одиннадцать (🤦‍♂️) видов тестового кода:

  • Generic random data generators - универсальные утилиты генерации рандомных данных - строк, дат и т.п*; Раньше я их писал руками, но недавно наткнулся на любопытную библиотеку - Datafaker - и, думаю, теперь перееду на неё;
  • Domain-specific random data generators - утилиты генерации рандомных значений специфичных для домена домену - длительность упражнения, дата и время приёма и т.п.
  • Object mothers - объекты генерации экземпляров продовых классов данных (сущностей, DTO, запросов и т.п.). В этих классах инкапсулируется структура продовых классов данных.
  • Backgrounds** - коллекции функций для сетапа сложной фикстуры (например, создать приём со всеми ссылками - клиент, тип, задача) и "подкожного" (в обход HTTP) считывания состояния системы для верификации
  • Apis - клиенты бэка, заводится как минимум по одному методу на каждый эндпоинт. В этих классах инкапсулируется HTTP API бэка.
  • Role clients - просто сборники Api, доступных для существующих ролей пользователей. Сейчас в TA только анонимы и терапевты. Но в перспективе там могут появится клиенты, админы, ДевОпсы и т.д.
  • Page objects - описание ожидаемой структуры HTTP-страниц. В контексте разработки бэкэндов актуально только в случае серверного рендеринга;
  • Assertions - функции верификации, специфичные для нашего домена. В этих функциях инкапсулируется структура продовых классов данных и правила собственно верификации.
  • Platform - утилиты для расширения тестовых библиотек;
  • Infra - код создания и и настройки инфраструктуры.
  • Cases - собственно кейсы;

Все эти виды кода и зависимости между ними можно визуализировать так:

tests structure.drawio

На этой картинке в первую очередь надо обратить внимание на то, что кейсы зависят от продового кода только косвенно - они могут объявлять переменные классов продового кода, но всё взаимодействие с ними - создание и обращение к полям и методам идёт через фикстурный код. Это позволяет ограничить "площадь поражения" рефакторинга продового кода несколькими методами фикстурного кода, а не десятками и потенциально сотнями методами кейсов.

Структура директорий тестов

Сейчас структура директорий строго не регламентирована, но общий план такой:

  1. На верхнем уровне, так же как и в продовом коде есть разделение на универсальный код и код приложения (pro.azhidkov и pro.qyoga*);
  2. В этих подпакетах есть по дополнительному подпакету tests;
  3. В подпакетах tests есть по подпакету на каждый (релевантный) вид тестового кода - assertions, cases, clients, fixtures, infra, pages, platform;
  4. Все эти подпакеты в целом повторяют структуру соответствующий части продового кода.

В Trainer Advisor это выглядит так:

Тестовый кодПродовый код
2024 02 19 11 03 45
2024 02 19 11 07 44

Именование тестов

Декомпозиция кейсов на классы и именование классов

Тут я ничего особо нового не придумал - кейсы я группирую по system under test, а сами классы называю по имени sut + суффикс Test.

sut я определяю по объекту, который выполнит тестируемое действие (забегая немного вперёд - код, который будет вызван в блоке When).

Но есть нюанс - объекты контроллеров могут быть sut-ом и внешнего теста (работающего через HTTP) и внутреннего (непосредственно вызывающего метод контроллера). Я решил это так: из внешних тестов убираю суффикс Controller, а из внутренних нет.

В итоге имена классов выглядят так:

  • Внешний тест - CreateExercisePageTest, SchedulePageTest, AppointmentTypesComboBoxTest;
  • Внутренний тест - CreateAppointmentPageControllerTest, CreateExercisePageControllerTest;
  • Интеграционный тест - ExercisesServiceTest, UserSettingsRepoTest, MinioFilesStorageTest, HydrationTest;
  • Юнит тест - TimeZonesTest, CalendarPageModelTest, ProgramDocxGeneratorTest;

Именование методов

Сейчас в Trainer Advisor я придерживаюсь правила, что имя теста должно быть сформулировано как требование к поведению системы:

  • Примеры названий внешних тестов:
    • After login with valid credentials user should be redirected to index page - после логина с корректными учётными данными, пользователь должен быть перенаправлен стартовую страницу;
    • Registration page should be rendered correctly - страница регистрации должна рендерится корректно;
    • Spring should respect X-Forwarded-For header - Spring должен учитывать заголовок X-Forwarded-For;
    • After creation of a client, he should appear in the clients table - после создания клиента, он должен появиться в списке клиентов;
  • Тест на моках:
    • Exercise deletion should fail in case of exercise deletion in db failure - удаление упражнения должно завершаться ошибкой в случае сбоя удаления упражнения в БД;
    • Exercise deletion should complete successfully even in case of steps images deletion failure - удаление упражнения должно завершаться успешно даже в случае сбоя удаления изображений шагов [упражнения];
  • Юнит тест:
    • Calendar should end after last appointment end time - календарь должен заканчиваться после времени конца последнего приёма;
    • Search result should not contain duplicates when a time zone matches both id and title - результат поиска не должен содержать дубли в случае, когда часовой пояс совпадает и с идентификатором и с названием
  • Интеграционные тесты :
    • When entity is hydrated with recursive fetch spec, then nested entity should be hydrated too - в случае, когда сущность гидрируется с рекурсивной спецификацией выборки, вложенная сущность должна быть так же гидрирована;
    • Delete by id should delete only specified files - удаление по идентификатору должно удалять только указанные файлы.

Общая структура тест кейса - Given/When/Then

Методы тест кейсов я структурирую "по классике" - через Given (Дано, при условии что), When (Когда), Then (Тогда) (aka Arrange, Act, Assert).

Пример простого теста. Смотреть на GitHub
@Test
fun `Search result should not contain duplicates when a time zone matches both id and title`() {
    // Given
    val timeZoneId = "Asia/Novosibirsk"
    val timeZoneTitle = "Нововсибирск"

    // When
    val searchResult = russianTimeZones.search(timeZoneId, timeZoneTitle)

    // Then
    searchResult.shouldBeUnique()
}
Пример сложного теста. Смотреть на GitHub
@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,
        // Этой строчкой я хотел отразить, что астрономическое время нового приёма
        // совпадает со временем старого приёма, но сейчас кажется, что лучше это
        // было сделать через вывод двух LocalDateTime из одного Instant.
        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
}

Не по классике у меня то, что я допускаю секции "And when" и "And then". Хотя и стараюсь их избегать.

Пример многошагового теста. Смотреть на GitHub
@Test
fun `After submit of registration form therapist should be created and creds should be sent to admin and success response should be returned`() {
    // Given
    val registerTherapistRequest = registerTherapistRequest(
        "Сергей",
        "Сергеев",
        "new-therapist@qyoga.pro"
    )

    // When
    val document = PublicClient.authApi.registerTherapist(registerTherapistRequest)

    // Then
    document shouldHaveComponent RegistrationSuccessFragment

    // And then
    val receivedMessages = greenMail.getReceivedMessagesForDomain(adminEmail)
    receivedMessages shouldHaveSize 1
    // Здесь я нарушаю правило "код должен делать одну вещь"
    // shouldMatch - и верифицирует сообщение и извлекает из него данные.
    // Если бы я вылизывал этот код, чтобы сделать из него эталон - я бы, скорее всего
    // вытащил получение емейла и пароля в отдельный метод.
    val (receivedTherapistEmail, password) = receivedMessages[0] shouldMatch registerTherapistRequest

    // And when
    val therapist = TherapistClient.login(receivedTherapistEmail, password)
    val getClientsResponse = therapist.clients.getClientsListPage()

    // Then
    getClientsResponse shouldBe ClientsListPage
}

Разработка, направляемая тестами

Ортогональным всем описанным выше принципам и практикам является вопрос "Когда писать тесты?" - до или после продового кода? Я долгое время утверждал, что классическая школа ТДД является одним из столпов Эргономичного подхода. Однако при написании этого поста, я понял что в целом я хоть и сторонник "tests-first" разработки, назвать мой подход Tests-Driven Development (или даже Design) - нельзя.

У меня при словах "test-driven development" в памяти всплывают видео, на которых Мартин как сумасшедший мечется налево и направо и долбит в воздухе по воображаемой клавиатуре, изображая ежесекундное переключение между тестовым и продовым кодом; или как Куксенко или Кекс под лозунгом "самый простой код, который сделает тест зелёными" пишет какую-то полную дичь, которую очевидно придётся переписать на следующей же итерации.

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

Фиксы я тоже начинаю с теста, но тут уже наоборот в первую очередь пытаюсь сделать более простой внутренний тест (работающий через прямой вызов метода контроллера), переходя к внешнему, только если ошибка видима только на уровне вёрстки.

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

Заключение

В этом посте я рассмотрел основополагающие принципы и модели, которых придерживаюсь при разработке тестов Trainer Advisor. В следующем посте я подробно расскажу всю мякотку самой "волосатой" части тестового кода - сетапа фикстуры - как я управляюсь с БД и создаю тестовые объекты.