Cómo Testear Aplicaciones Kubernetes en Go con EnvTest: Una Guía Práctica
Introducción
Cuando desarrollas aplicaciones que interactúan con un cluster Kubernetes (ya sean operadores, controladores o herramientas personalizadas), es fundamental implementer tests robustos. Los enfoques tradicionales pueden implicar iniciar un cluster completo con Minikube, usar TestContainers o incluso depender de un cluster de testing dedicado en CI/CD. Sin embargo, estas soluciones pueden ser intensivas en recursos o difíciles de integrar en todas tus pipelines.
Aquí es donde entra en juego EnvTest: una alternativa ligera y rápida proporcionada por el proyecto controller-runtime. EnvTest inicia un servidor API de Kubernetes y una instancia de etcd, lo que te permite realizar tests de integración que cubren las interacciones declarativas básicas con el servidor API sin la sobrecarga de un cluster completo.
En este artículo, te mostraré cómo configurar y usar EnvTest en tus proyectos Go, especialmente cuando testees operadores o controladores. Incluso volveré a mi ejemplo del plugin kubectl kill namespace para ilustrar escenarios de testing prácticos.
¿Qué es EnvTest?
EnvTest es un potente paquete dentro del proyecto controller-runtime diseñado para testear aplicaciones Kubernetes.
Aquí tienes algunos puntos clave:
- Ligero y Rápido: Inicia un servidor API local y etcd, proporcionándote un entorno casi idéntico a un cluster sin necesidad de toda la infraestructura de Kubernetes.
- Eficiente en Recursos: Perfecto para desarrollo local y pipelines de CI/CD donde iniciar un cluster completo es complicado.
- Centrado en Operaciones Declarativas: Ideal cuando tus tests implican crear, actualizar o eliminar recursos Kubernetes, en lugar de simular Pod scheduling u operaciones de nodos completas.
Nota
EnvTest no es adecuado para aplicaciones que requieran comportamientos completos de cluster (por ejemplo, programación o ejecución de Pods). Para esos casos, deberías considerar usar Minikube, TestContainers o un cluster de Kubernetes basado en la nube.
Configurando EnvTest
La mayoría de las guías se basan en que los binarios de EnvTest ya estén instalados en el sistema. Algunas de ellas te muestran cómo descargarlos usando un Makefile, mientras que otras ni siquiera mencionan cómo obtenerlos.
En esta sección, te mostraré cómo usar el paquete EnvTest directamente desde tu código Go, sin depender de ninguna herramienta externa.
Creando la función setupEnvTest
Comencemos creando una función setupEnvTest
encargada de configurar el entorno EnvTest.
El siguiente fragmento de código contiene pseudo-código para describir la función:
func setupEnvTest() *envtest.Environment {
// Download EnvTest binaries
// Create a new instance of the EnvTest environment
}
Esta función es responsable de descargar si es necesario los binarios de EnvTest, y de crear una nueva instancia del entorno EnvTest para dichos binarios.
Centrémonos en la primera parte, la descarga de los binarios.
func setupEnvTest() *envtest.Environment {
envTestDir, err := store.DefaultStoreDir()
if err != nil {
panic(err)
}
envTest := &env.Env{
FS: afero.Afero{Fs: afero.NewOsFs()},
Out: os.Stdout,
Client: &remote.HTTPClient{
IndexURL: remote.DefaultIndexURL,
},
Platform: versions.PlatformItem{
Platform: versions.Platform{
OS: runtime.GOOS,
Arch: runtime.GOARCH,
},
},
Version: versions.AnyVersion,
Store: store.NewAt(envTestDir),
}
envTest.CheckCoherence()
workflows.Use{}.Do(envTest)
}
Vamos a analizar el código paso a paso:
- Primero, obtenemos el directorio por defecto de EnvTest usando la función
store.DefaultStoreDir()
. De esta forma, podemos reutilizar cualquier binario que ya haya sido descargado por otros tests o herramientas externas. - A continuación, creamos una nueva instancia de la estructura
env.Env
. Esta estructura proporciona toda la información necesaria como el sistema operativo, la arquitectura, la versión, etc. para descargar los binarios. - Después, validamos e inicializamos el entorno usando la función
envTest.CheckCoherence()
. - Por último, usamos la función
workflows.Use{}.Do(envTest)
para descargar los binarios si es necesario.
La siguiente parte de la lógica de la función se encarga de crear una nueva instancia del entorno EnvTest:
func setupEnvTest() *envtest.Environment {
// Create a new instance of the EnvTest environment
versionDir := envTest.Platform.Platform.BaseName(*envTest.Version.AsConcrete())
return &envtest.Environment{
BinaryAssetsDirectory: filepath.Join(envTestDir, "k8s", versionDir),
}
}
Empleamos la función envTest.Platform.Platform.BaseName(*envTest.Version.AsConcrete())
para obtener el directorio que contiene los binarios de la versión de EnvTest que hemos descargado.
A continuación, creamos una nueva instancia de la estructura envtest.Environment
, pasando el directorio donde se encuentran los binarios.
Ahora que ya tenemos la función setupEnvTest
lista, continuemos viendo cómo usarla en nuestros tests.
Usando EnvTest para Tests de Integración
En esta sección, te mostraré cómo usar la función setupEnvTest
que creamos en la sección anterior para testear el plugin kubectl kill namespace que analicé en una publicación anterior.
Puedes encontrar el código completo de los tests en el archivo killns_test.go
del proyecto.
Ya que el plugin kubectl kill namespace se encarga de eliminar Namespaces, voy a centrarme en testear la lógica de eliminación de Namespaces. Para ello utilizaré subtests para iniciar el entorno EnvTest una sola vez y ejecutar cada escenario de test en un subtest diferente.
El siguiente fragmento de código muestra la configuración y la limpieza del entorno, y uno de los subtests de la función TestKillNamespace
:
func TestKillNamespace(t *testing.T) {
envTest := setupEnvTest()
envTestConfig, err := envTest.Start()
if err != nil {
t.Errorf("Error starting test environment: %s", err)
return
}
defer func() {
if stopErr := envTest.Stop(); stopErr != nil {
panic(stopErr)
}
}()
// Test with no namespace
// Test with existent namespace
t.Run("With existent namespace with finalizer", func(t *testing.T) {
// Given
client, _ := kubernetes.NewForConfig(envTestConfig)
client.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "finalizer",
},
Spec: corev1.NamespaceSpec{Finalizers: []corev1.FinalizerName{"kubernetes"}},
}, metav1.CreateOptions{})
// When
KillNamespace(envTestConfig, "finalizer")
// Then
_, err := client.CoreV1().Namespaces().Get(context.TODO(), "finalizer", metav1.GetOptions{})
if err.Error() != "namespaces \"finalizer\" not found" {
t.Errorf("Namespace should have been deleted, but it wasn't")
}
})
}
Desglose del Test:
- Inicio del Entorno:
Comienzo el test llamando a la funciónsetupEnvTest
para inicializar el entorno EnvTest.
A continuación, inicio el entorno llamando a la funciónenvTest.Start()
.
Si se produce algún error al iniciar el entorno, fallo el test y retorno. - Detención del Entorno:
Utilizo una sentenciadefer
para detener y limpiar el entorno una vez que el test finaliza.
De esta forma, me aseguro de que el entorno siempre se detiene, incluso si el test falla. - Inicialización del Cliente:
Inicializo una instancia del Cliente de Kubernetes client-go pasando la estructurarest.Config
que obtengo de la funciónenvTest.Start()
. - Subtests para Claridad:
Utilizo la funcionalidad de subtests de Go (t.Run
) para agrupar los diferentes escenarios que queremos testear.
De esta forma, puedo mantener el código del test organizado y fácil de leer. - Escenario de Test:
- Creo un nuevo Namespace con un finalizador usando el cliente (assemble/given).
- A continuación, llamo a la función
KillNamespace
que queremos testear (act/when). - Por último, compruebo que el Namespace fue eliminado intentando obtenerlo usando el cliente (assert/then).
Conclusión
En esta publicación, te he mostrado cómo aprovechar el paquete EnvTest de controller-runtime para realizar tests de integración ligeros pero efectivos para aplicaciones Kubernetes escritas en Go. He explicado cómo configurar el entorno de testing, descargar los binarios necesarios de forma programática y usar EnvTest en escenarios prácticos como testear la eliminación de Namespaces.
Integrando estas prácticas en tu flujo de desarrollo, puedes lograr ciclos de feedback más rápidos, tests más fiables y pipelines de CI/CD más eficientes, todo ello sin la sobrecarga de gestionar un cluster Kubernetes completo.
Para ver el código fuente completo y más ejemplos, visita el repositiorio de GitHub.