Всем привет! Меня зовут Макс Теричев. Я старший инженер по разработке ПО в YADRO. В первый день работы в компании меня отправили пройти Go Tour. После этого я приступил к работе по автоматизации тестирования Control Path сервисов в СХД. Чтобы увеличить их тестовое покрытие, был создан специальный фреймворк, который интегрировал написание тестов в процесс разработки. Что входит в этот подход, насколько дорого обходятся ошибки в разработке систем хранения данных и какие два вида мотивации здесь работают — читайте под катом.
Чем дальше в цикле разработки, тем больше дров…
Еще в 2021 году мы начали придерживаться подхода «тестирование со сдвигом влево», чтобы находить дефекты как можно раньше. За счет такого сдвига мы обнаруживаем дефекты программного обеспечения заранее и их исправления обходятся дешевле, чем на поздних этапах.

Такой подход начали использовать и для продукта, с которым я работал тогда, — это была система хранения данных TATLIN.UNIFIED. Она состоит из контроллерного шасси и дисковых полок. Контроллерное шасси включает сетевые интерфейсы и два storage-контроллера. Storage-контроллер, в простейшем понимании, — это сервер. У него есть свой процессор, оперативная память и системные диски. Инициатор (он же сервер в терминологии СХД) обращается к таргету (то есть к самой СХД) либо через блочный доступ по протоколам iSCSI, Fiber Channel и NVMe over TCP, либо через файловый доступ по протоколам NFS и CIFS.
Посмотрите, как устроена система администрирования TATLIN.UNIFIED, в ознакомительном туре.
Внутри «железной коробочки» системы хранения данных есть —
Control Path — путь для управляющих команд. Он отвечает за регулирование и конфигурирование системы хранения данных. Написан на Go и использует системные вызовы к низкоуровневым библиотекам и системным утилитам. И когда дело касается низкоуровневого кода, могут возникнуть сложности с тестированием — об этом пойдет речь ниже.
Data Path — путь, по которому проходят пользовательские данные. Он отвечает за хранение данных и кеш, общение с дисками и оперативной памятью, а также за TATLIN.RAID — T-RAID. Подробнее о нем читайте в статье Вячеслава Пачкова.
У нас есть специализированный Docker-образ с минимально необходимым набором пакетов для изолированной компиляции и сборки Control Path сервисов — это неотъемлемая составляющая CI/CD. С развитием тестовой инфраструктуры у нас появилась возможность запускать тестовые сценарии в этом изолированном окружении на рабочем оборудовании разработчиков. То есть инженер может написать код, а в Docker-контейнере все и скомпилировать, и протестировать у себя на компьютере.
Мы стали использовать компонентные тесты для проверки сервисов Control Path как самостоятельных единиц. Теперь мы можем не обращаться к другим сервисам, если нам требуется получить от них какие-либо данные. Тестируем сервис изолированно, подменяя ответы на запросы от ниже лежащих сервисов подготовленными корректными и некорректными наборами данных. Так мы проверяем контракт взаимодействия между сервисами и обработку некорректных ответов.
Что можно тестировать в сервисах на Go
Можно проверять качество кода за счет статической проверки с помощью популярного сейчас golangci-lint. Главное не гонять все линтеры, а грамотно сконфигурировать их под себя. Еще можно проводить статический анализ с помощью Svace, который направлен на поиск некорректного и уязвимого кода.
Также есть возможность автоматизировать тестирование на всех уровнях, в том числе модульное, компонентное и e2e-тестирование. Интеграционное тестирование — это экзотика для Golang, но реализуемая.
Как провести интеграционное тестирование в Golang
Нужно переопределить функцию os.Exit(), а также быть готовым к тому, что при вызове log.Fatal и panic() метрика code coverage не будет собрана. Также, чтобы произвести e2e-тестирование со сбором метрик покрытия сервиса, потребуется собирать специальный бинарный файл с использованием утилиты go test.
Что было не так в Control Path TATLIN.UNIFIED
Трудности в тестировании Control Path возникли из-за обильного количества legacy-кода, а также потому, что в мастер-ветке по какой-то причине оставили экспериментальный код. Отсутствовало архитектурное единообразие построения сервисов на Go — каждый сервис был написан по-своему.
Часть сервисов использовала низкоуровневые библиотеки на C, что вызывало трудности при тестировании, так как при проверке основного приложения мы еще проверяли и работу библиотеки. А нам это было не надо, мы хотели тестировать только свой код.
Мы начали с введения концепции «чистой архитектуры». Для этого мы разделили приложение на слои, каждый из которых решает конкретную задачу и выступает как самостоятельная единица. Внешние слои стали зависеть от интерфейсов, а не от конкретной реализации, а это позволило использовать тестовые дублеры — моки, наблюдатели, стабы.
Для каждого слоя мы генерировали свой мок с помощью GoMock от Google, затем перешли на форк от Uber, так как исходный пакет перестали поддерживать. Для генерации моков можно добавить Git Hooks, чтобы не генерировать моки вручную. И когда мы дописывали код или меняли интерфейс, при написании тестов не возникало проблем. В целом генерация моков — локальный, предсказуемый процесс, который не мешает тестам, так как выполняется до их запуска и попадает в репозиторий как готовый код.
Мы старались по минимуму использовать panic(). Если в коде есть паника, тесты зачастую падают и нет возможности собирать code coverage. Но сказать «никогда не используйте panic()» тоже неправильно. Это как с goto: вам могли говорить, что использовать его — это дурной тон. Потом вы вдруг оказываетесь в системном программировании и видите, что на goto строятся прошивки или что в исходниках Linux goto тоже есть повсюду. С panic() та же история. Если избежать panic() невозможно (например, в сторонних библиотеках), можно использовать recover() и логировать ошибку.
Для низкоуровневых вызовов мы стали делать специальные обертки в виде интерфейсов, для которых генерируем моки. Как это работаем мы рассмотрим на примере ниже.
Фреймворк поедания слона
Мы начинаем со сбора статистики покрытия, наличия юнит- и компонентных тестов для каждого сервиса. По результатам формируем таблицу сервисов и их покрытия. Ее мы анализируем, сортируем сервисы по возрастанию покрытия. Сервисы с наименьшим покрытием — проблемные, на них мы и делаем упор.
Изначально у нас были e2e- и интеграционные тесты, которые за счет модульных тестов друг друга дополняли, когда тестовое покрытие только начинали формировать. В дальнейшем часть тестовых e2e-сценариев была отключена или перешла на уровень компонентных тестов.
Вначале мы описываем, а затем имплементируем тестовые сценарии. Процесс повторяем в несколько итераций, пока процент покрытия не будет нас удовлетворять. Параллельно мы покрываем новую функциональность. Ведь, по большому счету, старая уже покрыта и протестирована на уровне e2e- и интеграционных тестов, а новая функциональность еще тестами не тронута.
Параллельно мы отслеживаем тестовое покрытие, чтобы повысить гарантии качества. С помощью визуализации покрытого кода с метрикой code coverage мы можем увидеть, какие ветви кода мы покрыли тестовыми сценариями, а какие нет.
Тестовое покрытие также позволяет облегчить рефакторинг. Допустим, наши тесты работают исправно, но мы решили переработать какой-то метод, перейти на новые практики или оптимизировать код. Если мы запустим тесты и все упадет, мы будем знать, где искать ошибки.
Не все герои носят плащи
Для написания тестовых сценариев у нас есть два варианта набора инструментов.
Первый — фреймворк Ginkgo и библиотека сопоставлений Gomega от onsi. Здесь тесты описываются в стиле «разработка на основе поведения» (Behavior-Driven Development). Gomega мы можем использовать как самостоятельное решение для написания различных моков. У Gomega также есть полезные пакеты ghttp, которые позволяют нам написать хороший тестовый дублер, когда мы вызываем ниже лежащий сервис, работающий по http.
Второй — стандартный пакет testing, в рамках которого мы и применяем форк GoMock от Uber для создания тестовых дублеров. Для проверки утверждений используем testify и его модули require и assert — они облегчают проверку самих утверждений и того, что нам возвращают вызванные методы.
Эти наборы инструментов — стандартный пакет testing с использованием пакета testify, GoMock от Google, который впоследствии был заменен на форк от Uber, в некоторых сервисах модульные тесты с Ginkgo и Gomega — нам года четыре назад открыл один хороший инженер из команды TATLIN.UNIFIED. Смысла убирать Ginkgo и Gomega мы не видели, так как тесты работали исправно. Главное — не забывать обновлять библиотеки.
Как добавить тестируемости
Мы можем вызвать системную утилиту iscsiadm() — в примере код из CSI-драйвера для TATLIN.UNIFIED:
func (iscsi *LinuxISCSI)] GetSessions() ([]ISCSISession, error) {
cmd := exec.Command("iscsiadm", "-m", "session", "-P", "2", "-S")
output, err := cmd.Output()
if err != nil {
return err
}
return iscsi.sessionParse(output), nil
}
Этот код исполняется на стороне инициатора — сервера, который подключен к системе хранения данных через сеть. СХД, как упоминалось ранее, для этого сервера — таргет. На инициаторе с использованием iscasiadam -m session мы запрашиваем существующие iSCSI-сессии.
Проблема здесь в том, что при тестировании мы не можем подменить код вывода и изолировать наши вызовы. Нам не хотелось вызывать эти команды в ядре Linux, перебирать их там, потому что тесты на уровне компонентов должны выполняться быстро. Мы решили эту проблему за счет добавления «щепотки» интерфейсов и генерации мока:
//go:generate mockgen -source=iscsi.go -destination=iscsi_mock.go -package=iscsi
type ISCSI interface {
GetSessions() ([]ISCSISession, error)
...
}
func (iscsi *linuxISCSI)] GetSessions() ([]ISCSISession, error) {
return iscsi.getSessions()
}
func (iscsi *linuxISCSI)] getSessions() ([]ISCSISession, error) {
cmd := exeс.Command("iscsiadm", "-m", "session", "-P", "2", "-S")
output, err : = cmd.Output()
if err != nil {
return err
}
return iscsi.sessionParse(output), nil
}
Объявляем интерфейс, перечисляем в нем все публично доступные методы. Можем наш метод спрятать, сделав его приватным, и просто вызывать его из реализации публичного метода. Либо же просто превратить старый код в новый, но из приватного метода все перенести в публичный.
Как использовать сгенерированный мок?
type ISCSI struct {
*iscsi.MockICSCI
}
func GivenISCSI(t *testing.T) (*ISCSI, func()) {
var (
controller = gomock.NewController(t)
mock = &ISCSI{MockICSCI: iscsi.NewMockISCSI(controller)}
)
mock.EXPECT().GetSessions(gomock.Any()).DoAndReturn(
func(ctx context.Context) ([]iscsi.ISCSISession, error) {
return []iscsi.ISCSISession[}, nil
},
).AnyTimes()
return mock, controller.Finish
}
В моке переопределяем функцию GetSessions(), которая будет возвращать отсутствие iSCSI-сессий. Можно сконфигурировать окружение, которое будет в дальнейшем исполняться в тестах:
func configureEnv(t *testing.T) (connector.Connector, func()) {
var (
iscsi *mocks.ISCSI
iscsiTeardown func()
)
mockCtrl := gomock.NewController(t)
iscsi, iscsiTeardown = mocks.GivenISCSI(t)
return connector.NewStorageConnector(iscsi) func() {
iscsiTeardown()
mockCtrl.Finish()
}
}
ConfigureEnv() — функция, которая готовит тестовое окружение. Она создает «поддельную» среду, чтобы тестировать код без настоящего оборудования или системных вызовов. За счет моков наши тесты не зависят от внешних систем, и мы можем тестировать логику работы кода без привязки к инфраструктуре. Благодаря этому мы получили возможность переносить эти тесты между различными средами. Теперь мы тестируем только наш код, а не рабочее окружение.
Как разработчики полюбили писать тесты?
В 2021 году была создана готовая инфраструктура для написания тестов: разработчики покрывают happy path сценарии, а тестировщики — негативные и другие сложные сценарии. Во всех сервисах был введен единый подход для написания тестовых сценариев, а для тестовых сценариев разработчиков — обязательное код-ревью.
Несколько месяцев спустя 10-15% покрытия тестами по всем 14 сервисам, реализующим Control Path, удалось повысить до 65-70%. Получив инфраструктуру для тестирования, инженеры стали сами писать тесты и без помощи тестировщиков. Это о пряниках. Теперь о кнутах.
Сначала любовь к тестам прививалась за счет дисциплины. На CI было добавлено отслеживание тестового покрытия. Если оно снижалось более чем на 8%, то код в мастер-ветку не попадал до тех пор, пока тесты не были написаны. Если тестовое покрытие снижалось менее чем на 8%, выдавалось предупреждение: «Неплохо бы покрыть код тестами! В мастер, так и быть, заезжайте. Но будьте добры когда-нибудь эти тесты дописать!» (sic!).
Подобный подход проник и в другие продуктовые команды. Например, один инженер из TATLIN.UNIFIED при переходе в YADRO.SUPREME принес с собой все инструменты для написания тестов: и testing/testify, и GoMock от Uber, и Ginkgo, и Gomega с ghttp. У последнего появилась продуктовая особенность: там его используют для подмены вызовов API каких-нибудь внешних систем. Здесь разработчики тоже стали довольно самостоятельными, и теперь справляются без помощи инженеров по тестированию. Тот же набор инструментов перешел и в CSI-Driver в TATLIN.UNIFIED.
Мы разработали свою TMS-систему TestY, в которой описываем все тестовые сценарии и имплементируем их в код. Она доступна в open source: каждый может установить ее у себя и принести свои исправления и замечания по опыту использования.
Благодаря разработанному фреймворку наши разработчики начали писать тесты сразу, не откладывая на потом. Инженеры успешно справляются без участия тестировщиков — качество их кода остается на высоком уровне. Это стало лучшим показателем эффективности подхода: тесты стали неотъемлемой частью процесса разработки.