Testing Practices
This skill covers comprehensive testing strategies for modern JavaScript/TypeScript applications with Remix and SST.
Core Philosophy
Good tests are:
-
Fast: Run quickly to encourage frequent execution
-
Isolated: Each test is independent
-
Readable: Clear what's being tested and why
-
Reliable: Same input always produces same output
-
Maintainable: Easy to update when code changes
Testing Pyramid
/\
/ \ E2E Tests (few)
/
/
/ Integr \ Integration Tests (some)
/__
/
/ Unit \ Unit Tests (many)
/__________\
Unit Tests (70%):
-
Test individual functions
-
Fast, isolated
-
Mock dependencies
Integration Tests (20%):
-
Test multiple components together
-
Test database queries
-
Test API routes
E2E Tests (10%):
-
Test full user workflows
-
Slow but high confidence
-
Critical paths only
Test Frameworks
Setup with Vitest
npm install -D vitest @vitest/ui
// vitest.config.ts import { defineConfig } from "vitest/config";
export default defineConfig({ test: { globals: true, environment: "node", setupFiles: ["./test/setup.ts"], coverage: { provider: "v8", reporter: ["text", "html"], exclude: ["/node_modules/", "/test/"] } } });
Setup for React/Remix
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
// test/setup.ts import "@testing-library/jest-dom"; import { cleanup } from "@testing-library/react"; import { afterEach } from "vitest";
afterEach(() => { cleanup(); });
Unit Testing Patterns
Pattern 1: Testing Pure Functions
// src/lib/utils.ts export function calculateTotal(items: CartItem[]): number { return items.reduce((sum, item) => sum + item.price * item.quantity, 0); }
// test/lib/utils.test.ts import { describe, test, expect } from "vitest"; import { calculateTotal } from "../../src/lib/utils";
describe("calculateTotal", () => { test("returns 0 for empty cart", () => { expect(calculateTotal([])).toBe(0); });
test("calculates total for single item", () => { const items = [{ price: 10, quantity: 2 }]; expect(calculateTotal(items)).toBe(20); });
test("calculates total for multiple items", () => { const items = [ { price: 10, quantity: 2 }, { price: 5, quantity: 3 } ]; expect(calculateTotal(items)).toBe(35); });
test("handles decimal prices", () => { const items = [{ price: 9.99, quantity: 2 }]; expect(calculateTotal(items)).toBeCloseTo(19.98); }); });
Pattern 2: Testing with Mocks
// src/lib/email.ts import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
export async function sendWelcomeEmail(email: string, name: string) { const ses = new SESClient({});
await ses.send(new SendEmailCommand({
Source: "noreply@example.com",
Destination: { ToAddresses: [email] },
Message: {
Subject: { Data: "Welcome!" },
Body: { Text: { Data: Hello ${name}! } }
}
}));
}
// test/lib/email.test.ts import { describe, test, expect, vi } from "vitest"; import { sendWelcomeEmail } from "../../src/lib/email"; import { SESClient } from "@aws-sdk/client-ses";
vi.mock("@aws-sdk/client-ses", () => ({ SESClient: vi.fn(), SendEmailCommand: vi.fn() }));
describe("sendWelcomeEmail", () => { test("sends email with correct parameters", async () => { const mockSend = vi.fn().mockResolvedValue({}); (SESClient as any).mockImplementation(() => ({ send: mockSend }));
await sendWelcomeEmail("user@example.com", "John");
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
Destination: { ToAddresses: ["user@example.com"] }
})
})
);
}); });
Pattern 3: Testing Async Functions
// src/lib/users.ts
export async function getUser(id: string): Promise<User | null> {
const result = await db.query({
TableName: Resource.Database.name,
KeyConditionExpression: "pk = :pk",
ExpressionAttributeValues: { ":pk": USER#${id} }
});
return result.Items?.[0] as User | null; }
// test/lib/users.test.ts import { describe, test, expect, beforeEach, vi } from "vitest"; import { getUser } from "../../src/lib/users";
// Mock the database vi.mock("../../src/lib/db", () => ({ db: { query: vi.fn() } }));
import { db } from "../../src/lib/db";
describe("getUser", () => { beforeEach(() => { vi.clearAllMocks(); });
test("returns user when found", async () => { const mockUser = { pk: "USER#123", name: "John" }; (db.query as any).mockResolvedValue({ Items: [mockUser] });
const user = await getUser("123");
expect(user).toEqual(mockUser);
});
test("returns null when not found", async () => { (db.query as any).mockResolvedValue({ Items: [] });
const user = await getUser("123");
expect(user).toBeNull();
});
test("throws on database error", async () => { (db.query as any).mockRejectedValue(new Error("DB Error"));
await expect(getUser("123")).rejects.toThrow("DB Error");
}); });
Testing Remix Routes
Pattern 1: Testing Loaders
// app/routes/posts.$id.tsx export async function loader({ params }: LoaderFunctionArgs) { const post = await getPost(params.id); if (!post) { throw new Response("Not Found", { status: 404 }); } return json({ post }); }
// test/routes/posts.$id.test.ts import { describe, test, expect, vi } from "vitest"; import { loader } from "../../app/routes/posts.$id";
vi.mock("../../src/lib/posts", () => ({ getPost: vi.fn() }));
import { getPost } from "../../src/lib/posts";
describe("posts.$id loader", () => { test("returns post data when found", async () => { const mockPost = { id: "123", title: "Test Post" }; (getPost as any).mockResolvedValue(mockPost);
const response = await loader({
params: { id: "123" },
request: new Request("http://localhost/posts/123")
} as any);
const data = await response.json();
expect(data.post).toEqual(mockPost);
});
test("throws 404 when post not found", async () => { (getPost as any).mockResolvedValue(null);
await expect(
loader({
params: { id: "123" },
request: new Request("http://localhost/posts/123")
} as any)
).rejects.toThrow("Not Found");
}); });
Pattern 2: Testing Actions
// app/routes/posts.new.tsx export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData(); const title = formData.get("title"); const content = formData.get("content");
if (!title || !content) { return json({ errors: { title: "Required", content: "Required" } }); }
const post = await createPost({ title, content });
return redirect(/posts/${post.id});
}
// test/routes/posts.new.test.ts import { describe, test, expect, vi } from "vitest"; import { action } from "../../app/routes/posts.new";
vi.mock("../../src/lib/posts", () => ({ createPost: vi.fn() }));
import { createPost } from "../../src/lib/posts";
describe("posts.new action", () => { test("creates post and redirects on success", async () => { const mockPost = { id: "123", title: "Test", content: "Content" }; (createPost as any).mockResolvedValue(mockPost);
const formData = new FormData();
formData.append("title", "Test");
formData.append("content", "Content");
const response = await action({
request: new Request("http://localhost/posts/new", {
method: "POST",
body: formData
})
} as any);
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("/posts/123");
});
test("returns errors for invalid data", async () => { const formData = new FormData(); formData.append("title", ""); formData.append("content", "");
const response = await action({
request: new Request("http://localhost/posts/new", {
method: "POST",
body: formData
})
} as any);
const data = await response.json();
expect(data.errors).toEqual({
title: "Required",
content: "Required"
});
}); });
Testing React Components
Pattern 1: Simple Component Tests
// app/components/PostCard.tsx export function PostCard({ post }: { post: Post }) { return ( <article> <h2>{post.title}</h2> <p>{post.content}</p> <time>{post.createdAt}</time> </article> ); }
// test/components/PostCard.test.tsx import { describe, test, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import { PostCard } from "../../app/components/PostCard";
describe("PostCard", () => { test("renders post information", () => { const post = { id: "123", title: "Test Post", content: "This is content", createdAt: "2025-01-02" };
render(<PostCard post={post} />);
expect(screen.getByText("Test Post")).toBeInTheDocument();
expect(screen.getByText("This is content")).toBeInTheDocument();
expect(screen.getByText("2025-01-02")).toBeInTheDocument();
}); });
Pattern 2: Interactive Components
// app/components/Counter.tsx import { useState } from "react";
export function Counter() { const [count, setCount] = useState(0);
return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}>Increment</button> <button onClick={() => setCount(0)}>Reset</button> </div> ); }
// test/components/Counter.test.tsx import { describe, test, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Counter } from "../../app/components/Counter";
describe("Counter", () => { test("increments count", async () => { const user = userEvent.setup(); render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
await user.click(screen.getByText("Increment"));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
await user.click(screen.getByText("Increment"));
expect(screen.getByText("Count: 2")).toBeInTheDocument();
});
test("resets count", async () => { const user = userEvent.setup(); render(<Counter />);
await user.click(screen.getByText("Increment"));
await user.click(screen.getByText("Increment"));
expect(screen.getByText("Count: 2")).toBeInTheDocument();
await user.click(screen.getByText("Reset"));
expect(screen.getByText("Count: 0")).toBeInTheDocument();
}); });
Testing SST Functions
Pattern 1: Testing Lambda Handlers
// src/api/posts.ts import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; import { getPosts } from "../lib/posts";
export async function handler( event: APIGatewayProxyEventV2 ): Promise<APIGatewayProxyResultV2> { const posts = await getPosts();
return { statusCode: 200, body: JSON.stringify({ posts }) }; }
// test/api/posts.test.ts import { describe, test, expect, vi } from "vitest"; import { handler } from "../../src/api/posts";
vi.mock("../../src/lib/posts", () => ({ getPosts: vi.fn() }));
import { getPosts } from "../../src/lib/posts";
describe("posts handler", () => { test("returns posts", async () => { const mockPosts = [{ id: "1", title: "Post 1" }]; (getPosts as any).mockResolvedValue(mockPosts);
const result = await handler({} as any);
expect(result.statusCode).toBe(200);
expect(JSON.parse(result.body!)).toEqual({ posts: mockPosts });
});
test("handles errors", async () => { (getPosts as any).mockRejectedValue(new Error("DB Error"));
const result = await handler({} as any);
expect(result.statusCode).toBe(500);
}); });
Integration Testing
Pattern 1: Testing with Real Database
// test/integration/posts.test.ts import { describe, test, expect, beforeAll, afterAll } from "vitest"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import { createPost, getPost } from "../../src/lib/posts";
// Use local DynamoDB for tests const client = DynamoDBDocumentClient.from( new DynamoDBClient({ endpoint: "http://localhost:8000" }) );
describe("Post operations", () => { beforeAll(async () => { // Create test table await createTestTable(); });
afterAll(async () => { // Clean up test table await deleteTestTable(); });
test("creates and retrieves post", async () => { const postData = { title: "Test Post", content: "This is a test", authorId: "user123" };
const created = await createPost(postData);
expect(created.id).toBeDefined();
expect(created.title).toBe(postData.title);
const retrieved = await getPost(created.id);
expect(retrieved).toEqual(created);
}); });
E2E Testing with Playwright
// e2e/auth.spec.ts import { test, expect } from "@playwright/test";
test("user can sign up and log in", async ({ page }) => { // Sign up await page.goto("/signup"); await page.fill('input[name="email"]', "test@example.com"); await page.fill('input[name="password"]', "password123"); await page.fill('input[name="name"]', "Test User"); await page.click('button[type="submit"]');
// Should redirect to dashboard await expect(page).toHaveURL("/dashboard"); await expect(page.locator("text=Welcome, Test User")).toBeVisible();
// Log out await page.click('button:has-text("Log out")'); await expect(page).toHaveURL("/");
// Log in await page.goto("/login"); await page.fill('input[name="email"]', "test@example.com"); await page.fill('input[name="password"]', "password123"); await page.click('button[type="submit"]');
// Should be logged in await expect(page).toHaveURL("/dashboard"); });
Test Best Practices
- Use Descriptive Test Names
✅ Do:
test("returns 404 when post not found", () => {}); test("creates user and sends welcome email", () => {});
❌ Don't:
test("test1", () => {}); test("works", () => {});
- Follow AAA Pattern
test("updates post title", async () => { // Arrange const post = await createPost({ title: "Old Title" });
// Act await updatePost(post.id, { title: "New Title" });
// Assert const updated = await getPost(post.id); expect(updated.title).toBe("New Title"); });
- Test One Thing Per Test
✅ Do:
test("validates email format", () => {}); test("validates email uniqueness", () => {});
❌ Don't:
test("validates email", () => { // Tests 5 different things });
- Mock External Dependencies
// Mock AWS services vi.mock("@aws-sdk/client-s3");
// Mock environment variables vi.stubEnv("API_KEY", "test-key");
// Mock time vi.useFakeTimers(); vi.setSystemTime(new Date("2025-01-02"));
- Clean Up After Tests
import { afterEach, beforeEach } from "vitest";
beforeEach(() => { // Set up test data });
afterEach(() => { // Clean up vi.clearAllMocks(); vi.restoreAllMocks(); });
Coverage Goals
Aim for:
-
Unit tests: 80%+ coverage
-
Integration tests: Key workflows
-
E2E tests: Critical user paths
Run tests with coverage
npm test -- --coverage
Set minimum coverage
vitest.config.ts: coverage: { lines: 80, functions: 80, branches: 75, statements: 80 }
CI/CD Integration
.github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 - run: npm install - run: npm test - run: npm run test:e2e
Further Reading
-
Vitest: https://vitest.dev/
-
Testing Library: https://testing-library.com/
-
Playwright: https://playwright.dev/
-
Test-Driven Development (TDD) principles