Testing¶
This document explains the testing framework used in eXeLearning, including unit, integration, frontend, and end-to-end (E2E) tests.
Test Structure¶
Tests are organized by type and location:
| Type | Runner | Location | Command |
|---|---|---|---|
| Unit | Bun test | src/**/*.spec.ts |
make test-unit |
| Integration | Bun test | test/integration/ |
make test-integration |
| Frontend | Vitest | public/app/**/*.spec.js |
make test-frontend |
| E2E | Playwright | test/e2e/playwright/ |
make test-e2e |
Run all tests with:
make test
Coverage Requirements¶
Minimum coverage: 90% for all new code in unit tests.
Check coverage with:
make test-coverage
Coverage reports are generated in the terminal. Files below 90% should be prioritized for improvement.
Unit Tests¶
Unit tests validate individual components in isolation. They are located next to the source files they test.
Naming Convention¶
src/
├── services/
│ ├── session-manager.ts
│ └── session-manager.spec.ts # Test file next to source
├── routes/
│ ├── project.ts
│ └── project.spec.ts
└── websocket/
├── room-manager.ts
└── room-manager.spec.ts
Running Unit Tests¶
# Run all unit tests with coverage
make test-unit
# Run specific test file
DB_PATH=:memory: ELYSIA_FILES_DIR=/tmp/test bun test src/services/session-manager.spec.ts
# Run tests matching pattern
DB_PATH=:memory: ELYSIA_FILES_DIR=/tmp/test bun test --filter "session"
Test Configuration¶
Unit tests use an in-memory SQLite database for: - Clean data for each test run - Fast execution without external database - No conflicts between test sessions
Configuration is in bunfig.toml:
[test]
root = "."
ignore = ["nestjs_legacy/**", "symfony_legacy/**", "node_modules/**"]
env = { DB_PATH = ":memory:", ELYSIA_FILES_DIR = "/tmp/exelearning-test" }
Dependency Injection Pattern¶
NEVER use mock.module() - it causes test pollution in Bun.
Use the Dependency Injection pattern instead:
// Source file: session-manager.ts
export interface SessionManagerDependencies {
db: Kysely<Database>;
queries: { findById: typeof findByIdDefault };
}
const defaultDeps: SessionManagerDependencies = {
db: defaultDb,
queries: { findById: findByIdDefault },
};
let deps = defaultDeps;
export function configure(newDeps: Partial<SessionManagerDependencies>): void {
deps = { ...defaultDeps, ...newDeps };
}
export function resetDependencies(): void {
deps = defaultDeps;
}
// Test file: session-manager.spec.ts
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { configure, resetDependencies, getSession } from './session-manager';
describe('SessionManager', () => {
const mockDb = { /* mock implementation */ };
const mockQueries = {
findById: async () => ({ id: 1, name: 'test' }),
};
beforeEach(() => {
configure({
db: mockDb,
queries: mockQueries,
});
});
afterEach(() => {
resetDependencies();
});
it('should return session by id', async () => {
const session = await getSession('test-id');
expect(session).toBeDefined();
});
});
Test Helpers¶
Common test utilities are in test/helpers/:
import { createTestDb, createMockSession } from '../../test/helpers';
describe('MyService', () => {
it('should work with test db', async () => {
const db = await createTestDb();
const session = createMockSession({ projectId: 123 });
// ...
});
});
Integration Tests¶
Integration tests verify multiple services working together. Located in test/integration/.
Running Integration Tests¶
make test-integration
Structure¶
test/integration/
├── export/ # Export format tests
│ ├── scorm12-export.integration.spec.ts
│ ├── epub3-export.integration.spec.ts
│ └── html5-export.integration.spec.ts
├── routes/ # API route integration tests
├── websocket/ # WebSocket integration tests
├── fixtures/ # Test data fixtures
└── helpers/ # Integration test helpers
Example Integration Test¶
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { Elysia } from 'elysia';
import { projectRoutes } from '../../src/routes/project';
describe('Project API Integration', () => {
let app: Elysia;
beforeAll(async () => {
app = new Elysia()
.use(projectRoutes);
});
it('should create and retrieve project', async () => {
const createRes = await app.handle(
new Request('http://localhost/api/projects', {
method: 'POST',
body: JSON.stringify({ name: 'Test Project' }),
})
);
expect(createRes.status).toBe(201);
const data = await createRes.json();
expect(data.uuid).toBeDefined();
});
});
Frontend Tests¶
Frontend tests use Vitest with happy-dom for DOM simulation.
Running Frontend Tests¶
# Run all frontend tests
make test-frontend
# Run with UI
bun run test:frontend:ui
Configuration¶
Frontend tests are configured in vitest.config.ts:
export default defineConfig({
test: {
environment: 'happy-dom',
include: ['public/app/**/*.spec.js'],
},
});
Example Frontend Test¶
// public/app/yjs/AssetManager.spec.js
import { describe, it, expect, vi } from 'vitest';
describe('AssetManager', () => {
it('should generate content-addressable URL', async () => {
const manager = new AssetManager('project-123');
const hash = 'abc123...';
const url = manager.hashToUUID(hash);
expect(url).toMatch(/^[0-9a-f-]{36}$/);
});
});
End-to-End Tests¶
E2E tests use Playwright to simulate real browser interactions.
Running E2E Tests¶
# Run all E2E tests
make test-e2e
# Run with Playwright UI
make test-e2e-ui
# Run specific test file
bunx playwright test test/e2e/playwright/specs/login.spec.ts
Structure¶
test/e2e/playwright/
├── specs/ # Test specifications
│ ├── login.spec.ts
│ ├── project.spec.ts
│ └── collaboration.spec.ts
├── pages/ # Page Object Model classes
│ ├── login.page.ts
│ ├── workarea.page.ts
│ └── project-modal.page.ts
├── fixtures/ # Test fixtures and setup
└── helpers/ # E2E helper utilities
Page Object Model¶
E2E tests follow the Page Object Model pattern:
// pages/login.page.ts
export class LoginPage {
constructor(private page: Page) {}
async navigate() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.page.fill('[data-testid="email"]', email);
await this.page.fill('[data-testid="password"]', password);
await this.page.click('[data-testid="submit"]');
}
}
// specs/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
test.describe('Login', () => {
test('should login successfully', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.navigate();
await loginPage.login('user@exelearning.net', '1234');
await expect(page).toHaveURL(/\/workarea/);
});
});
Test Users¶
Default test users:
| User | Password | |
|---|---|---|
| User 1 | user@exelearning.net |
1234 |
| User 2 | user2@exelearning.net |
1234 |
Collaboration Tests¶
For real-time collaboration testing, use multiple browser contexts:
test('should sync changes between users', async ({ browser }) => {
const context1 = await browser.newContext();
const context2 = await browser.newContext();
const page1 = await context1.newPage();
const page2 = await context2.newPage();
// Login as different users
await loginAs(page1, 'user@exelearning.net', '1234');
await loginAs(page2, 'user2@exelearning.net', '1234');
// Open same project
await openProject(page1, projectId);
await openProject(page2, projectId);
// Make changes and verify sync
await page1.fill('.editor', 'Hello from User 1');
await expect(page2.locator('.editor')).toContainText('Hello from User 1');
});
Continuous Integration¶
All tests run in GitHub Actions on every pull request:
- Unit tests - Must pass with 90%+ coverage
- Integration tests - Must pass
- Frontend tests - Must pass
- E2E tests - Must pass (with retry on flaky tests)
Failing tests block merges to keep the project stable.
Best Practices¶
General¶
- One assertion per test when possible - makes failures easier to diagnose
- Use descriptive test names -
it('should return 404 when project not found') - Keep tests independent - no shared state between tests
- Clean up after tests - use
afterEachto reset state
Unit Tests¶
- Use Dependency Injection - never
mock.module() - Always call
resetDependencies()inafterEach - Test edge cases - null, undefined, empty arrays, errors
- Mock external dependencies - database, file system, network
Integration Tests¶
- Use real database - in-memory SQLite for speed
- Test the full request/response cycle
- Include error scenarios - 400, 401, 404, 500
E2E Tests¶
- Use Page Objects - encapsulate UI interactions
- Avoid
sleep()- use Playwright's auto-waiting - Take screenshots on failure - for debugging
- Retry flaky tests - with
test.retry(2)
Coverage¶
- Aim for 90%+ coverage on all new code
- Don't test trivial code - getters, setters, type definitions
- Focus on business logic - services, utils, complex routes
- Test error paths - not just happy paths
Quick Reference¶
# All tests
make test
# Unit tests (src/**/*.spec.ts)
make test-unit
# Integration tests (test/integration/)
make test-integration
# Frontend tests (Vitest)
make test-frontend
# E2E tests (Playwright)
make test-e2e
make test-e2e-ui # With UI
# Coverage report
make test-coverage
# Single file
DB_PATH=:memory: bun test src/path/to/file.spec.ts
See Also¶
- Architecture Overview - System architecture
- Development Environment - Setup guide
- Real-Time Collaboration - WebSocket and Yjs