Руководство по написанию тестов#

Проект Trino стремится предоставлять пользователям высококачественные артефакты и функциональность. Набор тестов, используемый для разработки, CI-сборок и релизов, является критически важным для достижения этой цели.

Trino выступает как интеграционный слой для множества различных источников данных с помощью различных connector-плагинов, а также поддерживает другие плагины. Это приводит к необходимости тестирования большого количества сценариев с использованием внешних систем. Набор тестов Trino старается максимально ограничить количество выполняемых тестов для изменений в pull request, тогда как при слиянии в ветку master выполняются все тесты.

Следующие требования и рекомендации помогают контрибьюторам создавать тесты, соответствующие следующим целям:

  • Быстрое выполнение.

  • Низкие требования к оборудованию, программному обеспечению и вычислительным ресурсам, а значит — низкая стоимость.

  • Надёжное выполнение и воспроизводимые результаты тестов.

  • Достаточная сложность для покрытия основных путей выполнения без излишней сложности.

  • Возможность быстрого итеративного развития на рабочей станции разработчика.

Соглашения и рекомендации#

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

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

Следующие требования применяются ко всем новым тестам и рефакторингу существующих:

  • Все тесты должны использовать JUnit 5.

  • Все тесты должны использовать статически импортированные assertions из AssertJ, обычно из org.assertj.core.api.Assertions.

  • Имена тестовых классов должны начинаться с Test, например TestExample.

  • Тестовые классы должны быть package-private и final.

  • Имена тестовых методов должны начинаться с test, например testExplain().

  • Тестовые методы должны быть package-private.

  • Тесты должны быть написаны как unit-тесты, включая тесты с использованием TestContainers для абстракции инфраструктуры, где это возможно. Product- и интеграционные тесты следует избегать, так как они зависят от внешней инфраструктуры, используют полный runtime Trino, работают медленнее и менее надёжны.

  • Тесты не должны дублироваться между unit- и product-тестами или между различными плагинами и интеграциями.

Рекомендации#

Помимо требований, существуют дополнительные рекомендации для написания новых тестов и рефакторинга существующих.

Фокус на тестах с высокой ценностью#

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

Избегайте комбинаторных тестов#

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

Избегайте product-тестов#

Если возможно написать unit-тест — пишите его и избегайте product-тестов. В долгосрочной перспективе планируется сократить их количество, поэтому лучше не создавать новые.

Используйте product-тесты только в следующих случаях:

  • Минимальное высокоуровневое интеграционное тестирование с использованием полного сервера (например, проверка работы плагина с classloader и classpath).

  • Когда тест требует специализированной среды (например, контейнер с Kerberos). При этом запускайте только минимально необходимый набор тестов.

Избегайте создания собственных абстракций для тестирования#

Следующие подходы следует избегать, так как существующие инструменты уже предоставляют необходимые возможности:

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

  • Создание собственных assertion-фреймворков

  • Создание собственных фреймворков для параметризованных тестов

Избегайте data providers и параметризованных тестов#

Они добавляют лишнюю сложность. Вместо этого:

  • Большинство data provider’ов либо слишком простые, либо создают огромные комбинаторные наборы данных.

  • Для простых случаев (например boolean) пишите явные тесты.

  • Для небольших наборов используйте цикл по списку.

  • Для более крупных — используйте типобезопасные enum.

  • Для больших наборов данных обсуждайте подход с мейнтейнерами Trino.

  • Избегайте нескольких независимых data provider’ов, вложенных циклов и т.п.

Избегайте stateful тестовых классов#

Stateful-тесты могут приводить к утечкам состояния между тестами, особенно при параллельном выполнении. Это усложняет отладку и сопровождение. По возможности их следует избегать.

Не управляйте памятью вручную#

JUnit и JVM сами управляют жизненным циклом и памятью. Не нужно вручную очищать поля (например, присваивать null в @After). Можно безопасно использовать final поля даже для тяжёлых объектов.

Используйте простую инициализацию ресурсов#

Предпочитайте инициализацию ресурсов в конструкторах и их освобождение в методах @After, если необходимо. Это позволяет использовать final поля и упрощает код. Для упрощения очистки можно использовать класс Closer из Guava.

Сохраняйте простоту setup и teardown тестов#

Избегайте использования подхода @Before/@After для каждого тестового метода.

  • По возможности используйте try-with-resources

  • При необходимости используйте общий метод инициализации или очистки, который вызывается явно

  • Если у вас есть тест, которому действительно подходят @Before/After, обсудите это с мейнтейнерами для выработки подходящего решения

Обеспечивайте тестируемость новых возможностей плагинов и коннекторов#

Новые функции плагинов/коннекторов должны быть тестируемы с использованием одного из тестовых плагинов (например, memory или null). В настоящее время существуют функции, протестированные только в Hive-плагинах, но со временем ожидается покрытие через тестовые плагины.

Сохраняйте фокус на тестах плагинов и коннекторов#

Для плагинов, особенно connector-плагинов, сосредотачивайтесь на коде, уникальном для плагина. Не добавляйте тесты для функциональности ядра. Плагины должны проверять корректность реализации SPI и совместимость с внешними системами.

Избегайте нестабильных (flaky) тестов#

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

Существующие flaky-тесты на TestNG могут временно помечаться аннотацией @Flaky для повышения стабильности CI, пока не будет реализовано исправление:

  • В идеале тест нужно сделать надёжным

  • Переписать тест так, чтобы он не зависел от нестабильной инфраструктуры, включая отказ от использования HDFS

  • При необходимости можно добавить явные retry, но учитывать нагрузку на ресурсы

Если тест не исправлен в течение длительного времени, его следует удалить.

Новые тесты с аннотацией @Flaky добавлять нельзя, так как все новые тесты должны использовать JUnit. Перепишите тест так, чтобы он был стабильным, или откажитесь от него.

Избегайте отключения тестов#

Предпочтительно удалить тест, чем отключить его. Тесты требуют поддержки при изменении кодовой базы, и неактивные тесты только создают лишнюю нагрузку.

Отключённые тесты могут быть удалены в любой момент.

Избегайте использования Assumptions.abort()#

Использование Assumptions.abort() для пропуска теста, особенно глубоко в стеке вызовов, затрудняет отладку. Метод abort() выбрасывает исключение, которое может быть перехвачено промежуточным кодом, что приводит к запутанным stack trace и ошибкам тестов.

Избегайте наследования тестов#

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

Избегайте вспомогательных assertions#

Использование AssertJ предоставляет богатый набор assertions, что обычно делает собственные вспомогательные assertions ненужными. Пользовательские assertions усложняют чтение и отладку тестов.

Если вы всё же решили использовать вспомогательный assertion, учитывайте:

  • Начинайте имя с assert, например assertSomeLogicWorks

  • Предпочитайте private и static

Примеры#

Следующие примеры демонстрируют рекомендуемые и нерекомендуемые практики.

Параллелизм в тестах#

Используйте PER_CLASS для инстансов, так как QueryAssertions слишком затратно создавать для каждого метода, и это позволяет параллельное выполнение тестов с CONCURRENT:

@TestInstance(PER_CLASS)
@Execution(CONCURRENT)
final class TestJoin
{
    private final QueryAssertions assertions = new QueryAssertions();

    @AfterAll
    void teardown()
    {
        assertions.close();
    }

    @Test
    void testXXX()
    {
        assertThat(assertions.query(
            """
            ...
            """))
            .matches("...");
    }
}

Избегайте ручного управления жизненным циклом#

Избегайте управления жизненным циклом Closeable, например соединения, через @BeforeEach/@AfterEach, чтобы снизить накладные расходы:

@TestInstance(PER_METHOD)
final class Test
{
    private Connection connection;

    @BeforeEach
    void setup()
    {
        // WRONG: create this in the test method using try-with-resources
        connection = newConnection();
    }

    @AfterEach
    void teardown()
    {
        connection.close();
    }

    @Test
    void test()
    {
        ...
    }
}

Подход с try-with-resources позволяет чисто распараллеливать тесты и обеспечивает автоматическое управление памятью:

final class Test
{

    @Test
    void testSomething()
    {
        try (Connection connection = newConnection();) {
            ...
        }
    }

    @Test
    void testSomethingElse()
    {
        try (Connection connection = newConnection();) {
            ...
        }
    }
}

Избегайте фиктивных абстракций#

Избегайте использования фиктивных абстракций в тестах.

@DataProvider(name = "data")
void test(boolean flag)
{
    // WRONG: use separate test methods
    assertEqual(
        flag ? ... : ...,
        flag ? ... : ...);
}

Замените на упрощенные отдельные assertions:

void test()
{
    assertThat(...).isEqualTo(...); // case corresponding to flag == true
    assertThat(...).isEqualTo(...); // case corresponding to flag == false
}

Избегайте пользовательской распараллелизации#

Не разрабатывайте собственный фреймворк для параллельного выполнения тестов:

@Test(dataProvider = "parallelTests")
void testParallel(Runnable runnable)
{
   try {
       parallelTestsSemaphore.acquire();
   }
   catch (InterruptedException e) {
       Thread.currentThread().interrupt();
       throw new RuntimeException(e);
   }
   try {
       runnable.run();
   }
   finally {
       parallelTestsSemaphore.release();
   }
}

@DataProvider(name = "parallelTests", parallel = true)
Object[][] parallelTests()
{
   return new Object[][] {
        parallelTest("testCreateTable", this::testCreateTable),
        parallelTest("testInsert", this::testInsert),
        parallelTest("testDelete", this::testDelete),
        parallelTest("testDeleteWithSubquery", this::testDeleteWithSubquery),
        parallelTest("testUpdate", this::testUpdate),
        parallelTest("testUpdateWithSubquery", this::testUpdateWithSubquery),
        parallelTest("testMerge", this::testMerge),
        parallelTest("testAnalyzeTable", this::testAnalyzeTable),
        parallelTest("testExplainAnalyze", this::testExplainAnalyze),
        parallelTest("testRequestTimeouts", this::testRequestTimeouts)
   };
}

Вместо этого оставьте распараллеливание JUnit и реализуйте отдельные тестовые методы.

Избегайте параметризованных тестов#

Не создавайте собственный фреймворк параметризованных тестов:

@Test
void testTinyint()
{
    SqlDataTypeTest.create()
        .addRoundTrip(...)
        .addRoundTrip(...)
        .addRoundTrip(...)
        .execute(getQueryRunner(), trinoCreateAsSelect("test_tinyint"))
        .execute(getQueryRunner(), trinoCreateAndInsert("test_tinyint"))
        .addRoundTrip(...)
        .execute(getQueryRunner(), clickhouseQuery("tpch.test_tinyint"));
}