Skip to main content

Test Patterns

Best practices and patterns for writing tests in the Heimdall project.

Rust Unit Tests

Basic Pattern

// At the bottom of any .rs file
#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_sync_function() {
let result = my_function("input");
assert_eq!(result, "expected");
}

#[tokio::test]
async fn test_async_function() {
let result = async_function().await;
assert!(result.is_ok());
}

#[test]
fn test_error_handling() {
let result = function_that_can_fail("bad input");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("expected error"));
}
}

Integration Tests

// tests/my_feature_test.rs
use axum::http::StatusCode;
use heimdall_api::{create_test_app, TestClient};

#[tokio::test]
async fn test_health_endpoint() {
let app = create_test_app().await;
let client = TestClient::new(app);

let response = client.get("/health").send().await;
assert_eq!(response.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_protected_endpoint_requires_auth() {
let app = create_test_app().await;
let client = TestClient::new(app);

let response = client.get("/v1/me").send().await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}

TypeScript Unit Tests (Vitest)

Basic Pattern

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { myFunction } from './utils';

describe('myFunction', () => {
it('handles valid input', () => {
expect(myFunction('input')).toBe('expected');
});

it('handles edge cases', () => {
expect(myFunction('')).toBe('');
expect(myFunction(null)).toBeNull();
});
});

API Route Tests

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GET, POST } from './route';
import { NextRequest } from 'next/server';

vi.mock('@/lib/auth', () => ({
getServerSession: vi.fn(),
}));

describe('GET /api/user', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('returns 401 when not authenticated', async () => {
const { getServerSession } = await import('@/lib/auth');
vi.mocked(getServerSession).mockResolvedValue(null);

const request = new NextRequest('http://localhost/api/user');
const response = await GET(request);

expect(response.status).toBe(401);
});

it('returns user data when authenticated', async () => {
const { getServerSession } = await import('@/lib/auth');
vi.mocked(getServerSession).mockResolvedValue({
user: { id: '123', name: 'Test' },
});

const request = new NextRequest('http://localhost/api/user');
const response = await GET(request);

expect(response.status).toBe(200);
});
});

React Component Tests

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';

describe('Button', () => {
it('renders with children', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button')).toHaveTextContent('Click me');
});

it('calls onClick when clicked', () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click</Button>);

fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});

it('is disabled when loading', () => {
render(<Button loading>Submit</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});

E2E Tests (Playwright)

Basic Pattern

import { test, expect } from '@playwright/test';

test.describe('Feature Name', () => {
test('completes user flow', async ({ page }) => {
await page.goto('/path');
await expect(page.getByRole('heading')).toHaveText('Expected');
await page.click('button');
await expect(page).toHaveURL('/new-path');
});
});

Authentication Flow

test.describe('Authentication', () => {
test('redirects unauthenticated users to login', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login/);
});

test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'invalid@example.com');
await page.fill('[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');

await expect(page.getByText(/invalid credentials/i)).toBeVisible();
});
});
test.describe('Navigation', () => {
test('navigates through main menu', async ({ page }) => {
await page.goto('/');
await page.click('a[href="/about"]');
await expect(page).toHaveURL('/about');
});

test('mobile navigation works', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');

await page.click('[data-testid="mobile-menu-button"]');
await expect(page.getByRole('navigation')).toBeVisible();
});
});

Best Practices

General

  1. Write tests first when fixing bugs (TDD for bug fixes)
  2. Test edge cases - empty inputs, nulls, boundaries
  3. Test error paths - not just happy paths
  4. Keep tests isolated - no shared state between tests
  5. Use descriptive names - test name should explain what's tested

Naming Conventions

LanguageUnit TestsIntegrationE2E
RustInline #[cfg(test)] or tests/*_test.rstests/*.rsN/A
TypeScript*.test.ts*.integration.test.tse2e/*.spec.ts

Mocking

Rust:

  • Use mockall crate for mocking traits
  • Create test fixtures in tests/fixtures/
  • Use wiremock for HTTP mocking

TypeScript:

  • Use vi.mock() for module mocking
  • Use msw for API mocking
  • Create test utilities in tests/utils/