Technical docs

Testing

Backend unit tests, database tests, and end-to-end testing with Playwright.

Backend Tests

Go tests run against a real PostgreSQL database. No mocks for database interactions.

Running tests

# Run all tests
make test

# Run with verbose output
cd backend && go test ./tests/... -v

# Run specific test file
cd backend && go test ./tests/transaction_service_test.go -v

# Run specific test function
cd backend && go test ./tests/... -run TestCreateTransaction -v

Test coverage

# Generate coverage report
make test-coverage

# Opens HTML report in browser
open backend/coverage.html

Race condition detection

# Run with race detector (slower but catches data races)
make test-race

Test Suite (Database Tests)

Tests that interact with the database use the test suite for setup and cleanup.

Important Rules

  • All database tests MUST use backend/tests/suite.go
  • Tests run inside transactions that are rolled back automatically
  • Never perform manual cleanup
  • Never initialize DB connections outside the suite

Using the test suite

package tests

import (
    "testing"
    "github.com/godilite/invoice-backend/internal/services"
)

func TestCreateTransaction(t *testing.T) {
    // Setup: creates DB connection, wraps in transaction
    suite := NewTestSuite(t)
    defer suite.Cleanup()
    
    // Create test tenant
    tenant := suite.CreateTestTenant()
    
    // Create service with test DB
    service := services.NewTransactionService(suite.DB)
    
    // Test the service
    txn, err := service.Create(suite.Ctx, tenant.ID, &services.CreateInput{
        Description: "Test transaction",
        Amount:      10000,
        Currency:    "USD",
    })
    
    // Assertions
    if err != nil {
        t.Fatalf("expected no error, got %v", err)
    }
    if txn.Amount != 10000 {
        t.Errorf("expected amount 10000, got %d", txn.Amount)
    }
    
    // No cleanup needed - transaction is rolled back automatically
}

Test database configuration

Tests use the same PostgreSQL instance as development. Configure via environment:

# Defaults (same as docker-compose)
TEST_DB_HOST=localhost
TEST_DB_PORT=5432
TEST_DB_USER=postgres
TEST_DB_PASSWORD=postgres
TEST_DB_NAME=postgres

Test helpers

// suite.go provides helpers for common test scenarios

// Create a test tenant (organization + workspace)
tenant := suite.CreateTestTenant()

// Create a test user
user := suite.CreateTestUser("test@example.com")

// Create test transaction
txn := suite.CreateTestTransaction(tenant.ID, 10000, "USD")

// Create test category
category := suite.CreateTestCategory(tenant.ID, "Sales")

// Get authenticated context
ctx := suite.AuthenticatedContext(user.ID, tenant.ID)

E2E Tests (Playwright)

End-to-end tests run against the full stack using Playwright.

Critical: Run from frontend directory

Always run Playwright from the frontend directory. Running from the project root will fail with confusing errors.

Running E2E tests

# Navigate to frontend first!
cd frontend

# Run all tests
npx playwright test

# Run specific test file
npx playwright test e2e/transaction-v3.spec.ts

# Run tests matching a pattern
npx playwright test -g "create transaction"

# Run in headed mode (see the browser)
npx playwright test --headed --workers=1

# Debug mode (step through)
npx playwright test --debug

Viewing test results

# After test run, view HTML report
cd frontend && npx playwright show-report

# Screenshots on failure are in:
# frontend/test-results/

Make commands

# From project root (these cd into frontend)
make e2e              # Run all E2E tests
make e2e-headed       # Run with browser visible
make e2e-transactions # Run transaction tests
make e2e-payments     # Run payment tests
make e2e-tax          # Run tax tests

Test file locations

frontend/e2e/
├── transaction-v3.spec.ts  # Transaction CRUD
├── payments.spec.ts        # Payment flows
├── nigeria-tax.spec.ts     # Tax compliance
├── tax-reports.spec.ts     # Tax reporting
├── tax-demo-flow.spec.ts   # Full tax demo
└── docs-screenshots.spec.ts # Documentation screenshots

Writing Tests

Backend test example

func TestTransactionService_Create(t *testing.T) {
    suite := NewTestSuite(t)
    defer suite.Cleanup()
    
    tenant := suite.CreateTestTenant()
    category := suite.CreateTestCategory(tenant.ID, "Sales")
    
    service := services.NewTransactionService(suite.DB)
    
    tests := []struct {
        name    string
        input   *services.CreateInput
        wantErr bool
    }{
        {
            name: "valid income transaction",
            input: &services.CreateInput{
                Description: "Payment received",
                Amount:      10000,
                Currency:    "USD",
                Type:        "income",
                CategoryID:  category.ID,
            },
            wantErr: false,
        },
        {
            name: "missing description",
            input: &services.CreateInput{
                Amount:   10000,
                Currency: "USD",
            },
            wantErr: true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            _, err := service.Create(suite.Ctx, tenant.ID, tt.input)
            if (err != nil) != tt.wantErr {
                t.Errorf("Create() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

Playwright test example

import { test, expect } from "@playwright/test"

test.describe("Transactions", () => {
  test.beforeEach(async ({ page }) => {
    // Login before each test
    await page.goto("/login")
    await page.fill('[name="email"]', "test@example.com")
    await page.fill('[name="password"]', "password123")
    await page.click('button[type="submit"]')
    await page.waitForURL(/dashboard/)
  })
  
  test("create income transaction", async ({ page }) => {
    // Navigate to transactions
    await page.click('text=Transactions')
    await page.click('text=New Transaction')
    
    // Fill form
    await page.fill('[name="description"]', "Client payment")
    await page.fill('[name="amount"]', "1000")
    await page.selectOption('[name="type"]', "income")
    
    // Submit
    await page.click('button:has-text("Create")')
    
    // Verify
    await expect(page.locator("text=Client payment")).toBeVisible()
    await expect(page.locator("text=$1,000")).toBeVisible()
  })
  
  test("filter by date range", async ({ page }) => {
    await page.click('text=Transactions')
    
    // Open date filter
    await page.click('[data-testid="date-filter"]')
    await page.click('text=Last 30 days')
    
    // Verify filter applied
    await expect(page.locator('[data-testid="active-filter"]')).toContainText("30 days")
  })
})

Best practices

  • Test behavior, not implementation - Focus on what the code does, not how
  • Use table-driven tests - Group related test cases together
  • Keep tests independent - Each test should set up its own data
  • Use meaningful names - Test names should describe the scenario
  • Avoid testing private functions - Test through public APIs
  • No unnecessary mocks - Use real database for data layer tests