Как я перестал бояться и полюбил интеграционные тесты

 Публичный пост
29 марта 2026  79

Интеграционные тесты — это не про тестирование. Это про интеграцию. И вот почему.

Этот лонгрид написан с применением LLM, но ограниченным. Основные куски текста написал я сам, так что я очень прошу LLM-полицию расслабить булки. Пасиба.

Причина тряски?

Денис, 38 годиков
Денис, 38 годиков

Начнём с того, что я искренне заебался. Вам наверняка будет знакома ситуация: запиливаете пулл-реквест, автотесты где-то на другом конце планеты начинают шуршать, и через несколько (десятков) минут в почту падает письмо: тесты провалились, вот здесь и вот тут сломано, пожалуйста идите в жопу, такое мы мержить не будем.

Если в такой момент ваш первый порыв — это пойти в Jenkins, или что там у вас, и клацнуть restart, я вас поздравляю: ваши тесты — говно. Я не пытаюсь ни в коем случае никого оскорбить, я и сам такое писал, чтобы потом бесконечно сношаться с «моргающими» тестами. Но такое поведение команды и отдельных коллег говорит лишь о том, что вашим тестам нет доверия разработчиков, а это самая главная характеристика набора тестов. Я предпочту пять стабильных тестов набору, который покрывает всю кодовую базу, но проходит с пятого раза, и не всегда.

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

Весь ваш CI-пайплайн станет из места, куда разработчик может делегировать рутинные проверки кода, в место, которое нужно заставить позеленеть, чтобы изменения в коде можно было влить. И как только разработчик добьётся зелёного света, обязательно прибежит коллега, и заблокирует мерж каким-нибудь комментом вида «чота тут переменная не так называется». Блять, Вася, ну ёбана! Я щас код поправлю, а потом мне опять час тесты перезапускать чтобы позеленить! Может, и так сойдёт?

У любого, кто прочитал этот текст, будет свой взгляд на то, что в этой несчастной компании не так. Конечно, ситуация утрированная (и совсем не такая нереальная, как вам может показаться), но продемонстрировать проблему на таком примере всегда проще.

Подходы

Для начала, давайте определимся с тем, какого слона мы едим. Интеграционное тестирование, о котором я здесь рассуждаю, это тестирование, в котором разные куски системы тестируются в совместной работе, как единое целое. Если у вас есть некий сервис, который ходит в пяток моков во время прогона — это не интеграционное тестирование, по определению. Здесь вы тестируете сервис изолированно, взяв как данность то, что ваши моки ведут себя идентично реальным сервисам в проде. Не буду указывать, что это, мягко говоря, не так. А нет, буду.

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

Я за свою карьеру видел много разных способов интеграционного тестирования. И готовые фреймворки, и самопальные тесты на питоне, чего только не было. У многих (практически у всех) таких подходов к снаряду была одна жирная проблема, которая ставила крест на всём наборе тестов ещё до того, как его начинали писать.

Тесты должны быть изолированы друг от друга. Для каких-нибудь простеньких юнит-тестов, или изолированных функциональных тестах, это проще простого, они, ну, изолированы по определению. Но если ваш тест включает в себя два, а то и больше, сервиса, которые друг с другом связаны, начинаются проблемы, потому что такие наборы тестов в абсолютном большинстве случаев запускаются, «нацелившись» на какой-то готовый, заранее поднятый стейджинг. Даже если ваши тесты запускаются исключительно один за одним, да на абсолютно точно разных поднаборах данных, вы никак не можете гарантировать, что внутренние состояния ваших сервисов каким-нибудь образом не «протекут» из теста в тест. Хотя бы потому, что такую систему нужно рассматривать в четырёх измерениях. Ваша «протечка» просто ещё не произошла.

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

великий Брюс Шнайер импозантен
великий Брюс Шнайер импозантен

Вернёмся к истокам

Каждый из нас так делал, и, возможно, делает до сих пор.

Возьмём обычный REST API. Запилили эндпойнт, вроде собирается. Открываем терминал, в одном окошечке запускаем базу в Docker, наливаем миграции. В другой паре окошек запускаем парочку зависимостей, потом запускаем сервис. Оглядываем ляпоту: ошибок нет, никто не орет, чот хартбиты полетели, вроде тихо.

Оттопыриваем ещё одно окошко, копипастим из приготовленного файлика с командами какой-нибудь curl, ещё раз оглядываем терминалы, и запускаем.

Блять, в колхозе шухер, терминалы взрываются логами, кто-то сходил туда, этот сюда, входящие реквесты, исходящие, везде движуха. В основном терминале сервиса, над которым мы работаем, краснеют ошибки. Останавливаем сервисы, чтобы логи не учесали в Саратов, и начинаем изучать: что не так, и кто подставил кролика Роджера. Смотрим логи, запросы и ответы между сервисами, вот это всё. Аж олдскулы свело.

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

Короче, лонг стори шорт, оно существует. И называется это Expect-style тестирование. Возможно, некоторые из читателей про это знают и применяют, но каждый раз когда я об этом заговариваю с коллегами, почему-то никто об этом не слышал.

Ну показывай эту свою шелупонь

Началось всё в 1990 году с утилиты Expect. Эта штука позволяет автоматизировать взаимодействие с любой программой, которая умеет в терминал. От этой утилиты до полноценного фреймворка очень далеко, конечно. Но, на радость всем нам, такой фреймворк уже запилили ребята из бывшего Tail-f, ныне Cisco.

lux (LUcid eXpect scripting). Фреймворк на Erlang, который реально мне показал, что Expect-style тестирование может быть не набором хрупких скриптов, а вполне себе инженерным инструментом. Я познакомился с этим фреймворком когда работал в Cisco. Поначалу он показался мне очень неказистым и странным, но потом я влился.

С помощью него можно автоматизировать любое взаимодействие с любым сервисом, который умеет в терминал. Сходить по HTTP — дёрни curl или любым другим консольным тулом по выбору. Сходить по gRPC? Ну вот тебе grpcurl. Нужно бинарные данные поматчить? xxd или, на крайняк, самопальный специализированный инструмент: если у вас есть такие задачи, такой инструмент вам пригодится и вне тестирования. JSON? jq. XML? xmllint. Ну вы понели. Единственное, чего я не пробовал — это интеграции с браузерами. Говорят, у Playwright есть какая-то интеграция в консоль, но я фронтендом не занимаюсь, так что вы в меня этим вашим джаваскриптом не тычьте.

А ну убрал npm, щас ментов вызову! То-то же.

Я не буду долго распинаться о том, какой lux хороший, на это есть вполне себе актуальное выступление 2019 года на Erlang Factory.

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

Первое — зависимость от Erlang VM. Чтобы запустить lux, нужен установленный Erlang/OTP. Для Erlang-проекта это не проблема — он и так есть. Но если вы тестируете сервис на Go или на Python, то тащить за собой целый эрланговый рантайм — это очень большой оверхед.

Второе — DSL. Язык lux — плоский, строчный, без структуры. Там нет функций, только макросы, которые инлайнятся в код теста, отчего любая попытка привести библиотеку макросов в порядок превращается в ад. Там нет декларативного способа запуска зависимостей: если в тесте тебе нужно запустить десяток сервисов, то будь добр, запускай руками в отдельных шеллах. Можно организовать запуск зависимостей в макросы, но любая попытка навести порядок... Ну да.

Ну наконец-то! Спустя половину лонгрида он наконец-то соизволил рассказать нам реальную причину тряски!

Да, я переписал lux. Конечно, на Rust, а то как ваши тесты будут ездить блейзинг фаст.

Да, я переизобрёл DSL чтобы сделать тесты декларативными.

Да, я сейчас вам всё покажу.

Relux

Relux (читается как «релакс») я написал за месяц.

предполагаемый маскот проекта, находимся на финальных стадиях подписания контракта
предполагаемый маскот проекта, находимся на финальных стадиях подписания контракта

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

Но давайте по чесноку, хрен бы я такое написал за месяц без LLM. Спустя месяц работы я бы ещё думал над тем, будет ли вот такая конструкция в DSL работать, и нужна ли она вообще. Всё-таки LLM очень сильно ускоряют работу, пусть немного и в ущерб качеству.

На всякий случай, проект всё ещё находится в состоянии беты. Я буквально сегодня починил два критических бага в работе шелл-буфера, да и гарантировать стабильности DSL я пока не могу. Никакого способа установить софтину кроме как забрать репку и собрать из исходников пока не предусмотрено. Зато есть генеренные клодом плагины для VS Code и IntelliJ IDEA. Подсветка синтаксиса работает, я проверял. А ещё relux сам себя тестирует, кек лол.

Я успел написать два туториала: первый по DSL, с подробным описанием всех языковых конструкций, с описанием best practices, которые я принёс из своего опыта работы с lux. Второй туториал — приземлённый и нацеленный на более-менее реальный сценарий. Он написан из расчёта на то, что первый туториал вы осилили, и вас уже не напугать строчкой !? ^error: в листинге теста.

Эту статью я планировал написать по второму туториалу, сжав его до одной статьи, и добавив разъяснения по синтаксису. Честно говоря, получалась какая-то шляпа, так что я всё выбросил и начал заново.

Чтобы показать Relux в деле, возьмём стек из трёх HTTP-сервисов:

  • db_service — key-value база данных с JSON REST API.
  • auth_service — аутентификация, хранит пользователей в db_service.
  • task_service — управление задачами (обычный КРУД), авторизуется через auth, хранит данные в db.

Если что, все три «сервиса» — это питонячьи скрипты в один файл каждый, до смешного простые. Присылайте свои пентесты на zerofucksgiven@spawnlink.eu, я обязательно изучу :)

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

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

Первый тест: два шелла

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

test "create a database" {
    shell db {
        > ${__RELUX_SUITE_ROOT}/db_service.py
        <? ^listening on 9000$
    }

    shell client {
        > curl -s -X POST http://localhost:9000/db/testdb
        <? "created": "testdb"
        match_ok()
    }
}

Шеллы адресуются уникальными именами. Рантайм запустит новый шелл, если увидел имя впервые, и выполнит в нем блок кода. Если такой шелл уже встречался ранее, рантайм просто «переключится» в него и продолжит с того момента, когда остановился. При этом, конечно, весь вывод шелла накапливается в буфере без перерывов.

Синтаксис читается почти как лог терминала. > — отправить команду в шелл. <? — подождать, пока в выводе появится строка, соответствующая регулярному выражению. match_ok() — проверить, что последняя команда завершилась с кодом 0.

(Вообще > — это про отправку в шелл, < — про матчинг строчки из шелла; = — это буквальное значение, ? — регулярное выражение)

Шелл db запускает сервис на дефолтном порту 9000 и ждёт строку готовности. Шелл client отправляет curl-запрос и матчит часть JSON-ответа регуляркой (да-да, я знаю, потерпите, пожалуйста). Каждая операция матчинга имеет таймаут — если ожидаемый вывод не появился вовремя, тест падает. Так ловятся зависания, неожиданные и просто неправильные ответы. Relux умеет в несколько уровней таймаутов: таймаут на весь набор тестов, таймаут на конкретный тест, таймаут на каждую конкретную операцию. Все таймауты делятся на два типа (tolerance и assert), но это уже тонкости, если очень интересно — почитайте в туториале по DSL.

Запускаем:

relux run
test result: ok. 1 passed; 0 failed; finished in 127.4 ms

Тест прошёл. Но что будет, если запустить его ещё раз?

running 1 tests  
test db/smoke.relux/create-a-database: FAILED (2.1 s)  
Error: match timeout in shell `client`  
...
  Event log: file://.../logs/relux/tests/db/smoke/create-a-database/event.html

В логе видно, что база вернула {"error": "db testdb already exists"} — она осталась на диске с прошлого запуска. Как я говорил выше, тесты должны быть изолированы, без этого никак.

Изоляция: артефакты вместо cleanup

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

Лучший подход — дать каждому запуску свою директорию:

shell db {
    let db_root = "${__RELUX_TEST_ARTIFACTS}/database"

    > mkdir ${db_root}
    match_ok()

    > ${__RELUX_SUITE_ROOT}/db_service.py --data-dir ${db_root}
    <? ^listening on 9000$
}

__RELUX_TEST_ARTIFACTS — уникальная директория, которую Relux создаёт для каждого запуска каждого теста. Она не удаляется и сохраняется вместе с логами. Если тест упал и вы подозреваете проблему с данными — вся директория базы лежит в артефактах, готовая к анализу. Это точно так же будет работать с любыми другими артефактами на файловой системе.

Fail pattern: ловим неожиданные ошибки

db_service пишет все ошибки в stdout с префиксом error:. Вместо того, чтобы руками проверять логи после каждого запроса, можно поставить «сторожевой» паттерн:

shell db {
    ...
    !? ^error:

    > ${__RELUX_SUITE_ROOT}/db_service.py --data-dir ${db_root}
    <? ^listening on 9000$
}

Оператор !? — это fail pattern. С момента его установки Relux следит за выводом шелла в фоне. Если в любой момент теста в логе базы появится строка, начинающаяся с error:, тест немедленно падает. Это ловит проблемы, которые вы не предвидели: конфликт портов, битые данные, необработанные исключения. Ну или просто ошибки в сервисах-зависимостях, которые могут сигнализировать о том, что что-то пошло не так.

Хелперы и библиотеки: убираем бойлерплейт

Когда тестов становится больше, бойлерплейт начинает сильно мозолить глаза. Например, каждый curl-запрос — одна и та же последовательность:

> curl -s -X METHOD http://localhost:9000/PATH BODY
<? expected response
match_ok()

Как раз здесь lux предлагал использовать макросы, в классическом смысле. Макросы — это просто несколько команд с аргументами, которые даже не умеют возвращать значения. Relux умеет в настоящие функции, но со своими приколами.

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

Суем новый файл в relux/lib/:

// relux/lib/api/http.relux

# skip unless which("curl")
fn curl(url, method, req_body, extra_headers) {
    let outdir = "${__RELUX_TEST_ARTIFACTS}/http"
    > mkdir -p ${outdir}
    match_ok()

    let file_rand = rand(10)
    let filename = "${outdir}/${file_rand}.http_response.txt"

    > curl -v -X ${method} ${extra_headers} -d '${req_body}' -o ${filename} ${url}
    <? ^> $

    filename
}

fn http_request(expected_code, url, method, req_body) {
    let response_filename = curl(url, method, req_body)
    http_match_code(expected_code)
    match_ok()
    response_filename
}

Функция вызывает curl, матчит HTTP-код ответа, проверяет exit code и возвращает имя файла с телом ответа. Строка # skip unless which("curl") — это условный маркер. Такой можно повесить на функцию, эффект (о них позже) или тест.

В DSL не существует операторов ветвления. Любой тест является строго детерминированным, он идёт по шагам и проверяет всё раз за разом абсолютно одинаково. Если исходники ваших тестов не менялись, то и последовательность шагов в тесте у вас никогда не поменяется, независимо от параметров запуска или окружения. Это даёт некоторые интересные свойства, например, принципиальное отсутствие рекурсивных вызовов. Ну или вот эти прекрасные маркеры.

Маркеры вычисляются до запуска теста, и учитывают только переменные окружения. Если маркер пометит функцию, эффект, или тест как скипнутый, то все другие эффекты и тесты, которые эту функцию/эффект так или иначе трогают, транзитивно будут помечены как скипнутые.

Конкретно в этом случае цель должна быть понятна: если в системе недоступен curl, то и запускать тест, вызывающий эту функцию, смысла нет, он упадёт. Причём упадёт не потому, что ваши ассерты не сработали, а потому что система просто не способна прогнать тест, который дёргает эту функцию, и преуспеть. Это не ошибка в тесте, это косяк в окружении, поэтому такой тест не должен фейлиться, он должен пропускаться.

Для матчинга ответов сооружаем отдельную функцию:

// relux/lib/jq.relux

# skip unless which("jq")  
fn jq_match_query(filename, query, pattern) {  
    > jq -r '${query}' ${filename}  
    <? ${pattern}  
    match_ok()  
}

Оставлю рассуждения о том, как эта функция работает, в качестве умственного
упражнения.

В тестах остаётся:

import api/http
import jq

test "key-value CRUD" {
    ...
    shell client {
        let response = http_request(200, db_url("/db/mydb"), "POST")
        jq_match_query(response, ".created", "^mydb$")
        ...
    }
}

Читается чисто: сделай HTTP-запрос, проверь JSON-поле в ответе. Вся механика — где-то в библиотеке, где ей и место.

Импорты работают из директории relux/lib/. Можно импортировать всё (import api/http) или выборочно (import api/http { http_request }). Есть алиасы: import service/db { url as db_url } — чтобы не путать url() из разных модулей, например.

Эффекты: декларативная инфраструктура

Тесты базы выглядят, ну, окей. Но теперь нам нужно протестировать auth_service. Он зависит от запущенной базы с заранее созданной БД auth. Каждый auth-тест должен сначала стартовать базу, потом создать в ней БД, потом запустить auth. Это 15+ строк настройки перед первой проверкой. А task_service зависит от обоих — ещё больше бойлерплейта.

Эффекты решают эту проблему. Эффект — это переиспользуемый кусок кода, который запускает инфраструктуру и экспортирует единственный шелл в качестве артефакта:

// relux/lib/service/db.relux

effect StartDb -> db {
    shell db {
        let db_root = "${__RELUX_TEST_ARTIFACTS}/database"

        > mkdir ${db_root}
        match_ok()

        !? ^error:

        > ${__RELUX_SUITE_ROOT}/db_service.py --data-dir ${db_root}
        <? ^listening on 9000$
    }
}

effect StartDb -> db читается как «я эффект такой-то, я могу сделать магию и отдать тебе шелл такой-то». При этом шеллов можно назапускать сколько угодно, но все они будут автоматически пристрелены перед тем, как отдать один, избранный, шелл наружу. Ниже будет видно зачем это нужно.

Теперь в тесте вместо 10 строк настройки — одна:

test "key-value CRUD" {
    need StartDb

    shell client {
        // тут только тестовая логика
    }
}

need StartDb говорит Relux: перед этим тестом выполни эффект StartDb. Вся логика запуска базы лежит в одном месте, и может быть переиспользована много раз.

Цепочки зависимостей

Auth-сервис зависит от базы. Это первая цепочка зависимостей — эффект, которому нужен другой эффект:

// relux/lib/service/auth.relux

effect StartAuth -> auth {
    need StartDb

    shell db_client {
        http_request(200, db_url("/db/auth"), "POST")
    }

    shell auth {
        !? ^error:
        > ${__RELUX_SUITE_ROOT}/auth_service.py
        <? ^listening on 9010$
    }
}

StartAuth объявляет зависимость от StartDb. Когда тест говорит need StartAuth, Relux разруливает цепочку: сначала стартует StartDb, потом выполняет StartAuth.

Для авторизации часто нужны заранее созданные пользователи. Вместо того, чтобы регистрировать их в каждом тесте, создадим ещё один слой:

effect SeededAuth -> auth {
    need StartAuth as auth

    shell auth_client {
        http_request(200, url("/register"), "POST", "{\"login\": \"alice\", ...}")
        http_request(200, url("/register"), "POST", "{\"login\": \"bob\", ...}")
    }
}

Здесь видно, что мы можем вполне себе экспортировать из эффекта импортированный откуда-то из глубин цепочки зависимостей шелл (в это случае это auth). Шелл auth_client здесь «одноразовый»: он нужен только для того, чтобы выполнить пару команд и помереть. В реальном сценарии здесь, скорей всего, будут ваши миграции из вооон той папочки в репке. Да, из вот этой.

Теперь тесты выбирают нужный уровень: тесту регистрации достаточно need StartAuth, тесту логина нужен need SeededAuth с готовыми пользователями.

Ромбы зависимостей и дедупликация

Task_service зависит и от базы, и от auth. Но StartAuth уже внутри себя зависит от StartDb. Получается «ромб»:

  StartTasks
  |        |
  |     StartAuth
  |        |
  +---> StartDb

Да, граф зависимостей — это DAG, и разруливать его нужно соответственно.

Если бы Relux запускал новый экземпляр базы для каждого need StartDb, мы бы получили два процесса на одном порту. Relux решает это дедупликацией: он строит полный граф зависимостей и запускает каждый уникальный эффект ровно один раз.

В итоге тест task-сервиса выглядит так:

test "task CRUD" {
    need StartTasks
    need SeededAuth

    shell client {
        let response = http_request(200, tasks_url("/login"), "POST", "{...}")
        let token = jq_extract(response, ".token")

        let response = http_request_authorized(200, tasks_url("/tasks"), "POST", token, "{...}")
        ...
    }
}

Две строки need — и за кулисами поднимается весь стек: база, auth с пользователями, task_service с созданной БД tasks. Пять need-ов по всему графу, четыре уникальных эффекта, каждый запущен один раз.

Параллельный запуск: динамические порты

Всё работает, но сервисы используют жёстко прописанные порты: 9000, 9010, 9020. Запуск тестов последовательно — без проблем: каждый тест сносит инфраструктуру перед следующим. Но запустите четыре теста одновременно (relux run -j 4) — и четыре копии StartDb попытаются занять порт 9000 одновременно.

Здесь стоит сделать небольшую остановочку и объяснить как Relux работает с переменными.

Переменные окружения — это единственный способ передать эффекту свои пожелания. Каждый эффект может получить так называемый оверлей: нашлёпку сверху на статичное окружение, с которым был запущен сам Relux. Этот оверлей составляет вторую половину идентификатора конкретного эффекта. Давайте на примере.

test "CRUD" {
    need Service
    need Service
    need Service
    ...
}

Здесь у нас происходит вот что: тест говорит, что ему нужно аж три одинаковых сервиса. Каждый из них, впрочем, не имеет оверлея, а посему Relux будет их считать одной единственной зависимостью. Как я говорил выше, в DSL нет ветвлений, любой эффект так же детерминирован, как и любая функция и тест. Это значит, что нет никакой возможности запустить эти три сервиса одновременно в таком виде (при условии, что они хоть какой-то сайд-эффект имеют: пишут на диск, шуршат сокетами). Если вам действительно нужно запустить три разных сервиса, вам нужно выдать им разные конфиги.

test "CRUD" {
    need Service { PORT = 9001 }
    need Service { PORT = 9002 }
    need Service { PORT = 9003 }
    ...
}

Здесь каждый из сервисов будет запущен по отдельности, поскольку у них одно имя, но разные оверлеи. Relux посчитает хэшики и решит, что это три разных инстанса. Важно: хэшики считаются не значениями переменных и выражений, а именно что именами.

test "CRUD" {
    need Service { PORT = FOO }
    need Service { PORT = BAR }
    need Service { PORT = BAZ }
    ...
}

В этом случае Relux посчитает эти инстансы разными инстансами, даже если вы запустите тест как FOO=9000 BAR=9000 BAZ=9000 relux run. Все инстансы запустятся на одном порту, и это ваша проблема. Можем в комментариях обсудить почему это работает именно так, у меня есть глубокие аргументы на эту тему.

Теперь обратно к нашим пирогам. available_port() — встроенная функция, которая находит свободный порт. Делает это довольно глупо, но лучшего способа я не нашёл. Она биндит эфемерный порт, читает его номер, быстренько анбиндит обратно и отдаёт в качестве результата. Покуда ваши сервисы умеют в SO_REUSEPORT, всё будет работать.

test "task CRUD" {
    let db_port = available_port()
    let auth_port = available_port()
    let tasks_port = available_port()

    need StartTasks {
        DB_PORT = db_port
        AUTH_PORT = auth_port
        TASKS_PORT = tasks_port
    }

    shell client {
        ...
    }
}

Каждый тест аллоцирует свой набор портов и передаёт их вниз по графу зависимостей через overlay-блоки. Четыре параллельных теста — четыре независимых стека сервисов, каждый на своих портах, никаких конфликтов.

Дедупликация при этом продолжает работать: два need StartDb { DB_PORT = db_port } — один экземпляр. Дедупликация оперирует внутри дерева зависимостей одного теста, не между тестами. Если теперь запустить тесты параллельно, ничего не взорвётся:

~> relux run -j4

running 8 tests (4 workers)
test db/errors.relux/create-duplicate-database: ok (161.3 ms)
test auth/errors.relux/login-unknown-user: ok (241.8 ms)
test auth/smoke.relux/register-and-login: ok (268.3 ms)
test auth/errors.relux/register-duplicate-user: ok (300.7 ms)
test db/smoke.relux/key-value-crud: ok (165.3 ms)
test tasks/errors.relux/unauthorized-without-token: ok (291.6 ms)
test tasks/smoke.relux/task-crud: ok (377.4 ms)
test tasks/errors.relux/get-nonexistent-task: ok (413.3 ms)

test result: ok. 8 passed; 0 failed; finished in 690.3 ms (2.2 s cumulative)

CI

Несколько настроек делают Relux пригодным для CI.

Множитель таймаутов: CI-машины часто медленные, relux run -m 2.0 удваивает все tolerance-таймауты, не трогая assertion-таймауты.

Стратегия: --strategy fail-fast для локальной разработки (остановиться на первом падении), --strategy all для CI (прогнать всё, получить полную картину).

Маркеры условного запуска: # run if CI для тестов, которые имеют смысл только в CI, # skip if SMOKE для тестов, которые не нужны при быстрой проверке.

~> SMOKE=true relux run -j4
...

running 8 tests (4 workers)
test auth/errors.relux/register-duplicate-user: skipped [skip: clunky-gnu-4672]
test auth/errors.relux/login-unknown-user: skipped [skip: grave-chameleon-3850]
test db/errors.relux/create-duplicate-database: skipped [skip: watery-dragon-8014]
test tasks/errors.relux/unauthorized-without-token: skipped [skip: messy-oriole-3783]
test tasks/errors.relux/get-nonexistent-task: skipped [skip: musty-lamprey-0206]
test db/smoke.relux/key-value-crud: ok (164.1 ms)
test auth/smoke.relux/register-and-login: ok (206.3 ms)
test tasks/smoke.relux/task-crud: ok (377.6 ms)

test result: ok. 3 passed; 0 failed; 5 skipped; finished in 384.5 ms (748.0 ms cumulative)

Маркер [flaky] для «моргающих» тестов — Relux умеет в конфиг в своём манифесте, указывающий что делать с тестами, которые помеченные как «моргающие». По дефолту он не будет делать ничего, но вы можете указать, что нужно перезапускать такие тесты несколько раз, увеличивая множитель таймаутов, пока тест не пройдёт. Грустно, но прагматично.


Фуф, на этом у меня почти всё.

Я буду очень рад, если вы почитаете туториалы и попробуете запилить небольшой сет тестов. Пилите багрепорты и фичареквесты, вот это всё.

Буду очень признателен за звездочки в репозитории.

Ещё я как раз сейчас ищу работу. Если у вас есть задачи, связанные с распределенными системами, высокими нагрузками и высокой доступностью, я буду рад поболтать.

1 комментарий 👇

Интеграционные тесты — это не про тестирование. Это про интеграцию.

Этот лонгрид написан с применением LLM, но ограниченным. Основные куски текста написал я сам, так что я очень прошу LLM-полицию расслабить булки.

Дисклеймер лучше до тригерящего выражения, имхо. Я вот успел дёрнуться 😅

  Развернуть 1 комментарий

😎

Автор поста открыл его для большого интернета, но комментирование и движухи доступны только участникам Клуба

Что вообще здесь происходит?


Войти  или  Вступить в Клуб