Writing Your Own Tests

Best practices for writing E2E and unit tests in your MakerKit application. Covers Page Objects, test data management, and common patterns.

This guide covers patterns and best practices for adding tests to your MakerKit application. Learn the Page Object pattern, bootstrap helpers, and testing conventions used throughout the codebase.

Before reading this guide, familiarize yourself with E2E testing and unit testing 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" not data-testid="input"
  • Include context: data-testid="project-card-title" not just data-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

Use dependency injection or mock at the module level:

import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock the database module
vi.mock('@kit/database', () => ({
prisma: {
membership: {
findMany: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
},
}));
import { prisma } from '@kit/database';
import { getMemberships, removeMember } from '../memberships.service';
describe('MembershipsService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getMemberships', () => {
it('should return memberships for organization', async () => {
const mockMemberships = [
{ id: '1', userId: 'user-1', role: 'owner' },
{ id: '2', userId: 'user-2', role: 'member' },
];
vi.mocked(prisma.membership.findMany).mockResolvedValue(
mockMemberships
);
const result = await getMemberships('org-123');
expect(prisma.membership.findMany).toHaveBeenCalledWith({
where: { organizationId: 'org-123' },
});
expect(result).toEqual(mockMemberships);
});
});
describe('removeMember', () => {
it('should throw when removing last owner', async () => {
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{ id: '1', userId: 'user-1', role: 'owner' },
]);
await expect(
removeMember({ organizationId: 'org-123', userId: 'user-1' })
).rejects.toThrow('Cannot remove the last owner');
});
});
});

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 timing
const element = page.getByTestId('success-message');
await expect(element).toBeVisible();
// Good: Polls until condition is met or timeout
await 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 state
test('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 independent
test('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 names
test('should work', async () => {});
test('handles error', async () => {});
// Good: Describes behavior
test('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

  1. Create the Page Object in apps/e2e/tests/[feature]/[feature].po.ts
  2. Add test IDs to your React components
  3. Write specs in apps/e2e/tests/[feature]/[feature].spec.ts
  4. Add unit tests in packages/[package]/src/__tests__/
  5. Run locally before pushing:
    pnpm --filter web-e2e exec playwright test [feature] --workers=1
    pnpm --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.only left in code