Go BDD Testing with Gherkin and Kubernetes: End-to-End Testing at Scale

Go BDD Testing with Gherkin and Kubernetes: End-to-End Testing at Scale

Master behavioral-driven development in Go with Gherkin syntax. Learn how to write readable tests, integrate with Kubernetes, mock services, manage state, and validate production deployments with confidence.

By Omar Flores

Why Gherkin Matters

Most tests are written for machines. They use assertions, mocking frameworks, and abstract concepts. When a test fails, only developers understand why.

Gherkin is different. Gherkin tests are written for humans.

# This is Gherkin. A non-technical person can read this.
Scenario: User creates an invoice
  Given the user is authenticated
  When the user submits an invoice with amount 1000
  Then the invoice is stored in the database
  And the user receives a confirmation email

When this test fails, everyone on the teamβ€”product manager, QA, developer, architectβ€”understands exactly what broke.

At scale, this matters. When you have 50 microservices and something fails in production, you need tests that clearly communicate what went wrong.

This guide teaches you to write Gherkin tests for Go applications and run them against Kubernetes clusters.


Part 1: Gherkin Fundamentals

Gherkin is a domain-specific language for behavioral-driven development.

Structure: Feature, Scenario, Steps

# features/fiscal/invoice_creation.feature

Feature: Invoice Management System
  As a fiscal analyst
  I want to create and validate invoices
  So that I can maintain compliance records

  Background:
    Given the API server is running
    And the database is connected
    And the cache is cleared

  Scenario: Create valid invoice
    When I submit an invoice with:
      | Field          | Value          |
      | issuer         | ABC123456XYZ   |
      | receiver       | DEF789012XYZ   |
      | amount         | 1500.00        |
      | currency       | MXN            |
      | issuance_date  | 2026-03-04     |
    Then the response status is 201
    And the invoice is stored with status "pending"
    And the response contains field "invoice_id"

  Scenario: Reject invoice with missing issuer
    When I submit an invoice with:
      | Field          | Value          |
      | receiver       | DEF789012XYZ   |
      | amount         | 1500.00        |
      | currency       | MXN            |
    Then the response status is 400
    And the error message contains "issuer is required"
    And no invoice is created in the database

  Scenario: Process invoice through workflow
    Given an invoice exists with:
      | issuer         | ABC123456XYZ   |
      | amount         | 1500.00        |
      | status         | pending        |
    When I trigger the invoice processing
    Then the invoice status becomes "processing"
    And the system downloads the XML file
    And the invoice status becomes "validated"
    And the invoice is archived

Gherkin Keywords

KeywordPurposeExample
FeatureDescribes capability”Invoice Management System”
ScenarioIndividual test case”Create valid invoice”
GivenInitial state”User is authenticated”
WhenAction”User submits invoice”
ThenExpected outcome”Invoice is stored”
AndAdditional step”Email is sent”
ButNegative step”Error is not returned”
BackgroundSetup for all scenariosDatabase is connected
Scenario OutlineParameterized testTest multiple datasets

Scenario Outline (Data-Driven Testing)

Scenario Outline: Validate invoice amounts
  When I create an invoice with amount <amount>
  Then the invoice is created
  And the total including tax is <total>

  Examples:
    | amount | total  |
    | 1000   | 1160   |
    | 2500   | 2900   |
    | 5000   | 5800   |

Part 2: Setting Up Gherkin in Go

Use GoLang’s godog framework for Gherkin execution.

Installation and Project Structure

go get github.com/cucumber/godog/v2/cmd/godog@latest

# Project structure
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

Creating Step Definitions

Step definitions link Gherkin sentences to Go code.

// 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 runs before each scenario
func (ic *InvoiceContext) InitializeScenario(ctx context.Context, sc *godog.Scenario) (context.Context, error) {
	// Reset state
	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 {
	// Verify API is healthy
	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 {
	// Parse 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

	// Submit invoice via API
	resp, body, err := ic.apiClient.CreateInvoice(ctx, invoice)
	if err != nil {
		ic.lastError = err
		return err
	}

	ic.lastResponse = resp

	// Extract invoice ID from 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 {
	// Verify field exists in 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 registers all steps with godog
func (ic *InvoiceContext) RegisterSteps(s *godog.ScenarioContext) {
	// Background
	s.Step(`^the API server is running$`, ic.theAPIServerIsRunning)
	s.Step(`^the database is connected$`, ic.theDatabaseIsConnected)
	s.Step(`^the cache is cleared$`, ic.theCacheIsCleared)

	// When
	s.Step(`^I submit an invoice with:$`, ic.iSubmitAnInvoiceWith)

	// Then
	s.Step(`^the response status is (\d+)$`, ic.theResponseStatusIs)
	s.Step(`^the invoice is stored with status "([^"]*)"$`, ic.theInvoiceIsStoredWithStatus)
	s.Step(`^the response contains field "([^"]*)"$`, ic.theResponseContainsField)
	s.Step(`^the error message contains "([^"]*)"$`, ic.theErrorMessageContains)
	s.Step(`^no invoice is created in the database$`, ic.noInvoiceIsCreatedInTheDatabase)
}

Test Suite Initialization

// 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) {
			// Initialize context
			apiClient := api.NewClient("http://localhost:8080")
			dbConn := db.NewConnection("localhost:5432/fiscal")

			invoiceCtx := &steps.InvoiceContext{
				apiClient: apiClient,
				dbConn:    dbConn,
			}

			// Register all 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)
	}
}

Part 3: Running Gherkin Tests Against Kubernetes

Once your tests pass locally, run them against a live Kubernetes cluster.

Kubernetes Test Environment

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

Test Docker Image

# test/Dockerfile
FROM golang:1.21-alpine

WORKDIR /app

# Install dependencies
RUN apk add --no-cache git

# Copy source
COPY . .

# Download dependencies
RUN go mod download

# Install godog
RUN go install github.com/cucumber/godog/v2/cmd/godog@latest

# Run tests
CMD ["godog", "features", "--format", "junit:results/junit.xml"]

Kubernetes Test Job

# 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

Go Client for Running 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 submits test job to 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 polls until tests complete
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 retrieves results from completed job
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]

	// Get 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()

	// Read logs
	buf := new(strings.Builder)
	io.Copy(buf, logs)

	return buf.String(), nil
}

func int32Ptr(i int32) *int32 {
	return &i
}

Part 4: Advanced Gherkin Patterns

Database State Management

# features/invoice/invoice_workflow.feature

Scenario: Complete invoice workflow
  Given the database contains invoices:
    | uuid                                 | issuer      | amount | status    |
    | 550e8400-e29b-41d4-a716-446655440000 | ABC123456   | 1000   | pending   |
    | 550e8400-e29b-41d4-a716-446655440001 | DEF789012   | 2500   | pending   |
  And the following invoices are marked for processing
  When the processing worker runs
  Then all invoices have status "validated"
  And the XML files are archived
// 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
		}

		// Track for cleanup
		ic.createdInvoices = append(ic.createdInvoices, invoice.UUID)
	}

	return nil
}

Mocking External Services

// features/steps/mock_services.go

type MockSFTPServer struct {
	server *httptest.Server
}

func NewMockSFTPServer() *MockSFTPServer {
	mux := http.NewServeMux()

	mux.HandleFunc("/download/", func(w http.ResponseWriter, r *http.Request) {
		// Return mock XML
		w.Header().Set("Content-Type", "application/xml")
		w.Write([]byte(`<?xml version="1.0"?><invoice><id>123</id></invoice>`))
	})

	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})

	return &MockSFTPServer{
		server: httptest.NewServer(mux),
	}
}

// Use in step
func (ic *InvoiceContext) theFileServerIsAvailable(ctx context.Context) error {
	ic.mockServer = NewMockSFTPServer()
	// Set API to use mock server
	ic.apiClient.SetSFTPServerURL(ic.mockServer.server.URL)
	return nil
}

Async Validation

Scenario: Async processing completes successfully
  Given an invoice is in pending status
  When I trigger asynchronous processing
  Then the system processes the invoice
  And within 5 seconds the status becomes "validated"
  And an event is published to the message queue
func (ic *InvoiceContext) withinSecondsTheStatusBecomes(ctx context.Context, seconds int, status string) error {
	deadline := time.Now().Add(time.Duration(seconds) * time.Second)

	for {
		invoice, err := ic.dbConn.GetInvoice(ctx, ic.invoiceID)
		if err != nil {
			return err
		}

		if invoice.Status == status {
			return nil
		}

		if time.Now().After(deadline) {
			return fmt.Errorf("expected status %s within %d seconds, got %s",
				status, seconds, invoice.Status)
		}

		time.Sleep(100 * time.Millisecond)
	}
}

Part 5: CI/CD Integration

GitHub Actions Workflow

# .github/workflows/e2e-tests.yml

name: End-to-End Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  e2e-tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: fiscal_test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

      redis:
        image: redis:7-alpine
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 6379:6379

    steps:
    - uses: actions/checkout@v3

    - name: Set up Go
      uses: actions/setup-go@v4
      with:
        go-version: '1.21'

    - name: Run API server
      run: |
        go run ./cmd/server &
        sleep 2
      env:
        DATABASE_URL: postgres://test:test@localhost:5432/fiscal_test
        REDIS_URL: redis://localhost:6379

    - name: Run BDD tests
      run: |
        go install github.com/cucumber/godog/v2/cmd/godog@latest
        godog features --format progress

    - name: Upload test results
      if: always()
      uses: actions/upload-artifact@v3
      with:
        name: test-results
        path: results/junit.xml

    - name: Publish test results
      if: always()
      uses: EnricoMi/publish-unit-test-result-action@v2
      with:
        files: results/junit.xml

Kubernetes CI/CD Integration

# k8s/ci-cd-pipeline.yaml
apiVersion: tekton.dev/v1beta1
kind: Pipeline
metadata:
  name: fiscal-bdd-pipeline
spec:
  params:
  - name: image-tag
    type: string
  tasks:
  - name: build-test-image
    taskRef:
      name: buildah
    params:
    - name: IMAGE
      value: myregistry.azurecr.io/fiscal-tests:$(params.image-tag)
    - name: DOCKERFILE
      value: test/Dockerfile

  - name: run-tests
    runAfter: [build-test-image]
    taskRef:
      name: kubernetes-actions
    params:
    - name: script
      value: |
        kubectl create -f k8s/test-job.yaml
        kubectl wait --for=condition=complete job/fiscal-bdd-tests -n testing --timeout=300s
        kubectl logs -l job-name=fiscal-bdd-tests -n testing

  - name: publish-results
    runAfter: [run-tests]
    taskRef:
      name: publish-results
    params:
    - name: test-results-path
      value: results/junit.xml

Part 6: Test Strategy Matrix

Different types of tests with Gherkin:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Test Type       β”‚ Scope      β”‚ Speed        β”‚ Cost           β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Unit (Local)    β”‚ 1 function β”‚ <100ms       β”‚ Cheap (laptop) β”‚
β”‚ Integration     β”‚ 1 service  β”‚ <1s          β”‚ Cheap (local)  β”‚
β”‚ BDD (Local)     β”‚ 1 service  β”‚ <5s          β”‚ Cheap (local)  β”‚
β”‚ E2E (K8s)       β”‚ Full stack β”‚ <30s         β”‚ Moderate       β”‚
β”‚ Smoke (Prod)    β”‚ Full stack β”‚ <10s         β”‚ Production $   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Execution Path:

Code Changes
    ↓
Unit Tests (gotest)
    ↓
Integration Tests (docker-compose)
    ↓
BDD Tests (local godog)
    ↓
Push to Git
    ↓
Build Docker Image
    ↓
Deploy to Staging K8s
    ↓
E2E BDD Tests on Kubernetes
    ↓
Smoke Tests (prod subset)
    ↓
Approval β†’ Production Deployment

Part 7: Debugging Failed Tests

Verbose Gherkin Output

# Run with detailed output
godog features --format progress --verbose

# Run specific feature
godog features/fiscal/invoice_creation.feature

# Run specific scenario
godog features/fiscal/invoice_creation.feature:12

Debugging Steps

func (ic *InvoiceContext) debugLogResponse(ctx context.Context) error {
	if ic.lastResponse != nil {
		fmt.Printf("Response Status: %d\n", ic.lastResponse.StatusCode)
		fmt.Printf("Response Headers: %v\n", ic.lastResponse.Header)

		body, _ := ioutil.ReadAll(ic.lastResponse.Body)
		fmt.Printf("Response Body: %s\n", string(body))
	}
	return nil
}

func (ic *InvoiceContext) debugLogDatabase(ctx context.Context) error {
	invoices, err := ic.dbConn.ListAllInvoices(ctx)
	if err != nil {
		return err
	}

	fmt.Printf("Database contains %d invoices:\n", len(invoices))
	for _, inv := range invoices {
		fmt.Printf("  - %s: %s (status: %s)\n", inv.UUID, inv.Issuer, inv.Status)
	}
	return nil
}

// Use in scenario
And I debug log the response
And I debug log the database

Capturing Test Artifacts

type TestArtifacts struct {
	screenshotDir string
	logDir        string
}

func (ta *TestArtifacts) CaptureOnFailure(ctx context.Context, scenarioName string) error {
	timestamp := time.Now().Format("20060102_150405")

	// Capture API logs
	apiLogs, err := exec.CommandContext(ctx,
		"kubectl", "logs", "-l", "app=invoice", "-n", "default",
	).Output()
	if err == nil {
		ioutil.WriteFile(
			fmt.Sprintf("%s/%s_api_logs_%s.txt", ta.logDir, scenarioName, timestamp),
			apiLogs,
			0644,
		)
	}

	// Capture database state
	dbDump, _ := exec.CommandContext(ctx,
		"kubectl", "exec", "-it", "postgres-0",
		"--", "pg_dump", "fiscal_test",
	).Output()
	if err == nil {
		ioutil.WriteFile(
			fmt.Sprintf("%s/%s_db_state_%s.sql", ta.logDir, scenarioName, timestamp),
			dbDump,
			0644,
		)
	}

	return nil
}

Part 8: Best Practices for Gherkin in Go

βœ… DO

# Clear, business-focused language
Scenario: Verify invoice compliance with SAT rules
  Given an invoice with issuer RFC "ABC123456XYZ"
  When the system validates against SAT rules
  Then no compliance errors are returned

# Use data tables for clarity
When the user creates invoices with:
  | issuer      | amount | currency |
  | ABC123456   | 1000   | MXN      |
  | DEF789012   | 2500   | MXN      |

# One assertion per step
Then the response status is 201
And the invoice is stored in the database
And the notification email is queued

❌ DON’T

# Technical jargon
Scenario: JSON serialization of invoice entity
  Given an Invoice object with marshalled fields
  When the serializer processes the aggregate
  Then the DTO contains the correct values

# Multiple assertions per step
Then the response is 201 and stored in DB and email is sent

# Implementation details
When the goroutine processes the channel message
Then the mutex is unlocked and the cache is invalidated

Step Reusability

// DON'T: Duplicate steps
func (ic *InvoiceContext) iCreateAnInvoiceWith1000(ctx context.Context) error {
	return ic.iSubmitAnInvoiceWith(ctx, &godog.Table{...})
}

func (ic *InvoiceContext) iCreateAnInvoiceWith2500(ctx context.Context) error {
	return ic.iSubmitAnInvoiceWith(ctx, &godog.Table{...})
}

// DO: Use parameterized steps
func (ic *InvoiceContext) iCreateAnInvoiceWithAmount(ctx context.Context, amount string) error {
	return ic.iSubmitAnInvoiceWith(ctx, &godog.Table{
		Rows: []tableRow{{...}},
	})
}

Part 9: Performance Considerations

Parallel Test Execution

# Run tests in parallel (limited by default)
godog features --parallel 4

# Kubernetes parallel job pods
parallelism: 4
completions: 4

Test Data Cleanup

func (ic *InvoiceContext) AfterScenario(ctx context.Context) error {
	// Delete all created data
	for _, invoiceID := range ic.createdInvoices {
		ic.dbConn.DeleteInvoice(ctx, invoiceID)
	}

	// Clear caches
	ic.dbConn.ClearCache(ctx)

	// Close connections gracefully
	return nil
}

Timeout Management

# Scenario timeout: 1 minute
Scenario: Process large invoice batch
  Given 10000 invoices are ready
  When I trigger batch processing
  Then all invoices are processed within 60 seconds
func (ic *InvoiceContext) allInvoicesAreProcessedWithinSeconds(ctx context.Context, seconds int) error {
	ctx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second)
	defer cancel()

	return ic.pollUntilAllProcessed(ctx)
}

Conclusion: Tests as Living Documentation

Gherkin tests are the only documentation that never lies.

When a feature request comes in, you have a clear specification. When a bug is reported, you have a test that fails. When code changes, you have confidence because your tests are human-readable and comprehensive.

At scale, where 50 services depend on each other, Gherkin tests are your safety net. They communicate clearly to everyone on the teamβ€”developer, QA, product manager, architect.

The best tests are the ones that read like requirements. When a test fails, anyone on the team should understand exactly what went wrong, not just that β€œtest_xyz failed”. That clarity is worth more than any amount of code coverage percentage.

Tags

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