Tutorial: Writing Unit Tests
This guide shows how to write and run unit tests for Open Saasframe using Jest + ts-jest. It covers services, API handlers, and utilities, and explains how to keep tests out of the runtime bundle.
Prerequisites
- Node.js
24.x - Jest + ts-jest already configured in this repo
- Aliases resolved in tests via
jest.config.cjs
Running Tests
- All tests:
yarn test - Watch mode:
yarn test:watch
Jest Aliases (in tests)
@/generated/*→generated/*@/lib/*→packages/shared/src/lib/*@/types/*→packages/shared/src/types/*@saasframe/core/*,@saasframe/cli/*,@saasframe/shared/*, and other workspace packages- Fallback:
@/*→apps/saasframe/src/*
See jest.config.cjs: moduleNameMapper for the full mapping.
Patterns
1) Services (no DB)
Keep service tests fast by stubbing the EM (or other gateways) with minimal methods.
// packages/core/src/modules/auth/services/__tests__/authService.test.ts
import { AuthService } from '@saasframe/core/modules/auth/services/authService'
describe('AuthService', () => {
it('hashes password on create', async () => {
const em: any = {
calls: [],
persist: (e: any) => {
em.calls.push(['persist', e])
return { flush: async () => em.calls.push(['flush']) }
},
}
const svc = new AuthService(em)
await svc.createUser({ email: 'a@b.c', password: 'secret' } as any)
expect(em.calls[0][0]).toBe('persist')
})
})
Tips:
- Stub only what you use; avoid spinning up real DB connections.
- Extract common stubs to
__tests__/helpersif reused.
2) API Handlers (mock DI)
Import handlers and call them directly. Mock the DI container so handlers get fake services.
// packages/core/src/modules/auth/api/__tests__/login.test.ts
import { POST } from '@saasframe/core/modules/auth/api/login'
jest.mock('@/lib/di/container', () => ({
createRequestContainer: async () => ({
resolve: () => ({
findUserByEmail: async () => ({ id: 1, email: 'admin@acme.com', passwordHash: 'hash', tenant: { id: 1 }, organization: { id: 1 } }),
verifyPassword: async () => true,
issueSession: async () => ({ token: 'jwt-token' }),
}),
}),
}))
function makeRequest(body: any) {
return new Request('http://localhost/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
}
describe('auth login', () => {
it('returns 200 for valid credentials', async () => {
const res = await POST(makeRequest({ email: 'admin@acme.com', password: 'secret' }))
expect(res.status).toBe(200)
})
})
Tips:
- Use
Requestwith JSON bodies to simulate HTTP. - Assert on status codes and minimal response body (not framework internals).
3) Utilities
Plain utilities are simple to test:
import { someUtil } from '@saasframe/shared/lib/utils'
test('someUtil', () => {
expect(someUtil('x')).toBe('X')
})
File Layout and Discovery
- Co-locate tests in
__tests__folders next to code. - Filenames:
*.test.tsor*.test.tsx - Jest
testMatch:**/__tests__/**/*.test.(ts|tsx)
Keeping Tests Out of Runtime
Our module generator explicitly ignores tests so dev/runtime doesn’t import them:
- Skips folders named
__tests__and__mocks__ - Skips files ending with
.test.ts,.spec.ts
If you introduce new patterns, ensure they don’t live under module api/ unless named with .test.ts.
Troubleshooting
- “jest is not defined” during
yarn dev: means a test leaked into the runtime. Ensure tests are under__tests__and the generator filters apply. - Module not found in tests: verify alias in
jest.config.cjsand the file path.
Integration Testing (Playwright)
Use integration tests for full user flows and API scenarios that require a running app and real HTTP interactions.
What You Need to Start
- Node.js
24.x - Dependencies installed:
yarn install - Playwright browser installed (first run):
npx playwright install chromium - Seeded local environment (recommended for stable credentials):
yarn initialize - Running app for local mode:
yarn dev(or setBASE_URLto an already running instance) - For ephemeral mode only: Docker runtime running (
docker infomust succeed)
Default test credentials come from initialization:
superadmin@acme.com/secretadmin@acme.com/secretemployee@acme.com/secret
How to Create Integration Tests
- Create a new file in
.ai/qa/tests/<category>/namedTC-<CATEGORY>-<NNN>.spec.ts. - Use built-in helpers from
.ai/qa/tests/helpers/:- UI auth helper:
helpers/auth.ts(login(page, 'admin')) - API helper:
helpers/api.ts
- UI auth helper:
- Prefer Playwright semantic locators:
getByRolegetByLabelgetByTextgetByPlaceholder
- Keep each test independent (login and setup inside the test or
beforeEach). - If a markdown scenario exists, keep it in
.ai/qa/scenarios/TC-*.mdand align steps with the spec.
Example skeleton:
import { test, expect } from '@playwright/test'
import { login } from '../helpers/auth'
test.describe('TC-ADMIN-999: Example flow', () => {
test.beforeEach(async ({ page }) => {
await login(page, 'admin')
})
test('completes the scenario', async ({ page }) => {
await page.goto('/backend')
await expect(page).toHaveURL(/\/backend/)
})
})
How to Run Integration Tests
- Run full suite (headless):
yarn test:integration - Run one folder/category:
npx playwright test --config .ai/qa/tests/playwright.config.ts admin/
- Run one test file:
npx playwright test --config .ai/qa/tests/playwright.config.ts admin/TC-ADMIN-002-api-key-revocation.spec.ts
- Run with ephemeral environment (auto bootstrapped app + infra):
yarn test:integration:ephemeral - Run with interactive ephemeral menu (persisted app + DB):
yarn test:integration:ephemeral:interactive - Open HTML report:
yarn test:integration:report
How to speed up ephemeral integration runs
yarn test:integration:ephemeral rebuilds and boots a fresh isolated environment on each run. That is reliable but slower for tight development loops.
- Start once in interactive mode and reuse:
yarn test:integration:ephemeral:interactive --no-screenshots
- Reuse the active URL from
.ai/qa/ephemeral-env.md(base_url) - Then run only the target test against the printed base URL:
BASE_URL=http://127.0.0.1:<port> npx playwright test --config .ai/qa/tests/playwright.config.ts sales/TC-SALES-007.spec.ts --retries=0
- Run in parallel locally when tests are independent:
BASE_URL=http://127.0.0.1:<port> npx playwright test --config .ai/qa/tests/playwright.config.ts --workers=4 --retries=0
- If you still use full ephemeral command, filter aggressively:
yarn test:integration:ephemeral -- --filter sales/TC-SALES-007.spec.ts --no-screenshots
Use full yarn test:integration:ephemeral for pre-merge validation and CI-like isolation.
For repeated local runs, prefer yarn test:integration:ephemeral:interactive to select tests from a menu and run them against one persisted ephemeral environment.
Notes
- Base URL defaults to
http://localhost:3000and can be overridden withBASE_URL. - Ephemeral integration mode defaults to port
5001when available and writes the active URL to.ai/qa/ephemeral-env.md. - Reports are generated under
.ai/qa/tests/test-results/. - Capture screenshots for each step by setting
PW_CAPTURE_SCREENSHOTS=1.