Go BDD Testing con Gherkin y Kubernetes: Testing End-to-End a Escala

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 Omar Flores

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 ClavePropósitoEjemplo
FeatureDescribe capacidad”Sistema de Gestión de Facturas”
ScenarioCaso de test individual”Crear factura válida”
GivenEstado inicial”Usuario está autenticado”
WhenAcción”Usuario envía factura”
ThenResultado esperado”Factura se almacena”
AndPaso adicional”Email se envía”
ButPaso negativo”Error no se retorna”
BackgroundSetup para todos los scenariosBase de datos conectada
Scenario OutlineTest parametrizadoTest 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

#go #golang #testing #bdd #gherkin #cucumber #kubernetes #e2e-testing #integration-testing #quality-assurance #devops #ci-cd