Writing Tests for Your MakerKit Application
Best practices for writing E2E and unit tests in MakerKit. Learn the Page Object pattern, bootstrap helpers, Vitest mocking, and test data management.
This guide covers patterns and best practices for writing tests in your MakerKit Next.js application. Learn the Page Object pattern for Playwright, Vitest mocking with vi.hoisted(), bootstrap helpers for fast test setup, and testing conventions used throughout the codebase.
Before reading this guide, familiarize yourself with E2E testing with Playwright and unit testing with Vitest basics.
E2E Test Patterns
Creating a Page Object
Every feature should have a Page Object that encapsulates selectors and actions.
The examples below use a hypothetical "projects" feature. Adapt these patterns to your actual features:
import { type Page, expect } from '@playwright/test';export class ProjectsPageObject { constructor(private readonly page: Page) {} async goToProjects() { await this.page.goto('/home/projects'); } async createProject(params: { name: string; description?: string }) { await this.page.getByTestId('create-project-button').click(); await this.page.getByTestId('project-name-input').fill(params.name); if (params.description) { await this.page .getByTestId('project-description-input') .fill(params.description); } await this.page.getByTestId('submit-project-button').click(); } async expectProjectVisible(name: string) { await expect(this.page.getByText(name)).toBeVisible(); } async expectProjectCount(count: number) { const projects = this.page.getByTestId('project-item'); await expect(projects).toHaveCount(count); }}Writing Spec Files
Organize tests by user story:
import { expect, test } from '@playwright/test';import { bootstrapUserWithOrg } from '../utils/bootstrap-helpers';import { ProjectsPageObject } from './projects.po';test.describe('Project Management', () => { let projects: ProjectsPageObject; test.beforeEach(async ({ page }) => { projects = new ProjectsPageObject(page); }); test.describe('Creating Projects', () => { test('should create a project with name and description', async ({ page, }) => { const { org } = await bootstrapUserWithOrg(page); await projects.goToProjects(); await projects.createProject({ name: 'My First Project', description: 'A test project', }); await projects.expectProjectVisible('My First Project'); }); test('should show validation error for empty name', async ({ page }) => { await bootstrapUserWithOrg(page); await projects.goToProjects(); await page.getByTestId('create-project-button').click(); await page.getByTestId('submit-project-button').click(); await expect(page.getByTestId('project-name-error')).toBeVisible(); }); }); test.describe('Project Permissions', () => { test('member cannot delete project', async ({ page }) => { // Use bootstrapOrgMember to log in as a regular member const { bootstrapOrgMember } = await import( '../utils/bootstrap-helpers' ); const { org } = await bootstrapOrgMember(page); await projects.goToProjects(); // Delete button should not be visible for members await expect( page.getByTestId('delete-project-button') ).not.toBeVisible(); }); });});Using Test IDs
Add data-testid attributes to components for reliable selection:
export function CreateProjectButton() { return ( <Button data-testid="create-project-button" onClick={handleClick}> Create Project </Button> );}Conventions:
- Use kebab-case:
data-testid="create-project-button" - Be specific:
data-testid="project-name-input"notdata-testid="input" - Include context:
data-testid="project-card-title"not justdata-testid="title"
Testing Email Flows
MakerKit uses Mailpit for capturing emails in tests. The Mailbox helper fetches verification links:
import { Mailbox } from '../mailbox.po';test('should verify email address', async ({ page }) => { const mailbox = new Mailbox(page); const email = 'test@makerkit.dev'; // Trigger email (e.g., sign up) await auth.signUp({ email, password: 'Password123!' }); // Visit the verification link from the email await expect(async () => { const result = await mailbox.visitMailbox(email, { deleteAfter: true, }); expect(result).not.toBeNull(); }).toPass({ timeout: 10_000 });});Handling Authentication in Tests
Three approaches depending on your needs:
1. Bootstrap helper (fastest, no UI):
test('authenticated user flow', async ({ page }) => { const { user } = await bootstrapAuthenticatedUser(page); // User is logged in, ready to test});2. API login (when you need UI-created user):
test('login via API', async ({ page }) => { const auth = new AuthPageObject(page); await auth.loginAsUser({ email: 'existing@example.com', password: 'Password123!', });});3. Full UI flow (when testing auth itself):
test('sign in flow', async ({ page }) => { const auth = new AuthPageObject(page); await auth.goToSignIn(); await auth.signIn({ email, password }); await auth.expectToBeSignedIn();});Unit Test Patterns
Testing Pure Functions
import { describe, expect, it } from 'vitest';import { formatCurrency, slugify } from '../utils';describe('formatCurrency', () => { it('should format USD amounts', () => { expect(formatCurrency(1000, 'USD')).toBe('$10.00'); }); it('should handle zero', () => { expect(formatCurrency(0, 'USD')).toBe('$0.00'); });});describe('slugify', () => { it('should convert spaces to hyphens', () => { expect(slugify('Hello World')).toBe('hello-world'); }); it('should remove special characters', () => { expect(slugify('Hello! World?')).toBe('hello-world'); });});Testing Services with Dependencies
MakerKit services use factory functions with dependency injection. Mock external dependencies at the module level using vi.mock and vi.hoisted:
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';import { createMembershipsService } from '../memberships.service';// Hoist mock functions so they're available before module mockingconst { mockListMembers, mockGetActiveMemberRole, mockUpdateMemberRole,} = vi.hoisted(() => ({ mockListMembers: vi.fn(), mockGetActiveMemberRole: vi.fn(), mockUpdateMemberRole: vi.fn(),}));// Mock the auth module that the service depends onvi.mock('@kit/better-auth', () => ({ auth: { api: { listMembers: mockListMembers, getActiveMemberRole: mockGetActiveMemberRole, updateMemberRole: mockUpdateMemberRole, }, },}));vi.mock('next/headers', () => ({ headers: vi.fn(() => Promise.resolve(new Headers())),}));describe('MembershipsService', () => { let service: ReturnType<typeof createMembershipsService>; beforeEach(() => { service = createMembershipsService(); vi.clearAllMocks(); mockGetActiveMemberRole.mockResolvedValue({ role: 'owner' }); mockUpdateMemberRole.mockResolvedValue({ success: true }); }); afterEach(() => { vi.clearAllMocks(); }); it('updates member role when allowed', async () => { mockListMembers.mockResolvedValue({ members: [ { id: 'member-1', userId: 'user-2', role: 'admin' }, ], }); await expect( service.updateMemberRole({ userId: 'user-1', organizationId: 'org-1', memberId: 'member-1', newRole: 'member', }) ).resolves.toEqual({ success: true }); expect(mockUpdateMemberRole).toHaveBeenCalled(); }); it('rejects when member is missing', async () => { mockListMembers.mockResolvedValue({ members: [] }); await expect( service.updateMemberRole({ userId: 'user-1', organizationId: 'org-1', memberId: 'member-1', newRole: 'member', }) ).rejects.toThrow('Member not found'); expect(mockUpdateMemberRole).not.toHaveBeenCalled(); });});Key patterns:
vi.hoisted()- Defines mock functions before module mocking executes- Factory functions - Services like
createMembershipsService()enable clean test isolation - Module mocking - Mock external dependencies (
@kit/better-auth,next/headers) at the module level - Clear mocks - Reset state in
beforeEach/afterEachto prevent test pollution
Depending on your service's imports, you may need additional mocks for @kit/better-auth/errors, @kit/shared/logger, or other dependencies. Check the actual service file to identify all imports that need mocking.
Testing Zod Schemas
import { describe, expect, it } from 'vitest';import { createInvitationSchema } from '../invitation.schema';describe('createInvitationSchema', () => { it('should accept valid invitation', () => { const result = createInvitationSchema.safeParse({ email: 'test@example.com', role: 'member', }); expect(result.success).toBe(true); }); it('should reject invalid email', () => { const result = createInvitationSchema.safeParse({ email: 'not-an-email', role: 'member', }); expect(result.success).toBe(false); expect(result.error?.issues[0]?.path).toContain('email'); }); it('should reject invalid role', () => { const result = createInvitationSchema.safeParse({ email: 'test@example.com', role: 'superadmin', // Not a valid role }); expect(result.success).toBe(false); });});Common Patterns
Polling Assertions
When waiting for async operations:
// Bad: Single check that might fail due to timingconst element = page.getByTestId('success-message');await expect(element).toBeVisible();// Good: Polls until condition is met or timeoutawait expect(async () => { const element = page.getByTestId('success-message'); await expect(element).toBeVisible();}).toPass({ timeout: 5_000 });Test Isolation
Each test should create its own data:
// Bad: Tests depend on shared statetest('test 1', async ({ page }) => { await createProject('Shared Project');});test('test 2', async ({ page }) => { // Fails if test 1 didn't run await editProject('Shared Project');});// Good: Each test is independenttest('test 1', async ({ page }) => { const { org } = await bootstrapUserWithOrg(page); await createProject('Project A'); // ...});test('test 2', async ({ page }) => { const { org } = await bootstrapUserWithOrg(page); await createProject('Project B'); // ...});Descriptive Test Names
// Bad: Vague namestest('should work', async () => {});test('handles error', async () => {});// Good: Describes behaviortest('should display validation error when email is invalid', async () => {});test('should redirect to dashboard after successful sign in', async () => {});test('owner should see delete button, member should not', async () => {});Adding Tests for New Features
- Create the Page Object in
apps/e2e/tests/[feature]/[feature].po.ts - Add test IDs to your React components
- Write specs in
apps/e2e/tests/[feature]/[feature].spec.ts - Add unit tests in
packages/[package]/src/__tests__/ - Run locally before pushing:pnpm --filter web-e2e exec playwright test [feature] --workers=1pnpm --filter @kit/[package] test:unit
Checklist
Before marking a feature complete:
- [ ] Critical user flows have E2E tests
- [ ] Page Objects encapsulate selectors
- [ ] Test IDs added to interactive elements
- [ ] Business logic has unit tests
- [ ] Tests pass locally with
--workers=1 - [ ] No
test.onlyleft in code