Skip to main content

Testing Philosophy

Aya follows a pragmatic testing approach:
  • Business logic MUST have tests - Pure functions, services, and domain logic
  • Adapters should have integration tests - Database, HTTP clients, external APIs
  • UI components use snapshot tests - Anti-regression testing for React components
  • Test code is production code - Apply the same quality standards
All tests must pass before committing. The make ok command runs all tests in CI mode.

Frontend Testing (Deno)

Snapshot Testing with Deno

Aya uses Deno’s native test runner with @std/testing/snapshot for anti-regression testing.

Test File Structure

/// <reference lib="deno.ns" />
import { assertSnapshot } from "@std/testing/snapshot";
import { myFunction } from "./my-utils.ts";

Deno.test("myFunction - returns formatted locale string", async (t) => {
  const result = myFunction("en-US");
  await assertSnapshot(t, result);
});

Deno.test("myFunction - handles invalid locale", async (t) => {
  const result = myFunction("invalid");
  await assertSnapshot(t, result);
});
Why /// <reference lib="deno.ns" />? The project’s tsconfig.json targets Vite/React, not Deno. This directive enables Deno types for test files.

Running Tests

cd apps/webclient

# Run tests (writes snapshots)
deno task test

# Run tests in CI mode (read-only, fails if snapshots don't match)
deno task test:ci

# Update snapshots after intentional changes
deno task test:update
Test commands in package.json:
{
  "test": "deno test --no-check --allow-read --allow-write=. src/",
  "test:update": "deno test --no-check --allow-read --allow-write=. -- --update src/",
  "test:ci": "deno test --no-check --allow-read src/"
}
make ok uses test:ci (read-only mode). Never commit snapshot mismatches. Update snapshots explicitly with deno task test:update.

When to Update Snapshots

1

Make Intentional Change

Change function behavior, update UI component, or fix a bug.
2

Verify Test Failure

deno task test:ci
Should fail with snapshot mismatch.
3

Review Diff

Manually verify the diff shows your intended changes (not accidental breakage).
4

Update Snapshots

deno task test:update
5

Commit Updated Snapshots

git add .
git commit -m "test: update snapshots for locale formatting"

Testing Pure Functions

Extract pure logic for testability (see Code Style: Pure Function Extraction).
export const SUPPORTED_LOCALES = ["en", "tr", "fr", "de"] as const;

export function isValidLocale(locale: string): boolean {
  return SUPPORTED_LOCALES.includes(locale as any);
}

export function normalizeLocale(locale: string): string {
  return locale.toLowerCase().trim();
}
Key principles:
  • Test files co-located with source: foo.tsfoo.test.ts
  • Use descriptive test names: "functionName - specific behavior"
  • Test edge cases: empty strings, null, undefined, boundary values
  • Use assertions from @std/assert: assertEquals, assertThrows, etc.

Testing React Components (Snapshot Tests)

Snapshot tests verify component output doesn’t change unexpectedly.
/// <reference lib="deno.ns" />
import { assertSnapshot } from "@std/testing/snapshot";
import { render } from "@testing-library/react";
import { ProfileCard } from "./profile-card.tsx";

Deno.test("ProfileCard - renders with all props", async (t) => {
  const { container } = render(
    <ProfileCard
      profile={{
        id: "123",
        slug: "test-user",
        title: "Test User",
        bio: "Test bio",
      }}
    />,
  );
  await assertSnapshot(t, container.innerHTML);
});

Deno.test("ProfileCard - renders without bio", async (t) => {
  const { container } = render(
    <ProfileCard
      profile={{
        id: "123",
        slug: "test-user",
        title: "Test User",
        bio: null,
      }}
    />,
  );
  await assertSnapshot(t, container.innerHTML);
});
Best practices:
  • Test different prop combinations
  • Test conditional rendering (null checks, feature flags)
  • Focus on output, not implementation details
  • Keep snapshots small and focused

Backend Testing (Go)

Table-Driven Tests

Use table-driven tests for comprehensive coverage with minimal boilerplate.
package user

import "testing"

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

    cases := []struct {
        name      string
        birthYear int
        expected  int
    }{
        {"born in 2000", 2000, 24},
        {"born in 1990", 1990, 34},
        {"born in 2024", 2024, 0},
    }

    for _, tc := range cases {
        tc := tc  // Capture range variable
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            result := CalculateAge(tc.birthYear)
            if result != tc.expected {
                t.Errorf("got %d, want %d", result, tc.expected)
            }
        })
    }
}
Key elements:
  • t.Parallel() - Run tests concurrently
  • tc := tc - Capture range variable for parallel execution
  • t.Run() - Subtests with descriptive names
  • Clear error messages: "got X, want Y"

Testing Business Logic with Mocks

Business logic depends on interfaces, not concrete types. Use mocks for dependencies.
package user

type Repository interface {
    FindByID(ctx context.Context, id string) (*User, error)
}

type Service struct {
    repo Repository
}

func NewService(repo Repository) *Service {
    return &Service{repo: repo}
}

func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
    if id == "" {
        return nil, ErrInvalidID
    }
    return s.repo.FindByID(ctx, id)
}
Mock guidelines:
  • Implement only the interface methods needed for the test
  • Use simple in-memory data structures (maps, slices)
  • Consider using mockery for complex interfaces

Integration Tests for Adapters

Adapters (database, HTTP clients, Redis) should have integration tests with real dependencies.
package adapters

import (
    "context"
    "testing"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/postgres"
)

func TestUserRepository_Integration(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test")
    }

    ctx := context.Background()
    
    // Start PostgreSQL container
    pgContainer, err := postgres.Run(ctx, "postgres:16")
    if err != nil {
        t.Fatalf("failed to start postgres: %v", err)
    }
    defer pgContainer.Terminate(ctx)
    
    connStr, _ := pgContainer.ConnectionString(ctx)
    
    // Test repository with real database
    repo := NewUserRepository(connStr)
    
    user := &User{ID: "123", Name: "Test"}
    err = repo.Create(ctx, user)
    if err != nil {
        t.Fatalf("failed to create user: %v", err)
    }
    
    found, err := repo.FindByID(ctx, "123")
    if err != nil {
        t.Fatalf("failed to find user: %v", err)
    }
    if found.Name != "Test" {
        t.Errorf("got name %s, want Test", found.Name)
    }
}
Integration test practices:
  • Skip with testing.Short() for fast unit test runs
  • Use testcontainers for isolated environments
  • Clean up resources with defer
  • Test real interactions, not mocked behavior

Running Go Tests

cd apps/services

# Run all tests with race detection
make test
# Equivalent to: go test -failfast -race -count 1 ./...

# Run tests with coverage
make test-cov

# View coverage in browser
make test-view-html

# Run only unit tests (skip integration tests)
go test -short ./...

# Run specific package
go test ./pkg/api/business/user/

# Run specific test
go test -run TestService_GetUser ./pkg/api/business/user/

Test Organization

Frontend (Deno)

apps/webclient/src/
├── lib/
│   ├── locale-utils.ts          # Pure functions
│   ├── locale-utils.test.ts     # Unit tests
│   ├── __snapshots__/           # Generated snapshots
│   │   └── locale-utils.test.ts.snap
│   └── config.ts                # Uses import.meta.env (not tested)
└── components/
    ├── profile-card.tsx
    └── profile-card.test.tsx    # Snapshot tests
Test file naming: *.test.ts for Deno tests

Backend (Go)

apps/services/pkg/api/
├── business/
│   └── user/
│       ├── service.go           # Business logic
│       └── service_test.go      # Unit tests with mocks
└── adapters/
    ├── postgres/
    │   ├── user_repository.go       # Adapter
    │   └── user_repository_test.go  # Integration tests
    └── appcontext/
        └── appcontext.go        # Composition root
Test file naming: *_test.go (Go convention)

Testing Best Practices

Test Naming

Use descriptive names that explain what’s being tested:
Deno.test("isValidLocale - returns true for supported locale", () => {});
Deno.test("isValidLocale - returns false for empty string", () => {});
Deno.test("ProfileCard - renders bio when present", async (t) => {});
Deno.test("ProfileCard - hides bio when null", async (t) => {});

Test Independence

Each test should be independent - no shared state between tests.
// ✅ Independent tests
Deno.test("test 1", () => {
  const user = { id: "123", name: "Test" };  // Fresh data
  assertEquals(processUser(user).name, "Test");
});

Deno.test("test 2", () => {
  const user = { id: "456", name: "Other" };  // Fresh data
  assertEquals(processUser(user).name, "Other");
});

// ❌ Shared state (fragile)
const sharedUser = { id: "123", name: "Test" };

Deno.test("test 1", () => {
  sharedUser.name = "Modified";  // Mutates shared state
});

Deno.test("test 2", () => {
  // This test's behavior depends on test 1's execution
  assertEquals(sharedUser.name, "Test");  // Fails if test 1 runs first
});

Explicit Assertions

Use explicit equality checks (follows project convention):
// ✅ Explicit checks
assertEquals(result, null);
assertEquals(items.length, 0);
assertEquals(value !== undefined, true);

// ❌ Truthy/falsy (avoid per project standards)
assert(!result);  // Ambiguous
assert(!items.length);  // Fails for 0

Test Coverage

Aim for meaningful coverage, not 100%:
  • High priority: Business logic, data transformations, validation
  • Medium priority: Adapters, HTTP handlers, utilities
  • Low priority: Simple getters/setters, generated code
# Check backend coverage
cd apps/services
make test-cov
# View report
make test-view-html

Common Testing Patterns

Always test error paths:
Deno.test("parseUser - throws on invalid JSON", () => {
  assertThrows(
    () => parseUser("invalid json"),
    Error,
    "Invalid JSON",
  );
});

Deno.test("parseUser - throws on missing required field", () => {
  assertThrows(
    () => parseUser('{"name": "Test"}'),  // Missing "id"
    Error,
    "Missing required field: id",
  );
});
Use async/await consistently:
Deno.test("fetchUser - returns user data", async () => {
  const user = await fetchUser("123");
  assertEquals(user.id, "123");
});

Deno.test("fetchUser - handles network error", async () => {
  await assertRejects(
    async () => await fetchUser("invalid"),
    Error,
    "Network error",
  );
});
Always pass context.Context:
func TestService_WithContext(t *testing.T) {
    ctx := context.Background()
    
    // Test cancellation
    cancelCtx, cancel := context.WithCancel(ctx)
    cancel()  // Cancel immediately
    
    _, err := service.GetUser(cancelCtx, "123")
    if !errors.Is(err, context.Canceled) {
        t.Errorf("expected context.Canceled, got %v", err)
    }
}

CI/CD Integration

Tests run automatically on every push:
# .github/workflows/test.yml (example)
name: Test
on: [push, pull_request]

jobs:
  frontend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: denoland/setup-deno@v1
      - run: cd apps/webclient && deno task test:ci
  
  backend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
      - run: cd apps/services && make test
Local CI simulation:
# Run exactly what CI runs
make ok

Troubleshooting

Symptom: deno task test:ci fails with snapshot mismatch.Diagnosis:
  1. Review the diff in terminal output
  2. Check if change was intentional or a bug
Fix:
  • If intentional: deno task test:update
  • If bug: Fix the code, don’t update snapshots
Symptom: make test fails with race detector warnings.Diagnosis: Concurrent access to shared data without synchronization.Fix:
  • Use channels for communication between goroutines
  • Protect shared state with sync.Mutex
  • Avoid global mutable state
Symptom: Tests hang or timeout.Diagnosis: Blocking operations without timeout.Fix:
// Add timeout to Deno tests
Deno.test({
  name: "slow operation",
  async fn() {
    // Test code
  },
  sanitizeOps: false,
  sanitizeResources: false,
});
// Add timeout to Go tests
func TestSlow(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    // Use ctx in test
}

Further Reading

Build docs developers (and LLMs) love