Go BDD Testing con Gherkin y Kubernetes: Testing End-to-End a Escala
Domina desarrollo dirigido por comportamiento en Go con sintaxis Gherkin. Aprende a escribir tests legibles, integrar con Kubernetes, mockear servicios, gestionar estado, y validar despliegues de producción con confianza.
Por Qué Gherkin Importa
La mayoría de tests se escriben para máquinas. Usan assertions, frameworks de mocking, y conceptos abstractos. Cuando un test falla, solo desarrolladores entienden por qué.
Gherkin es diferente. Los tests Gherkin se escriben para humanos.
# Esto es Gherkin. Una persona no-técnica puede leerlo.
Scenario: Usuario crea una factura
Given el usuario está autenticado
When el usuario envía una factura con cantidad 1000
Then la factura se almacena en la base de datos
And el usuario recibe un email de confirmación
Cuando este test falla, todos en el equipo—gerente de producto, QA, desarrollador, arquitecto—entienden exactamente qué se rompió.
A escala, esto importa. Cuando tienes 50 microservicios y algo falla en producción, necesitas tests que comuniquen claramente qué salió mal.
Esta guía te enseña a escribir tests Gherkin para aplicaciones Go y ejecutarlos contra clusters Kubernetes.
Parte 1: Fundamentos de Gherkin
Gherkin es un lenguaje específico de dominio para desarrollo dirigido por comportamiento.
Estructura: Feature, Scenario, Steps
# features/fiscal/invoice_creation.feature
Feature: Sistema de Gestión de Facturas
As un analista fiscal
I want crear y validar facturas
So que pueda mantener registros de cumplimiento
Background:
Given el servidor API está ejecutándose
And la base de datos está conectada
And el cache está limpio
Scenario: Crear factura válida
When envío una factura con:
| Field | Value |
| issuer | ABC123456XYZ |
| receiver | DEF789012XYZ |
| amount | 1500.00 |
| currency | MXN |
| issuance_date | 2026-03-04 |
Then el estado de respuesta es 201
And la factura se almacena con estado "pending"
And la respuesta contiene campo "invoice_id"
Scenario: Rechazar factura sin emisor
When envío una factura con:
| Field | Value |
| receiver | DEF789012XYZ |
| amount | 1500.00 |
| currency | MXN |
Then el estado de respuesta es 400
And el mensaje de error contiene "issuer is required"
And ninguna factura se crea en la base de datos
Scenario: Procesar factura a través del workflow
Given una factura existe con:
| issuer | ABC123456XYZ |
| amount | 1500.00 |
| status | pending |
When disparo el procesamiento de factura
Then el estado de la factura es "processing"
And el sistema descarga el archivo XML
And el estado de la factura es "validated"
And la factura se archiva
Palabras Clave Gherkin
| Palabra Clave | Propósito | Ejemplo |
|---|---|---|
Feature | Describe capacidad | ”Sistema de Gestión de Facturas” |
Scenario | Caso de test individual | ”Crear factura válida” |
Given | Estado inicial | ”Usuario está autenticado” |
When | Acción | ”Usuario envía factura” |
Then | Resultado esperado | ”Factura se almacena” |
And | Paso adicional | ”Email se envía” |
But | Paso negativo | ”Error no se retorna” |
Background | Setup para todos los scenarios | Base de datos conectada |
Scenario Outline | Test parametrizado | Test múltiples datasets |
Scenario Outline (Data-Driven Testing)
Scenario Outline: Validar montos de factura
When creo una factura con monto <amount>
Then la factura se crea
And el total incluyendo impuesto es <total>
Examples:
| amount | total |
| 1000 | 1160 |
| 2500 | 2900 |
| 5000 | 5800 |
Parte 2: Configurando Gherkin en Go
Usa el framework godog de GoLang para ejecución Gherkin.
Instalación y Estructura de Proyecto
go get github.com/cucumber/godog/v2/cmd/godog@latest
# Estructura de proyecto
project/
├── features/
│ ├── fiscal/
│ │ ├── invoice_creation.feature
│ │ ├── invoice_validation.feature
│ │ └── invoice_processing.feature
│ └── steps/
│ ├── invoice_steps.go
│ ├── http_steps.go
│ └── database_steps.go
├── test/
│ ├── api/
│ │ └── client.go
│ ├── db/
│ │ └── connection.go
│ └── fixtures/
│ └── data.go
└── suite_test.go
Creando Definiciones de Steps
Las definiciones de steps vinculan frases Gherkin a código Go.
// features/steps/invoice_steps.go
package steps
import (
"context"
"fmt"
"net/http"
"github.com/cucumber/godog"
"yourmodule/test/api"
"yourmodule/test/db"
)
type InvoiceContext struct {
apiClient *api.Client
dbConn *db.Connection
lastResponse *http.Response
lastError error
invoiceID string
createdData map[string]interface{}
}
// InitializeScenario corre antes de cada scenario
func (ic *InvoiceContext) InitializeScenario(ctx context.Context, sc *godog.Scenario) (context.Context, error) {
// Resetear estado
ic.lastResponse = nil
ic.lastError = nil
ic.createdData = make(map[string]interface{})
return ctx, nil
}
// Background Steps
func (ic *InvoiceContext) theAPIServerIsRunning(ctx context.Context) error {
// Verificar API está saludable
resp, err := ic.apiClient.Health(ctx)
if err != nil {
return fmt.Errorf("API not running: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("API unhealthy: status %d", resp.StatusCode)
}
return nil
}
func (ic *InvoiceContext) theDatabaseIsConnected(ctx context.Context) error {
return ic.dbConn.Ping(ctx)
}
func (ic *InvoiceContext) theCacheIsCleared(ctx context.Context) error {
return ic.dbConn.ClearCache(ctx)
}
// When Steps
func (ic *InvoiceContext) iSubmitAnInvoiceWith(ctx context.Context, dataTable *godog.Table) error {
// Parsear data table
invoice := make(map[string]interface{})
for _, row := range dataTable.Rows[1:] {
key := row.Cells[0].Value
value := row.Cells[1].Value
invoice[key] = value
}
ic.createdData = invoice
// Enviar factura vía API
resp, body, err := ic.apiClient.CreateInvoice(ctx, invoice)
if err != nil {
ic.lastError = err
return err
}
ic.lastResponse = resp
// Extraer invoice ID de response
if resp.StatusCode == http.StatusCreated {
// Parse JSON response
if id, ok := body["invoice_id"]; ok {
ic.invoiceID = id.(string)
}
}
return nil
}
// Then Steps
func (ic *InvoiceContext) theResponseStatusIs(ctx context.Context, statusCode int) error {
if ic.lastResponse == nil {
return fmt.Errorf("no response received")
}
if ic.lastResponse.StatusCode != statusCode {
return fmt.Errorf("expected status %d, got %d", statusCode, ic.lastResponse.StatusCode)
}
return nil
}
func (ic *InvoiceContext) theInvoiceIsStoredWithStatus(ctx context.Context, status string) error {
if ic.invoiceID == "" {
return fmt.Errorf("no invoice ID to query")
}
invoice, err := ic.dbConn.GetInvoice(ctx, ic.invoiceID)
if err != nil {
return err
}
if invoice.Status != status {
return fmt.Errorf("expected status %s, got %s", status, invoice.Status)
}
return nil
}
func (ic *InvoiceContext) theResponseContainsField(ctx context.Context, fieldName string) error {
// Verificar campo existe en response
// Implementation depends on your response structure
return nil
}
func (ic *InvoiceContext) theErrorMessageContains(ctx context.Context, expectedMessage string) error {
if ic.lastError == nil {
return fmt.Errorf("expected error, got none")
}
if !strings.Contains(ic.lastError.Error(), expectedMessage) {
return fmt.Errorf("expected error containing '%s', got '%s'", expectedMessage, ic.lastError.Error())
}
return nil
}
func (ic *InvoiceContext) noInvoiceIsCreatedInTheDatabase(ctx context.Context) error {
if ic.invoiceID != "" {
return fmt.Errorf("expected no invoice to be created, but found %s", ic.invoiceID)
}
return nil
}
// RegisterSteps registra todos los steps con godog
func (ic *InvoiceContext) RegisterSteps(s *godog.ScenarioContext) {
// Background
s.Step(`^el servidor API está ejecutándose$`, ic.theAPIServerIsRunning)
s.Step(`^la base de datos está conectada$`, ic.theDatabaseIsConnected)
s.Step(`^el cache está limpio$`, ic.theCacheIsCleared)
// When
s.Step(`^envío una factura con:$`, ic.iSubmitAnInvoiceWith)
// Then
s.Step(`^el estado de respuesta es (\d+)$`, ic.theResponseStatusIs)
s.Step(`^la factura se almacena con estado "([^"]*)"$`, ic.theInvoiceIsStoredWithStatus)
s.Step(`^la respuesta contiene campo "([^"]*)"$`, ic.theResponseContainsField)
s.Step(`^el mensaje de error contiene "([^"]*)"$`, ic.theErrorMessageContains)
s.Step(`^ninguna factura se crea en la base de datos$`, ic.noInvoiceIsCreatedInTheDatabase)
}
Inicialización de Test Suite
// suite_test.go
package features
import (
"context"
"testing"
"github.com/cucumber/godog"
"yourmodule/features/steps"
"yourmodule/test/api"
"yourmodule/test/db"
)
func TestFeatures(t *testing.T) {
suite := godog.TestSuite{
ScenarioInitializer: func(s *godog.ScenarioContext) {
// Inicializar contexto
apiClient := api.NewClient("http://localhost:8080")
dbConn := db.NewConnection("localhost:5432/fiscal")
invoiceCtx := &steps.InvoiceContext{
apiClient: apiClient,
dbConn: dbConn,
}
// Registrar todos los steps
invoiceCtx.RegisterSteps(s)
// Setup/Teardown
s.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) {
return invoiceCtx.InitializeScenario(ctx, sc)
})
s.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
// Cleanup
dbConn.Cleanup(ctx)
return ctx, nil
})
},
Options: &godog.Options{
Format: "progress",
Paths: []string{"features"},
},
}
status := suite.Run()
if status != 0 {
t.Fatalf("test suite failed with status %d", status)
}
}
Parte 3: Ejecutando Tests Gherkin Contra Kubernetes
Una vez que tus tests pasan localmente, ejecútalos contra un cluster Kubernetes en vivo.
Entorno de Test Kubernetes
graph TB
subgraph "Local Development"
GHERKIN["Gherkin Tests<br/>godog"]
end
subgraph "Kubernetes Cluster"
INGRESS["Ingress<br/>api.example.com"]
INVOICE["Invoice Pod<br/>Port 8080"]
DB["PostgreSQL<br/>Service"]
CACHE["Redis<br/>Service"]
end
subgraph "Test Execution"
TEST_POD["Test Pod<br/>runs godog<br/>mounts features/"]
RESULTS["Test Results<br/>junit.xml"]
end
GHERKIN -->|Deploy| TEST_POD
TEST_POD -->|HTTP| INGRESS
INGRESS -->|Route| INVOICE
INVOICE -->|Query| DB
INVOICE -->|Cache| CACHE
TEST_POD -->|Produce| RESULTS
style INGRESS fill:#2196f3
style TEST_POD fill:#ff9800
style RESULTS fill:#4caf50
Imagen Docker de Test
# test/Dockerfile
FROM golang:1.21-alpine
WORKDIR /app
# Instalar dependencias
RUN apk add --no-cache git
# Copiar source
COPY . .
# Descargar dependencias
RUN go mod download
# Instalar godog
RUN go install github.com/cucumber/godog/v2/cmd/godog@latest
# Ejecutar tests
CMD ["godog", "features", "--format", "junit:results/junit.xml"]
Test Job de Kubernetes
# k8s/test-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: fiscal-bdd-tests
namespace: testing
spec:
template:
spec:
serviceAccountName: test-runner
containers:
- name: test-runner
image: fiscal-tests:latest
imagePullPolicy: Always
env:
# Point tests to Kubernetes service
- name: API_BASE_URL
value: "http://invoice-service.default.svc.cluster.local:8080"
- name: DATABASE_URL
value: "postgres://test:test@postgres.default.svc.cluster.local:5432/fiscal_test"
- name: REDIS_URL
value: "redis://redis.default.svc.cluster.local:6379"
- name: ENVIRONMENT
value: "kubernetes"
volumeMounts:
- name: test-results
mountPath: /app/results
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1"
livenessProbe:
exec:
command:
- test
- -f
- /app/results/junit.xml
initialDelaySeconds: 30
periodSeconds: 10
volumes:
- name: test-results
emptyDir: {}
restartPolicy: Never
backoffLimit: 1
completions: 1
parallelism: 1
Cliente Go para Ejecutar Tests
// infrastructure/testing/job_runner.go
package testing
import (
"context"
"fmt"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
type TestJobRunner struct {
clientset *kubernetes.Clientset
namespace string
}
func NewTestJobRunner(namespace string) (*TestJobRunner, error) {
config, err := rest.InClusterConfig()
if err != nil {
return nil, err
}
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
return nil, err
}
return &TestJobRunner{
clientset: clientset,
namespace: namespace,
}, nil
}
// RunTests envía job de test a Kubernetes
func (tjr *TestJobRunner) RunTests(ctx context.Context, imageTag string) (*batchv1.Job, error) {
job := &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("fiscal-tests-%d", time.Now().UnixNano()),
Namespace: tjr.namespace,
Labels: map[string]string{
"app": "fiscal-tests",
"type": "bdd",
},
},
Spec: batchv1.JobSpec{
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"app": "fiscal-tests",
},
},
Spec: corev1.PodSpec{
ServiceAccountName: "test-runner",
RestartPolicy: corev1.RestartPolicyNever,
Containers: []corev1.Container{
{
Name: "test-runner",
Image: fmt.Sprintf("myregistry.azurecr.io/fiscal-tests:%s", imageTag),
Env: []corev1.EnvVar{
{
Name: "API_BASE_URL",
Value: "http://invoice-service.default.svc.cluster.local:8080",
},
{
Name: "DATABASE_URL",
Value: "postgres://test:test@postgres.default.svc.cluster.local:5432/fiscal_test",
},
{
Name: "ENVIRONMENT",
Value: "kubernetes",
},
},
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceMemory: "512Mi",
corev1.ResourceCPU: "500m",
},
Limits: corev1.ResourceList{
corev1.ResourceMemory: "1Gi",
corev1.ResourceCPU: "1",
},
},
},
},
},
},
BackoffLimit: int32Ptr(1),
Completions: int32Ptr(1),
Parallelism: int32Ptr(1),
},
}
createdJob, err := tjr.clientset.BatchV1().Jobs(tjr.namespace).Create(ctx, job, metav1.CreateOptions{})
if err != nil {
return nil, err
}
return createdJob, nil
}
// WaitForTestCompletion poll hasta que tests se completen
func (tjr *TestJobRunner) WaitForTestCompletion(ctx context.Context, jobName string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
for {
job, err := tjr.clientset.BatchV1().Jobs(tjr.namespace).Get(ctx, jobName, metav1.GetOptions{})
if err != nil {
return err
}
if job.Status.Succeeded > 0 {
return nil // Tests passed
}
if job.Status.Failed > 0 {
return fmt.Errorf("tests failed")
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
}
}
}
// GetTestResults recupera resultados del job completado
func (tjr *TestJobRunner) GetTestResults(ctx context.Context, jobName string) (string, error) {
// Get pod created by job
pods, err := tjr.clientset.CoreV1().Pods(tjr.namespace).List(ctx, metav1.ListOptions{
LabelSelector: fmt.Sprintf("job-name=%s", jobName),
})
if err != nil {
return "", err
}
if len(pods.Items) == 0 {
return "", fmt.Errorf("no pods found for job")
}
pod := pods.Items[0]
// Obtener logs
req := tjr.clientset.CoreV1().Pods(tjr.namespace).GetLogs(pod.Name, &corev1.PodLogOptions{})
logs, err := req.Stream(ctx)
if err != nil {
return "", err
}
defer logs.Close()
// Leer logs
buf := new(strings.Builder)
io.Copy(buf, logs)
return buf.String(), nil
}
func int32Ptr(i int32) *int32 {
return &i
}
Parte 4: Patrones Gherkin Avanzados
Gestión de Estado de Base de Datos
# features/invoice/invoice_workflow.feature
Scenario: Workflow completo de factura
Given la base de datos contiene facturas:
| uuid | issuer | amount | status |
| 550e8400-e29b-41d4-a716-446655440000 | ABC123456 | 1000 | pending |
| 550e8400-e29b-41d4-a716-446655440001 | DEF789012 | 2500 | pending |
And las siguientes facturas están marcadas para procesamiento
When el worker de procesamiento corre
Then todas las facturas tienen estado "validated"
And los archivos XML se archivan
// features/steps/database_steps.go
func (ic *InvoiceContext) theDatabaseContainsInvoices(ctx context.Context, dt *godog.Table) error {
for _, row := range dt.Rows[1:] {
invoice := &Invoice{
UUID: row.Cells[0].Value,
Issuer: row.Cells[1].Value,
Amount: parseAmount(row.Cells[2].Value),
Status: row.Cells[3].Value,
}
err := ic.dbConn.InsertInvoice(ctx, invoice)
if err != nil {
return err
}
// Rastrear para cleanup
ic.createdInvoices = append(ic.createdInvoices, invoice.UUID)
}
return nil
}
Conclusión: Tests como Documentación Viviente
Los tests Gherkin son la única documentación que nunca miente.
Cuando una solicitud de características llega, tienes una especificación clara. Cuando un bug se reporta, tienes un test que falla. Cuando el código cambia, tienes confianza porque tus tests son legibles por humanos y comprensivos.
A escala, donde 50 servicios dependen uno del otro, los tests Gherkin son tu red de seguridad. Comunican claramente a todos en el equipo—desarrollador, QA, gerente de producto, arquitecto.
Los mejores tests son los que leen como requisitos. Cuando un test falla, cualquier persona en el equipo debería entender exactamente qué salió mal, no solo que “test_xyz failed”. Esa claridad vale más que cualquier porcentaje de cobertura de código.
Tags
Artículos relacionados
API Versioning Strategies: Cómo Evolucionar APIs sin Romper Clientes
Una guía exhaustiva sobre estrategias de versionado de APIs: URL versioning vs Header versioning, cómo deprecar endpoints sin shock, migration patterns reales, handling de cambios backwards-incompatibles, y decisiones arquitectónicas que importan. Con 50+ ejemplos de código en Go.
Automatizando tu vida con Go CLI: Guía profesional para crear herramientas de línea de comandos escalables
Una guía exhaustiva y paso a paso sobre cómo crear herramientas CLI escalables con Go 1.25.5: desde lo básico hasta proyectos empresariales complejos con flags, configuración, logging, y ejemplos prácticos para Windows y Linux.
AWS Desde la Perspectiva de un Solutions Architect: Teoría, Decisiones y Go
Una guía teórica exhaustiva sobre AWS desde la mentalidad de un Solutions Architect: cómo pensar en decisiones arquitectónicas, trade-offs fundamentales, integración con Go y otros lenguajes, patrones de diseño empresarial, y cómo aplicar esto en proyectos reales.