Тестирование и CI

GitHub Actions: e2e-джоб, который читает настоящие письма

Тест регистрации, который считывает код подтверждения из ящика, работает на ноутбуке — а потом встречается с раннером CI: нет секрета, который нужно смонтировать, есть вопрос исходящего трафика, дедлайн, который должен уместиться в задание, и ящик, который должен быть новым при каждом прогоне и при каждом повторе. Вот workflow — для Playwright и для pytest — с частями, которые важны только на раннере.

  • Средний
  • 15 мин на чтение
Три серые шестерёнки приводят в движение ленту, несущую синий конверт к серым воротам с синим фонарём наверху

Что меняется на раннере

Сам тест не меняется. Меняется всё вокруг него, и на каждый из этих вопросов есть конкретный ответ, а не просто пожатие плечами:

Монтировать секрет не нужно
Чтение публичного ящика не требует ни ключа, ни аккаунта, ни заголовка, так что почтовая сторона ничего не добавляет в secrets. Единственные учётные данные в задании — те, что вашему приложению и так нужны, чтобы отправлять почту, — SendGrid, Postmark, SES, что угодно, — и принадлежат они приложению, а не тесту.
Раннер должен дотягиваться до интернета
Исходящий HTTPS к grabmail.io и исходящий трафик к тому, что использует ваш почтовый сервис. Раннеры GitHub по умолчанию разрешают и то, и другое; self-hosted раннеру за фильтром исходящего трафика нужно добавить одно правило.
Прогоны пересекаются во времени
Два pull request, четыре шарда, повтор нестабильного задания — несколько копий одного и того же теста читают почту одновременно. Общий адрес позволил бы им читать чужие коды; новый адрес на каждый тест делает весь этот класс проблем невозможным.
Время тарифицируется
Тест, который ждёт почту шестьдесят секунд, — это нормально. Задание, которое ждёт шестьдесят секунд в каждом из сорока тестов, — это сорок минут оплаченного времени раннера. Ожидания должны быть ограничены, а набор тестов, разрастаясь, должен разбиваться на шарды.

Тестовый код, который запускают эти workflow, — тот же самый, что из руководства по Playwright или руководства по Python: новый адрес, ожидание с дедлайном, извлечение, привязанное к вашему шаблону. В нём нет ничего специфичного для CI, в этом и смысл, — всё специфичное для раннера живёт в файле workflow.

Workflow для Playwright

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

.github/workflows/e2e.yml
name: e2e

on:
  push:
    branches: [main]
  pull_request:

jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 20                 # the whole job, comfortably above every wait inside it

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npx playwright install --with-deps chromium

      # Your application, started the way it runs in staging: a REAL outbound
      # mailer. Its credentials are YOUR secret; the mailbox side needs none.
      - name: Start the application
        run: npm run start:test &
        env:
          MAILER_API_KEY: ${{ secrets.MAILER_API_KEY }}
          APP_URL: http://localhost:3000

      - name: Wait for the application
        run: npx wait-on --timeout 60000 http://localhost:3000/health

      - name: Run the suite
        run: npx playwright test
        env:
          BASE_URL: http://localhost:3000

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

Основную нагрузку несут три строки. timeout-minutes: 20 — внешняя граница, внутрь которой вложено всё остальное. Приложение запускается с настоящими учётными данными отправителя писем, потому что тесту, читающему настоящую почту, нужна настоящая отправленная почта. А отчёт загружается только при провале, с коротким сроком хранения, — в успешном прогоне сохранять нечего.

Workflow для pytest

Та же схема, но с инструментарием Python: запустить приложение, дождаться его health-эндпоинта, запустить набор тестов с таймаутом на тест выше дедлайна ожидания почты, сохранить отчёт JUnit при провале.

.github/workflows/e2e.yml
name: e2e

on:
  push:
    branches: [main]
  pull_request:

jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: pip

      - run: pip install -r requirements.txt -r requirements-test.txt

      - name: Start the application
        run: python -m app.server &
        env:
          MAILER_API_KEY: ${{ secrets.MAILER_API_KEY }}
          APP_URL: http://localhost:8000

      - name: Wait for the application
        run: |
          for i in $(seq 1 60); do
            curl -sf http://localhost:8000/health && exit 0
            sleep 1
          done
          echo "application did not come up" >&2; exit 1

      - name: Run the suite
        run: pytest tests/e2e -q --timeout=120 --junitxml=report.xml
        env:
          BASE_URL: http://localhost:8000

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: pytest-report
          path: report.xml

--timeout=120 приходит из плагина pytest-timeout и задаёт потолок на тест; дедлайн ожидания почты внутри помощника — шестьдесят секунд, так что тест, ждущий одно письмо, а затем выполняющий ещё немного работы в браузере, всё равно укладывается. Цикл проверки готовности прописан вручную, а не подключён как готовый action, потому что это восемь строк, в которых просто нечему сломаться.

Единственное сетевое правило

Чтение ящика — это исходящий HTTPS-запрос от раннера к grabmail.io. Это весь сетевой след целиком:

  • Никаких входящих соединений. К раннеру ничего не подключается. Нет вебхука, который нужно принимать, нет SMTP-сервера, который нужно запускать, нет порта, который нужно открывать наружу.
  • Никакого SMTP с раннера. Почту отправляет ваше приложение через своего провайдера, по API или SMTP-эндпоинту этого провайдера, — точно так же, как в продакшене. Сам раннер никогда не говорит по SMTP.
  • Добавить нужно только grabmail.io:443 на раннере с белым списком исходящего трафика — вдобавок к вашему почтовому провайдеру и реестру пакетов, которые заданию и так уже были нужны.
на защищённом раннере разрешите именно это
      - uses: step-security/harden-runner@v2
        with:
          egress-policy: block
          allowed-endpoints: >
            grabmail.io:443
            api.your-mail-provider.example:443
            registry.npmjs.org:443

Если набор тестов проходит локально, а в CI падает с ошибкой соединения от помощника, это правило — первое, что стоит проверить, и почти всегда этим всё и объясняется. В self-hosted раннере в корпоративной сети исходящий HTTPS обычно фильтруется по имени хоста; разрешить нужно имя хоста API, а запрос — обычный HTTPS на 443.

Шарды, матричные задания и повторы

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

четыре шарда, каждый — отдельное задание
  e2e:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      # ... the same steps as above, then:
      - run: npx playwright test --shard=${{ matrix.shard }}/4

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

Два pull request одновременно
Два задания, два набора случайных адресов, никаких пересечений. Потолок API на клиента — 1200 запросов в минуту, то есть двадцать ящиков, опрашиваемых раз в секунду, — а задание, опрашивающее по одному ящику за раз, и близко к этому не подходит.
Повторённое задание
Повтор снова выполняет тело теста, которое снова придумывает новый адрес. Старый ящик всё ещё хранит старое письмо в течение 5 дней, но его никто не читает — повтор его никогда не увидит.
Собственные retries в Playwright
То же самое, но уровнем ниже: каждая попытка заново выполняет фикстуру. Не переносите адрес в beforeAll ради экономии времени — это как раз то совместное использование, из-за которого попытка может прочитать код предыдущей попытки.

Таймауты, вложенные внутрь задания

Здесь четыре счётчика времени, и они должны быть вложены друг в друга, самый короткий — внутри всех. Когда это не так, о провале сообщает не тот счётчик, и указывает он не на ту причину.

СчётчикГде задаётсяРазумное значение
Дедлайн ожидания почтыВнутри помощника (timeoutMs, timeout=)60 секунд. Транзакционная почта приходит за секунды; минута покрывает медленную очередь у провайдера.
Таймаут тестаplaywright.config.ts / --timeout120 секунд. Выше дедлайна плюс работа браузера вокруг него.
Таймаут шагаtimeout-minutes на шаге, если заданОбычно не задаётся; хватает границы задания.
Таймаут заданияtimeout-minutes на задании20 минут. Хватает на установку, запуск, набор тестов и выгрузку отчёта; достаточно мало, чтобы зависшее приложение не тарифицировалось на целый час.

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

Как читать провал

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

  1. Логируйте адрес. Сообщение о провале от помощника называет ящик, который он ждал. Выведите его ещё раз в начале теста, чтобы он остался в логе задания, даже если проверка провалилась где-то ещё.
  2. Откройте ящик вручную. Письма хранятся 5 дней, так что /inbox/<address> на этом сайте покажет ровно то, что видел — или не видел — раннер, до конца недели. Это самое полезное отличие настоящего ящика от замоканного.
  3. Сохраняйте отчёт при провале. Трассировка Playwright показывает клик, который должен был отправить письмо; файл JUnit показывает, какой тест и сколько времени ждал.
  4. Проверьте статус сервиса, прежде чем винить тест. Страница статуса опрашивается снаружи каждые две минуты; если на момент прогона приём почты был недоступен, провал настоящий и не по вашей вине.

Уборка — по желанию

Всё истекает через 5 дней независимо от того, удалит это кто-то или нет, так что прогон без уборки ничего не стоит. Тем не менее удаление того, что прочитал прогон, стоит отдельного шага, потому что тогда следующий провал будет читаться на фоне по-настоящему пустого ящика. Это идемпотентно — повторное удаление всё равно отвечает 200, — так что само по себе оно никогда не сможет провалить сборку:

шаг уборки, который выполняется всегда
      - name: Delete what the run read
        if: always()
        run: |
          for addr in $(cat .e2e-addresses 2>/dev/null); do
            curl -sG https://grabmail.io/api/v1/mailbox --data-urlencode "address=$addr" \
            | jq -r '.messages[].id' \
            | xargs -r -I{} curl -sX DELETE -G "https://grabmail.io/api/v1/message/{}" --data-urlencode "mailbox=$addr" -o /dev/null
          done

Сделайте его if: always() и никогда не делайте обязательным: провалившаяся уборка должна быть предупреждением в логе, а не красной сборкой.

Прежде чем считать задачу закрытой

  • Никаких учётных данных для ящика в secrets; только ключ отправителя писем вашего собственного приложения.
  • Разрешён исходящий HTTPS к grabmail.io, и ничего входящего.
  • Новый адрес на каждый тест, придуманный в теле теста, — безопасно при шардах и повторах.
  • Четыре таймаута вложены друг в друга: дедлайн < тест < шаг < задание.
  • Отчёт загружается при провале, адрес присутствует в логе.
  • Уборка — как шаг, который выполняется всегда, но никогда не обязателен.

Это всё, что добавляет раннер. Дисциплина самого теста — дедлайн, новый адрес, привязанный шаблон — описана в «Сквозное тестирование потока проверки», а правила извлечения сами по себе — в «OTP-коды в автотестах».

Вопросы

Нужно ли добавлять в репозиторий секрет GrabMail?

Нет. Публичные домены не требуют ни ключа, ни аккаунта, ни заголовка, так что в secrets добавлять нечего. Единственные учётные данные в workflow — те, что использует ваше приложение для отправки почты, и они нужны ему в любом случае, чтобы вообще работать.

Работает ли это в приватном репозитории или на self-hosted раннере?

Да. Раннер делает исходящие HTTPS-запросы к grabmail.io и больше ничего; где именно вы размещаете раннер — не важно. На self-hosted раннере за фильтром исходящего трафика разрешите этот хост на порту 443.

Упрутся ли параллельные задания в лимит запросов?

На практике нет. Лимит на адрес — одно чтение в секунду, и помощник его соблюдает, а потолок на клиента — 1200 запросов в минуту, то есть двадцать ящиков, опрашиваемых раз в секунду, с одного раннера. Несколько раннеров — это несколько клиентов. На 429 отвечает заголовок Retry-After, и помощник просто засыпает на это время, а не падает.

Можно ли запускать это по расписанию, как синтетическую проверку регистрации в продакшене?

Да, и это хорошее применение: workflow с on: schedule, который каждый час регистрируется с новым адресом и считывает код, проверяет весь почтовый путь продакшена целиком, включая провайдера. Делайте префикс адреса узнаваемым, чтобы такие регистрации было легко вычистить на своей стороне.

Что если моё приложение отказывает одноразовым доменам?

Направьте на сервис собственный домен — одна MX-запись, без аккаунта — и используйте этот домен в фикстуре. Настройка занимает несколько минут, а «Неограниченные тестовые аккаунты на одном домене» показывает этот приём в наборе тестов.

Есть ли в ящике хоть что-то приватное?

Нет. Прочитать его может любой, кто знает адрес, — и на публичном домене, и на вашем собственном. Для случайного адреса с одним одноразовым кодом это не имеет значения; а для staging-окружения, отправляющего настоящие данные клиентов, это неприемлемо — не направляйте такое сюда.

Читать дальше

Попробуйте, пока свежо

Адрес — это один клик, без аккаунта и карты. Всё из этого руководства сразу заработает на нём.

С возвращением

Ваши ящики и ваши домены в одном месте.