Unit Tests
Testing components and functions in isolation
Unit tests provide the fastest feedback during development. These tests check individual pieces of code without external dependencies, making them quick and reliable. Use *.unit.test.ts[x]
naming convention for clarity.
Unit Testing Principle
Test behavior, not implementation. Validate what users see and experience, not internal state or DOM structure.
Component Testing
React components get tested using React Testing Library with the renderWithServices
helper:
import { screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TestServicesProvider } from "@workspace/app-shell/lib/test-utils";
vi.mock("@workspace/auth/server", () => ({
auth: {
api: {
getSession: vi.fn().mockResolvedValue({
user: {
id: "user_123",
name: "Test User",
email: "test@example.com"
}
})
}
}
}));
describe("Dashboard Layout", () => {
it("renders authenticated layout with user data", async () => {
renderWithServices(<DashboardLayout />);
await waitFor(() => {
expect(screen.getByTestId("sidebar-user-name"))
.toHaveTextContent("Test User");
expect(screen.getByTestId("sidebar-user-email"))
.toHaveTextContent("test@example.com");
});
});
});
import { render } from "@testing-library/react";
import { TestServicesProvider } from "@workspace/app-shell/lib/test-utils";
import type { Services } from "@workspace/app-shell/lib/types";
const createMockServices = (): Services => ({
auth: {
signIn: { social: vi.fn().mockResolvedValue({ success: true }) },
signOut: vi.fn().mockResolvedValue(undefined),
getSession: vi.fn().mockResolvedValue({
user: { id: "user_123", email: "test@example.com" }
})
},
toast: {
error: vi.fn(),
success: vi.fn()
},
nav: {
useRouter: vi.fn().mockReturnValue({
push: vi.fn(),
replace: vi.fn()
}),
usePathname: vi.fn().mockReturnValue("/")
},
billing: {
checkout: vi.fn(),
attach: vi.fn(),
check: vi.fn(),
useCustomer: vi.fn()
}
});
it("handles auth errors gracefully", async () => {
const mockServices = createMockServices();
render(
<TestServicesProvider services={mockServices}>
<Component />
</TestServicesProvider>
);
// Test component with mocked services
});
import { render, screen, waitFor } from "@testing-library/react";
import { TestServicesProvider } from "@workspace/app-shell/lib/test-utils";
import userEvent from "@testing-library/user-event";
it("handles form submission", async () => {
const user = userEvent.setup();
render(
<TestServicesProvider>
<SignInForm />
</TestServicesProvider>
);
await user.type(screen.getByLabelText("Email"), "test@example.com");
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByText(/check your email/i)).toBeInTheDocument();
});
});
Test Utilities
The framework provides comprehensive test utilities in packages/app-shell/src/lib/test-utils
:
Hook Testing
Custom hooks receive specialized testing with renderHookWithProviders
:
import { renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
describe("useServices", () => {
it("provides auth service", () => {
const { result } = renderHook(() => useServices(), {
wrapper: ({ children }) => (
<ServicesProvider services={mockServices}>
{children}
</ServicesProvider>
)
});
expect(result.current.auth).toBeDefined();
expect(result.current.auth.signIn).toBeDefined();
});
});
Heads up
Hooks that use context must be wrapped with their provider. Use the wrapper
option to provide necessary context.
Environment Configuration
Component tests run in jsdom by default. Override when needed:
// No annotation needed - jsdom is the default environment
import { describe, it } from "vitest";
describe("Component Tests", () => {
it("renders correctly", () => {
// Test uses jsdom automatically
});
});
/** @vitest-environment node */
import { describe, it } from "vitest";
describe("Server-side Logic", () => {
it("processes data without DOM", () => {
// Test runs in Node.js environment
});
});
/** @vitest-environment happy-dom */
import { describe, it } from "vitest";
describe("Lightweight DOM Tests", () => {
it("uses happy-dom for faster execution", () => {
// Alternative to jsdom for simpler cases
});
});
Real-World Example
Complete test from the codebase demonstrating best practices:
import { screen, waitFor } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Services } from "../lib/providers/services-context";
import { renderWithServices } from "../lib/test-utils";
// Mock Next.js navigation
vi.mock("next/navigation", () => ({
redirect: vi.fn((url: string) => {
throw new Error(`Redirect to ${url}`);
}),
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn()
}),
usePathname: () => "/"
}));
// Mock auth server
vi.mock("@workspace/auth/server", () => ({
auth: {
api: {
getSession: vi.fn()
}
}
}));
describe("Dashboard Layout Authentication", () => {
const createMockServices = (overrides: Partial<Services> = {}): Services => ({
auth: {
signIn: {
social: vi.fn().mockResolvedValue({ success: true })
},
signOut: vi.fn().mockResolvedValue(undefined),
getSession: vi.fn().mockResolvedValue({
user: { id: "user_123", email: "test@example.com" }
}),
...overrides.auth
},
toast: {
error: vi.fn(),
success: vi.fn(),
...overrides.toast
},
nav: {
useRouter: vi.fn().mockReturnValue({
push: vi.fn(),
replace: vi.fn()
}),
usePathname: vi.fn().mockReturnValue("/")
},
billing: {
checkout: vi.fn(),
attach: vi.fn(),
check: vi.fn(),
useCustomer: vi.fn()
},
...overrides
});
beforeEach(() => {
vi.clearAllMocks();
});
it("redirects unauthenticated users to sign-in", async () => {
const { auth } = await import("@workspace/auth/server");
(auth.api.getSession as any).mockResolvedValue(null);
const TestLayoutWithAuth = () => {
const [session, setSession] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(true);
React.useEffect(() => {
auth.api
.getSession({ headers: new Headers() })
.then((result) => {
if (!result) {
throw new Error("Redirect to /sign-in");
}
setSession(result);
})
.catch((err) => {
if (err.message.includes("Redirect")) {
setSession(null);
}
})
.finally(() => setIsLoading(false));
}, []);
if (isLoading) {
return <div data-testid="loading">Loading...</div>;
}
if (!session) {
return (
<div data-testid="redirect-triggered">
Redirecting to sign-in...
</div>
);
}
return (
<div data-testid="authenticated-layout">
Dashboard content
</div>
);
};
const mockServices = createMockServices();
renderWithServices(<TestLayoutWithAuth />, { services: mockServices });
await waitFor(() => {
expect(screen.getByTestId("redirect-triggered")).toBeInTheDocument();
});
expect(auth.api.getSession).toHaveBeenCalledWith({
headers: expect.any(Headers)
});
});
});
Key Patterns
- Mock external dependencies at the module level
- Use
createMockServices
factory for consistent service mocks - Clear mocks in
beforeEach
to prevent test pollution - Use
waitFor
for async state updates - Test actual user-facing behavior, not implementation details
Best Practices
Test user behavior
Validate what users see and do, not component internals:
// Good: Test user-facing behavior
expect(screen.getByRole("button", { name: /sign in/i })).toBeEnabled();
// Bad: Test implementation details
expect(component.state.isLoading).toBe(false);
Use semantic queries
Prefer accessible queries over test IDs:
// Best: Use role and accessible name
screen.getByRole("button", { name: /submit/i });
// Good: Use label text
screen.getByLabelText("Email address");
// Acceptable: Use test ID when needed
screen.getByTestId("submit-button");
Avoid CSS selectors
Never test CSS classes or styling:
// Bad: Testing CSS classes
expect(button.className).toContain("bg-blue-500");
// Good: Test visual state through semantics
expect(button).toBeEnabled();
expect(button).toHaveAttribute("aria-pressed", "true");
Mock at boundaries
Mock external dependencies, not internal logic:
// Good: Mock external API
vi.mock("@workspace/auth/server", () => ({
auth: { api: { getSession: vi.fn() } }
}));
// Bad: Mock internal functions
vi.mock("./internal-utils", () => ({
calculateTotal: vi.fn()
}));