Illustration - How to test code in Go with a database?

Введение

Как протестировать код на Go с базой данных? В этой статье опишу пример такого тестирования в связке с Postgres, очисткой на основе копирования базы данных и рассмотрю некоторые альтернативы.

Зачем тестировать с базой данных?

Чтобы получить более полезные тесты, которые:

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

Интересная цитата на тему таких тестов:

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

Владимир Хориков [1, с. 296-297]

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

Контроль качества не должен находить дефекты

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

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

Роберт Мартин [2, с. 125]

Тестовое окружение

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

  • docker-compose - c запуском через CLI;
  • docker-compose c запуском через testcontainers-go;
  • запуск Postgres и миграций через testcontainers-go.

Я рассмотрю пример с docker-compose, с помощью которого будет запускаться тестовое окружение как локально на машине разработчика, так и в CI.

Наиболее оптимальным кандидатом мог стать docker-compose с запуском через testcontainers-go, но там обнаружились проблемы с переопределением зависимостей которые не хотелось копировать из проекта в проект.

Примечание

Given the version includes the Compose dependency, and the Docker folks added a replace directive in their go.mod, we were forced to add it as well. As a result, users of Testcontainers for Go need to add the following replace directive to their go.mod files.

И в итоге был выбран простой и лаконичный вариант с docker-compose в связке с Makefile.

Запуск окружения

Для начала понадобится docker-compose c Postgres нужной версии, после этого нужно определиться с тем как накатывать миграции. Предлагаю рассмотреть добавление инструмента миграции в docker-compose, если это возможно, потому что эта автоматизация упрощает работу с тестовым окружением, а параметр depends_on в связке с healthcheck позволит дождаться полноценного старта Postgresql и не будет проблем вызванных недостаточно инициализированной базой данных до момента запуска тестов.

version: '3.8'

services:
    postgres:
        image: postgres:15
        environment:
            POSTGRES_DB: postgres
            POSTGRES_PASSWORD: postgres
            POSTGRES_USER: postgres
        healthcheck:
            test: pg_isready --username "postgres" --dbname "postgres"
            interval: 1s
            retries: 3
            timeout: 5s
        ports:
            - "5432:5432"

    migrate:
        image: migrate/migrate:4
        command: -source 'file:///migrations' -database 'postgresql://postgres:postgres@postgres:5432/reference?sslmode=disable' up
        depends_on:
            postgres:
                condition: service_healthy
        volumes:
            - ./migrations:/migrations:ro

Чтобы упростить запуск и очистку окружения, а также использовать одинаковый набор команд локально и на CI предлагаю завернуть их в Makefile.

  • make test-env-up - запускает тестовое окружение и выполняет миграции.
  • make test-env-down - останавливает тестовое окружение, удаляет созданные контейнеры и данные в них.

Это уменьшит дублирование команд и будет гарантировать их работоспособность регулярно перепроверяя в CI.

Подключение к базе данных

Окей, БД запустили, как к ней подключаться? Понадобится пакет для подключения к тестовой БД, один пакет поможет не растаскивать подключения по разным местам в кодовой базе. Подход к тестированию и подключению к БД со временем может измениться, и будет проблематично выковыривать дробь с подключением к тестовой базе из тестового кода. В моём случае это пакет testingpg в котором будет сокрыта логика по заданию подключения.

Здесь нужно будет определиться как получать параметры подключения к тестовому окружению, на практике хорошо показал себя подход с переменными окружения и значениями по умолчанию. То есть конфигурационные параметры получаются через os.Getenv("...") но если параметр не указан подставляем значение по-умолчанию.

// testingpg.go
urlStr := os.Getenv("TESTING_DB_URL")
if urlStr == "" {
    urlStr = "postgresql://postgres:postgres@localhost/postgres"
    const format = "env TESTING_DB_URL is empty, used default value: %s"
    t.Logf(format, urlStr)
}

// ...

Это удобно в связке с локальным docker-compose потому, что такие тесты без проблем запускается как через CLI, так и в IDE без дополнительных плясок с бубном, а также позволяют гибко настраивать среду если кто-то из команды хочет настроить под себя, естественно заплатив за это пониманием нюансов работы тестового окружения и тестовой обвязки.

Запуск тестов

Я думаю читатели скорее всего знакомы с командой go test ./..., предположу что разного рода флаги -count=1,-p=1,-v,-short,-race тоже вам не в диковинку. Если вдруг в не знакомы, то посмотрите описание с помощью go help testflag,go help test, go help testfunc, но я отвлекся. Постоянно использовать команду go test ... и перечислять все нужные флаги не удобно, по этому предлагаю завернуть наиболее часто используемую вариацию этой команды в Makefile, в моём примере это:

.PHONY: test
test: ## Run all tests.
	@go test -count=1 -coverprofile=coverage.out ./...
	@go tool cover -func=coverage.out | grep ^total | tr -s '\t'

Это упростит задачу локального запуска тестов и запуск их CI при этом использование единой команды будет гарантировать работоспособность команды make test.

Окей, круто, есть окружение, есть подключение к базе, и можно прогнать все тесты одной командой, и вот мы уже можем бодро шевеля мозгами придумывать кейсы и имплементировать их в коде. Но вдруг, после написания нескольких кейсов и нескольких прогонах тестов мы замечаем что тесты перестали проходить. Делайте ваши ставки в чем проблема? Ладно, проблем может быть море от не продуманных кейсов до нерабочего кода, но здесь я намекаю на очистку и изоляцию состояния БД для разных тестов.

Жизненный цикл тестовых данных

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

Следующий шаг это сохранение одинакового первоначального состояния базы данных в рамках одного тестового прогона и в рамках нескольких прогонов.

Подробнее с описанием проблемам, подходами и советами можно ознакомится в [1, с. 284-298][3, с. 657-675][4, p. 243-256][5, p. 649-668][6, p. 145-165].

Очистка по завершении теста

Идейно простой вариант очистки базы данных это сделать отложенный вызов через t.Cleanup() и удалить всё что накопилось за время прохождения теста например запросами delete или заготовленным скриптом, способ идейно простой, но на практике он порождает множество различных ухищрений и приспособлений для очистки базы которые засоряют тесты и осложняют чтение. Ещё одна проблема связана с архитектурой go test, Go запускает тесты для каждого пакета параллельно и если в разных пакетах тесты затрагивают одни и тоже таблицы БД они будут падать в произвольный момент. А также может быть весьма проблематично отследить тест, который оставляет за собой мусор. В общем вариант рабочий, но не самый удобный. [4, p. 244][1, с. 285].

Очистка усечением таблиц

Основная идея проста, после прохождения теста запускаем команду TRUNCATION TABLE, для всех таблиц затронутых тестом или заранее написанный скрипт. Это очень эффективно удаляет все данные из таблиц без каких-либо побочных эффектов (например, триггеров).

Подробнее об этом методе можно прочитать в [5, с. 661-664] [3, с. 668-669]

Сохранение идентификаторов созданных сущностей

Вы можете сохранять идентификаторы всех созданных во время тестов сущностей, а после прохождения теста удалять все эти сущности, подробнее в [6, p. 336-337].

Очистка перед тестом

Суть этого подхода в очистке БД перед тестом, такой подход лучше тем что очистка не будет пропущена в случае неожиданного завершения теста, но то такой подход также ограничивает возможность параллельного запуска тестов, подробнее [4, p. 244][7, p. 291][1, с. 285].

Транзакционная очистка

Альтернативой отложенной очистке может быть очистка через транзакцию, в этом случае все тестируемые компоненты в рамках теста инициализируются с соединением в котором уже запущена транзакция и по завершению теста она откатывается через t.Cleanup(), но такой способ плохо сочетается со сценариями в которых мы сами управляем транзакцией в тестируемом коде, потому что мы либо не открываем новую транзакцию и как бы не полноценно тестируем основной код, либо коммитим транзакцию и получаем мусор, подробнее [4, p. 244][7, p. 292][1, с. 285].

Изоляция через разные учетные данные

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

Изоляция через тенантность

Интересным вариантом распараллеливания тестов и упрощения очистки может быть создание тестовых данных в разных тенантах, если в вашем продукте на уровне БД есть деление на тенанты.

Изоляция с помощью копирования базы данных

Альтернативным подходом может послужить вариант с изоляцией базы данных. В этом подходе для каждого теста создается своя копия базы данных. В рамках этой изолированной базы данных проходят все проверки и по завершению база данных удаляется через t.Cleanup() со всем мусором который накопился.

Как это работает? Нам понадобится СУБД с двумя базами данных назовём их postgres и reference. Ранее я описывал пакет testingpg настало время его доработать, каждый раз когда тест запрашивает новое соединение к базе данных мы подключаемся к базе данных empty, после чего создаем копию базы данных reference с каким-то произвольным именем и регистрируем функцию очистки через t.Cleanup() которая удаляет базу данных по завершению теста, далее открываем соединение с этой копией и отдаем в наш тест. Да, такая очистка может оставлять за собой мусор в случае неожиданного прерывания, но это не окажет влияния на следующие прогоны тестов, если имя базы данных достаточно уникально.

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

Раздельный запуск тестов

Про раздельный запуск интеграционных и unit тестов можно почитать в статье Разделение Интеграционных и Unit тестов в Go с помощью флага -short, или подчеркнуть из примера кода к этой статье.

Заключение

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

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

И ещё одна цитата на тему:

МЫ НЕ БУДЕМ ЗАВАЛИВАТЬ РАБОТОЙ ОТДЕЛ КОНТРОЛЯ КАЧЕСТВА

Я ожидаю, что мы не будем создавать стрессовые ситуации для отдела контроля качества.

Зачем такие отделы вообще существуют? По какой причине компании оплачивают работу людей, проверяющих результаты труда программистов? Ответ очевиден и неутешителен. Решение создавать отделы контроля качества программного обеспечения обусловлено тем, что программисты плохо делали свою работу.

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

Представляете, какое давление испытывают члены команды QA? Тестирование — очень напряженная и утомительная работа, которую приходится сокращать, если поджимает время до срока сдачи проекта. Понятно, что качество при этом не гарантировано.

Роберт Мартин [8, с. 273]

И да, заглядывайте в пример кода, и не забывайте про звезды.

Источники

  • [1] Принципы юнит-тестирования. — СПб.: Питер, 2021. — 320 с.: ил. — (Серия «Для профессионалов»). / Владимир Хориков
  • [2] Идеальный программист: как стать профессионалом разработки ПО. — СПб.: Питер, 2019. — 224 с.: ил. / Мартин Роберт
  • [3] Шаблоны тестирования xUnit: рефакторинг кода тестов. : Пер. с англ. М. : ООО ‘‘И.Д. Вильямс’’, 2009. 832 с. : ил. Парал. тит. англ. ISBN 978-5-8459-1448-4 (рус.) /Джерард Месарош
  • [4] Unit Testing Principles, Practices, and Patterns — Manning Publications, 2019. / Vladimir Khorikov
  • [5] XUnit test patterns : refactoring test code — Addison-Wesley, 2007. / Gerard Meszaros.
  • [6] Complete Guide to Test Automation: Techniques, Practices, and Patterns for Building and Maintaining Effective Software Projects — Apress, 2018 / Arnon Axelrod
  • [7] Growing Object-Oriented Software, Guided by Tests — Addison Wesley, 2010 / Steve Freeman, Nat Pryce
  • [8] Идеальная работа. Программирование без прикрас. — СПб.: Питер, 2022. — 384 с.: ил. — (Серия «Библиотека программиста»). / Мартин Роберт

FAQ

  • Можно ли обойтись без Makefile? — Конечно можно, но это довольно удобный инструмент для унификации консольных команд среди команды разработки и CI.
  • Можно ли чем заменить docker-compose? - Да, можете например попробовать https://github.com/testcontainers/testcontainers-go
  • У меня кешируются результаты тестов зависящих от базы данных, что делать? — Для того чтобы go не использовал кеш тестов идиоматичным способом будет добавить флаг -count=1 подробнее смотри в go help testflag, вторым способом может быть использование команды go clean -testcache перед каждым запуском тестов.
  • Зачем комментарии вида ## Run all tests. таргетов в Makefile ? — Чтобы выводить подсказку с помощью make help смотри подробнее в репозитории с примером.
  • Почему используется не используется testcontainers-go? — Потому что он медленный, для каждого теста сборка контейнера занимает ~10s (на моём устройстве).
  • А пробовали использовать опцию reuse для testcontainers-go? — Да, но это полностью убивает возможность использовать t.Parallel() из-за ошибок возникающих при запуске контейнеров и в сочетании с методом изоляции тестов через копирован с go test -p=1 это дает уже ощутимые накладные расходы.