Сегодня Kubernetes стал де‑факто стандартом для развёртывания SaaS‑приложений и сервисов. Практически каждый разработчик работает с ним ежедневно, но большая часть этой работы связана с установкой уже готовых компонентов и манифестов. Если базового функционала начинает не хватать, возникает потребность в расширении. И вот тут начинается путешествие в уникальный мир k8s‑операторов.
Всё, начиная с архитектурных паттернов, заканчивая поддержкой и тестированием, сильно отличается от привычных подходов, поэтому перед разработчиком встаёт большой пласт материалов, требующих изучения. Об этом и поговорим.

Меня зовут Антон Железнов, я разработчик в команде Managed Kubernetes облака MWS Cloud Platform. И в этой статье я хочу рассказать о тестировании операторов не на абстрактных примерах, а на устройстве нашего решения. Итак, поехали!
Дисклеймер
Тема устройства Kubernetes, в общем, и операторов, в частности, достаточно обширна, поэтому я заранее сделаю несколько допущений.
Первое допущение, статья ориентирована на читателя, который работает с k8s в качестве пользователя и на минимальном уровне знает о его устройстве. Под минимальным уровнем подразумеваю, что читатель:
знает, из каких блоков состоит k8s‑кластер;
знаком с понятием reconciliation‑loop;
понимает принцип работы k8s‑контроллеров и операторов.
Второе допущение, в статье сознательно упрощены некоторые темы. Это сделано, чтобы не утонуть в деталях и уложиться в ограничения формата. Но для тех, кто захочет изучить материалы более глубоко, оставлены ссылки на дополнительные источники.
И третье допущение, так уж повелось, что для решения одной и той же проблемы почти всегда существует более одного решения. И чтобы не превращать статью в каталог с перечислением технологий, я сфокусируюсь именно на тех решениях, что используются у нас в команде.
Как устроен Managed Kubernetes в облаке и при чём тут операторы
С Managed Kubernetes сталкивался, наверное, каждый разработчик. Идея продукта проста — ты выбираешь конфигурацию в консоли облачного провайдера, затем нажимаешь одну «волшебную» кнопку создания и через некоторое время получаешь готовый к использованию кластер. Но за всей этой простотой использования скрывается пласт технологий, обеспечивающих функционирование системы. И в основе нашего Managed Kubernetes решения лежит использование Cluster API.
Cluster API
Cluster API (CAPI) — проект, который предоставляет декларативный API и набор инструментов для управления жизненным циклом k8s‑кластеров. Его основная идея — создавать k8s‑кластеры тем же образом, как мы создаём привычные нам k8s‑деплойменты, сервисы и поды. В некотором смысле с его помощью мы создаём кубер с помощью другого кубера. Рассмотрим подробнее, из чего состоит Cluster API. В Cluster API все кластеры разделены на два типа: управляющие (Management Clusters) и рабочие (Workload Clusters).
Управляющий кластер: служебный k8s‑кластер, в котором развёрнуты контроллеры и сопутствующие им элементы. Он выступает в качестве центра управления и принятия решений.
Рабочий кластер: k8s‑кластеры, созданные для размещения пользовательских нагрузок. Именно их мы видим в консоли управления нашим облаком. Рабочие кластеры создаются контроллерами CAPI, размещёнными в управляющем кластере.
В общем виде создание рабочего кластера выглядит следующим образом:
клиент подготавливает манифест рабочего кластера;
манифест загружается в управляющий кластер;
контроллеры CAPI, в рамках реконсиляции, создают все необходимые элементы инфраструктуры и настраивают их. После чего рабочий кластер становится доступным пользователю.
Компоненты Cluster API
Рассмотрим подробнее, какие компоненты размещаются в управляющем кластере CAPI.
Core Provider
Центральный компонент Cluster API, он включает в себя набор основных Custom Resource Definitions (CRDs) и контроллеров, ответственных за их обработку. Core Provider управляет жизненным циклом рабочих кластеров, координирует работу всех остальных провайдеров, обеспечивает согласованность желаемого и текущего состояния.
Infrastructure Provider
Компонент, который отвечает за взаимодействие с конкретным слоем инфраструктуры. В качестве инфраструктурного слоя могут выступать IaaS‑сервисы облака или средства виртуализации. Основная задача инфраструктурного провайдера — управление жизненным циклом объектов инфраструктуры (VM, сети, load balancer‑ы) необходимых для функционирования k8s‑кластера.
Bootstrap Provider
Компонент, который отвечает за начальную настройку виртуальных машин. По сути, он превращает «чистую» VM в полноценную k8s‑ноду.
Control Plane Provider
Компонент, который создаёт и управляет Kubernetes Control Plane. В его зону ответственности входит создание master node и размещение на них компонентов control plane (etcd, API server, controller‑manager и scheduler).
На этом наш краткий обзор Cluster API можно считать завершённым, но если вам хочется поглубже погрузиться в тему, то рекомендую вам пройти Cluster API Quick Start. Пройдя это руководство, вы сможете поднять на своей машине Cluster API и попробовать себя в роли Kubernetes‑провайдера.
Наше Managed Kubernetes решение
Теперь, когда мы знаем, что такое Cluster API, давайте верхнеуровнево рассмотрим наше решение.

Точка входа для нашего решения — mk8s CPL. Этот сервис доступен через api gateway и представляет собой главный управляющий сервис для managed k8s‑кластеров. На его уровне хранится desired state для всех кластеров во всех регионах.
Ниже CPL находится несколько инсталляций Cluster API Management‑кластеров. Каждая инсталляция находится в собственной системной сети и отвечает за обслуживание своего региона. Помимо стандартных компонентов, в каждом CAPI management‑кластере развёрнуты разработанные нашей командой infrastructure и control‑plane провайдеры и ряд дополнительных операторов.
Отличительная черта нашего решения — каждый Workload‑кластер разделён на два сегмента. Control Plane кластера находятся в нашей системной сети, рядом с управляющими элементами, а нод‑группы находятся в сети и пространстве клиента. О причинах, почему так сделано, и подробностях реализации можно прочитать в статье «Как спрятать CPL Kubernetes от любопытных глаз».
Если интересно узнать про какой‑то из элементов нашего k8s‑решения подробнее, можно посмотреть выпуск реалити‑проекта Building the Cloud. В этом эпизоде коллеги разобрали наше решение очень подробно.
MWS Infrastructure provider
В нашем облаке собственный IaaS‑слой, поэтому обойтись без создания инфрапровайдера при разработке managed kubernetes было невозможно. Инфраструктурный провайдер представляет собой k8s‑оператор, обрабатывающий две группы CRD:
infrastructure cluster — отвечает за подготовку базового окружения кластера (сети, балансировщики, политики доступа, endpoints и так далее), необходимого для запуска как мастер‑, так и воркер‑нод;
infrastructure machine — отвечают за создание VM для размещения k8s‑нод (как мастер‑, так и workload).
Принцип работы провайдера прост. Допустим, клиент запросил создание нового кластера. Для того чтобы кластер мог работать, нужно создать:
виртуальные машины для размещения мастер нод кластера;
виртуальные машины для размещения рабочих нод кластера;
сетевую и прочую инфраструктуру, позволяющую машинам общаться и собраться в k8s‑кластер.
Именно эти задачи и выполняет инфрапровайдер, и на его примере мы будем рассматривать подходы, применяемые в тестировании операторов.
Переходим к тестированию
Ну вот мы и добрались до тестирования. Если посмотреть издалека, то тестирование k8s‑операторов не выглядит чем‑то сложным. В конце концов, это тот же backend‑код, выполняющий некоторую логику. Но когда начинаешь погружаться в тему, то понимаешь, что есть ряд особенностей, сильно усложняющих этот процесс. Эти особенности и разберём дальше.
Custom Resource — комплексный объект
Практически всегда за одним конкретным Custom Resource (CR) лежит целая иерархия связанных объектов. Это могут быть как стандартные сущности k8s (поды, сервисы и deployment‑ы), так и другие Custom Resource‑ы.
Связано это чаще всего с тем, что CR инкапсулирует в себе какой‑то реально существующий сложный программный объект. Например, это могут быть отдельные инстансы Postgres БД или Apache Kafka.
В случае инфраструктурного провайдера достаточно посмотреть на представленную ниже схему Custom Resource, чтобы осознать весь масштаб проблемы.
При этом, находясь на уровне инфрапровайдера, мы обрабатываем только InfrastructureCluster, InfrastructureMachine и InfrastructureMachineTemplate. Но при написании тестов нам приходится учитывать и связанные сущности.

Зависимость от Kubernetes‑окружения
Оператор разворачивается в среде Kubernetes и по определению зависит от неё. И если мы хотим качественно его протестировать, нам придётся или разворачивать реальный кластер или делать его полноценную эмуляцию. При этом ни один из этих вариантов не «серебряная пуля». Ведь если мы решили развернуть реальный кластер, то чаще всего это будет не полноразмерный кластер на кучу CPU и RAM, а его мини‑версия, развёрнутая в окружении, не похожем на продуктовое. Эмуляция же, по своему определению, — лишь модель оригинального кластера. Эта проблема влияет не только на работу тестов в CI‑пайплайнах, но и на запуск и отладку оператора при локальной разработке.
Асинхронность работы оператора
Работа операторов основана на reconciliation loop и, следовательно, — асинхронна. Это создаёт дополнительную сложность при тестировании. Сами сущности или их зависимости могут меняться в случайный момент времени, и это надо учитывать при написании тестов. Например, добавлять задержку перед переходом зависимостей в целевое состояние или отражать в mock‑ах время наступления тех или иных событий.
К чему привели особенности тестирования операторов
Эти особенности проявляются неравномерно на разных уровнях пирамиды тестирования. Так, на уровне unit‑тестов они практически не видны, но, начиная с интеграционного уровня и выше, они проявляются во весь рост. Для нейтрализации этих эффектов индустрия тестирования пошла в двух направлениях:
Первое направление — это разработка специального tooling‑а, облегчающего тестирование операторов. Наиболее популярные: FakeClient и EnvTest.
Второе направление — это применение более мощных тестовых фреймворков, таких как Ginkgo и Gomega. Они позволяют упростить настройку внешнего окружения, обрабатывать асинхронные операции и облегчают написание matching‑правил для сложных объектов. Рассмотрим эти инструменты подробнее.
K8S Tooling
Говоря про k8s tooling, важно сразу обозначить, для тестов какого уровня он применяется. В этой статье мы говорим преимущественно об интеграционных тестах. Мы выбрали именно интеграционный уровень, потому что на слое unit‑тестов тестирование оператора не сильно отличается от тестирования другого ПО. А уровень e2e‑тестов — слишком большая тема, чтобы её можно было разобрать за одну статью.
Итак, на интеграционном уровне для эмулирования k8s наша команда выбрала EnvTest, потому что мы хотели проверять оператор в условиях, максимально приближенных к реальному кластеру.
EnvTest
EnvTest — это часть экосистемы Kubernetes controller‑runtime, предназначенная для интеграционного тестирования контроллеров и операторов в контролируемом локальном окружении. EnvTest позволяет запустить локальные etcd и kubeapi server без controller‑manager, scheduler и cloud‑control‑manager. Это открывает ряд возможностей для написания интеграционных тестов, подробности ниже.
Возможности EnvTest
Имитация состояния кластера без запуска других контроллеров
Первая возможность — это имитация состояния кластера без запуска реальных контроллеров. Рассмотрим этот кейс подробнее.
Когда в Kubernetes создаётся объект, например Deployment, его желаемое состояние (.spec) сохраняется в хранилище. Далее соответствующий контроллер наблюдает за этим объектом, обновляет его текущее состояние (.status), а также создаёт или управляет дочерними ресурсами, например ReplicaSet, Pod.
В реальном кластере эта схема работает отлично, но при тестировании собственного контроллера она становится проблемой. Ведь чтобы проверить поведение контроллера, нам нужно смоделировать состояние зависимых ресурсов, например Pods, Services и других. Запускать при этом настоящие контроллеры нежелательно — это делает тесты медленными, хрупкими и менее предсказуемыми.
EnvTest решает эту проблему, предоставляя лёгковесный локальный Kubernetes API server, который позволяет напрямую создавать, обновлять и удалять любые ресурсы, имитируя любое состояние кластера. Таким образом, мы полностью контролируем окружение, в котором работает наш контроллер, и можем проверять его реакцию на любые комбинации состояний дочерних объектов — без запуска других контроллеров.
Поддержка механизма Watch
Второй важной возможностью EnvTest является поддержка механизма watch, встроенного в Kubernetes API.
Watch позволяет клиентам подписываться на события об изменении состояния ресурсов (создание, обновление, удаление). Этот механизм лежит в основе работы большинства контроллеров и операторов: они используют watch для реакции на изменения в кластере.
При тестировании с использованием fake‑клиентов механизм watch недоступен, что затрудняет сквозное тестирование логики контроллера.
EnvTest — часть тестовой подсистемы controller‑runtime, он инициализирует запуск настоящих etcd и kube‑apiserver, полностью поддерживающих watch. Это позволяет писать интеграционные тесты, максимально приближенные к реальному поведению контроллера в кластере, а значит, решает проблему доступности механизма watch.
Поддержка пользовательских типов ресурсов (CRD)
Третья важная возможность EnvTest — поддержка Custom Resource Definitions (CRD). При разработке операторов и собственных контроллеров мы чаще всего создаём собственные типы ресурсов, которые расширяют Kubernetes API. Эти ресурсы описываются через CRD и становятся полноценной частью кластера наравне с Pod, Service и другими стандартными объектами.
При использовании fake‑клиентов работа с CRD ограничена: необходимо вручную регистрировать схемы типов, а отсутствие реального API‑сервера не позволяет проверить корректность самих CRD‑манифестов и поведение Kubernetes при работе с кастомными ресурсами.
EnvTest решает эту проблему, позволяя устанавливать CRD в тестовый кластер перед запуском тестов. Можно загружать CRD из YAML‑файлов или генерировать их программно, после чего создавать, обновлять и удалять экземпляры собственных ресурсов так же, как и встроенные объекты Kubernetes.
EnvTest позволяет тестировать операторы в окружении, максимально приближенном к реальному кластеру, где ваши CRD работают вместе со стандартными ресурсами Kubernetes, а контроллер может полноценно взаимодействовать с обоими типами объектов.
Тестирование admission веб‑хуков
Ещё EnvTest полноценно поддерживает тестирование admission веб‑хуков (Mutating и Validating).
При написании операторов admission‑хуки используются для автоматической модификации ресурсов перед их сохранением (mutating) или для валидации конфигурации на соответствие бизнес‑правилам (validating). Тестирование такой логики критично: ошибки в хуках могут приводить к созданию некорректных объектов или блокировке легитимных запросов конечных пользователей. При использовании fake‑клиентов или моков тестирование admission‑контроля невозможно, так как эти инструменты не эмулируют стадию обработки запросов в kube‑apiserver.
EnvTest решает эту задачу, предоставляя встроенные инструменты для автоматической конфигурации веб‑хуков и запуска их в локальном API‑сервере.
При создании или обновлении ресурсов через реальный клиент запрос проходит стандартный конвейер API‑сервера, попадает в целевой хук, а в тестах можно проверять как изменённый объект, так и корректность возвращаемых ошибок валидации. Он позволяет писать интеграционные тесты для admission‑логики без развёртывания полноценного кластера, сохраняя при этом полное соответствие реальному поведению Kubernetes API и упрощая работу с сертификатами и маршрутизацией.
Пример использования EnvTest
Рассмотрим использование EnvTest на примере.
В качестве сценария будем рассматривать обработку cloud provider‑ом добавления новой ноды в k8s‑кластер. При появлении новой ноды в k8s‑кластере cloud provider должен ее инициализировать. В рамках этого он:
связывает объект ноды с конкретной VM‑машиной через заполнение spec.providerID;
заполняет метаданные ноды. В нашем случае в метаданные добавляются данные о внутреннем IP‑адресе;
снимает uninitialized taint, разрешая тем самым планировщику размещать pod‑ы на данной ноде.
Теперь посмотрим, как мы можем отразить это поведение в тесте.
План теста
В общем случае наш тест можно свести к следующему плану:
Нам надо создать тестовое окружение.
Зарегистрировать в нём наш cloud provider.
Добавить новую ноду в кластер и подождать, пока её обработает provider.
Проверить, что все ожидаемые действия выполнены.
Очистить тестовое окружение.
Создание и очистка окружения
Для удобства восприятия рассмотрим создание и очистку окружения в одном пункте.
Пример кода инициализации и очистки окружения:
var _ = BeforeSuite(func() { ... By("bootstrapping test environment") testEnv = &envtest.Environment{ BinaryAssetsDirectory: filepath.Join("..", "..", "bin", "k8s", fmt.Sprintf("1.34.1-%s-%s", runtime.GOOS, runtime.GOARCH)), } restConfig, err := testEnv.Start() Expect(err).NotTo(HaveOccurred()) Expect(restConfig).NotTo(BeNil()) clientSet, err = kubernetes.NewForConfig(restConfig) Expect(err).NotTo(HaveOccurred()) Expect(clientSet).NotTo(BeNil()) } var _ = AfterSuite(func() { By("tearing down the test environment") err := testEnv.Stop() Expect(err).NotTo(HaveOccurred()) })
Рассмотрим поближе этот пример. Методы BeforeSuite и AfterSuite отрабатывают перед запуском набора тестов и по его окончании. Сейчас нам интересно их содержимое, а сами методы мы рассмотрим позже, в части про Ginkgo.
В самом начале мы создаём экземпляр нашего тестового окружения через инициализацию структуры envtest.Environment. Конкретно в этом случае единственное, что мы указываем, — это путь до бинарных файлов, необходимых для запуска EnvTest. Но в общем случае здесь же можно указать путь к директории с CRD, которые планируется использовать в тесте. Подробнее про все параметры запуска можно посмотреть в документации.
Далее мы запускаем наш локальный ControlPlane, используя метод Start. На выходе мы получаем конфиг, который мы можем использовать для подключения к нему. После чего мы создаём обычный k8s‑клиент и можем взаимодействовать с нашим тестовым ControlPlane‑м так же, как и с любым другим k8s‑кластером. Для остановки окружения нам достаточно вызвать метод Stop. После чего окружение будет очищено автоматически.
Регистрация Cloud Provider
Следующий шаг нашего теста — запуск тестируемого клауд‑провайдера. Технически клауд‑провайдер не запускается сам по себе. Вместо этого он регистрируется в Cloud Controller Manager (CCM). После чего уже сам CCM отвечает за его вызов. Давайте посмотрим, как мы можем реализовать данное поведение в нашем тесте.
Пример кода для регистрации и запуска клауд‑провайдера:
import ( cloudprovider "k8s.io/cloud-provider" cloudtesting "k8s.io/cloud-provider/app/testing" ) var _ = BeforeSuite(func() { ... kubeconfigPath, err = utils.CreateKubeconfigFileForRestConfig(restConfig) Expect(err).NotTo(HaveOccurred()) // create mocks testName := GinkgoT().Name() cloudprovider.RegisterCloudProvider(testName, func(_ io.Reader) (cloudprovider.Interface, error) { return mws.NewMws("fakeProject", "fakeVPC", "fakeCluster", time.Minute*5, logger.CreateLogger(nil), mockComputeClient, mockNlbClient, mockAddressClient, ), nil }) ccm, err = cloudtesting.StartTestServer(GinkgoT().Context(), []string{ "--leader-elect=false", "--cloud-provider=" + testName, "--kubeconfig=" + kubeconfigPath, }) Expect(err).NotTo(HaveOccurred()) }
С точки зрения кода регистрация провайдера продолжается в методе BeforeSuite, сразу за созданием тестового окружения.
В первую очередь из rest‑конфига мы создаём полноценный kubeconfig‑файл, который понадобится позже для запуска CloudControllerManager (CCM). Далее мы создаем mock‑и внешних зависимостей провайдера и регистрируем метод, который будет создавать в runtime экземпляр клауд‑провайдера. В финале этапа запускается тестовая версия CCM. Из важного на старте ССМ получает конфиг для подключения к ControlPlane‑у и указание на использование нашего cloud provider‑а.
Написание логики теста
К текущему моменту мы закончили подготовку окружения и запустили CCM. Это означает, что мы можем приступать непосредственно к написанию логики нашего теста.
Так как мы взяли достаточно простой бизнес‑кейс, наша логика будет состоять из двух шагов:
Добавление нового объекта node.
Проверка того, что cloud provider выполнил все необходимые шаги.
Добавление нового объекта node
Для добавления нового объекта node мы можем воспользоваться следующим кодом:
By("add fake node") fakeNode := &v1.Node{ ObjectMeta: metav1.ObjectMeta{ Name: "kwok-node-0", }, Spec: v1.NodeSpec{ Taints: []v1.Taint{ { Key: "node.cloudprovider.kubernetes.io/uninitialized", Value: "true", Effect: "NoSchedule", }, }, }, } _, err := clientset.CoreV1().Nodes().Create(GinkgoT().Context(), fakeNode, metav1.CreateOptions{}) Expect(err).NotTo(HaveOccurred())
Как видно из кода, мы используем обычный go‑клиент для добавления node‑ы. Всё взаимодействие происходит так же, как если бы мы работали с реальным k8s‑кластером.
Проверка выполнения cloud provider‑ом требуемой логики
После того как node‑а была добавлена в кластер, нам надо дождаться момента окончания её обработки cloud provider‑ом.
Одним из маркеров того, что нода обработана, может служить заполненное поле spec.providerID.
Для проверки этого условия мы можем использовать следующий код:
var node *v1.Node By("wait for cloud-provider to process the node completely", func() { Eventually(func() (bool, error) { node, err = clientset.CoreV1().Nodes().Get(GinkgoT().Context(), "kwok-node-0", metav1.GetOptions{}) if err != nil { return false, err } if node.Spec.ProviderID != "" && len(node.Status.Addresses) > 0 { return true, nil } return false, nil }).WithPolling(time.Millisecond * 500).WithTimeout(30 * time.Second).Should(BeTrue()) })
Основная особенность здесь заключается в использовании Eventually assertion функции. Благодаря ей мы можем не переживать, что объект node выйдет в целевое состояние не сразу. Подробнее про этот механизм будет рассказано ниже, в секции Gomega.
Как только мы получили подтверждение того, что объект node был обработан, мы можем проверять соответствие фактического результата ожидаемому. Это мы можем сделать, используя следующий код:
By("Node has to get ProviderID in MWS format", func() { Expect(node.Spec.ProviderID).To(Equal("mws://fakeProject/kwok-node-0")) }) By("Taint has to be removed from Node", func() { isExternalCloudProviderTaintKeyExists := slices.ContainsFunc(node.Spec.Taints, func(taint v1.Taint) bool { return taint.Key == cloudproviderapi.TaintExternalCloudProvider }) Expect(isExternalCloudProviderTaintKeyExists).To(BeFalse()) }) By("Node got IP", func() { Expect(node.Status.Addresses[0].Type).To(Equal(v1.NodeInternalIP)) Expect(node.Status.Addresses[0].Address).To(Equal("192.168.0.1")) })
Подробнее про функции, используемые при проверке, будет описано также в секции про Gomega.
Собираем вместе всё описание теста
Собирая всё вместе, мы получаем следующий тест:
When("Join Node to the cluster", func() { It("wait for ProviderID to be assigned to the Node", func() { computeClient.EXPECT().GetVirtualMachine(gomock.Any(), gomock.Any()).Return(stubVM(), nil) addressClient.EXPECT().GetAddress(gomock.Any(), gomock.Any()).Return(stubInternalIPAddress(), nil) By("add fake node") fakeNode := &v1.Node{ ObjectMeta: metav1.ObjectMeta{ Name: "kwok-node-0", }, Spec: v1.NodeSpec{ Taints: []v1.Taint{ { Key: "node.cloudprovider.kubernetes.io/uninitialized", Value: "true", Effect: "NoSchedule", }, }, }, } _, err := clientset.CoreV1().Nodes().Create(GinkgoT().Context(), fakeNode, metav1.CreateOptions{}) Expect(err).NotTo(HaveOccurred()) var node *v1.Node By("wait for cloud-provider to process the node completely", func() { Eventually(func() (bool, error) { node, err = clientset.CoreV1().Nodes().Get(GinkgoT().Context(), "kwok-node-0", metav1.GetOptions{}) if err != nil { return false, err } if node.Spec.ProviderID != "" && len(node.Status.Addresses) > 0 { return true, nil } return false, nil }).WithPolling(time.Millisecond * 500).WithTimeout(30 * time.Second).Should(BeTrue()) }) By("Node has to get ProviderID in MWS format", func() { Expect(node.Spec.ProviderID).To(Equal("mws://fakeProject/kwok-node-0")) }) By("Taint has to be removed from Node", func() { isExternalCloudProviderTaintKeyExists := slices.ContainsFunc(node.Spec.Taints, func(taint v1.Taint) bool { return taint.Key == cloudproviderapi.TaintExternalCloudProvider }) Expect(isExternalCloudProviderTaintKeyExists).To(BeFalse()) }) By("Node got IP", func() { Expect(node.Status.Addresses[0].Type).To(Equal(v1.NodeInternalIP)) Expect(node.Status.Addresses[0].Address).To(Equal("192.168.0.1")) }) }) })
Организация тестового кода
Golang славится своим минимализмом, и подход к тестированию здесь не стал исключением.
Пакет стандартной библиотеки testing предоставляет минимально необходимый функционал, которого хватает для выполнения большинства типовых задач и написания unit‑тестов. Однако, когда дело доходит до тестирования более сложных вещей, таких как сервисы со сложной логикой, интеграционные или e2e‑тесты, то базовых возможностей становится недостаточно.
Ответом на эту потребность стало появление более продвинутых инструментов тестирования. Наиболее яркими их представителями стали тест‑фреймворк ginkgo и matcher‑библиотека gomega. Формально это 2 разных инструмента и их вполне можно использовать независимо друг от друга. Но именно при совместном использовании они раскрываются максимально эффективно. Поэтому не удивительно, что в области тестирования k8s‑контроллеров и операторов связка Ginkgo + Gomega стала золотым стандартом.
Давайте рассмотрим эти инструменты и попробуем определить их особенности, которые могут быть полезны при тестировании контроллеров.
Ginkgo
Ginkgo — это современный фреймворк для тестирования на языке Go, реализующий идеи BDD (Behavior‑Driven Development) и позволяющий выразительно описывать сложную тестовую логику.
Дерево тестов
Основная особенность Ginkgo в том, что он представляет отдельные тестовые спецификации в виде дерева.
Это не похоже на традиционные табличные тесты в Go. Но как раз эта особенность позволяет Ginkgo выразительно описывать максимально сложные случаи. Побочный эффект этого — тесты Ginkgo читаются практически как полноценная документация. Недостаток же такого подхода — высокий порог входа. Давайте попробуем посмотреть на пример тестового дерева Ginkgo.

Слева находится непосредственно дерево элементов, а справа — как эти элементы соотносятся с кодом теста.
Как видно из примера, узлы дерева можно разделить на несколько категорий:
Узлы контейнеры (Describe, Context и так далее).
Узлы подготовки и очистки окружения (BeforeAll, AfterAll и так далее).
Узлы проверки условий (в нашем случае это It).
Рассмотрим каждый из типов узлов подробнее.
Container Nodes
Узлы контейнеры используются для организации тестового кода (группировка тестовых спецификаций, документирование условий и так далее).
В ginkgo выделяют следующие контейнерные узлы:
Describe
Context
When
Семантически они идентичны, различие есть только в самих ключевых словах. Это сделано специально, чтобы разработчик мог наиболее точно описывать, что конкретно делает тест, в каких условиях и так далее. Во многом именно благодаря этим узлам тесты ginkgo читаются как документация на естественном языке.
Рассмотрим небольшой пример организации тестов:

Даже небольшого прохода по коду теста достаточно, чтобы понять, что именно проверяет этот тест, в каких условиях проходит эта проверка и к какой области домена она относится. По нашему опыту, когда начинаешь проектировать тесты в ginkgo, надо отойти от парадигмы табличных тестов. Вместо этого лучше попробовать перенести свои use‑cases из бизнес‑логики в тесты, потому что тогда ginkgo раскроется максимально эффективно.
Setup Nodes
Setup‑узлы, как видно из названия, отвечают за настройку и очистку тестового окружения.
В качестве типичных узлов установки окружения можно выделить:
BeforeSuite/AfterSuite
BeforeAll/AfterAll
BeforeEach/AfterEach
Этот список можно продолжить дальше, но уже видна основная тенденция.
Узлы настройки окружения позволяют выполнять действия до и/или после некого объекта или объектов. Этими объектами и выступают контейнерные узлы, которые мы рассмотрели в предыдущем пункте.
Давайте рассмотрим пример:
Describe("MWSCluster reconciliation check", func() { BeforeEach(func() { // additional setup testNamespace, err = e.createNamespaceWithPrefix(ctx, "qwerty") Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { // additional cleanup Expect(e.deleteNamespace(ctx)).ToNot(HaveOccurred()) }) When("Creating MWSCluster and reconcile it", func() { It("Should create cloud resources if they does not exists, set ready status and set finalizer", func() { // test logic }) }) When("Deleting MWSCluster", func() { It("Should delete an MWSCluster with cloud resources", func() { // test logic }) }) })
В данном случае у нас есть блок Describe, который описывает тест‑кейсы для проверки реконсиляции ресурса MWSCluster.
В примере описаны два тест‑кейса: для создания и для удаления кластера. В реальности их больше, но для иллюстрации идеи этого хватит.
Работа с каждым объектом MWSCluster происходит в отдельном namespace‑е. Для его создания и удаления мы и используем блоки BeforeEach и AfterEach.
Блок BeforeEach отработает перед запуском спек:
Creating MWSCluster and reconcile it
Deleting MWSCluster
Для каждого из этих кейсов будет создан новый случайный namespace с указанным prefix‑ом. Блок AfterEach удалит созданный namespace после каждого тест‑кейса. За счёт использования этих и других блоков настройки окружения мы можем конфигурировать системы с большим количеством предусловий и не раздувать при этом само тела теста. А ещё мы явно отделяем логику подготовки окружения от логики вызова и проверки условий.
Subject Nodes
Subject‑узлы используются для проверки соответствия фактического результата ожидаемому.
В ginkgo есть только 2 subject node‑ы:
It
Specify
Подобно контейнерным узлам, обе subject‑ноды семантически идентичны. Разделение ключевых слов было сделано только для возможности писать более выразительные тестовые спецификации.
Пример использования узла проверки условий:
It("Should not reconcile an MWSCluster without parent CAPI Cluster", func() { mwsCluster := e.getMWSCluster(testNamespace.Name) Expect(e.client.Create(ctx, mwsCluster)).To(Succeed()) })
Использование блоков проверки условий в Ginkgo мало чем отличается от традиционного тестирования в Go.
Исходя из моего опыта, в использовании блоков проверки условий я бы посоветовал следующее:
Старайтесь сохранять блоки проверки условий небольшими. Если же возникает потребность в крупном блоке, то разделяйте проверки внутри него через инструкцию By().
Используйте выбранную библиотеку матчинга по максимуму.
Gomega
И раз мы заговорили про матчинг, значит, пришло время рассказать о Gomega. Gomega — это библиотека матчинга, от авторов Ginkgo. Её можно использовать отдельно от Ginkgo, но на практике такое встречается не часто.
По своей сути Gomega — это большой набор методов для сравнения ожидаемого и фактического состояния объектов. Кроме базовых типов, есть методы для работы с коллекциями, http‑ответами, для частичного сравнения структур и много другого. С полным списком возможностей можно познакомиться, перейдя по ссылке. Но в этой статье я постараюсь рассказать о тех возможностях, которые особенно полезны при написании тестов для k8s‑контроллеров.
Поддержка polling‑а
K8S‑контроллеры асинхронны по своей природе: между тем, как мы добавим целевой ресурс в тестовый кластер, и тем моментом, когда он будет обработан (и приведён к desired state), пройдёт некоторый промежуток времени.
Написать такое на стандартной библиотеки теоретически возможно, но сделать это нормально (быстро, чтобы код получился поддерживаемый и без костылей) — точно нет.
И вот тут gomega предоставляет ряд удобных механик. Мы рассмотрим только самые базовые из них, но даже этого уже хватает, чтобы покрыть большое количество проблемных кейсов.
Итак, базовые функции для работы с polling‑ом:
Eventually — проверяет, что условие будет выполнено в конечном итоге.
Consistently — проверяет, что условие выполняется весь период.
Допустим, мы тестируем удаление нашего MWSCluster. Запустили удаление объекта кластера и теперь должны дождаться того, что удаление будет успешно обработано.
Для этого мы можем использовать такой код:
When("Deleting MWSCluster", func() { It("Should delete an MWSCluster with cloud resources", func() { // test logic // Delete cluster Expect(e.client.Delete(ctx, mwsCluster)).NotTo(HaveOccurred()) // Wait until MWSCluster is removed. Eventually(func() bool { mwsCluster := &v1alpha1.MWSCluster{} key := client.ObjectKey{ Name: e.clusterName, Namespace: testNamespace.Name, } if err := e.client.Get(ctx, key, mwsCluster); err != nil { if apierrors.IsNotFound(err) { return true } By("Error occurred while getting MWSCluster") } return false }, 5*time.Second, time.Second).Should(BeTrue(), "the MWSCluster should be deleted") }) })
Здесь вызывается функция Eventually. В неё передаётся предикат, проверяющий состояние кластера и параметры тайм‑аута и частоты опроса.
Далее происходит одно из 2 событий:
Или менее чем через 5 секунд предикат подтвердит, что кластер удалён. И в этом случае мы считаем тест успешным.
Или тест автоматически будет считаться провальным, так как предикат не вышел в целевое состояние за указанный промежуток времени.
Важный момент: если предикат дошёл до целевого состояния раньше тайм‑аута, дальнейшее ожидание производиться не будет. Этим мы выигрываем время по сравнению со стратегией «просто поставить ожидание и использовать обычный assert».
Это лишь один из базовых примеров использования функционала, связанного с polling‑ом. Если погрузиться в Gomega глубже, то можно найти инструменты, ещё более подходящие под вашу задачу.
Работа со структурами
Следующая возможность Gomega, которая особенно полезна в домене k8s, — это работа со сложными структурами. Ведь Custom Resource‑ы, как и базовые сущности k8s, это всегда достаточно большие объекты, часто с большой степенью вложенности.
И сравнение актуального и целевого состояния для них сводится к задаче полного или частичного сравнения структур.
Рассмотрим наиболее распространённые методы работы со структурами:
gstruct.MatchAllFields — позволяет описать ожидаемый результат, учитывающий все поля структуры;
gstruct.MatchFields — позволяет описать ожидаемый результат для некоторого подмножества полей структуры;
HaveField — проверяет, что у структуры есть поле с заданным именем и значением.
Теперь давайте посмотрим на возможности использования этих инструментов.
Предположим, что у нас есть следующий Custom Resource:
{ "kind": "MWSMachine", "apiVersion": "infra.mws.mts.ru/v1alpha1", "metadata": { "name": "example-mwsmachine", "namespace": "default", "labels": {}, "annotations": {} }, "spec": { "cpu": 2, "memory": "4GB", "subnetName": "example-subnet" }, "status": { "ready": true } }
Предположим, что мы хотим проверить, что у актуального объекта MWSMachine значение cpu = 2, а значение RAM = 4GB.
Мы можем сделать это следующим образом:
Expect(mwsMachine).To(gstruct.MatchFields(gstruct.IgnoreExtras, gstruct.Fields{ "Spec": gstruct.MatchFields(gstruct.IgnoreExtras, gstruct.Fields{ "Cpu": Equal(2), "Memory": Equal("4GB"), }), }))
Получается, что мы с минимальными затратами реализовали частичное сравнение структур.
Рассмотрим ещё один интересный пример работы со структурами. Допустим, мы хотим проверить одно конкретное поле в большой структуре с вложенными элементами. Для этого мы можем использовать метод HaveField в комбинации с json path.
Представим, что мы хотим проверить имя подсети, указанной в spec‑е MWSMachine. Мы можем сделать это следующим буквально в одну строчку:
Expect(mwsMachine).Should(HaveField("Spec.SubnetName", "example-subnet"))
За счёт использования json path мы можем работать с полями, находящимися на большой глубине вложенности.
Работа с коллекциями
Следующей возможностью, которая полезна при работе с сущностями k8s, стала поддержка работы с коллекциями.
Работая с сущностями k8s, мы часто сталкиваемся с коллекциями:
Taint‑ы
Label‑ы
Списки сущностей и другое
И когда возникает необходимость проверки наличия того или иного элемента в коллекции, сделать это бывает не так просто. Особенно когда у нас список не элементарных значений, а структур. Gomega позволяет значительно упростить обработку таких сценариев.
В целом Gomega предоставляет большой набор методов работы с коллекциями, но мы рассмотрим только самые базовые:
ContainElement — проверяет, что коллекция содержит элемент;
HaveEach — проверяет, что переданный предикат выполняется на всех элементах коллекции.
Описание и примеры работы с более сложными методами можно найти в документации проекта. Сами по себе методы ContainElement и HaveEach не выглядят чем‑то необычным. Но если учитывать, что они совместимы с другими матчерами Gomega, то картина радикально меняется.
Давайте посмотрим пример, допустим, у нас есть следующая модель Node:
apiVersion: v1 kind: Node metadata: name: example-node-with-taint spec: # ... other node specific spec fields ... taints: - key: "node.cloudprovider.kubernetes.io/uninitialized" value: "true" effect: "NoSchedule"
И, предположим, мы хотим проверить, что мы сняли uninitialized taint с этого объекта Node, используя Gomega, мы можем сделать это следующим образом:
Expect(node.Spec.Taints).ToNot(ContainElements(HaveField("Key", Equal("node.cloudprovider.kubernetes.io/uninitialized"))))
Таким образом, за одну строку мы:
Получили доступ к коллекции структур, лежащей не на верхнем уровне.
Проверили, что коллекция не содержит структуру с заданным полем.
С одной стороны, это не кажется чем‑то большим. Но в реальных тест‑кейсах подобных проверок очень много, и даже небольшое их улучшение на масштабе проекта становится очень полезным. Например, тесты становятся лаконичнее, их можно передать коллегам, которые не работают с Go, и они всё равно поймут их логику. Получается, что наши тесты становятся альтернативой Confluence.
Заключение
Итак, мы закончили рассматривать наш стек для интеграционных тестов в домене k8s. Теперь если вам будет нужно написать тест для вашего оператора, вы знаете, с чего начать.
Да, на освоение указанного стека потребуется некоторое время, но по опыту нашей команды оно многократно окупается, например, из‑за того, что теперь мы пишем каждый отдельный тест сильно быстрее. Плюс сами тесты остаются читаемыми даже при росте их количества и сложности покрываемой логики.
А ещё использование тестов как документации позволяет быстрее онбордить новых сотрудников и тратить меньше времени на переключение контекста между сервисами, в случаях когда задачи разбросаны по разным сервисам системы.
Если вам интересны другие наши технические решения и задачи, посмотрите выпуски реалити‑проекта Building the Cloud.
Вступайте в сообщество MWS Cloud Platform, чтобы обсуждать интересные кейсы или задавать вопросы инженерам платформы.
