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.
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
| Keyword | Purpose | Example |
|---|---|---|
Feature | Describes capability | βInvoice Management Systemβ |
Scenario | Individual test case | βCreate valid invoiceβ |
Given | Initial state | βUser is authenticatedβ |
When | Action | βUser submits invoiceβ |
Then | Expected outcome | βInvoice is storedβ |
And | Additional step | βEmail is sentβ |
But | Negative step | βError is not returnedβ |
Background | Setup for all scenarios | Database is connected |
Scenario Outline | Parameterized test | Test 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
Related Articles
Building Automation Services with Go: Practical Tools & Real-World Solutions
Master building useful automation services and tools with Go. Learn to create production-ready services that solve real problems: log processors, API monitors, deployment tools, data pipelines, and more.
Automation with Go: Building Scalable, Concurrent Systems for Real-World Tasks
Master Go for automation. Learn to build fast, concurrent automation tools, CLI utilities, monitoring systems, and deployment pipelines. Go's concurrency model makes it perfect for real-world automation.
Automation Tools for Developers: Real Workflows Without AI - CLI, Scripts & Open Source
Master free automation tools for developers. Learn to automate repetitive tasks, workflows, deployments, monitoring, and operations. Build custom automation pipelines with open-source toolsβno AI needed.