BDD, TDD & Agile Tools in Go: From Zero to Professional — The Complete Guide
Master Behavior Driven Development with Gherkin, Godog, TDD, Docker, and CI/CD from a developer's perspective. A complete guide from absolute beginner to deep professional using Go.
BDD, TDD & Agile Tools in Go: From Zero to Professional
The Story That Starts Every Team
Picture this.
You join a new team. It is your third week. Your lead asks you to fix a bug in the payment service.
You open the codebase. There are no tests. There is no documentation. There is a 900-line function called processPayment that does everything from validating cards to sending emails.
You make your fix. You think it works. You push the code.
Two hours later: production is down. The currency conversion broke. Turns out, processPayment was also calculating exchange rates in a loop nobody knew about.
You spend the next four hours debugging. Everyone is stressed. The product manager is in the Slack channel. The CTO sends a message that ends with “we need to talk.”
Sound familiar?
This is what happens when teams build software without a testing strategy. Not because they are lazy or bad engineers. But because nobody taught them the system.
This guide is that system.
We are going to cover Behavior Driven Development (BDD), Test Driven Development (TDD), Docker, CI/CD pipelines, and how all of these connect inside an Agile team — using Go as our language of choice.
You do not need prior testing experience. You do not need to know BDD. You do not even need to be a Go expert.
What you need is the willingness to read slowly, follow the examples, and accept that testing is not a chore. Testing is how you sleep at night.
Part 1 - The Problem with Software Nobody Tests
Why Developers Skip Tests
Let us be honest about something.
Developers do not skip tests because they are irresponsible. They skip tests because of pressure. Because deadlines exist. Because the ticket says “due Friday.” Because the product manager asked for the feature last Tuesday.
The result?
A codebase where nobody wants to touch anything because everything might break. A system where bugs from six months ago live because nobody dares refactor. A team where “it works on my machine” is a daily sentence.
This is called technical debt. And it compounds like credit card interest — silently, until the bill arrives and it is bigger than you expected.
Testing is not the overhead. The lack of testing is.
Here is the math.
A bug caught while writing the feature: 30 minutes to fix. A bug caught in code review: 2 hours to fix. A bug caught in QA: half a day to fix. A bug caught in production: a full day, an incident report, possibly a lost customer, and definitely a very uncomfortable meeting.
The earlier you catch a bug, the cheaper it is. That is it. That is the entire business case for testing.
The Testing Pyramid
Before we dive into BDD and TDD, you need to know about the testing pyramid.
It looks like this:
/\
/ \
/ E2E\ <- Few, slow, expensive
/------\
/ \
/ Integration\ <- Medium, somewhat slow
/------------\
/ \
/ Unit Tests \ <- Many, fast, cheap
/------------------\
From bottom to top:
Unit Tests — Test a single function or method in isolation. No database. No HTTP calls. No file system. Pure logic. These should make up 70-80% of your test suite. They run in milliseconds.
Integration Tests — Test how two or more components work together. A service talking to a database. An API calling another API. These take longer and need real infrastructure (or fakes of it).
End-to-End (E2E) Tests — Test the entire system from the user’s perspective. A browser clicks a button, a payment goes through, an email arrives. These are slow, brittle, and expensive. You want very few of these.
Good teams have many unit tests, a reasonable number of integration tests, and just enough E2E tests to cover critical flows.
BDD lives mostly at the integration and E2E level. TDD lives at the unit level.
Let’s start from the beginning.
Part 2 - What is TDD? The Art of Writing Tests First
The Rules
Test Driven Development was popularized by Kent Beck in the early 2000s. The rules are brutally simple.
Rule 1: Write a failing test before you write any production code. Rule 2: Write only enough production code to make the test pass. Rule 3: Refactor the code, keeping the tests green.
This cycle is called Red-Green-Refactor.
Red: Your test is failing. It does not even compile yet. That is fine. That is the point. Green: You wrote the minimum code to make the test pass. It might be ugly. That is also fine. Refactor: You clean up the code. The tests tell you if you broke something.
Repeat.
That is TDD. Three steps. Repeat hundreds of times.
Why This Seems Backwards (And Why It Isn’t)
The first time you hear TDD, your brain rebels.
“Write a test for code that doesn’t exist yet? How?”
Here is the mindset shift.
When you write the test first, you are not testing implementation. You are describing behavior. You are saying: “Given these inputs, I expect these outputs.” The test is a contract. The production code is the fulfillment of that contract.
This forces you to think about the API before you write it. It forces you to think about edge cases before you are deep inside the implementation. It forces you to keep functions small and focused, because large complex functions are hard to test.
TDD does not just test your code. It shapes it.
TDD in Go: A Complete Example
Let us build something real. A simple calculator service.
First, create the project:
mkdir calculator-service
cd calculator-service
go mod init calculator-service
Now, create the test file first. Notice: we write the test before the implementation.
// calculator_test.go
package calculator
import (
"testing"
)
// TestAdd verifies basic addition behavior.
// We write this test first. It will fail to compile.
// That is expected. That is TDD.
func TestAdd(t *testing.T) {
// Arrange: set up the values
a := 5.0
b := 3.0
expected := 8.0
// Act: call the function (it doesn't exist yet)
result := Add(a, b)
// Assert: verify the result
if result != expected {
t.Errorf("Add(%v, %v) = %v, expected %v", a, b, result, expected)
}
}
// TestAddNegatives verifies that negative numbers work correctly.
// Edge cases should always have their own test.
func TestAddNegatives(t *testing.T) {
result := Add(-5.0, -3.0)
expected := -8.0
if result != expected {
t.Errorf("Add(-5, -3) = %v, expected %v", result, expected)
}
}
// TestDivide verifies division behavior.
func TestDivide(t *testing.T) {
result, err := Divide(10.0, 2.0)
expected := 5.0
if err != nil {
t.Fatalf("Divide(10, 2) returned unexpected error: %v", err)
}
if result != expected {
t.Errorf("Divide(10, 2) = %v, expected %v", result, expected)
}
}
// TestDivideByZero verifies that division by zero returns an error.
// This is a classic edge case. Never forget it.
func TestDivideByZero(t *testing.T) {
_, err := Divide(10.0, 0.0)
if err == nil {
t.Error("Divide(10, 0) expected an error, got nil")
}
}
Run the tests. They fail. They do not even compile.
go test ./...
# calculator-service [calculator-service.test]
# ./calculator_test.go:16:13: undefined: Add
# ./calculator_test.go:30:13: undefined: Add
# ./calculator_test.go:38:16: undefined: Divide
That is the Red phase. Now make it Green.
// calculator.go
package calculator
import "errors"
// Add returns the sum of two float64 values.
// Simple. Does one thing. Easy to test.
func Add(a, b float64) float64 {
return a + b
}
// Divide returns a/b.
// Returns an error if b is zero, because dividing by zero is
// not a math error in most languages — it is a logic error.
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("cannot divide by zero")
}
return a / b, nil
}
Run again:
go test ./...
ok calculator-service 0.002s
Green. Now refactor.
In this case, the code is already clean. No refactor needed. But notice what happened: you never wrote code without a test describing its behavior. Every function has at least one test. Every edge case has a test.
Table-Driven Tests: Go’s Superpower
Go has a pattern called table-driven tests. It is one of the most elegant testing patterns in any language.
Instead of writing ten separate test functions for ten scenarios, you write one test function with ten scenarios in a table.
// calculator_test.go — extended with table-driven approach
func TestAddTableDriven(t *testing.T) {
// Define a table of test cases.
// Each case is: inputs + expected output + case name.
testCases := []struct {
name string
a float64
b float64
expected float64
}{
{name: "positive numbers", a: 5, b: 3, expected: 8},
{name: "negative numbers", a: -5, b: -3, expected: -8},
{name: "mixed numbers", a: 5, b: -3, expected: 2},
{name: "zeros", a: 0, b: 0, expected: 0},
{name: "decimals", a: 1.5, b: 2.5, expected: 4.0},
{name: "large numbers", a: 1000000, b: 2000000, expected: 3000000},
}
for _, tc := range testCases {
// t.Run creates a sub-test for each case.
// You can run a single sub-test with: go test -run TestAddTableDriven/positive_numbers
t.Run(tc.name, func(t *testing.T) {
result := Add(tc.a, tc.b)
if result != tc.expected {
t.Errorf("Add(%v, %v) = %v, expected %v", tc.a, tc.b, result, tc.expected)
}
})
}
}
This pattern scales beautifully. Adding a new scenario is just adding a new row to the table. No copy-pasting test functions. No cognitive overhead.
Test Helpers and the testing.T Contract
Go’s testing package is minimal by design. There is no assert.Equal out of the box. But you can write helper functions.
// testhelpers_test.go
package calculator
import "testing"
// assertEqual is a simple test helper that reduces boilerplate.
// Note: it lives in a _test.go file, so it only exists during tests.
func assertEqual(t *testing.T, got, expected float64) {
t.Helper() // Marks this function as a helper.
// When it fails, Go shows the caller's line, not this line.
if got != expected {
t.Errorf("got %v, expected %v", got, expected)
}
}
// assertError verifies that an error is not nil.
func assertError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Error("expected an error but got nil")
}
}
// assertNoError verifies that an error is nil.
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
Using these helpers:
func TestDivideWithHelpers(t *testing.T) {
result, err := Divide(10, 2)
assertNoError(t, err)
assertEqual(t, result, 5.0)
}
Cleaner. More readable. Still plain Go.
Part 3 - What is BDD? The Language Everyone Understands
The Problem TDD Does Not Solve
TDD is great for developers. It forces good design. It catches bugs early. It documents behavior through code.
But there is a gap.
TDD tests are written by developers, for developers. A product manager cannot read a Go test. A QA engineer without coding experience cannot write test cases in Go. A business analyst cannot verify that the behavior matches the requirements.
And here is the cruel irony: the requirements are usually wrong before code is written. Not because the product manager is bad. But because they described requirements in natural language, the developer interpreted them differently, and the test verifies the developer’s interpretation, not the original intent.
BDD was invented to close this gap.
The Three Amigos
BDD was created by Dan North in 2006. The core idea is simple: before writing any code, three people sit together to define behavior.
Those three people are:
The Business Person (Product Owner/Manager): Knows what the feature should do. Does not know (or care) how. The Developer: Knows how to build it. Sometimes misunderstands what the business wants. The QA Engineer: Asks annoying questions like “what happens if the user enters nothing?” which everyone else forgot about.
These are the Three Amigos. Together, they write scenarios in plain language that everyone understands.
This conversation — before code is written — is where most bugs are actually prevented.
Gherkin: The Plain Language for Behavior
The output of the Three Amigos meeting is written in Gherkin. Gherkin is not a programming language. It is a structured natural language with very specific keywords.
A Gherkin file looks like this:
# features/login.feature
Feature: User Login
As a registered user
I want to log in to the application
So that I can access my account
Scenario: Successful login with valid credentials
Given the user "alice@example.com" exists in the system
And the password for "alice@example.com" is "SecurePass123"
When the user submits the login form with "alice@example.com" and "SecurePass123"
Then the user should be redirected to the dashboard
And the user should see a welcome message "Welcome back, Alice"
Scenario: Failed login with wrong password
Given the user "alice@example.com" exists in the system
When the user submits the login form with "alice@example.com" and "WrongPassword"
Then the user should see the error "Invalid email or password"
And the user should remain on the login page
Scenario: Login attempt with non-existent user
When the user submits the login form with "nobody@example.com" and "AnyPassword"
Then the user should see the error "Invalid email or password"
Every Gherkin file has:
Feature — What capability are we describing? This is the big picture. Scenario — One concrete example of the feature in action. Given — The context. What state is the world in before this scenario? When — The action. What event triggers the behavior? Then — The outcome. What should have changed? And / But — Continuation keywords. Avoid repeating Given/When/Then. Background — Runs before every scenario in the feature file (like a shared setup).
The beauty of Gherkin is that anyone can read and write it. A CEO can read a feature file and understand what the system does. A QA engineer can write scenarios without knowing Go. A new developer can understand the system’s behavior before reading a single line of code.
Gherkin Keywords Deep Dive
Let us go through each keyword with real examples.
Feature — Describes the capability. Write it from the user’s perspective.
Feature: Shopping Cart
As an online shopper
I want to add and remove items from my cart
So that I can buy exactly what I need
Background — Runs before every scenario. Use it for common setup.
Feature: User Account
Background:
Given the application is running
And the database is empty
Scenario: Create new user
When I register with "test@example.com"
Then the user should exist in the database
Scenario Outline — Like table-driven tests, but in Gherkin. Run one scenario with multiple inputs.
Scenario Outline: Withdraw money
Given my account balance is <initial_balance>
When I withdraw <amount>
Then my balance should be <final_balance>
Examples:
| initial_balance | amount | final_balance |
| 1000 | 200 | 800 |
| 500 | 100 | 400 |
| 100 | 100 | 0 |
Tags — Organize your scenarios. Run subsets.
@smoke @login
Scenario: Successful login
@regression @payment
Scenario: Process credit card payment
You can run specific tags:
# Run only smoke tests
godog --tags @smoke
# Run everything except slow tests
godog --tags ~@slow
Part 4 - Godog: BDD for Go
What is Godog
Godog is the official Cucumber implementation for Go. Cucumber is the tool that reads Gherkin feature files and executes them against your code.
The flow is:
Feature File (.feature) --> Godog --> Step Definitions (Go) --> Your Code
The step definitions are the bridge. They translate each Gherkin step into a Go function call.
Let us build a real project from scratch.
Setting Up Your First BDD Project
mkdir payment-service
cd payment-service
go mod init payment-service
# Install Godog
go get github.com/cucumber/godog@v0.14.1
# Create the directory structure
mkdir -p features
mkdir -p internal/payment
mkdir -p internal/steps
Project structure:
payment-service/
├── go.mod
├── go.sum
├── features/
│ └── payment.feature <- Gherkin scenarios
├── internal/
│ ├── payment/
│ │ ├── service.go <- Production code
│ │ └── service_test.go <- Unit tests
│ └── steps/
│ └── payment_steps.go <- Step definitions
└── main_test.go <- Godog entry point
Writing the Feature File First
Remember: in BDD, you write the feature file before any code. This is the BDD equivalent of “write tests first.”
# features/payment.feature
Feature: Payment Processing
As an e-commerce platform
I want to process customer payments
So that customers can complete their purchases
Background:
Given the payment service is running
And the following customers exist:
| id | name | balance |
| 001 | Alice Johnson | 1000.00 |
| 002 | Bob Smith | 500.00 |
| 003 | Carol White | 0.00 |
Scenario: Successful payment with sufficient funds
Given customer "001" wants to pay "50.00" USD
When the payment is processed
Then the payment should succeed
And the customer "001" balance should be "950.00"
Scenario: Failed payment due to insufficient funds
Given customer "003" wants to pay "100.00" USD
When the payment is processed
Then the payment should fail with error "insufficient funds"
And the customer "003" balance should remain "0.00"
Scenario: Failed payment with invalid amount
Given customer "001" wants to pay "-10.00" USD
When the payment is processed
Then the payment should fail with error "invalid amount"
Scenario: Failed payment for non-existent customer
Given customer "999" wants to pay "50.00" USD
When the payment is processed
Then the payment should fail with error "customer not found"
Scenario Outline: Multiple payment scenarios
Given customer "<customer_id>" wants to pay "<amount>" USD
When the payment is processed
Then the payment result should be "<result>"
Examples:
| customer_id | amount | result |
| 001 | 100.00 | success |
| 001 | 1100.00 | failure |
| 002 | 500.00 | success |
| 002 | 501.00 | failure |
Now everyone on the team can read this. The product manager approves the scenarios. QA adds edge cases. The developer implements them.
The Payment Service: Production Code
Now we write the actual code that the steps will call.
// internal/payment/service.go
package payment
import (
"errors"
"fmt"
"strconv"
)
// Common errors. Define them as variables so callers can compare with errors.Is().
var (
ErrCustomerNotFound = errors.New("customer not found")
ErrInsufficientFunds = errors.New("insufficient funds")
ErrInvalidAmount = errors.New("invalid amount")
)
// Customer represents a customer in the system.
type Customer struct {
ID string
Name string
Balance float64
}
// PaymentRequest represents a request to process a payment.
type PaymentRequest struct {
CustomerID string
Amount float64
Currency string
}
// PaymentResult represents the outcome of a payment attempt.
type PaymentResult struct {
Success bool
CustomerID string
Amount float64
Error error
}
// PaymentService handles payment operations.
// It uses an in-memory store. In production, this would be a database.
type PaymentService struct {
customers map[string]*Customer
}
// NewPaymentService creates a new PaymentService with empty customer store.
func NewPaymentService() *PaymentService {
return &PaymentService{
customers: make(map[string]*Customer),
}
}
// AddCustomer adds a customer to the service. Used in tests and setup.
func (s *PaymentService) AddCustomer(id, name string, balance float64) {
s.customers[id] = &Customer{
ID: id,
Name: name,
Balance: balance,
}
}
// GetCustomer returns a customer by ID.
func (s *PaymentService) GetCustomer(id string) (*Customer, error) {
customer, exists := s.customers[id]
if !exists {
return nil, fmt.Errorf("%w: %s", ErrCustomerNotFound, id)
}
return customer, nil
}
// ProcessPayment processes a payment request.
// This is the core business logic. Each failure case returns a descriptive error.
func (s *PaymentService) ProcessPayment(req PaymentRequest) PaymentResult {
// Validate the amount first. Negative amounts are always invalid.
if req.Amount <= 0 {
return PaymentResult{
Success: false,
CustomerID: req.CustomerID,
Amount: req.Amount,
Error: ErrInvalidAmount,
}
}
// Find the customer. Fail fast if they don't exist.
customer, err := s.GetCustomer(req.CustomerID)
if err != nil {
return PaymentResult{
Success: false,
CustomerID: req.CustomerID,
Amount: req.Amount,
Error: err,
}
}
// Check if the customer has enough balance.
if customer.Balance < req.Amount {
return PaymentResult{
Success: false,
CustomerID: req.CustomerID,
Amount: req.Amount,
Error: ErrInsufficientFunds,
}
}
// Deduct the amount. In production, this would be a database transaction.
customer.Balance -= req.Amount
return PaymentResult{
Success: true,
CustomerID: req.CustomerID,
Amount: req.Amount,
}
}
// ParseAmount converts a string amount to float64.
// Helper function used by step definitions.
func ParseAmount(s string) (float64, error) {
return strconv.ParseFloat(s, 64)
}
Step Definitions: The Bridge Between Gherkin and Go
Now the exciting part. The step definitions translate Gherkin steps into Go function calls.
Each Gherkin step has a matching Go function. The function signature uses regular expressions to capture values from the step text.
// internal/steps/payment_steps.go
package steps
import (
"context"
"fmt"
"github.com/cucumber/godog"
"payment-service/internal/payment"
)
// paymentScenarioContext holds state for a single scenario.
// Each scenario gets a fresh context. State does not bleed between scenarios.
type paymentScenarioContext struct {
service *payment.PaymentService
lastRequest payment.PaymentRequest
lastResult payment.PaymentResult
}
// InitializeScenario registers all step definitions.
// This is the function Godog calls to wire up the feature files.
func InitializeScenario(sc *godog.ScenarioContext) {
ctx := &paymentScenarioContext{}
// Background steps
sc.Step(`^the payment service is running$`, ctx.thePaymentServiceIsRunning)
sc.Step(`^the following customers exist:$`, ctx.theFollowingCustomersExist)
// Given steps
sc.Step(`^customer "([^"]*)" wants to pay "([^"]*)" USD$`, ctx.customerWantsToPayUSD)
// When steps
sc.Step(`^the payment is processed$`, ctx.thePaymentIsProcessed)
// Then steps
sc.Step(`^the payment should succeed$`, ctx.thePaymentShouldSucceed)
sc.Step(`^the payment should fail with error "([^"]*)"$`, ctx.thePaymentShouldFailWithError)
sc.Step(`^the customer "([^"]*)" balance should be "([^"]*)"$`, ctx.theCustomerBalanceShouldBe)
sc.Step(`^the customer "([^"]*)" balance should remain "([^"]*)"$`, ctx.theCustomerBalanceShouldRemain)
sc.Step(`^the payment result should be "([^"]*)"$`, ctx.thePaymentResultShouldBe)
}
// thePaymentServiceIsRunning initializes the service for this scenario.
func (ctx *paymentScenarioContext) thePaymentServiceIsRunning() error {
ctx.service = payment.NewPaymentService()
return nil
}
// theFollowingCustomersExist reads a Gherkin data table and adds customers.
func (ctx *paymentScenarioContext) theFollowingCustomersExist(table *godog.Table) error {
// The table has headers in the first row. Skip it.
for _, row := range table.Rows[1:] {
id := row.Cells[0].Value
name := row.Cells[1].Value
balanceStr := row.Cells[2].Value
balance, err := payment.ParseAmount(balanceStr)
if err != nil {
return fmt.Errorf("invalid balance %q for customer %q: %w", balanceStr, id, err)
}
ctx.service.AddCustomer(id, name, balance)
}
return nil
}
// customerWantsToPayUSD stores the payment request for later processing.
// Notice: we do NOT process the payment here. "Given" is setup, not action.
func (ctx *paymentScenarioContext) customerWantsToPayUSD(customerID, amountStr string) error {
amount, err := payment.ParseAmount(amountStr)
if err != nil {
return fmt.Errorf("invalid amount %q: %w", amountStr, err)
}
ctx.lastRequest = payment.PaymentRequest{
CustomerID: customerID,
Amount: amount,
Currency: "USD",
}
return nil
}
// thePaymentIsProcessed executes the payment. This is the "When" step.
func (ctx *paymentScenarioContext) thePaymentIsProcessed() error {
ctx.lastResult = ctx.service.ProcessPayment(ctx.lastRequest)
return nil // We do not fail here even if payment fails. That is the "Then" step's job.
}
// thePaymentShouldSucceed verifies the payment succeeded.
func (ctx *paymentScenarioContext) thePaymentShouldSucceed() error {
if !ctx.lastResult.Success {
return fmt.Errorf("expected payment to succeed, but it failed with: %v", ctx.lastResult.Error)
}
return nil
}
// thePaymentShouldFailWithError verifies the payment failed with a specific error.
func (ctx *paymentScenarioContext) thePaymentShouldFailWithError(expectedError string) error {
if ctx.lastResult.Success {
return fmt.Errorf("expected payment to fail with %q, but it succeeded", expectedError)
}
if ctx.lastResult.Error == nil {
return fmt.Errorf("expected error %q, but error was nil", expectedError)
}
if ctx.lastResult.Error.Error() != expectedError {
// Try checking if it contains the expected message (handles wrapped errors).
return fmt.Errorf("expected error %q, got %q", expectedError, ctx.lastResult.Error.Error())
}
return nil
}
// theCustomerBalanceShouldBe verifies a customer's current balance.
func (ctx *paymentScenarioContext) theCustomerBalanceShouldBe(customerID, expectedBalanceStr string) error {
expectedBalance, err := payment.ParseAmount(expectedBalanceStr)
if err != nil {
return fmt.Errorf("invalid expected balance %q: %w", expectedBalanceStr, err)
}
customer, err := ctx.service.GetCustomer(customerID)
if err != nil {
return err
}
if customer.Balance != expectedBalance {
return fmt.Errorf("expected customer %q balance to be %v, got %v",
customerID, expectedBalance, customer.Balance)
}
return nil
}
// theCustomerBalanceShouldRemain is an alias for theCustomerBalanceShouldBe.
// Having both makes the Gherkin more readable ("remain" vs "be").
func (ctx *paymentScenarioContext) theCustomerBalanceShouldRemain(customerID, expectedBalanceStr string) error {
return ctx.theCustomerBalanceShouldBe(customerID, expectedBalanceStr)
}
// thePaymentResultShouldBe checks result as a string ("success" or "failure").
func (ctx *paymentScenarioContext) thePaymentResultShouldBe(expectedResult string) error {
switch expectedResult {
case "success":
if !ctx.lastResult.Success {
return fmt.Errorf("expected success but got failure: %v", ctx.lastResult.Error)
}
case "failure":
if ctx.lastResult.Success {
return fmt.Errorf("expected failure but got success")
}
default:
return fmt.Errorf("unknown result %q, expected 'success' or 'failure'", expectedResult)
}
return nil
}
The Godog Entry Point
Finally, wire everything together:
// main_test.go
package main
import (
"testing"
"github.com/cucumber/godog"
"payment-service/internal/steps"
)
// TestFeatures is the entry point for all BDD tests.
// Running "go test ./..." will execute all feature files.
func TestFeatures(t *testing.T) {
suite := godog.TestSuite{
ScenarioInitializer: steps.InitializeScenario,
Options: &godog.Options{
// Format: "pretty" gives human-readable output. "progress" is compact.
Format: "pretty",
Paths: []string{"features"}, // Where to find .feature files
TestingT: t, // Integrates with go test
Tags: "", // Run all tags (empty = no filter)
// Strict: true means undefined steps fail the test.
Strict: true,
},
}
if suite.Run() != 0 {
t.Fatal("non-zero status returned, failed to run feature tests")
}
}
Run the BDD tests:
go test ./...
You will see output like this:
Feature: Payment Processing
As an e-commerce platform...
Background: # features/payment.feature:6
Given the payment service is running # steps/payment_steps.go:29
And the following customers exist: # steps/payment_steps.go:35
Scenario: Successful payment with sufficient funds # features/payment.feature:15
Given customer "001" wants to pay "50.00" USD # steps/payment_steps.go:52
When the payment is processed # steps/payment_steps.go:65
Then the payment should succeed # steps/payment_steps.go:72
And the customer "001" balance should be "950.00" # steps/payment_steps.go:83
5 scenarios (5 passed)
16 steps (16 passed)
ok payment-service 0.003s
Every step printed. Every scenario result visible. The product manager can read this output and understand exactly what passed and failed.
Part 5 - Context and State Management in BDD
The Problem with Shared State
One of the trickiest parts of BDD in Go is managing state between steps in a scenario.
Look at this scenario:
Given customer "001" wants to pay "50.00" USD
When the payment is processed
Then the payment should succeed
The “Given” step stores the request somewhere. The “When” step reads it. The “Then” step reads the result. They need to share state.
The naive approach is to use global variables. Do not do this. Global state causes tests to bleed into each other. A failing scenario can corrupt the state for the next one.
The right approach: use a context struct that is created fresh for each scenario.
Godog supports this with the context.Context:
// A more advanced approach using context.Context to carry state.
// paymentKey is a private type for the context key. This prevents collisions
// with other packages using the same context.
type contextKey string
const paymentCtxKey contextKey = "payment"
// paymentState holds all scenario state.
type paymentState struct {
service *payment.PaymentService
lastRequest payment.PaymentRequest
lastResult payment.PaymentResult
}
// getState retrieves the current scenario state from context.
// If it doesn't exist, it creates a new one (lazy initialization).
func getState(ctx context.Context) *paymentState {
if s, ok := ctx.Value(paymentCtxKey).(*paymentState); ok {
return s
}
return &paymentState{}
}
// InitializeScenarioWithContext uses context-based state (recommended for parallel scenarios).
func InitializeScenarioWithContext(sc *godog.ScenarioContext) {
sc.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) {
// Create fresh state for this scenario. Nothing from other scenarios leaks in.
state := &paymentState{
service: payment.NewPaymentService(),
}
return context.WithValue(ctx, paymentCtxKey, state), nil
})
sc.Step(`^the payment service is running$`,
func(ctx context.Context) (context.Context, error) {
// Service already initialized in Before hook.
return ctx, nil
})
}
The context-based approach enables parallel test execution. Each scenario has its own isolated state bubble.
Part 6 - Docker: Killing “Works On My Machine”
The Classic Problem
Imagine you write a test that connects to a PostgreSQL database. On your machine, PostgreSQL is running. The test passes.
Your teammate runs the same test. She does not have PostgreSQL installed. The test fails.
You say: “Works on my machine.”
She says: “Yes, but not on mine.”
And thus the most infamous sentence in software development was born.
Docker solves this. Docker is a containerization platform. It packages your application (or just a PostgreSQL server) and all its dependencies into a container. A container runs the same everywhere. On your laptop. On your teammate’s laptop. On the CI server. On production.
No more “works on my machine.”
Docker Concepts You Need to Know
Before we go further, let’s nail down the vocabulary.
Image — A blueprint. A read-only template that describes what a container should contain. postgres:15 is an image.
Container — A running instance of an image. You can run ten containers from the same image simultaneously.
Dockerfile — A script that builds a custom image. Step-by-step instructions.
Docker Compose — A tool for running multiple containers together. One command starts your entire stack.
Volume — Persistent storage for containers. Data in a volume survives container restarts.
Network — A virtual network connecting containers. Containers on the same network can talk to each other by name.
Your First Dockerfile
Let’s package our payment service:
# Dockerfile
# Stage 1: Build
# We use the official Go image. The version is pinned. Never use "latest" in production.
FROM golang:1.22-alpine AS builder
# Set the working directory inside the container.
WORKDIR /app
# Copy go.mod and go.sum first. Docker caches layers.
# If these files don't change, Docker skips the dependency download step.
COPY go.mod go.sum ./
RUN go mod download
# Copy the rest of the source code.
COPY . .
# Build the binary. CGO_ENABLED=0 for a fully static binary.
# -o specifies the output path.
RUN CGO_ENABLED=0 GOOS=linux go build -o /payment-service ./cmd/server
# Stage 2: Runtime
# We use scratch (empty image) or alpine for a tiny final image.
# The final image does NOT include Go tools. Just the binary.
FROM alpine:3.19 AS runtime
# Add a non-root user for security. Never run containers as root.
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# Copy only the binary from the builder stage.
COPY --from=builder /payment-service .
# Switch to non-root user.
USER appuser
# Document the port the application uses.
EXPOSE 8080
# The command to run when the container starts.
CMD ["./payment-service"]
This is a multi-stage build. The first stage compiles. The second stage runs. The final image is tiny because it only contains the binary, not the Go compiler.
Build and run:
# Build the image. Tag it as "payment-service:latest"
docker build -t payment-service:latest .
# Run a container from it. -p maps host port 8080 to container port 8080.
docker run -p 8080:8080 payment-service:latest
Docker for Testing: Testcontainers-Go
This is where Docker and testing become a love story.
Testcontainers is a library that lets you spin up Docker containers from within your test code. Your integration tests can start a real PostgreSQL database, run the tests, and tear down the database when done.
No more mock databases in integration tests. No more “I need to install PostgreSQL to run these tests.”
go get github.com/testcontainers/testcontainers-go@latest
go get github.com/testcontainers/testcontainers-go/modules/postgres@latest
Here is an integration test for a repository layer:
// internal/payment/repository_integration_test.go
package payment_test
import (
"context"
"database/sql"
"testing"
"time"
_ "github.com/lib/pq" // PostgreSQL driver
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
// TestCustomerRepository_Integration tests the repository against a real database.
// This test is skipped in normal unit test runs. Use -tags integration to run it.
func TestCustomerRepository_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
ctx := context.Background()
// Start a PostgreSQL container.
// The container is created just for this test and destroyed afterward.
pgContainer, err := postgres.RunContainer(ctx,
testcontainers.WithImage("postgres:15-alpine"),
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpassword"),
// Wait until PostgreSQL is ready to accept connections.
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second),
),
)
if err != nil {
t.Fatalf("failed to start PostgreSQL container: %v", err)
}
// Ensure the container is cleaned up when the test finishes.
defer pgContainer.Terminate(ctx)
// Get the connection string for this container.
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("failed to get connection string: %v", err)
}
// Connect to the database.
db, err := sql.Open("postgres", connStr)
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
defer db.Close()
// Run database migrations.
if err := runMigrations(db); err != nil {
t.Fatalf("failed to run migrations: %v", err)
}
// Now test the actual repository.
repo := NewCustomerRepository(db)
// Test: Create a customer
t.Run("create customer", func(t *testing.T) {
customer := &Customer{
ID: "001",
Name: "Alice Johnson",
Balance: 1000.00,
}
err := repo.Create(ctx, customer)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
})
// Test: Get the customer back
t.Run("get customer", func(t *testing.T) {
got, err := repo.GetByID(ctx, "001")
if err != nil {
t.Fatalf("GetByID() error = %v", err)
}
if got.Name != "Alice Johnson" {
t.Errorf("GetByID() name = %v, want %v", got.Name, "Alice Johnson")
}
if got.Balance != 1000.00 {
t.Errorf("GetByID() balance = %v, want %v", got.Balance, 1000.00)
}
})
}
// runMigrations applies the database schema.
func runMigrations(db *sql.DB) error {
schema := `
CREATE TABLE IF NOT EXISTS customers (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
balance NUMERIC(15,2) NOT NULL DEFAULT 0.00
);
`
_, err := db.Exec(schema)
return err
}
Run with:
# Normal tests (skips integration tests)
go test ./...
# Include integration tests
go test -count=1 ./...
# Only short tests (skips integration)
go test -short ./...
Docker Compose: Your Full Stack in One File
For BDD tests that need a complete environment (API + database + cache), Docker Compose is the answer.
# docker-compose.yml
version: "3.9"
services:
# The payment service itself
payment-service:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- DB_HOST=postgres
- DB_PORT=5432
- DB_NAME=payments
- DB_USER=appuser
- DB_PASSWORD=apppassword
- REDIS_HOST=redis
- REDIS_PORT=6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- payment-network
# PostgreSQL database
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: payments
POSTGRES_USER: appuser
POSTGRES_PASSWORD: apppassword
volumes:
# Named volume: data persists between container restarts
- postgres-data:/var/lib/postgresql/data
# Init scripts: runs on first container start
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
# Healthcheck: Compose waits for this before marking the service as healthy
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d payments"]
interval: 5s
timeout: 5s
retries: 5
networks:
- payment-network
# Redis for caching
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks:
- payment-network
# A separate service for running tests
# Run with: docker compose run test
test:
build:
context: .
dockerfile: Dockerfile.test
environment:
- DB_HOST=postgres
- DB_PORT=5432
- DB_NAME=payments
- DB_USER=appuser
- DB_PASSWORD=apppassword
depends_on:
postgres:
condition: service_healthy
networks:
- payment-network
networks:
payment-network:
driver: bridge
volumes:
postgres-data:
A separate Dockerfile for tests:
# Dockerfile.test
FROM golang:1.22-alpine
WORKDIR /app
# Install any system dependencies (none needed here, but commonly you need git)
RUN apk add --no-cache git
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# The test command. Runs all tests including BDD.
CMD ["go", "test", "-v", "./..."]
Now running your full test suite is:
# Start the environment
docker compose up -d postgres redis
# Wait for services to be healthy, then run tests
docker compose run test
# Tear down when done
docker compose down
Part 7 - CI/CD: The Pipeline That Never Sleeps
What is CI/CD
Continuous Integration (CI) means: every time someone pushes code, the tests run automatically. Always. Every push. Every pull request.
Continuous Delivery (CD) means: if the tests pass, the code can be deployed automatically.
Together, CI/CD is the safety net that catches problems before they reach production. It is also the mechanism that makes deploying software boring, which is exactly what you want. Boring deployments are good deployments.
The CI pipeline typically does:
- Lint the code (check formatting and style)
- Run unit tests
- Run integration tests
- Build the Docker image
- Push the image to a registry
- (Optional) Deploy to staging
The CD pipeline typically does:
- Pull the built image from the registry
- Run database migrations
- Deploy to production (with rolling update or blue-green strategy)
- Run smoke tests against production
- Notify the team
GitHub Actions: CI/CD That Feels Like Code
GitHub Actions is GitHub’s built-in CI/CD system. Your pipeline is defined in YAML files in the .github/workflows/ directory.
Let us build a complete pipeline for the payment service.
# .github/workflows/ci.yml
name: CI Pipeline
# When to run this pipeline.
on:
# On every push to any branch.
push:
branches: ["**"]
# On every pull request targeting main.
pull_request:
branches: [main, develop]
# Environment variables available to all jobs.
env:
GO_VERSION: "1.22"
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ─── Job 1: Code Quality ─────────────────────────────────────────────────
lint:
name: Lint & Format
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true # Cache Go modules between runs
- name: Install golangci-lint
uses: golangci/golangci-lint-action@v4
with:
version: latest
args: --timeout=5m
- name: Check formatting
run: |
# gofmt -l prints files that differ from standard formatting.
# If any files are printed, the pipeline fails.
files=$(gofmt -l .)
if [ -n "$files" ]; then
echo "The following files are not properly formatted:"
echo "$files"
echo "Run 'gofmt -w .' to fix formatting."
exit 1
fi
# ─── Job 2: Unit Tests ───────────────────────────────────────────────────
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
needs: lint # Only run if lint passes
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Run unit tests
run: |
# -short skips tests marked with testing.Short().
# -race detects race conditions (essential for concurrent code).
# -coverprofile generates a coverage report.
go test -short -race -coverprofile=coverage.out ./...
- name: Display test coverage
run: |
go tool cover -func=coverage.out | tail -1
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.out
- name: Check coverage threshold
run: |
# Fail if coverage falls below 70%.
COVERAGE=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}' | tr -d '%')
echo "Coverage: ${COVERAGE}%"
if (( $(echo "$COVERAGE < 70" | bc -l) )); then
echo "Coverage ${COVERAGE}% is below the 70% threshold"
exit 1
fi
# ─── Job 3: BDD / Integration Tests ─────────────────────────────────────
bdd-tests:
name: BDD & Integration Tests
runs-on: ubuntu-latest
needs: lint
# Services are Docker containers started before the job.
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_DB: payments_test
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpassword
# Map the container port to the host. The tests connect via localhost.
ports:
- 5432:5432
# Wait for PostgreSQL to be ready.
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Run database migrations
env:
DATABASE_URL: postgres://testuser:testpassword@localhost:5432/payments_test?sslmode=disable
run: |
go run ./cmd/migrate up
- name: Run BDD tests
env:
DB_HOST: localhost
DB_PORT: 5432
DB_NAME: payments_test
DB_USER: testuser
DB_PASSWORD: testpassword
REDIS_HOST: localhost
REDIS_PORT: 6379
run: |
# Run all tests including integration tests.
# The BDD tests (godog) are included since they use "go test ./..."
go test -v -count=1 ./...
- name: Upload BDD report
if: always() # Run even if tests fail
uses: actions/upload-artifact@v4
with:
name: bdd-report
path: test-reports/
# ─── Job 4: Build Docker Image ────────────────────────────────────────────
build:
name: Build Docker Image
runs-on: ubuntu-latest
needs: [unit-tests, bdd-tests] # Only build if all tests pass
permissions:
contents: read
packages: write # Needed to push to GitHub Container Registry
outputs:
# Pass the image tag to the deploy job.
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Tag with branch name: ghcr.io/user/repo:main
type=ref,event=branch
# Tag with PR number: ghcr.io/user/repo:pr-42
type=ref,event=pr
# Tag with semver if a tag is pushed: ghcr.io/user/repo:v1.2.3
type=semver,pattern={{version}}
# Always tag as "latest" on main branch
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Cache layers from the previous build to speed things up.
cache-from: type=gha
cache-to: type=gha,mode=max
# ─── Job 5: Deploy to Staging ────────────────────────────────────────────
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: build
# Only deploy from the main or develop branch.
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
environment:
name: staging
url: https://staging.payment-service.example.com
steps:
- name: Deploy to staging
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.STAGING_HOST }}
username: ${{ secrets.STAGING_USER }}
key: ${{ secrets.STAGING_SSH_KEY }}
script: |
cd /opt/payment-service
docker compose pull payment-service
docker compose up -d payment-service
docker compose exec payment-service ./migrate up
echo "Deployed successfully"
- name: Run smoke tests against staging
run: |
# Simple smoke test: check the health endpoint.
curl --fail --retry 5 --retry-delay 5 \
https://staging.payment-service.example.com/health
Golangci-lint: Catching Style Problems Automatically
Linting is automated code review for style and common mistakes.
Create a .golangci.yml file to configure it:
# .golangci.yml
run:
# Maximum time a linter can run.
timeout: 5m
# Go version to use.
go: "1.22"
linters:
enable:
- gofmt # Standard formatting
- govet # Reports suspicious constructs
- errcheck # Checks that errors are handled
- staticcheck # Advanced static analysis
- gosimple # Suggests simplifications
- ineffassign # Detects ineffectual assignments
- unused # Checks for unused code
- misspell # Catches common spelling mistakes in comments
- gocyclo # Checks cyclomatic complexity
- bodyclose # Checks HTTP response body is closed
- contextcheck # Checks context usage
- noctx # Finds http.NewRequest without context
- revive # Drop-in replacement for golint
linters-settings:
gocyclo:
# Functions with complexity higher than this will be flagged.
min-complexity: 15
errcheck:
# Check for errors in type assertions: i.(Type)
check-type-assertions: true
revive:
rules:
- name: exported
severity: warning
# Require comments on exported functions.
arguments:
- disableStutteringCheck
issues:
exclude-rules:
# Test files are allowed to have longer functions and skip some checks.
- path: "_test.go"
linters:
- gocyclo
- errcheck
Part 8 - Mocking: Testing Without Side Effects
Why Mocks Exist
Unit tests should test one thing in isolation. But most code has dependencies: databases, HTTP clients, email services, payment gateways.
You do not want your unit tests to:
- Connect to a real database (slow, requires setup, causes side effects)
- Make real HTTP calls (flaky, depends on external services, costs money)
- Send real emails (please, no)
Mocks replace real dependencies with fake versions that you control. The fake behaves however you tell it to behave.
Interfaces: The Key to Testability
In Go, you cannot mock a concrete type. You can only mock an interface.
This is actually a feature, not a limitation. It forces your code to depend on abstractions, not concrete implementations. This is the Dependency Inversion Principle.
// internal/payment/interfaces.go
// CustomerRepository defines the contract for customer data access.
// The real implementation talks to PostgreSQL.
// The test implementation (mock) stores data in memory.
type CustomerRepository interface {
GetByID(ctx context.Context, id string) (*Customer, error)
Save(ctx context.Context, customer *Customer) error
UpdateBalance(ctx context.Context, id string, newBalance float64) error
}
// NotificationService defines the contract for sending notifications.
// The real implementation sends emails/SMS.
// The mock records which notifications were sent.
type NotificationService interface {
SendPaymentConfirmation(ctx context.Context, customer *Customer, amount float64) error
SendPaymentFailure(ctx context.Context, customer *Customer, reason string) error
}
// PaymentGateway defines the contract for charging external payment methods.
type PaymentGateway interface {
Charge(ctx context.Context, customerID string, amount float64, currency string) (string, error)
}
Now the service accepts interfaces, not concrete types:
// internal/payment/service.go — improved version
type PaymentService struct {
repo CustomerRepository
notifications NotificationService
gateway PaymentGateway
}
func NewPaymentService(
repo CustomerRepository,
notifications NotificationService,
gateway PaymentGateway,
) *PaymentService {
return &PaymentService{
repo: repo,
notifications: notifications,
gateway: gateway,
}
}
Handwritten Mocks
The simplest mocks are just struct types that implement the interface:
// internal/payment/mocks_test.go
package payment_test
import (
"context"
"fmt"
"payment-service/internal/payment"
)
// mockCustomerRepository is a fake implementation that stores data in memory.
type mockCustomerRepository struct {
customers map[string]*payment.Customer
saveError error // If set, Save() returns this error
getError error // If set, GetByID() returns this error
saveCalled int // How many times Save() was called
getCalled int // How many times GetByID() was called
}
func newMockCustomerRepository() *mockCustomerRepository {
return &mockCustomerRepository{
customers: make(map[string]*payment.Customer),
}
}
func (m *mockCustomerRepository) GetByID(ctx context.Context, id string) (*payment.Customer, error) {
m.getCalled++
if m.getError != nil {
return nil, m.getError
}
customer, exists := m.customers[id]
if !exists {
return nil, fmt.Errorf("%w: %s", payment.ErrCustomerNotFound, id)
}
return customer, nil
}
func (m *mockCustomerRepository) Save(ctx context.Context, customer *payment.Customer) error {
m.saveCalled++
if m.saveError != nil {
return m.saveError
}
m.customers[customer.ID] = customer
return nil
}
func (m *mockCustomerRepository) UpdateBalance(ctx context.Context, id string, newBalance float64) error {
customer, exists := m.customers[id]
if !exists {
return fmt.Errorf("%w: %s", payment.ErrCustomerNotFound, id)
}
customer.Balance = newBalance
return nil
}
// mockNotificationService records which notifications were sent.
type mockNotificationService struct {
confirmationsSent []string // CustomerIDs that received confirmations
failuresSent []string // CustomerIDs that received failure notifications
sendError error // If set, all sends return this error
}
func (m *mockNotificationService) SendPaymentConfirmation(
ctx context.Context,
customer *payment.Customer,
amount float64,
) error {
if m.sendError != nil {
return m.sendError
}
m.confirmationsSent = append(m.confirmationsSent, customer.ID)
return nil
}
func (m *mockNotificationService) SendPaymentFailure(
ctx context.Context,
customer *payment.Customer,
reason string,
) error {
if m.sendError != nil {
return m.sendError
}
m.failuresSent = append(m.failuresSent, customer.ID)
return nil
}
Now the unit test uses these mocks:
// internal/payment/service_test.go
func TestPaymentService_ProcessPayment_Notification(t *testing.T) {
// Arrange
repo := newMockCustomerRepository()
notifications := &mockNotificationService{}
gateway := &mockPaymentGateway{chargeID: "ch_123"}
// Add a customer to the mock repo
repo.customers["001"] = &payment.Customer{
ID: "001",
Name: "Alice",
Balance: 1000.00,
}
svc := payment.NewPaymentService(repo, notifications, gateway)
// Act
result := svc.ProcessPayment(context.Background(), payment.PaymentRequest{
CustomerID: "001",
Amount: 50.00,
Currency: "USD",
})
// Assert
if !result.Success {
t.Fatalf("expected success, got failure: %v", result.Error)
}
// Verify notification was sent
if len(notifications.confirmationsSent) != 1 {
t.Errorf("expected 1 confirmation sent, got %d", len(notifications.confirmationsSent))
}
if notifications.confirmationsSent[0] != "001" {
t.Errorf("expected confirmation sent to 001, got %s", notifications.confirmationsSent[0])
}
}
The test is fast (no database, no HTTP calls), deterministic (the mock always behaves the same), and self-contained.
Mockery: Generating Mocks Automatically
Writing mocks by hand gets tedious. Use mockery to generate them.
# Install mockery
go install github.com/vektra/mockery/v2@latest
# Generate mocks for all interfaces in the payment package
mockery --all --keeptree --output internal/payment/mocks
Mockery generates mock files like this:
// internal/payment/mocks/CustomerRepository.go (auto-generated, do not edit)
package mocks
import (
"context"
mock "github.com/stretchr/testify/mock"
"payment-service/internal/payment"
)
type CustomerRepository struct {
mock.Mock
}
func (_m *CustomerRepository) GetByID(ctx context.Context, id string) (*payment.Customer, error) {
ret := _m.Called(ctx, id)
// ... generated code
}
Using the generated mock with testify:
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"payment-service/internal/payment/mocks"
)
func TestWithGeneratedMock(t *testing.T) {
repo := &mocks.CustomerRepository{}
// Set up expectations: when GetByID is called with "001",
// return this customer and no error.
repo.On("GetByID", mock.Anything, "001").Return(&payment.Customer{
ID: "001",
Name: "Alice",
Balance: 1000.00,
}, nil)
// ... test code ...
// Assert that expectations were met
repo.AssertExpectations(t)
}
Part 9 - BDD + TDD + CI/CD: The Complete Team Workflow
How It All Connects
Let us put everything together with a complete workflow, as it would work in a real Scrum team.
Week 1, Monday — Refinement Session (The Three Amigos)
Product manager: “We need to add a feature: customers should be able to schedule recurring payments.”
Developer: “What happens if the payment fails on the scheduled date?”
QA: “What if the customer cancels the subscription between scheduling and the next payment date?”
Product manager: “Good questions. If it fails, retry three times with a 24-hour gap. If canceled, no more payments.”
The outcome of this conversation is a Gherkin feature file written collaboratively:
# features/recurring-payment.feature
Feature: Recurring Payment Scheduling
As a customer
I want to schedule automatic recurring payments
So that I don't have to manually pay each month
Background:
Given the payment service is running
And customer "001" exists with balance "500.00"
Scenario: Schedule a recurring payment
When customer "001" schedules a monthly payment of "100.00" USD starting "2026-03-01"
Then the schedule should be created with status "active"
And the next payment date should be "2026-03-01"
Scenario: First payment in a schedule
Given customer "001" has an active schedule of "100.00" USD monthly
When the scheduler runs on "2026-03-01"
Then the payment of "100.00" should be processed
And the next payment date should be "2026-04-01"
Scenario: Retry on payment failure
Given customer "001" has an active schedule of "600.00" USD monthly
And customer "001" balance is "200.00"
When the scheduler runs on "2026-03-01"
Then the payment should fail
And the schedule status should be "retry-pending"
And the next retry date should be "2026-03-02"
Scenario: Cancel a recurring schedule
Given customer "001" has an active schedule of "100.00" USD monthly
When customer "001" cancels the schedule
Then the schedule status should be "cancelled"
When the scheduler runs on the next scheduled date
Then no payment should be processed
Tuesday — Developer Starts TDD Cycle
The developer starts with unit tests for the scheduler logic before writing any production code.
// internal/scheduler/scheduler_test.go
func TestScheduler_CalculateNextDate(t *testing.T) {
testCases := []struct {
name string
lastDate time.Time
frequency string
expected time.Time
}{
{
name: "monthly from Jan",
lastDate: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
frequency: "monthly",
expected: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
},
{
name: "monthly from December",
lastDate: time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC),
frequency: "monthly",
expected: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC),
},
{
name: "weekly",
lastDate: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
frequency: "weekly",
expected: time.Date(2026, 1, 8, 0, 0, 0, 0, time.UTC),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := CalculateNextDate(tc.lastDate, tc.frequency)
if !result.Equal(tc.expected) {
t.Errorf("CalculateNextDate(%v, %v) = %v, expected %v",
tc.lastDate, tc.frequency, result, tc.expected)
}
})
}
}
Red. Write the implementation. Green. Refactor. Repeat.
Wednesday — Step Definitions and BDD Tests
The developer writes step definitions that connect the Gherkin scenarios to the Go code:
// internal/steps/recurring_payment_steps.go
func InitializeRecurringPaymentScenario(sc *godog.ScenarioContext) {
ctx := &recurringPaymentContext{}
sc.Step(`^customer "([^"]*)" schedules a monthly payment of "([^"]*)" USD starting "([^"]*)"$`,
ctx.customerSchedulesMonthlyPayment)
sc.Step(`^the schedule should be created with status "([^"]*)"$`,
ctx.theScheduleShouldBeCreatedWithStatus)
sc.Step(`^the next payment date should be "([^"]*)"$`,
ctx.theNextPaymentDateShouldBe)
// ... more step definitions
}
Thursday — Push, CI Runs
The developer pushes the branch. GitHub Actions automatically:
- Runs the linter — passes.
- Runs unit tests — passes.
- Starts a PostgreSQL service container.
- Runs BDD tests — passes.
- Builds the Docker image.
- Deploys to staging.
The QA engineer visits the staging environment. The product manager demos the feature. Everyone is happy.
Friday — Pull Request + Code Review + Merge
The PR is reviewed. The tests are green. The feature is merged to develop.
At no point did anyone have to manually test anything. At no point did a bug go undetected for more than minutes.
That is the workflow.
Part 10 - Advanced Patterns
Subtests and Parallel Testing
Go supports running subtests in parallel. For unit tests, this can dramatically speed up the test suite:
func TestPaymentService_Parallel(t *testing.T) {
// Set up shared resources once (read-only objects are safe to share).
repo := newMockCustomerRepository()
repo.customers["001"] = &payment.Customer{ID: "001", Balance: 1000}
testCases := []struct {
name string
customerID string
amount float64
wantSuccess bool
}{
{"valid payment", "001", 50, true},
{"missing customer", "999", 50, false},
{"negative amount", "001", -10, false},
}
for _, tc := range testCases {
tc := tc // Capture range variable. Essential for parallel subtests.
t.Run(tc.name, func(t *testing.T) {
t.Parallel() // This subtest can run concurrently with others.
// Each subtest uses its own mock instances to avoid data races.
localRepo := newMockCustomerRepository()
for id, c := range repo.customers {
localRepo.customers[id] = c
}
svc := payment.NewPaymentService(localRepo, &mockNotificationService{}, &mockPaymentGateway{})
result := svc.ProcessPayment(context.Background(), payment.PaymentRequest{
CustomerID: tc.customerID,
Amount: tc.amount,
})
if result.Success != tc.wantSuccess {
t.Errorf("ProcessPayment() success = %v, want %v", result.Success, tc.wantSuccess)
}
})
}
}
Run with:
go test -parallel 4 ./...
Benchmark Tests
Go has first-class support for benchmarks. Use them to measure performance:
// internal/payment/benchmark_test.go
// BenchmarkProcessPayment measures how fast a payment is processed.
// Run with: go test -bench=. -benchmem ./internal/payment/
func BenchmarkProcessPayment(b *testing.B) {
repo := newMockCustomerRepository()
repo.customers["001"] = &payment.Customer{ID: "001", Balance: 999999}
svc := payment.NewPaymentService(repo, &mockNotificationService{}, &mockPaymentGateway{})
req := payment.PaymentRequest{
CustomerID: "001",
Amount: 1.00,
Currency: "USD",
}
// Reset the timer after setup.
b.ResetTimer()
// b.N is adjusted by the testing framework until results are stable.
for i := 0; i < b.N; i++ {
svc.ProcessPayment(context.Background(), req)
}
}
// BenchmarkProcessPaymentParallel measures throughput under concurrency.
func BenchmarkProcessPaymentParallel(b *testing.B) {
repo := newMockCustomerRepository()
repo.customers["001"] = &payment.Customer{ID: "001", Balance: 999999}
svc := payment.NewPaymentService(repo, &mockNotificationService{}, &mockPaymentGateway{})
req := payment.PaymentRequest{
CustomerID: "001",
Amount: 1.00,
Currency: "USD",
}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
svc.ProcessPayment(context.Background(), req)
}
})
}
Output looks like:
BenchmarkProcessPayment-8 2000000 600 ns/op 128 B/op 3 allocs/op
BenchmarkProcessPaymentParallel-8 5000000 250 ns/op 128 B/op 3 allocs/op
Reading this: ProcessPayment took 600 nanoseconds per operation, used 128 bytes, and made 3 allocations.
Test Coverage: What It Means and What It Doesn’t
Coverage is the percentage of code lines executed by tests.
# Run tests with coverage
go test -coverprofile=coverage.out ./...
# View coverage by function
go tool cover -func=coverage.out
# View as HTML (opens in browser)
go tool cover -html=coverage.out
A word of warning: 100% coverage does not mean your code is bug-free. It means every line was executed at least once. A line can execute and still have bugs.
Coverage is a floor, not a ceiling. Aim for 70-80% coverage on business logic. Do not chase 100%. You will end up writing tests that test nothing meaningful just to hit the number.
What matters more than coverage: are the important behaviors tested? Are the edge cases covered? Are the error paths exercised?
Fixtures and Testdata
For tests that need complex input data, use testdata directories:
internal/payment/
├── service.go
├── service_test.go
└── testdata/
├── valid_payment_request.json
├── invalid_payment_request.json
└── customer_list.json
Load fixture data in tests:
// testhelpers_test.go
// loadFixture reads a JSON fixture file and unmarshals it.
func loadFixture(t *testing.T, filename string, v any) {
t.Helper()
// go test sets the working directory to the package directory.
// testdata/ is relative to the package.
data, err := os.ReadFile(filepath.Join("testdata", filename))
if err != nil {
t.Fatalf("loadFixture: failed to read %q: %v", filename, err)
}
if err := json.Unmarshal(data, v); err != nil {
t.Fatalf("loadFixture: failed to unmarshal %q: %v", filename, err)
}
}
// Usage in a test:
func TestProcessComplexPayment(t *testing.T) {
var req payment.PaymentRequest
loadFixture(t, "valid_payment_request.json", &req)
// Test with the loaded fixture...
}
Part 11 - The Makefile: Your Command Center
Why a Makefile
Every project should have a Makefile (or equivalent) that documents and standardizes common tasks. New developers should be able to clone the repo and run make setup to have everything they need.
# Makefile
# ─── Variables ─────────────────────────────────────────────────────────────
BINARY_NAME := payment-service
GO_VERSION := 1.22
DOCKER_COMPOSE := docker compose
GOTEST := go test
GOLINT := golangci-lint
# ─── Default target ─────────────────────────────────────────────────────────
.DEFAULT_GOAL := help
# ─── Help ───────────────────────────────────────────────────────────────────
.PHONY: help
help: ## Show this help message
@echo "Usage: make <target>"
@echo ""
@echo "Targets:"
@awk 'BEGIN {FS = ":.*##"; printf ""} /^[a-zA-Z_-]+:.*?##/ { printf " %-20s %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
# ─── Setup ──────────────────────────────────────────────────────────────────
.PHONY: setup
setup: ## Install all development dependencies
go install github.com/vektra/mockery/v2@latest
go install github.com/cucumber/godog/cmd/godog@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
go mod download
# ─── Development ────────────────────────────────────────────────────────────
.PHONY: run
run: ## Run the service locally
go run ./cmd/server
.PHONY: dev
dev: ## Run with hot reload (requires air)
air
# ─── Testing ────────────────────────────────────────────────────────────────
.PHONY: test
test: ## Run unit tests only
$(GOTEST) -short -race ./...
.PHONY: test-verbose
test-verbose: ## Run unit tests with verbose output
$(GOTEST) -short -race -v ./...
.PHONY: test-integration
test-integration: ## Run integration tests (requires Docker)
$(DOCKER_COMPOSE) up -d postgres redis
$(GOTEST) -count=1 -race ./...
$(DOCKER_COMPOSE) down
.PHONY: test-bdd
test-bdd: ## Run BDD tests only
$(GOTEST) -v -run TestFeatures ./...
.PHONY: test-all
test-all: test-integration test-bdd ## Run all tests
.PHONY: bench
bench: ## Run benchmarks
$(GOTEST) -bench=. -benchmem ./...
.PHONY: coverage
coverage: ## Generate and display test coverage
$(GOTEST) -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report: coverage.html"
# ─── Code Quality ────────────────────────────────────────────────────────────
.PHONY: lint
lint: ## Run all linters
$(GOLINT) run ./...
.PHONY: fmt
fmt: ## Format all Go files
gofmt -w .
goimports -w .
.PHONY: vet
vet: ## Run go vet
go vet ./...
.PHONY: mocks
mocks: ## Generate all mocks
mockery --all --keeptree --output internal/mocks
# ─── Docker ─────────────────────────────────────────────────────────────────
.PHONY: docker-build
docker-build: ## Build Docker image
docker build -t $(BINARY_NAME):latest .
.PHONY: docker-up
docker-up: ## Start all services with Docker Compose
$(DOCKER_COMPOSE) up -d
.PHONY: docker-down
docker-down: ## Stop all services
$(DOCKER_COMPOSE) down
.PHONY: docker-logs
docker-logs: ## Follow service logs
$(DOCKER_COMPOSE) logs -f payment-service
# ─── Database ───────────────────────────────────────────────────────────────
.PHONY: migrate-up
migrate-up: ## Run pending migrations
go run ./cmd/migrate up
.PHONY: migrate-down
migrate-down: ## Rollback last migration
go run ./cmd/migrate down
# ─── Build ──────────────────────────────────────────────────────────────────
.PHONY: build
build: ## Build the binary
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o bin/$(BINARY_NAME) ./cmd/server
.PHONY: clean
clean: ## Remove build artifacts
rm -rf bin/ coverage.out coverage.html
With this Makefile, onboarding a new developer is:
git clone https://github.com/org/payment-service
cd payment-service
make setup
make docker-up
make test
That is it. No “read the 40-page setup document.” No “ask Alice to explain the environment.” Just make.
Part 12 - The Complete Project Structure
How Everything Fits Together
Here is the complete directory structure for a production-grade Go service with BDD, TDD, Docker, and CI/CD:
payment-service/
│
├── .github/
│ └── workflows/
│ ├── ci.yml # Main CI pipeline
│ └── release.yml # Release pipeline
│
├── cmd/
│ ├── server/
│ │ └── main.go # HTTP server entry point
│ └── migrate/
│ └── main.go # Database migration tool
│
├── features/ # Gherkin feature files
│ ├── payment.feature
│ ├── recurring-payment.feature
│ └── authentication.feature
│
├── internal/
│ ├── payment/
│ │ ├── service.go # Core business logic
│ │ ├── service_test.go # Unit tests (TDD)
│ │ ├── interfaces.go # Interface definitions
│ │ ├── repository.go # Data access layer
│ │ ├── repository_integration_test.go # Integration tests
│ │ └── testdata/ # Fixtures for tests
│ │
│ ├── scheduler/
│ │ ├── scheduler.go
│ │ └── scheduler_test.go
│ │
│ ├── api/
│ │ ├── handlers.go
│ │ └── handlers_test.go
│ │
│ └── steps/ # BDD step definitions
│ ├── payment_steps.go
│ ├── recurring_payment_steps.go
│ └── common_steps.go
│
├── migrations/ # SQL migration files
│ ├── 001_create_customers.sql
│ └── 002_create_schedules.sql
│
├── scripts/
│ └── init-db.sql
│
├── Dockerfile
├── Dockerfile.test
├── docker-compose.yml
├── docker-compose.test.yml
├── .golangci.yml
├── Makefile
├── main_test.go # Godog entry point
├── go.mod
└── go.sum
The main_test.go in Full
// main_test.go
package main
import (
"testing"
"github.com/cucumber/godog"
"payment-service/internal/steps"
)
func TestFeatures(t *testing.T) {
suite := godog.TestSuite{
// InitializeScenario combines all step definitions.
ScenarioInitializer: func(sc *godog.ScenarioContext) {
steps.InitializePaymentScenario(sc)
steps.InitializeRecurringPaymentScenario(sc)
steps.InitializeAuthenticationScenario(sc)
steps.InitializeCommonSteps(sc)
},
Options: &godog.Options{
Format: "pretty",
Paths: []string{"features"},
TestingT: t,
Strict: true,
// Enable concurrency for faster test runs.
// Each scenario runs in its own goroutine.
Concurrency: 4,
},
}
if suite.Run() != 0 {
t.Fatal("BDD tests failed")
}
}
Part 13 - Standards and Conventions
The Go Testing Conventions
Go has strong community conventions for testing. Following them makes your codebase readable for any Go developer.
Test file naming: *_test.go. The compiler ignores these in normal builds.
Test function naming: TestFunctionName_Scenario. Use underscores to separate the function being tested from the scenario.
func TestProcessPayment_SuccessfulPayment(t *testing.T) {}
func TestProcessPayment_InsufficientFunds(t *testing.T) {}
func TestProcessPayment_InvalidAmount(t *testing.T) {}
Benchmark function naming: BenchmarkFunctionName.
Example function naming: ExampleFunctionName. These appear in documentation AND run as tests.
// ExampleAdd documents how to use Add and verifies the output.
func ExampleAdd() {
result := Add(5, 3)
fmt.Println(result)
// Output: 8
}
TestMain: For setup that runs before all tests in a package:
func TestMain(m *testing.M) {
// Setup: start a database, load fixtures, etc.
setup()
// Run all tests in this package.
code := m.Run()
// Teardown: clean up.
teardown()
os.Exit(code)
}
Gherkin Writing Standards
Good Gherkin is not just readable — it is maintainable.
Rule 1: Scenarios test behavior, not implementation.
Bad:
When I call the ProcessPayment() function with customerID "001" and amount 50.0
Good:
When the customer "001" pays "50.00" USD
Rule 2: One scenario, one behavior.
Bad: A scenario that tests login, then creates a post, then verifies the post count. That is three behaviors in one scenario. Split it.
Rule 3: Background is for setup, not assertions.
Background steps run before every scenario. Never put assertions in Background. Only setup.
Rule 4: Avoid UI details in steps.
Bad:
When I click the blue "Submit" button in the upper right corner
Good:
When I submit the payment form
The UI changes. The behavior doesn’t. Keep scenarios stable.
Rule 5: Use present tense.
Given the customer has a balance of "100.00" # Good
Given the customer had a balance of "100.00" # Avoid
CI/CD Best Practices
Keep pipelines fast. Nobody waits 30 minutes for CI. Target under 5 minutes for the full pipeline. Use parallelism. Cache aggressively.
Fail fast. Run the fastest checks first (lint, then unit tests, then integration tests). If lint fails, do not waste time running the full test suite.
Never block on flaky tests. A test that sometimes passes and sometimes fails is worse than no test. Fix it or delete it.
Protect the main branch. Require passing CI before any merge to main. No exceptions. Not even for hotfixes. Especially not for hotfixes.
Version your images. Never deploy latest tag to production. Always use a specific tag like the git commit SHA. This makes rollbacks instant.
# Good: specific tag
image: ghcr.io/org/payment-service:sha-a1b2c3d
# Bad: floating tag
image: ghcr.io/org/payment-service:latest
Environment parity. Your test environment should match production as closely as possible. Same PostgreSQL version. Same Redis version. Same OS. Differences between environments are where bugs hide.
Part 14 - Debugging Failing BDD Tests
When a Step is Undefined
If you add a new Gherkin step and forget to write the matching step definition, Godog tells you:
You can implement step definitions for undefined steps with these snippets:
func theCustomerExistsWithBalance(arg1 string, arg2 string) error {
return godog.ErrPending
}
Godog generates the function signature for you. Implement it.
When a Scenario Fails
Godog prints a clear failure message:
--- Failed steps:
Scenario: Successful payment with sufficient funds # features/payment.feature:15
Then the customer "001" balance should be "950.00" # features/payment.feature:20
Error: expected customer "001" balance to be 950, got 1000
1 scenarios (1 failed)
4 steps (3 passed, 1 failed)
The message tells you: which scenario failed, which step failed, what was expected, what was received. Fix the production code or the test.
The -godog.tags Flag
Run a subset of scenarios during development:
# Run only scenarios tagged @smoke
go test ./... -args --godog.tags=@smoke
# Run scenarios tagged @payment but not @slow
go test ./... -args --godog.tags="@payment && ~@slow"
# Run a single specific scenario by name
go test ./... -args --godog.tags=@wip
During active development, tag the scenario you are working on with @wip (work in progress) and run only that tag.
Verbose Step Output
When debugging, the pretty formatter shows each step. For even more detail:
Options: &godog.Options{
Format: "pretty",
// Add this to print each step's duration.
Randomize: time.Now().UTC().UnixNano(), // Randomize order to detect ordering bugs.
},
Conclusion: The System That Protects Your Sleep
You made it.
Let us recap what you now know.
TDD gives you confidence. You write tests first. The code grows incrementally. Each function has a specification. Refactoring is safe. Bugs are caught in seconds, not weeks.
BDD gives you alignment. The Three Amigos write scenarios in Gherkin before code exists. The product manager, QA engineer, and developer share a single truth. Misunderstandings are caught before they become bugs.
Godog connects Gherkin to Go. Step definitions translate plain English into function calls. The feature files run as tests. The output is readable by everyone.
Docker eliminates environment problems. Your service and its dependencies run in containers. The CI server, your colleague’s laptop, and production see the same environment.
Testcontainers brings Docker into your test code. Integration tests spin up real databases, run, and clean up — automatically.
CI/CD with GitHub Actions runs everything automatically. Every push triggers lint, unit tests, BDD tests, Docker build, and deployment. The pipeline is your safety net. It catches problems before they reach production.
The Makefile documents the workflow. Any developer can clone the repo and start contributing in minutes.
The story at the beginning of this guide — the payment service with no tests, the broken deployment, the 4-hour debugging session — does not happen to teams with this system.
It does not happen because bugs are caught at the unit test level before they are ever committed. It does not happen because BDD scenarios force everyone to agree on behavior before code is written. It does not happen because CI/CD prevents untested code from reaching production.
You built something here.
Not just a list of tools. A philosophy. A discipline. A way of building software that respects the people who use it and the team that maintains it.
The testing pyramid is your guide. Write many unit tests. Write reasonable integration tests. Write just enough E2E tests. Automate everything. Talk to the product manager and QA before writing code. Use Docker to make environments reproducible.
That is the system.
Go build something with it.
Quick Reference: The Commands You Will Actually Use
# Run unit tests only (fast, no Docker required)
go test -short -race ./...
# Run all tests including integration
go test -count=1 -race ./...
# Run BDD tests
go test -v -run TestFeatures ./...
# Run specific BDD scenarios by tag
go test ./... -args --godog.tags=@smoke
# Run tests and show coverage
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
# Run benchmarks
go test -bench=. -benchmem ./...
# Run linter
golangci-lint run ./...
# Generate mocks
mockery --all --keeptree --output internal/mocks
# Start full stack with Docker Compose
docker compose up -d
# Run test suite against Docker stack
docker compose run test
# See test output in real-time
go test -v ./...
Quick Reference: Gherkin Syntax
Feature: Name of the feature
Background: # Runs before every scenario
Given ...
Scenario: Name
Given ... # Setup/context
When ... # Action/trigger
Then ... # Expected outcome
And ... # Continuation (Given/When/Then)
But ... # Negative continuation
Scenario Outline: Parameterized scenario
Given value is "<param>"
Examples:
| param |
| A |
| B |
@tag1 @tag2
Scenario: Tagged scenario
...
Quick Reference: GitHub Actions Key Concepts
on: # Trigger events
push:
branches: [main]
jobs:
my-job: # A job
runs-on: ubuntu-latest
services: # Docker services (databases, caches)
postgres:
image: postgres:15
steps: # Steps within a job
- uses: actions/checkout@v4 # Pre-built actions
- run: go test ./... # Shell commands
needs: other-job # Dependency between jobs
if: github.ref == 'refs/heads/main' # Conditional execution Tags
Related Articles
Architecture in Agile & Scrum: Communication, Planning & Execution
Master how to work effectively as an architect within Agile and Scrum teams. Learn to balance architectural vision with sprint velocity, communicate with product owners, and drive technical excellence in iterative development.
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.