Мы в API отказались от большого количества unit-тестов в пользу большого количества интеграционных/системных тестов, чтобы: не писать тесты на каждую небольшую функцию системы (которые могут периодически изменяться), а вместо этого тестировать интерфейс на верхнем уровне (1); наблюдать за взаимодействием разных частей системы (2); в случае жалоб от клиентов обращаться не к детальным тестам подкласса, скрытого в недрах какого-то внутреннего сервиса, а обратится к своим тестам и понять действительно ли (НЕ)работает сервис (3); при проведение рефакторинга и реструктуризаций кода не переделывать половину юнит-тестов (4).

На самом деле мы просто решили писать тесты не на отдельные классы/методы, а на интерфейс api, которым пользуются клиенты - на сервисы этого API. Тестируя их, мы убиваем двух зайцев: проверяем логику работы сервисов api + форматирование результата. Пришлось написать небольшую обвязку над библиотекой тестирования, чтобы она понимала декларативное описание: какой запрос и с какими параметрами сделать, что в ответе проверить, какие параметры взять и подставить во второй запрос, и так далее.

А такое тестирование сопряжено с несколькими проблемами:

  1. Каждый тест занимает много времени (загрузить фикстуры в БД, проинициализировать приложение).

  2. Тесты требуют больше сервисов: БД, кэши, добавьте своё.

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

  4. Тесты будут вызывать код, который делает внешние запросы/выбирает внешние данные.

Как мы решаем эти проблемы:

  1. Собираем образ чистой нетронутой БД приложением со всеми миграциями для тестов. Запускаем БД с хранилищем в памяти.

  2. Распиливаем тесты по группам и запускаем параллельно (с помощью нескольких джоб в ci/cd), чтобы сэкономить время, потратив больше ресурсов на тесты;

  3. Выключаем переинициализацию приложения после каждого запроса или уменьшаем оверхед от неё;

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

Сборка чистой БД для тестов

Мы используем postgresql. Для минимизации сложности инициализации необходимо схлопнуть миграции, просто записав их все в один sql-файл, которым инициализируется БД. А для тестов мы готовим проинициализированный образ бд, выполняем миграции и записываем получившийся образ как базу для тестов. На каждый коммит высчитывается hash от списка миграций, чтобы при добавлении новой миграции он менялся и приходилось пересобирать образ (т.к новая миграция = наличие изменений в БД).

Делается это в несколько шагов.

  1. Берём базовый образ postgresql, копируем в него SQL-дампы, добавляем в него флажок для пропуска старта БД после инициализации, выполяняем инициализацию и копируем полученное хранилище postgresql в каталог, которые не будет перезаписан при старте контейнера.

FROM postgresql:13
COPY /docker/build/services/db/init/ /docker-entrypoint-initdb.d/

# Отключаем обязательный запуск БД после инициализации
# инициализируем БД
# копируем хранилище БД
RUN sed -i 's/exec "$@"/if [ -z "${INIT_ONLY}" ]; then echo "Initialization complete" ; else exec "$@"; fi/' /docker-entrypoint.sh  \
    && /docker-entrypoint.sh "postgres" && cp -r $PGDATA /root/pgdata
  1. Далее нам нужно взять этот образ БД и через приложение накатить миграции.

#!/bin/bash
set -e

# Образы, нужные для выполнения
DB_SOURCE_IMAGE=${REGISTRY_BASE_URL}db-full:${BRANCH}
APP_TESTS_IMAGE=${REGISTRY_BASE_URL}app-tests:${BRANCH}
PGBOUNCER_IMAGE=${REGISTRY_BASE_URL}pgbouncer

# Считаем хэш
ROOT_DIRECTORY=../..
echo Migrations list: `find ${ROOT_DIRECTORY}/migrations/ -type f -exec basename {} \; | grep -v .gitignore | sort | paste -sd ' '`
MIGRATIONS_HASH=`find ${ROOT_DIRECTORY}/migrations/ -type f -exec basename {} \; | grep -v .gitignore | sort | paste -sd ' ' | md5sum - | awk '{print $1}'`
DB_TARGET_IMAGE=${REGISTRY_BASE_URL}/dev/db-full:$MIGRATIONS_HASH

# Проверяем, готов ли уже образ БД с этим набором миграций, и пытаемся скачать
echo $MIGRATIONS_HASH
if [ ! -z "`docker image ls -q --filter reference=$DB_TARGET_IMAGE`" ]; then
  echo "Image $DB_TARGET_IMAGE exists";
  exit;

else
  echo "Pulling $DB_TARGET_IMAGE ...";
  docker pull $DB_TARGET_IMAGE && exit;
  echo "Image $DB_TARGET_IMAGE is not found. Building $MIGRATIONS_HASH ...";
fi;

# Если образ не найден, то собираем
# Подключаем переменные окружения для поднятия контейнеров
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
source ${SCRIPT_DIR}/../../.env

PREFIX="full-db-build"
NETWORK="${PREFIX}-network"

# Останавливаем ранее поднятые контейнеры для сборки БД
docker network inspect ${NETWORK} 2>/dev/null || docker network create --attachable ${NETWORK}
if [ ! -z "`docker ps --filter name=${PREFIX} -q -a`" ]; then
  echo "Stopping existing ${PREFIX} containers"
  docker stop $(docker ps --filter name=$PREFIX -q -a);
fi;

# Запускаем БД
# start DB
echo "Starting DB ..."
docker run --rm --detach --network ${NETWORK} --name $PREFIX-db --network-alias=db --env INIT_ONLY=0 $DB_SOURCE_IMAGE bash -c 'cp -r /root/pgdata/* $PGDATA && ls $PGDATA && rm -rf /root/pgdata && /docker-entrypoint.sh'
echo "Starting pgbouncer ..."
docker run --rm --detach --network ${NETWORK} --name $PREFIX-pgbouncer --network-alias=pgbouncer \
    --env DATABASE0_DSN="master=host=db dbname=${POSTGRES_DB} user=${POSTGRES_USER} password=${POSTGRES_PASSWORD}" \
    --env POSTGRESQL_HOST=db \
    --env POSTGRESQL_PORT=5432 \
    --env POSTGRESQL_DBNAME=${POSTGRES_DB} \
    --env POSTGRESQL_USER=${POSTGRES_USER} \
    --env POSTGRESQL_PASSWORD=${POSTGRES_PASSWORD} \
    --env PGBOUNCER_LISTEN_PORT=5432 \
    --env PGBOUNCER_POOL_MODE=transaction \
    --env PGBOUNCER_AUTH_TYPE=any \
    --env PGBOUNCER_PORT=5432 \
    $PGBOUNCER_IMAGE

# migrate - Выполняем сами миграции
echo "Migrating ..."
docker run --rm --network ${NETWORK} --name $PREFIX-app --env DB_CONNECTION_HOST=pgbouncer --env REDIS_HOST=$PREFIX-redis --env DB_MASTER_HOST=$PREFIX-db $APP_TESTS_IMAGE /apply-migrations.sh
docker run --rm --network ${NETWORK} --name $PREFIX-app --env DB_CONNECTION_HOST=pgbouncer --env REDIS_HOST=$PREFIX-redis $APP_TESTS_IMAGE ./migrate-test --compact=1 --interactive=0

# Сохраняем результаты выполнения миграций в отдельный каталог
echo "Backing up data ..."
docker exec $PREFIX-db bash -c 'cp -r $PGDATA /root/pgdata && ls /root/pgdata'

# Фиксируем контейнер как образ и пушим в реестр
docker commit $PREFIX-db $DB_TARGET_IMAGE
docker tag $DB_TARGET_IMAGE ${REGISTRY_BASE_URL}/db-full:latest
docker push $DB_TARGET_IMAGE
docker push ${REGISTRY_BASE_URL}/db-full:latest

# Останавливаем контейнеры, нужные для сборки
docker stop $(docker ps -f name=$PREFIX -q -a)
docker network rm $NETWORK
docker image ls --filter reference=$DB_SOURCE_IMAGE
  1. Теперь готовый образ БД можно подключать как есть и она уже будет готова к прогону тестов, так как состояние актуальное.

# Надо перед запуском этого конфига docker-compose создать переменную окружения MIGRATIONS_HASH
# MIGRATIONS_HASH=$(find ../../migrations/ -type f -exec basename {} \; | grep -v .gitignore | sort | paste -sd ' ' | md5sum - | awk '{print $1}')
services:
  db-host:
    image: ${REGISTRY_BASE_URL}/db-full:${MIGRATIONS_HASH}
    command:
      - bash
      - -c
      - "cp -r /root/pgdata/* /var/lib/postgresql/data && /docker-entrypoint.sh"
    env_file:
      - .env
    tmpfs:
      - /var/lib/postgresql/data

Пройдя все эти этапы у нас получается готовый образ БД, который не требует инициализации. Так как БД располагается в tmpfs, то операции в ней будут происходить очень быстро.

Разделяем тесты на группы

Мы используем PHP и Codeception (под капотом у которого PhpUnit), поэтому пользуемся простой группировкой тестов с помощью @group, а запускаем тесты в нескольких разных проектах docker-compose (приходится запускать по приложению и БД для каждой группы тестов, так как для каждого процесса тестирования нужна своя база).

services:
  functional-profile:
    <<: *service
    depends_on:
      - pgbouncer
    entrypoint:
      - php
      - vendor/bin/codecept
      - run
      - -v
      - --group=profile
      - --html
      - report-functional.html
      - --coverage-text
      - functional-coverage
      - functional

  functional-complex:
    <<: *service
    depends_on:
      - minio
      - pgbouncer
      - redis
    entrypoint:
      - /var/www/wait-for-it.sh
      - minio:9000
      - --timeout=0
      - --
      - php
      - vendor/bin/codecept
      - run
      - -v
      - --group=complex
      - --html
      - report-functional.html
      - --coverage-text
      - functional-coverage
      - functional

  functional-other:
    <<: *service
    depends_on:
      - minio
      - pgbouncer
      - redis
    entrypoint:
      - /var/www/wait-for-it.sh
      - minio:9000
      - --timeout=0
      - --
      - php
      - vendor/bin/codecept
      - run
      - -v
      - --skip-group=profile
      - --skip-group=complex
      - --html
      - report-functional.html
      - --coverage-text
      - functional-coverage
      - functional

А в отдельных джобах в ci/cd мы стартуем разные сервисы. Чтобы они могли запускаться на одном сервере, мы разделяем их по docker-compose-проектам с помощью указания TESTING_SUITE, который потом будет использован как: COMPOSE_PROJECT_NAME=tests-${TESTING_SUITE}

# .gitlab-ci.yml

.test-suite:
  # в задании .test-template просто происходит авторизация в реестре образов docker и скачивание образов
  extends: .test-template
  after_script:
    - make test-down
  only:
    - develop
  except:
    refs:
      - schedules
    variables:
      - $CI_COMMIT_MESSAGE =~ /#notest/
  artifacts:
    paths:
      - tests/_output/
    expire_in: 15 minutes
    when: always

# а в отдельных заданиях запускаются отдельные группы тестов (а точнее отдельные сервисы в docker-compose проекта тестирования)
# тк сервисы все требуют БД/..., то вместе с запрошенным сервисом поднимаются и обязательные сервисы
test-functional-profile:
  extends: .test-suite
  variables:
    TESTING_SUITE: functional-profile
  script:
    - make test-functional-profile

test-functional-complex:
  extends: .test-suite
  variables:
    TESTING_SUITE: functional-complex
  script:
    - make test-functional-complex

test-functional-other:
  extends: .test-suite
  variables:
    TESTING_SUITE: functional-other
  script:
    - make test-functional-other

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

Таким образом у нас уже возможен запуск нескольких параллельных джоб, в каждой из которых поднимается приложение, готовая к использованию БД и другие обязательные сервисы.

Не сбрасываем состояние приложения после тестов

В нашем случае (PHP, Codeception, Yii2) существует простая опция, позволяющая не пересоздавать постоянно приложение, а также выполнять изменения БД в рамках откатываемой после теста транзакции. Выглядит примерно так:

class_name: FunctionalTester
modules:
    enabled:
        - Yii2:
            configFile: 'config/test.php'
            part: [orm, fixtures, email]
            transaction: true
            cleanup: true
            recreateApplication: false

Также для тестирования API есть у codeception коннектор к используемому фреймворку, который позволяет не делать сетевые запросы через веб-сервер, а отправлять их напрямую в приложение, что довольно сильно экономит время выполнения.

Меняем конфигурацию

В конфигурации для тестов (config/test.php) мы переопределяем компоненты для тестирования:

  • убираем лишние зависимости к кэшу (или делаем его локальным);

  • переопределяем очереди - делаем их либо синхронными (если нам важен результат выполнения джоб), либо закрываем заглушками;

  • меняем настройки подключения к БД (убираем использование слейвов);

  • добавляем моки или заглушки для внешних вызовов;

Время - деньги

Итоговая схема ci/cd у нас выглядит следующим образом (задания в одной колонке выполняются параллельно):

(1) Сборка

(2) Тестирование

(3) Деплой

(a) сборка образов приложения для запуска (приложение без отладочной информации, остальные сервисы)

(a+b) прогон функциональных тестов группы profile

(a) деплоим приложение на тестовый контур, если все тесты прошли успешно

(a+b) прогон функциональных тестов группы complex

(a+b) прогон функциональных тестов группы other

(b) сборка образов приложения для тестов и отладки (образ чистой БД, приложение с отладочной инфрормацией)

(a+b) прогон юнит-тестов

(a+b) прогон тестов ...

(a) прогон проверки кодстайла

(a)прогон стат. анализа

Если про цифры, то полный проход ci/cd со сборкой, запуском всех тестов (Tests: 1336, Assertions: 48236) и деплоем занимает около 15 минут (3 на сборку, 10 на ~6 параллельных групп тестов + проверки разные, ещё 2 на деплой). Покрытие при этом суммарное выходит около 51% (какая-то часть мёртвого кода портит статистку).

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

Стоит отметить, что гоняя тесты таким образом невозможно получить итоговый coverage, но для этих цели у нас запускается один раз в сутки pipeline, в котором все тесты проходят последовательно, собирая при этом корректный coverage. Его выполнение сейчас занимает чуть больше 70 минут.

P.S: возможно, тестирование API силами разработки не всегда хорошая идея (хотя если api действительно большой и должен иметь высокий SLA, то почему бы и нет) или ей должна заниматься команда автоматизации QA, но если есть задача создания больших и сложных тестов на свои же сервисы, то их вполне реально гонять быстро и почти незаметно!

Комментарии (6)


  1. flygrounder
    09.08.2023 16:26

    Мы в API отказались от большого количества unit-тестов в пользу большого количества интеграционных/модульных тестов,

    unit-тест как раз переводится на русский как модульный тест


    1. wapmorgan Автор
      09.08.2023 16:26

      Перепутал с системными. Изменю. спасибо


  1. 0Bannon
    09.08.2023 16:26

    Но ведь если у вас мало юнит тестов и много интеграционных тестов, это получается ice cream cone. Что не очень хорошо.


    1. mikronavt
      09.08.2023 16:26
      +3

      Ice cream это когда больше всего e2e тестов, а много интеграционных - это "кубок" (trophy), который порой считается эффективнее классической пирамиды


  1. kemsky
    09.08.2023 16:26

    Неплохая идея. В SQL Server есть механизм снепшотов, по сути такие сейв-пойнты, можно сделать снепшот и после теста вернутся в исходное состояние и обойтись без рестартов, может есть ли что-либо похоже в postgresql?