Микросервисы можно тестировать по-разному. У каждого подхода есть свои плюсы и минусы, поэтому, чтобы выбрать свой путь и избежать на нём «граблей», лучше всего учиться на чужом опыте. А ещё лучше — на конкретных примерах.

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

Видео моего выступления на конференции Golang Live 2020 можно посмотреть здесь.

Про инфраструктуру OZON

Ozon — это cайт и мобильное приложение с 27 миллионами товаров, 80 миллионами посетителей в месяц. За второй квартал 2021 года мы доставили более 40 миллионов заказов. Наша разработка поделена на кросс-функциональные команды, состоящие из разработчиков, тестировщиков и одного product-менеджера. У каждой команды — небольшая часть функционала и один или несколько микросервисов, которых всего более двух тысяч. Основные языки для их реализации — Golang и C#. Основной способ взаимодействия между микросервисами: Protocol Buffers - нейтральной к языку и платформе инструмент сериализации структурированных данных и gRPC фреймворк, который позволяет соединять микросервисы между собой. Приведу небольшой пример, чтобы продемонстрировать, как у нас пишутся микросервисы:

Пример
syntax = "proto";

package item;
option go_package = "api;reviews";

service Reviews {
	rpc CreateReview (CreateReviewRequest) returns (CreateReviewResponse);
	rpc GetRating (GetRatingRequest) returns (GetRatingResponse);
}

message CreateReviewRequest {
  int64 user_id = 1;
  int64 item_id = 2;
  int32 score = 3;
  string message = 4;
}
  
message CreateReviewResponse {
	int64 id = 1;
}
  
message GetRatingRequest {
	int64 item_id = 1;
}
  
message GetRatingResponse {
	float rating = 1;
}

Разработка микросервиса начинается с создания proto-документа. В нем описывается, как пользователь может взаимодействовать с сервисом. После этого запускается генерация кода, в которой реализуется бизнес-логика и взаимодействие с источниками данных. В качестве источников данных мы используем PostgreSQL, MS SQL и Apache Kafka.

GitLab CI/CD & STG

В качестве CI мы взяли GitLab CI.

После того как разработчик написал код и отправил его в репозиторий, запускается сборка приложения, потом — unit-тесты. Проходит валидация, автоматически проверяется безопасность, собирается docker image и приложение разворачивается на staging environment. Каждой версии приложения назначается отдельный хост. После этого запускаются системные и интеграционные тесты, а также ручные проверки.

Для функционального тестирования мы используем staging, над которым работают разработчики, тестировщики, владельцы продукта, дизайнеры и аналитики. На staging развернуты все актуальные версии сервисов и баз данных. В базах данных хранятся анонимизированные данные с production. С помощью service mesh можно проверить свою версию приложения. Также staging поддерживает команда инфраструктуры.

Интеграционное тестирование в Golang

Мы подходим к тестированию нашего приложения по-разному.

Например, пишем unit-тесты, которые проверяют код приложения. Такие тесты вместо реальных объектов используют подставные, например, Моки. Скорость их исполнения приблизительно 1 миллисекунда.

Также мы тестируем наше приложение с помощью системных или end2end тестов. Они запускают браузер или мобильное приложение, используют настоящее окружение и даже могут запускаться на production. Продолжительность End2end-тестов зависит от сценария, но в среднем один тест исполняется 5-10 секунд.

Так как микросервисов у нас много, то особое внимание мы уделяем интеграционному тестированию. Оно позволяет взаимодействовать с реальными объектами, например, БД или другими микросервисами, но тест не включает в себя браузеры и мобильные приложения. Мы просто отправляем запрос к сервису, после этого проверяем либо ответ от сервиса, либо состояние самого сервиса. В среднем такой тест исполняется до 100 миллисекунд, а за счет того, что они быстро исполняются, написать их можно много.

Давайте поговорим про то, к чему мы стремимся при написании тестов.

Качества хорошего теста:

  • Понятное название, чтобы по нему сразу понимать, что происходит в тесте.

  • Небольшой тест, который проверяет только одну функциональность. Потому что в случае падения большого теста, который проверяют всё, сложно понять, что в нём происходит.

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

  • Тесты не должны деградировать. При запуске тысячу раз подряд, они должны исполняться и показывать один и тот же результат.

  • Тесты должны быть воспроизводимыми, чтобы понимать, что произошло и иметь возможность повторить сценарий, который происходит в тесте.

Сначала мы использовали стандартные инструменты нашей компании, но столкнулись с их недостаточным функционалом и другими особенностями. Например, в Python можно тестировать gRPC и подготавливать данные с использованием SQL, но нельзя переиспользовать Go-библиотеки, которые разрабатываются внутри компании, например, клиенты для БД и Kafka. А из-за того, что Python надо устанавливать на свою машину, Go разработчики неохотно запускают тесты, и тем более не хотят разбираться, что произошло в упавшем тесте. Поэтому мы приняли решение переписать наши интеграционные тесты на Go.

AAA / GivenWhenThen

Мы начали использовать подход AAA (Arrange–Act–Assert или GivenWhenThen), при котором тесты делятся на три части:

  1. Подготовка данных;

  2. Целевое действие, в основном запрос к сервису по Rest или gRPC;

  3. Проверка данных от сервиса с помощью библиотеки Testify.

В Go есть возможность исполнять одни и те же тесты для разного набора данных. Это называется TableDrivenTests. Чтобы его написать, нужно объявить slice структур и после этого в цикле запустить тест:

Тест
func TestValidateionFileds(t *testing.T) {
  
	testCases := []struct {
		name string
		req pb.CreateReviewRequest
	}{
		{name: "empty request", req: pb.CreateReviewRequest{}},
		{name: "empty item id", req: pb.CreateReviewRequest{
			UserId: helpers.NewID(t),
			Score: 1,
		}}
		{name: "empty user id", req: pb.CreateReviewRequest{
			ItemId: helpers.NewID(t),
			Score: 5,
		}},
    {name: "empty score", req: pb.CreateReviewRequest{
			UserId: helpers.NewID(t),
			ItemId: helpers.NewID(t),
		}}

Распространенные ошибки

t.Parallel() TableDrivenTests

В Go можно гибко настраивать параллельные тесты: вам нужно только добавлять команду t.Parallel(). Причем добавлять ее только там, где она нужна. Например, если тесты исполняются на одной среде, то один тест может повлиять на работу другого. 

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

В Go достаточно умная система параллелизации, при которой параллельные тесты исполняются вместе с параллельными, а последовательные — с последовательными, поэтому всё работает быстро.

defer vs os.Exit (до go 1.15)

До версии Go 1.15 мы часто сталкивались с ошибкой совместного использования defer и os.Exit. Потому что os.Exit вызывает мгновенное прерывание программ и defer не отрабатывает:

Поэтому либо внимательно следите за этими командами, либо обновите Go до версии 1.15, где m.Run не требует использования os.Exit.

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

Поскольку мы решили делать небольшие функциональные тесты, чтобы запускать и разрабатывать их по отдельности, нам нужно было разделить unit-тесты и интеграционные тесты. Для этого мы использовали custom build tags. В начале тестовых файлов нужно написать «// +build test»(или «//go:build test» в версии 1.17), а чтобы запустить тесты — добавить команду tags в команду go test.

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

PHONY: test
test:
	go test ./... -tags test -count=1

.PHONY: local
local: export BASE_URL=localthost:50051
local: test

Собираться файлы тестов и приложений будут по отдельности. В случае необходимости можно, например, выкатить hotfix со сломанными тестами.

В Go есть возможность кэшировать тесты. Это удобный инструмент для unit-тестирования, но при интеграционном тестировании приложение может иметь состояние или его поведение, которое зависит от данных. Бывают случаи, когда в первый раз ваш запрос отработает, а во второй упадет или вернет ошибку, поэтому для интеграционных тестов мы отключаем кэширование. Это можно сделать, добавив флаг -count=1 в качестве аргумента go test.

Кроме unit-тестирования, мы тестируем и скомпилированные, уже запущенные приложения. В общем случае приложение можно запускать прямо из тестов, но мы так не делаем. Мы запускаем наши приложения из Continuous Integration и используем разные docker-образы для запуска тестов и приложения. 

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

Config

Если вы хотите запускать тесты на разных средах, для хранения переменных разных сред понадобится конфиг. Мы попробовали две библиотеки: envconfig и viper.      

Config
// Config help running test on different enviroments

type Config struct {
	BaseURL string `envconfig:"BASE_URL" required:"true"`
}

// ProcessConfig create new config from enviroment variables
func ProcessConfig() *Config {
	var c Config

  err := envconfig.Process("",&c)
	if err != nil {
	log.Fatal(err.Error())
	}
  
	log.Printf("BASE_URL: %v\n", c.BaseURL)
	return &c
}

На мой взгляд, envconfig — хороший инструмент. Он простой и позволяет хранить переменные в переменных среды. У Viper более широкое API, которое подходит, например, для хранения конфигурации в файлах.

Инструменты тестирования

Для своих тестов мы используем различные инструменты тестирования. Очень удобно до того, как писать автотесты, проверять функциональность с помощью таких инструментов как cURL или grpc_cli. Один из самых удачных примеров, который мы делали — это логировали упавший запрос сразу в виде команды cURL или grpc_cli.

gRPC command line tool

Такая функциональность добавляется в клиенты для rest или gRPC. Когда тест падает, в лог сразу пишется cURL с аргументами для теста. Это позволяет не вникая в сам тест быстро воспроизвести упавший тест в консоль.

Подготовка данных для тестов

Часто в тестах нужны данные для тестирования, а источники данных не связаны с тестируемым сервисом. Например, нужен пользователь с именем, номером телефона, фамилией и чтобы он был зарегистрирован в системе с присвоенным user ID. Изначально мы хранили подготовленные списки ID для тестирования, но они быстро устаревали. Чтобы решить эту задачу мы разработали сервис для подготовки данных для тестов и назвали его QAAPI.

QAAPI выполняет сразу несколько функций:

Proxy, когда тестам необязательно знать, где подготавливаются данные. Это удобно, если одни и те же данные используются в разных микросервисах. Подготовка данных настраивается в одном месте, а потом разные тесты используют их для разных микросервисов.

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

Fixture, когда подготовка данных требует вызова разных API или других сложных действий, вместо этого необходимые фикстуры сразу кладутся в БД. Конечно, такое тестирование менее приближено к production, но зато позволяет быстро проверить сложные кейсы.

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

Test Reporting

На мой взгляд в Go не самая лучшая система reporting.

Результаты тестов пишутся в консоль. Если у вас мало тестов, то этого достаточно, но по мере увеличения проекта и количества тестов могут, например, появиться flacky тесты, которые то падают, то проходят. 

Возможно вам придется разделять ошибки тестов, среды и приложения. Для этого мы разработали несколько инструментов, чтобы расширить возможности отчетов в Go тестировании.

В gotest2allure вы запускаете тесты с флагом -json и сохраняете результаты тестов в обычный текстовый файл. После чего можете вызвать утилиту командной строки для построения allure-отчета. Несмотря на флаг JSON, Go тест может вернуть не JSON формат ошибки. Например, такое случается, если у вас не проходит стадия build для тестов.

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

Плюсы и минусы написания тестов на Go

Преимущества

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

  • Мы стали переиспользовать тесты в коде библиотек и приложений, которые разрабатываются в компании. 

  • За счет гибкого использования параллельности в тестах, увеличилась скорость выполнения тестов. Go сам по себе очень быстрый язык.

Минусы

  • При написании тестов мы столкнулись с некоторыми проблемами, но разработали инструменты для тестирования микросервисов. Например, сервис QAAPI или allure-портал для хранения тестовых отчетов.

  • Один из минусов написания тестов на Go — отсутствие готовых специалистов на рынке. Найти тестировщика со знанием Go достаточно тяжело, поэтому мы сами обучаем разработчиков и тестировщиков на специальных курсах внутри компании.

Конференция GolangConf 2021 пройдёт в виде отдельных треков в рамках большой весенней HighLoad++ 17 и 18 марта 2022 года в Крокус-Экспо в Москве.

Расписание и билеты.