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 -vTest coverage
# Generate coverage report
make test-coverage
# Opens HTML report in browser
open backend/coverage.htmlRace condition detection
# Run with race detector (slower but catches data races)
make test-raceTest 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=postgresTest 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 --debugViewing 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 testsTest 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 screenshotsWriting 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