This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Contributing

How to contribute to Rivaas

Thank you for your interest in contributing to Rivaas! We welcome contributions from everyone.

Ways to Contribute

You can help Rivaas in many ways:

  • Report bugs — Tell us what’s broken
  • Suggest features — Share your ideas
  • Write documentation — Help others understand Rivaas
  • Fix bugs — Submit pull requests
  • Add features — Build new functionality
  • Review code — Help us maintain quality

Getting Started

1. Find Something to Work On

Good first steps:

2. Set Up Your Environment

Fork and clone the repository:

git clone https://github.com/YOUR-USERNAME/rivaas.git
cd rivaas

Rivaas uses Nix for development. If you have Nix installed:

nix develop

This gives you all the tools you need.

3. Make Your Changes

Create a new branch:

git checkout -b my-feature

Make your changes and test them:

# Run tests
go test ./...

# Run tests with race detection
go test -race ./...

# Check code style
golangci-lint run

4. Submit Your Work

Push your changes and create a pull request:

git push origin my-feature

Then open a pull request on GitHub.

Development Standards

We have clear standards for code quality. Please follow these guides:

Documentation Standards

Learn how to write good documentation for Go code.

Documentation Standards →

Testing Standards

Learn how to test your code properly.

Testing Standards →

Code Review Process

When you submit a pull request:

  1. Automated checks run — Tests, linting, and coverage checks
  2. Maintainer reviews — A maintainer looks at your code
  3. Feedback loop — You address any comments
  4. Approval — Maintainer approves when ready
  5. Merge — Your code becomes part of Rivaas!

Pull Request Guidelines

Good pull requests:

  • Focus on one thing — Don’t mix unrelated changes
  • Include tests — Test your changes
  • Update documentation — Keep docs current
  • Follow style guides — Match existing code style
  • Write clear commit messages — Explain what and why

Commit messages:

Use clear, descriptive commit messages:

Add user authentication middleware

This adds JWT authentication middleware for protecting routes.
It validates tokens and adds user info to the context.

Fixes #123

Format:

  • First line: Brief summary (under 72 characters)
  • Blank line
  • Detailed description (if needed)
  • Reference issues with Fixes #123 or Closes #456

Code of Conduct

We want Rivaas to be welcoming to everyone. Please:

  • Be respectful — Treat others kindly
  • Be constructive — Give helpful feedback
  • Be patient — Everyone learns at different speeds
  • Be inclusive — Welcome diverse perspectives

Questions?

Not sure about something? Ask!

License

By contributing to Rivaas, you agree that your contributions will be licensed under the Apache License 2.0.

Thank You!

Your contributions make Rivaas better for everyone. Thank you for helping!

1 - Documentation Standards

How to write clear documentation for Go code

This page explains how to write documentation for Rivaas code. Good documentation helps everyone understand how the code works.

Main Goal

Write clear documentation that explains:

  • What the code does
  • How to use it
  • What inputs it needs and outputs it gives

What NOT to Write

Don’t mention these things in documentation:

Performance Details

Don’t use words like:

  • “fast”, “slow”, “efficient”
  • “optimized”, “quick”
  • “high-performance”
  • Any speed comparisons

Algorithm Details

Don’t include:

  • Big-O notation (like O(1), O(n))
  • Time or space complexity
  • Algorithm names used to show speed

Benchmark Results

Don’t mention:

  • “zero allocations”
  • “optimized for speed”
  • “50% faster”
  • Any performance numbers

Memory Usage

Don’t talk about:

  • “low memory usage”
  • “minimal allocations”
  • “memory-efficient”
  • Specific memory amounts

Visual Decorations

Don’t use:

  • Lines of equals signs or dashes
  • ASCII art
  • Empty comment lines for spacing
  • Comments that add no information

TODO Comments About Moving Code

Don’t write:

  • “TODO: move this to…”
  • “FIXME: this should be in…”
  • “NOTE: consider moving to…”

Why not? If code needs to move, move it now. Don’t leave a comment about it. Use version control (git) to track changes.

File History Comments

Don’t write:

  • “merged from…”
  • “moved from…”
  • “originally in…”
  • “Benchmarks from X file”

Why not? Git tracks file history. Comments should explain what code does now, not where it came from.

What You SHOULD Write

Your documentation must focus on:

Purpose

  • What the function, type, or method does
  • Why it exists
  • When to use it

Functionality

  • What it does in simple words
  • How it changes inputs to outputs
  • Step-by-step behavior (when helpful)

Usage

  • How to use it (with brief examples)
  • Common use cases
  • How to integrate it

Code Examples in Documentation

Public functions should include examples:

  • Use tab-indented code blocks with // Example: header
  • Show typical usage patterns
  • Keep examples short and focused
  • Use valid Go code that compiles
  • Put examples after main description

Important: GoDoc needs tab indentation (not spaces) for code blocks.

Inline example format:

// FunctionName does something useful.
// It processes the input and returns a result.
//
// Example:
//
//	result := FunctionName("input")
//	fmt.Println(result)
//
// Parameters:
//   - input: description
func FunctionName(input string) string { ... }

Runnable Example functions (preferred):

For public APIs, create Example functions in *_test.go files:

// In example_test.go
func ExampleFunctionName() {
	result := FunctionName("input")
	fmt.Println(result)
	// Output: expected output
}

Parameters and Return Values

  • What each parameter means
  • What values are returned
  • Error conditions and their meanings

Behavior and Edge Cases

  • Expected behavior normally
  • Edge cases and how they’re handled
  • Side effects (if any)
  • Thread safety (if relevant)

Constraints and Requirements

  • Requirements to use it
  • Limitations or known issues
  • Dependencies

Error Documentation

Document when errors happen:

// Parse parses the input string into a Result.
// It returns an error if parsing fails.
//
// Errors:
//   - [ErrInvalidFormat]: input string is malformed
//   - [ErrEmpty]: input is an empty string
//   - [ErrTooLong]: input exceeds maximum length
func Parse(input string) (Result, error) { ... }

Deprecation

Mark deprecated APIs clearly:

// Deprecated: Use [NewRouter] instead. This function will be removed in v2.0.
func OldRouter() *Router { ... }

// Deprecated: Use [Context.Value] with [RequestIDKey] instead.
func (c *Context) RequestID() string { ... }

Interface vs Implementation

Interfaces document the contract:

// Handler handles HTTP requests.
// Implementations must be safe for concurrent use.
// Handle should not modify the request after returning.
type Handler interface {
	Handle(ctx *Context) error
}

Implementations reference the interface:

// JSONHandler implements [Handler] for JSON request/response handling.
// It automatically parses JSON request bodies and encodes JSON responses.
type JSONHandler struct { ... }

Generic Types

Document type parameter requirements:

// BindInto binds values from a ValueGetter into a struct of type T.
// T must be a struct type; using non-struct types results in an error.
// T should have exported fields with appropriate struct tags.
//
// Example:
//
//	result, err := BindInto[UserRequest](getter, "query")
func BindInto[T any](getter ValueGetter, tag string) (T, error) { ... }

Thread Safety

Document concurrency behavior when relevant:

// Router is safe for concurrent use by multiple goroutines.
// Routes should be registered before calling [Router.ServeHTTP].
type Router struct { ... }

// Counter provides a thread-safe counter.
// All methods may be called concurrently from multiple goroutines.
type Counter struct { ... }

// Builder is NOT safe for concurrent use.
// Create separate Builder instances for each goroutine.
type Builder struct { ... }

Cross-References

Use bracket syntax [Symbol] to link to other symbols (Go 1.19+):

// Handle processes the request using the provided [Context].
// It returns a [Response] or an error.
// See [Router.Register] for how to register handlers.
func Handle(ctx *Context) (*Response, error) { ... }

Link targets:

  • [FunctionName] — links to function in same package
  • [TypeName] — links to type in same package
  • [TypeName.MethodName] — links to method
  • [pkg.Symbol] — links to symbol in other package (e.g., [http.Handler])

Style Rules

GoDoc Standards

  • Start with the name — Begin function comments with the function/type name

    • // Register adds a new route...
    • // This function registers...
  • Use third-person — Write “Handler creates…” not “I create…”

    • ✅ “Handler creates…”, “Router registers…”, “Context stores…”
    • ❌ “This creates…”, “We register…”, “I store…”

Clarity and Conciseness

  • Use full sentences
  • Keep comments short but meaningful
  • Avoid unnecessary words
  • Be direct and clear

Language Guidelines

  • No marketing language — Avoid adjectives like:
    • “simple”, “powerful”, “robust”, “amazing”
    • “best”, “perfect”, “ideal”
  • No superlatives — No “fastest” or “most reliable”
  • Focus on facts — Describe what code does

Code Examples

  • Public APIs need examples
  • Use tab indentation (not spaces) for code blocks
  • Prefer runnable Example functions in *_test.go files
  • Keep examples minimal and focused

Package Documentation Files (doc.go)

When package documentation is long (more than a few lines), use a doc.go file:

  • File name: Must be exactly doc.go (lowercase)
  • Location: In the package root directory
  • Content: Only package comment and package declaration
  • Purpose: Keeps package overview separate from code

Format requirements:

  • Start with // Package [name] and clear description
  • First sentence is summary (shown in listings)
  • Use markdown headers (#) for sections
  • Include code examples when helpful
  • Cover: purpose, main concepts, usage patterns

What to include:

  • Package overview and purpose
  • Key features
  • Architecture (when relevant)
  • Quick start examples
  • Common usage patterns
  • Links to examples or related packages

What NOT to include:

  • Performance details
  • Algorithm complexity
  • File organization history
  • Individual function documentation (put those in their files)

Example structure:

// Package router provides an HTTP router for Go.
//
// The router implements a routing system for cloud-native applications.
// It features path matching, parameter extraction, and comprehensive middleware support.
//
// # Key Features
//
//   - Path matching for static and parameterized routes
//   - Parameter extraction from URL paths
//   - Context pooling for request handling
//
// # Quick Start
//
//	package main
//
//	import "rivaas.dev/router"
//
//	func main() {
//	    r := router.New()
//	    r.GET("/", handler)
//	    r.Run(":8080")
//	}
//
// # Examples
//
// See the examples directory for complete working examples.
package router

When to use doc.go:

  • Use doc.go: Package documentation is long (multiple paragraphs, sections)
  • Use inline comments: Package documentation is brief (1-3 sentences)

Examples

Good Documentation

// Register adds a new route to the [Router] using the given method and pattern.
// It returns the created [Route], which can be further configured.
// Register should be called during application setup before the server starts.
func (r *Router) Register(method, pattern string) *Route { ... }

// Context represents an HTTP request context.
// It provides access to the request, response writer, and route parameters.
// Context instances are pooled and reused across requests.
// Context is NOT safe for use after the handler returns.
type Context struct { ... }

// Param returns the value of the named route parameter.
// It returns an empty string if the parameter is not found.
// Parameters are extracted from the URL path during route matching.
//
// Example:
//
//	userID := c.Param("id")
//	fmt.Println(userID)
func (c *Context) Param(name string) string { ... }

Bad Documentation

// Register is a highly optimized router method with zero allocations.
// Uses O(1) lookup for fast routing.
// Extremely efficient performance characteristics.
func (r *Router) Register(method, pattern string) *Route { ... }

// Context is a fast, memory-efficient request context.
// Uses minimal allocations and provides high-performance access.
// Benchmarks show 50% faster than alternatives.
type Context struct { ... }

// Param returns the value with O(1) lookup time.
// Optimized for speed with zero allocations.
func (c *Context) Param(name string) string { ... }

// ========================================
// HTTP Context Methods
// ========================================
func (c *Context) Param(name string) string { ... }

// TODO: move this to a separate file
// Param returns the value of the named route parameter.
func (c *Context) Param(name string) string { ... }

Review Checklist

When writing or reviewing documentation, check:

Content Rules

  • No performance words (fast, efficient, optimized)
  • No algorithm complexity (Big-O, O(1))
  • No benchmark claims
  • No memory usage details
  • No marketing language
  • No decorative comment lines
  • No TODO/FIXME about moving code
  • No file history comments (merged from, moved from)
  • Comments provide useful information

Style Rules

  • Comments start with function/type name
  • Third-person, descriptive language
  • Clear explanation of what code does

Documentation Completeness

  • Parameters and return values documented
  • Error conditions documented with specific types
  • Edge cases and constraints mentioned
  • Thread safety documented when relevant
  • Generic type constraints documented

Examples and References

  • Public APIs include examples
  • Code examples use tab indentation
  • Cross-references use [Symbol] syntax

Special Cases

  • Deprecated functions use // Deprecated: prefix
  • Interfaces document contract, implementations reference interface
  • Long package docs use doc.go file
  • doc.go files start with // Package [name]

Additional Resources

Summary

Remember: Documentation explains what code does and how to use it, not how well it performs. Focus on functionality, behavior, and usage patterns. If performance is implied by code, don’t mention it in documentation.

2 - Testing Standards

How to write tests for Rivaas code

This page explains how to write tests for Rivaas. Good tests help us keep the code working correctly.

Test File Structure

All packages must have these test files:

  1. *_test.go — Unit tests (same package)
  2. example_test.go — Examples for documentation (external package)
  3. *_bench_test.go — Performance benchmarks (same package)
  4. integration_test.go — Integration tests (external package)
  5. testing.go — Test helpers (if needed)

File Naming

Test TypeFile NamePackage
Unit tests{package}_test.go{package}
Benchmarks{package}_bench_test.go{package}
Examplesexample_test.go{package}_test
Integrationintegration_test.go{package}_test
Helperstesting.go{package}

Test Naming

Use clear, descriptive names:

PatternUse CaseExample
TestFunctionNameBasic testTestParseConfig
TestFunctionName_ScenarioSpecific scenarioTestParseConfig_EmptyInput
TestFunctionName_ErrorCaseError caseTestParseConfig_InvalidJSON
TestType_MethodNameMethod testTestRouter_ServeHTTP

Subtest Naming

For table-driven tests, use names that explain the scenario:

tests := []struct {
    name string
    // ...
}{
    {name: "valid email address"},           // ✅ Good - descriptive
    {name: "empty string returns error"},    // ✅ Good - explains behavior
    {name: "test1"},                         // ❌ Bad - not descriptive
    {name: "case 1"},                        // ❌ Bad - not helpful
}

Grouping with Subtests

Use nested t.Run() for related tests:

func TestUser(t *testing.T) {
    t.Parallel()

    t.Run("Create", func(t *testing.T) {
        t.Parallel()
        t.Run("valid input succeeds", func(t *testing.T) {
            t.Parallel()
            // test code
        })
        t.Run("invalid email returns error", func(t *testing.T) {
            t.Parallel()
            // test code
        })
    })

    t.Run("Delete", func(t *testing.T) {
        t.Parallel()
        t.Run("existing user succeeds", func(t *testing.T) {
            t.Parallel()
            // test code
        })
    })
}

Package Organization

Unit Tests

  • Package: Same as source (package router)
  • Access: Can test public and internal APIs
  • Use for: Testing individual functions, internal details, edge cases
  • Framework: Standard testing with testify/assert or testify/require

Integration Tests

  • Package: External (package router_test)
  • Access: Only public APIs (black-box testing)
  • Use for: Testing full request/response cycles, component interactions
  • Framework:
    • Standard testing for simple tests
    • Ginkgo/Gomega for complex scenarios

Example Tests

  • Package: External (package router_test)
  • Access: Only public APIs
  • Use for: Showing how to use public APIs in documentation

Test Data Management

The testdata Directory

Go has special handling for testdata/ directories:

  • Ignored by go build
  • Used for test fixtures and sample data
  • Accessible via relative path from tests
package/
├── handler.go
├── handler_test.go
└── testdata/
    ├── fixtures/
    │   ├── valid_request.json
    │   └── invalid_request.json
    └── golden/
        ├── expected_output.json
        └── expected_error.txt

Loading Test Data

func TestHandler(t *testing.T) {
    t.Parallel()

    // Load test fixture
    input, err := os.ReadFile("testdata/fixtures/valid_request.json")
    require.NoError(t, err)

    // Use in test
    result, err := ProcessRequest(input)
    require.NoError(t, err)

    // Compare with golden file
    expected, err := os.ReadFile("testdata/golden/expected_output.json")
    require.NoError(t, err)
    assert.JSONEq(t, string(expected), string(result))
}

Golden File Testing

Golden files store expected output. Use -update flag to regenerate:

var updateGolden = flag.Bool("update", false, "update golden files")

func TestOutput_Golden(t *testing.T) {
    result := GenerateOutput()
    goldenPath := "testdata/golden/output.txt"

    if *updateGolden {
        err := os.WriteFile(goldenPath, []byte(result), 0644)
        require.NoError(t, err)
        return
    }

    expected, err := os.ReadFile(goldenPath)
    require.NoError(t, err)
    assert.Equal(t, string(expected), result)
}

Update golden files:

go test -update ./...

Assertions

Important: Always use assertion libraries. Don’t use manual if statements with t.Errorf().

testify/assert vs testify/require

  • assert: Continues test after failure (checks multiple things)
  • require: Stops test after failure (when later checks depend on it)
// Use require when later code needs the value
result, err := FunctionThatShouldSucceed()
require.NoError(t, err)  // Must succeed to continue
assert.Equal(t, expected, result)

// Use assert for independent checks
assert.NoError(t, err)
assert.Equal(t, expected, result)
assert.Contains(t, message, "success")  // All run even if first fails

Error Checking

Always use testify error functions, not manual error checks.

Available Functions

  • assert.NoError(t, err) — Verify no error occurred
  • assert.Error(t, err) — Verify an error occurred
  • assert.ErrorIs(t, err, target) — Verify error wraps specific error
  • assert.ErrorAs(t, err, target) — Verify error is specific type
  • assert.ErrorContains(t, err, substring) — Verify error message contains text

When to Use Each

NoError / require.NoError:

result, err := FunctionThatShouldSucceed()
require.NoError(t, err)  // Use require if result is needed
assert.Equal(t, expected, result)

Error / assert.Error:

_, err := FunctionThatShouldFail()
assert.Error(t, err)  // Any error is fine

ErrorIs / assert.ErrorIs:

var ErrNotFound = errors.New("not found")

_, err := FunctionThatReturnsWrappedError()
assert.ErrorIs(t, err, ErrNotFound)  // Check for specific error

ErrorAs / require.ErrorAs:

type ValidationError struct {
    Field string
}

_, err := FunctionThatReturnsTypedError()
var validationErr *ValidationError
require.ErrorAs(t, err, &validationErr)  // Use require if you need validationErr
assert.Equal(t, "email", validationErr.Field)

ErrorContains / assert.ErrorContains:

_, err := FunctionThatReturnsDescriptiveError()
assert.ErrorContains(t, err, "invalid input")

When to Use require vs assert for Errors

Use require when:

  1. Setup must succeed:
tmpfile, err := os.CreateTemp("", "test-*.txt")
require.NoError(t, err)  // Must succeed to continue
defer os.Remove(tmpfile.Name())
  1. Need non-nil value:
db, err := sql.Open("postgres", dsn)
require.NoError(t, err)  // Must succeed
require.NotNil(t, db)    // Must not be nil

rows, err := db.Query("SELECT ...")  // Safe to use db
  1. Later assertions depend on it:
err := c.Format(200, data)
require.NoError(t, err)  // Must succeed for rest of test

// These assume Format succeeded
assert.Contains(t, w.Header().Get("Content-Type"), "application/xml")
assert.Contains(t, w.Body.String(), "<?xml")

Use assert when:

  1. Independent validations:
assert.NoError(t, err)
assert.Equal(t, expected, result)
assert.Contains(t, message, "success")  // All checked even if first fails
  1. Non-critical checks:
err := optionalOperation()
assert.NoError(t, err)  // Nice to have, but test can continue
assert.Equal(t, http.StatusOK, w.Code)

Table-Driven Tests

All tests with multiple cases should use table-driven pattern:

func TestFunctionName(t *testing.T) {
    t.Parallel()

    tests := []struct {
        name    string
        input   any
        want    any
        wantErr bool
    }{
        {
            name:    "valid input",
            input:   "test",
            want:    "result",
            wantErr: false,
        },
        {
            name:    "invalid input",
            input:   "",
            want:    nil,
            wantErr: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()

            got, err := FunctionName(tt.input)
            if tt.wantErr {
                assert.Error(t, err)
                return
            }
            assert.NoError(t, err)
            assert.Equal(t, tt.want, got)
        })
    }
}

Example Tests

All public APIs must have example tests in example_test.go:

package package_test

import (
    "fmt"
    "rivaas.dev/package"
)

// ExampleFunctionName demonstrates basic usage.
func ExampleFunctionName() {
    result := package.FunctionName("input")
    fmt.Println(result)
    // Output: expected output
}

// ExampleFunctionName_withOptions demonstrates usage with options.
func ExampleFunctionName_withOptions() {
    result := package.FunctionName("input",
        package.WithOption("value"),
    )
    fmt.Println(result)
    // Output: expected output
}

Example Guidelines

  • Package must be {package}_test
  • Function names start with Example
  • Include // Output: comments for deterministic examples
  • Use log.Fatal(err) for error handling (acceptable in examples)

Benchmarks

Critical paths must have benchmarks in *_bench_test.go:

func BenchmarkFunctionName(b *testing.B) {
    setup := prepareTestData()
    b.ResetTimer()
    b.ReportAllocs()

    // Preferred: Go 1.23+ syntax
    for b.Loop() {
        FunctionName(setup)
    }
}

func BenchmarkFunctionName_Parallel(b *testing.B) {
    setup := prepareTestData()
    b.ResetTimer()
    b.ReportAllocs()

    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            FunctionName(setup)
        }
    })
}

Benchmark Guidelines

  • Use b.ResetTimer() after setup
  • Use b.ReportAllocs() to track memory
  • Prefer b.Loop() for Go 1.23+
  • Test both sequential and parallel execution
  • Use b.Context() instead of context.Background() (Go 1.24+)
  • Use b.Fatal(err) for setup failures (acceptable in benchmarks)

Integration Tests

Integration tests use the integration build tag:

//go:build integration

package package_test

import (
    "net/http"
    "net/http/httptest"
    "testing"

    "rivaas.dev/package"
)

func TestIntegration(t *testing.T) {
    r := package.MustNew()
    // Integration test code
}

Build Tags for Test Separation

Test TypeBuild TagRun Command
Unit tests//go:build !integrationgo test ./...
Integration tests//go:build integrationgo test -tags=integration ./...

Why build tags?

  • Tests excluded at compile time, not skipped at runtime
  • Cleaner coverage reports
  • Faster unit test runs
  • Easy to run different suites in parallel

Ginkgo Integration Tests

For complex scenarios, use Ginkgo. Important: Only one RunSpecs call per package.

Suite file (one per package):

// {package}_integration_suite_test.go

//go:build integration

package package_test

import (
    "testing"

    . "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"
)

func TestPackageIntegration(t *testing.T) {
    RegisterFailHandler(Fail)
    RunSpecs(t, "Package Integration Suite")
}

Test files (multiple allowed):

// integration_test.go

//go:build integration

package package_test

import (
    "net/http"
    "net/http/httptest"

    . "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"

    "rivaas.dev/package"
)

var _ = Describe("Feature Integration", func() {
    var r *package.Router

    BeforeEach(func() {
        r = package.MustNew()
    })

    Describe("Scenario A", func() {
        Context("with condition X", func() {
            It("should behave correctly", func() {
                req := httptest.NewRequest("GET", "/path", nil)
                w := httptest.NewRecorder()
                r.ServeHTTP(w, req)

                Expect(w.Code).To(Equal(http.StatusOK))
            })
        })
    })
})

Using Labels for Filtering

Use labels to organize tests:

var _ = Describe("Router Stress Tests", Label("stress", "slow"), func() {
    It("should handle high concurrent load", Label("stress"), func() {
        // Stress test
    })
})

Run with labels:

# Run only stress tests
ginkgo -label-filter=stress ./package

# Run everything except stress tests
ginkgo -label-filter='!stress' ./package

# Run tests with multiple labels (AND)
ginkgo -label-filter='integration && versioning' ./package

Test Helpers

Common utilities go in testing.go:

package package

import (
    "testing"
    
    "github.com/stretchr/testify/assert"
)

// testHelper creates a test instance with default configuration.
func testHelper(t *testing.T) *Config {
    t.Helper()
    return MustNew(WithTestDefaults())
}

// assertError checks if error matches expected.
func assertError(t *testing.T, err error, wantErr bool, msg string) {
    t.Helper()
    if wantErr {
        assert.Error(t, err, msg)
    } else {
        assert.NoError(t, err, msg)
    }
}

Always use t.Helper() in helper functions.

HTTP Testing Patterns

Testing Handlers

func TestHandler_GetUser(t *testing.T) {
    t.Parallel()

    handler := NewUserHandler(mockRepo)

    req := httptest.NewRequest(http.MethodGet, "/users/123", nil)
    req.Header.Set("Content-Type", "application/json")

    w := httptest.NewRecorder()
    handler.ServeHTTP(w, req)

    assert.Equal(t, http.StatusOK, w.Code)
    assert.Contains(t, w.Header().Get("Content-Type"), "application/json")

    var response User
    err := json.NewDecoder(w.Body).Decode(&response)
    require.NoError(t, err)
    assert.Equal(t, "123", response.ID)
}

Testing with Request Body

func TestHandler_CreateUser(t *testing.T) {
    t.Parallel()

    body := strings.NewReader(`{"name": "Test User", "email": "test@example.com"}`)
    req := httptest.NewRequest(http.MethodPost, "/users", body)
    req.Header.Set("Content-Type", "application/json")

    w := httptest.NewRecorder()
    handler.ServeHTTP(w, req)

    assert.Equal(t, http.StatusCreated, w.Code)
}

Testing Middleware

func TestAuthMiddleware(t *testing.T) {
    t.Parallel()

    tests := []struct {
        name           string
        authHeader     string
        wantStatusCode int
    }{
        {
            name:           "valid token",
            authHeader:     "Bearer valid-token",
            wantStatusCode: http.StatusOK,
        },
        {
            name:           "missing header",
            authHeader:     "",
            wantStatusCode: http.StatusUnauthorized,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()

            nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                w.WriteHeader(http.StatusOK)
            })

            handler := AuthMiddleware(nextHandler)

            req := httptest.NewRequest(http.MethodGet, "/protected", nil)
            if tt.authHeader != "" {
                req.Header.Set("Authorization", tt.authHeader)
            }

            w := httptest.NewRecorder()
            handler.ServeHTTP(w, req)

            assert.Equal(t, tt.wantStatusCode, w.Code)
        })
    }
}

Context and Timeout Patterns

Testing with Context

func TestService_WithTimeout(t *testing.T) {
    t.Parallel()

    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    t.Cleanup(cancel)

    result, err := service.SlowOperation(ctx)

    require.NoError(t, err)
    assert.NotNil(t, result)
}

Using Test Context (Go 1.24+)

In Go 1.24+, use t.Context() instead of context.Background():

func TestWithContext(t *testing.T) {
    t.Parallel()

    // ✅ Preferred: Use t.Context()
    ctx := t.Context()
    
    // ❌ Avoid: context.Background()
    // ctx := context.Background()

    result, err := service.Operation(ctx)
    require.NoError(t, err)
    assert.NotNil(t, result)
}

Benefits: Automatically cancelled when test ends.

Mocking

Interface-Based Mocking (Preferred)

// Define interface
type UserRepository interface {
    FindByID(ctx context.Context, id string) (*User, error)
    Save(ctx context.Context, user *User) error
}

// Test implementation (fake)
type fakeUserRepository struct {
    users map[string]*User
    err   error
}

func (f *fakeUserRepository) FindByID(ctx context.Context, id string) (*User, error) {
    if f.err != nil {
        return nil, f.err
    }
    return f.users[id], nil
}

// Test using the fake
func TestUserService_GetUser(t *testing.T) {
    t.Parallel()

    repo := &fakeUserRepository{
        users: map[string]*User{
            "123": {ID: "123", Name: "Test User"},
        },
    }
    service := NewUserService(repo)

    user, err := service.GetUser(context.Background(), "123")
    require.NoError(t, err)
    assert.Equal(t, "Test User", user.Name)
}

HTTP Client Mocking

func TestAPIClient_FetchData(t *testing.T) {
    t.Parallel()

    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        assert.Equal(t, "/api/data", r.URL.Path)
        assert.Equal(t, "Bearer token123", r.Header.Get("Authorization"))

        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte(`{"id": "123", "name": "test"}`))
    }))
    t.Cleanup(server.Close)

    client := NewAPIClient(server.URL, "token123")
    data, err := client.FetchData(context.Background())

    require.NoError(t, err)
    assert.Equal(t, "123", data.ID)
}

Test Coverage

Requirements

Package TypeMinimumTarget
Core packages80%90%
Utility packages75%85%
Integration packages70%80%

Measuring Coverage

# Package coverage
go test -cover ./package

# Detailed report
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html

# Coverage by function
go tool cover -func=coverage.out

Best Practices

  1. Parallel Execution: Use t.Parallel() for all tests (except testing.AllocsPerRun)

  2. Assertions: Always use testify/assert or testify/require

  3. Error Messages: Include descriptive messages

  4. Test Isolation: Each test should be independent

  5. Cleanup: Use t.Cleanup() instead of defer:

func TestWithResource(t *testing.T) {
    t.Parallel()

    resource := createResource()
    t.Cleanup(func() {
        resource.Close()
    })

    // Use resource...
}
  1. Descriptive Names: Use clear test and subtest names

  2. Documentation: Document complex test scenarios

  3. Race Detection: Always run with -race in CI

  4. Deterministic Tests: Avoid depending on:

    • Current time (use clock injection)
    • Random values (use fixed seeds)
    • Network availability (use mocks)
    • Filesystem state (use temp directories)

Running Tests

# Run unit tests (excludes integration)
go test ./...

# Run unit tests with verbose output
go test -v ./...

# Run unit tests with race detection (REQUIRED in CI)
go test -race ./...

# Run integration tests with race detection
go test -tags=integration -race ./...

# Run unit tests with coverage
go test -cover ./...

# Run benchmarks
go test -bench=. -benchmem ./...

# Run specific test by name
go test -run TestFunctionName ./...

# Run tests with timeout
go test -timeout 5m ./...

CI Commands

# Unit tests with race and coverage (CI)
go test -race -coverprofile=coverage.out -timeout 10m ./...

# Integration tests with race and coverage (CI)
go test -tags=integration -race -coverprofile=coverage-integration.out -timeout 10m ./...

Summary

Good tests:

  • Use clear, descriptive names
  • Use table-driven patterns for multiple cases
  • Always use assertion libraries
  • Run in parallel when possible
  • Include examples for public APIs
  • Test both success and error cases
  • Use proper build tags for integration tests
  • Have good coverage (80%+)

Remember: Tests are documentation too. Write them clearly!