Testing
Complete guide for testing Laju applications with Vitest and Playwright.
Setup
Configuration
Laju uses Vitest for testing with the following setup:
typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html']
}
}
});Test Database
typescript
// tests/setup.ts
import DB from '../app/services/DB';
beforeAll(async () => {
// Use test database
process.env.DB_CONNECTION = 'test';
// Run migrations
await DB.migrate.latest();
});
afterAll(async () => {
await DB.destroy();
});
beforeEach(async () => {
// Clean tables before each test
await DB.deleteFrom('users').execute();
await DB.deleteFrom('posts').execute();
});Commands
bash
# Unit & Integration Tests (Vitest)
npm run test:run # Run all tests
npm run test:ui # Run with UI mode
npm run test:coverage # Run with coverage report
npx vitest # Watch mode
# E2E Tests (Playwright)
npm run test:e2e # Run all E2E tests
npm run test:e2e:ui # Run with UI mode (recommended)
npm run test:e2e:debug # Run with debug mode
npm run test:e2e:install # Install Playwright browsersUnit Tests
Testing Services
typescript
// tests/unit/services/Authenticate.test.ts
import { describe, it, expect } from 'vitest';
import Authenticate from '../../app/services/Authenticate';
describe('Authenticate', () => {
describe('hash', () => {
it('should hash password with salt', async () => {
const password = 'mypassword123';
const hashed = await Authenticate.hash(password);
expect(hashed).toContain(':');
expect(hashed.split(':').length).toBe(2);
});
it('should generate different hashes for same password', async () => {
const password = 'mypassword123';
const hash1 = await Authenticate.hash(password);
const hash2 = await Authenticate.hash(password);
expect(hash1).not.toBe(hash2);
});
});
describe('compare', () => {
it('should return true for matching password', async () => {
const password = 'mypassword123';
const hashed = await Authenticate.hash(password);
const result = await Authenticate.compare(password, hashed);
expect(result).toBe(true);
});
it('should return false for wrong password', async () => {
const password = 'mypassword123';
const hashed = await Authenticate.hash(password);
const result = await Authenticate.compare('wrongpassword', hashed);
expect(result).toBe(false);
});
});
});Testing Utilities
typescript
// tests/unit/utils/validation.test.ts
import { describe, it, expect } from 'vitest';
function isValidEmail(email: string): boolean {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
describe('isValidEmail', () => {
it('should return true for valid emails', () => {
expect(isValidEmail('test@example.com')).toBe(true);
expect(isValidEmail('user.name@domain.co.id')).toBe(true);
});
it('should return false for invalid emails', () => {
expect(isValidEmail('invalid')).toBe(false);
expect(isValidEmail('invalid@')).toBe(false);
expect(isValidEmail('@domain.com')).toBe(false);
});
});Integration Tests
Testing Database Operations
typescript
// tests/integration/posts.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import DB from '../../app/services/DB';
describe('Posts CRUD', () => {
beforeEach(async () => {
await DB.deleteFrom('posts').execute();
});
it('should create a post', async () => {
const post = {
id: 'post-1',
title: 'Test Post',
content: 'Test content',
created_at: Date.now(),
updated_at: Date.now()
};
await DB.insertInto('posts').values(post).execute();
const saved = await DB.selectFrom('posts')
.selectAll()
.where('id', '=', 'post-1')
.executeTakeFirst();
expect(saved?.title).toBe('Test Post');
expect(saved?.content).toBe('Test content');
});
it('should update a post', async () => {
await DB.insertInto('posts').values({
id: 'post-1',
title: 'Original',
content: 'Content',
created_at: Date.now(),
updated_at: Date.now()
}).execute();
await DB.updateTable('posts')
.set({
title: 'Updated',
updated_at: Date.now()
})
.where('id', '=', 'post-1')
.execute();
const post = await DB.selectFrom('posts')
.selectAll()
.where('id', '=', 'post-1')
.executeTakeFirst();
expect(post?.title).toBe('Updated');
});
it('should delete a post', async () => {
await DB.insertInto('posts').values({
id: 'post-1',
title: 'To Delete',
content: 'Content',
created_at: Date.now(),
updated_at: Date.now()
}).execute();
await DB.deleteFrom('posts')
.where('id', '=', 'post-1')
.execute();
const post = await DB.selectFrom('posts')
.selectAll()
.where('id', '=', 'post-1')
.executeTakeFirst();
expect(post).toBeUndefined();
});
});E2E Tests
End-to-end testing with Playwright for testing user flows in real browsers.
Setup
Installation
bash
# Install Playwright
npm install -D @playwright/test
# Install browsers (only required once)
npm run test:e2e:installConfiguration
Playwright is configured in playwright.config.ts:
typescript
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: 'html',
use: {
baseURL: 'http://localhost:5555',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
// Auto-start server in CI
webServer: process.env.CI ? {
command: 'npm run build && npm run start',
url: 'http://localhost:5553',
timeout: 120 * 1000,
} : undefined,
});Commands
bash
# Run all E2E tests
npm run test:e2e
# Run with UI mode (recommended for debugging)
npm run test:e2e:ui
# Run with debug mode (step-by-step)
npm run test:e2e:debug
# Run specific test file
npx playwright test homepage.spec.ts
# Run in specific browser
npx playwright test --project=chromiumTesting Authentication Flows
typescript
// tests/e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('should display login form', async ({ page }) => {
const emailInput = page.locator('input[name="email"]');
const passwordInput = page.locator('input[name="password"]');
const submitButton = page.locator('button[type="submit"]');
await expect(emailInput).toBeVisible();
await expect(passwordInput).toBeVisible();
await expect(submitButton).toBeVisible();
});
test('should toggle password visibility', async ({ page }) => {
const passwordInput = page.locator('input[name="password"]');
const toggleButton = passwordInput.locator('xpath=../button');
// Initially password should be hidden
await expect(passwordInput).toHaveAttribute('type', 'password');
// Click toggle button
await toggleButton.click();
// Password should now be visible
await expect(passwordInput).toHaveAttribute('type', 'text');
});
test('should disable submit button during submission', async ({ page }) => {
const submitButton = page.locator('button[type="submit"]');
// Fill form with credentials
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
// Submit form
await submitButton.click();
// Button should be disabled during submission
await expect(submitButton).toBeDisabled();
// Check for loading text
await expect(submitButton).toContainText('Signing in');
});
test('should navigate to register page', async ({ page }) => {
// Use text-based selector for Inertia links
const registerLink = page.getByText('Create one');
// Click register link
await registerLink.click();
// Should navigate to register page
await page.waitForURL('**/register', { timeout: 5000 });
expect(page.url()).toContain('/register');
});
});Testing Forms
typescript
// tests/e2e/register.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Registration Form', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/register');
});
test('should validate password mismatch', async ({ page }) => {
const passwordInput = page.locator('input[name="password"]');
const confirmPasswordInput = page.locator('input[name="confirm-password"]');
const submitButton = page.locator('button[type="submit"]');
// Fill with mismatched passwords
await page.fill('input[name="name"]', 'Test User');
await page.fill('input[name="email"]', 'test@example.com');
await passwordInput.fill('password123');
await confirmPasswordInput.fill('different123');
// Submit form
await submitButton.click();
// Check for password mismatch error
const errorText = page.locator('.text-red-400');
await expect(errorText.first()).toBeVisible();
await expect(errorText.first()).toContainText('do not match');
});
test('should generate password', async ({ page }) => {
const generateButton = page.locator('button:has-text("Generate secure password")');
const passwordInput = page.locator('input[name="password"]');
const confirmPasswordInput = page.locator('input[name="confirm-password"]');
// Click generate button
await generateButton.click();
// Password fields should be filled
const passwordValue = await passwordInput.inputValue();
const confirmValue = await confirmPasswordInput.inputValue();
expect(passwordValue.length).toBeGreaterThan(0);
expect(confirmValue).toBe(passwordValue);
});
});Best Practices for E2E
Test Critical User Flows
- Focus on important user journeys (login, registration, checkout)
- Test success paths AND error paths
- Verify form validation
Use Selective Testing
- Don't test every single field
- Test key interactions and edge cases
- Prioritize critical functionality
Wait Properly
- ❌
await page.waitForTimeout(5000)- flaky - ✅
await page.waitForURL('**/dashboard')- reliable - ✅
await expect(element).toBeVisible()- auto-waiting
- ❌
Use Descriptive Test Names
typescript// Good test('should redirect to dashboard after successful login', () => {}); // Bad test('test login redirect', () => {});Keep Tests Independent
- Each test should work alone
- Don't rely on state from previous tests
- Use
beforeEachto set up fresh state
Test Utilities
Factory Functions
typescript
// tests/factories/user.ts
import { randomUUID } from 'crypto';
import Authenticate from '../../app/services/Authenticate';
import DB from '../../app/services/DB';
export async function createUser(overrides = {}) {
const defaults = {
id: randomUUID(),
name: 'Test User',
email: `test-${Date.now()}@example.com`,
password: await Authenticate.hash('password123'),
is_admin: false,
is_verified: false,
created_at: Date.now(),
updated_at: Date.now()
};
const user = { ...defaults, ...overrides };
await DB.insertInto('users').values(user).execute();
return user;
}
export async function createPost(userId: string, overrides = {}) {
const defaults = {
id: randomUUID(),
user_id: userId,
title: 'Test Post',
content: 'Test content',
status: 'draft',
created_at: Date.now(),
updated_at: Date.now()
};
const post = { ...defaults, ...overrides };
await DB.insertInto('posts').values(post).execute();
return post;
}Using Factories
typescript
import { createUser, createPost } from '../factories/user';
describe('Post with User', () => {
it('should associate post with user', async () => {
const user = await createUser({ name: 'Author' });
const post = await createPost(user.id, { title: 'My Post' });
const result = await DB.selectFrom('posts')
.innerJoin('users', 'posts.user_id', 'users.id')
.where('posts.id', '=', post.id)
.select(['posts.title', 'users.name as author_name'])
.executeTakeFirst();
expect(result?.author_name).toBe('Author');
expect(result?.title).toBe('My Post');
});
});Best Practices
1. Isolate Tests
typescript
// Each test should be independent
beforeEach(async () => {
await DB.deleteFrom('users').execute();
});2. Use Descriptive Names
typescript
// Good
it('should return 401 when password is incorrect', () => {});
// Bad
it('test login', () => {});3. Test Edge Cases
typescript
describe('validateEmail', () => {
it('should handle empty string', () => {});
it('should handle null', () => {});
it('should handle very long email', () => {});
it('should handle unicode characters', () => {});
});4. Mock External Services
typescript
import { vi } from 'vitest';
// Mock email service
vi.mock('../../app/services/Resend', () => ({
MailTo: vi.fn().mockResolvedValue({ success: true })
}));5. Use Arrange-Act-Assert
typescript
it('should update user profile', async () => {
// Arrange
const user = await createUser({ name: 'Old Name' });
// Act
await DB.updateTable('users')
.set({ name: 'New Name' })
.where('id', '=', user.id)
.execute();
// Assert
const updated = await DB.selectFrom('users')
.selectAll()
.where('id', '=', user.id)
.executeTakeFirst();
expect(updated?.name).toBe('New Name');
});