Всем привет! Меня зовут Макс Теричев. Я старший инженер по разработке ПО в 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: каждый может установить ее у себя и принести свои исправления и замечания по опыту использования. 

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

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