Руководство по написанию тестов#
Проект 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"));
}