Testing
ExisJS provides an integrated testing suite built on top of the native Node.js Test Runner (node:test) and TypeScript execution environment (tsx). You can execute unit tests, mock dependencies, and perform full-pipeline HTTP integration testing with zero external test runners.
The Testing Module (exisjs/testing)
The exisjs/testing module re-exports test runner primitives, assertions, mocking helpers, and the HTTP testing harness:
| Export | Source / Role | Description |
|---|---|---|
describe, test, it | node:test | Test suite and test case definition functions with .only, .skip, .todo, and .each. |
before, after, beforeEach, afterEach | node:test | Lifecycle execution hooks. |
expect | ExisJS matcher engine | Jest-compatible fluent assertion matchers (toBe, toEqual, toMatchObject, toThrow, etc.). |
createTestContext(app) | ExisJS HTTP harness | Boots an Exis application instance, sets up lifecycle hooks, and returns a fluent TestApp client. |
createTestApp(app) | ExisJS HTTP harness | Wraps an existing App or ExisAppDefinition in a TestApp client without global test hooks. |
createMockRequest(opts) | ExisJS mock helper | Creates a synthetic ExisRequest for unit-testing controllers or middlewares. |
createMockResponse() | ExisJS mock helper | Creates a synthetic ExisResponse capturing status codes and output buffers. |
ex | ExisJS mocking suite | Mock functions (ex.fn), method spies (ex.spyOn), module mocks (ex.mock), and fake timers (ex.useFakeTimers). |
registerCleanup(fn) | ExisJS cleanup | Registers a teardown callback invoked after test context completion. |
Integration Testing with createTestContext
createTestContext initializes your application definition, executes onStart hooks, establishes database connections (Mongoose, Prisma, or DatabaseManager), and tears down connections gracefully in afterAll.
Tests always load routes from your source files, never from a previous exisjs build. exisjs test reads .env.test on top of .env, so point it at a separate database. A test file that crashes while loading is reported as failed, and a run that finds no test files fails (set EXIS_PASS_WITH_NO_TESTS=1 to allow it).
Functional Paradigm Application Test
Given a functional application defined in src/http/server.ts:
import { describe, it, expect, createTestContext } from 'exisjs/testing'import app from '../src/http/server'
describe('Users API Integration', () => { // Automatically boots the app and manages database lifecycle const api = createTestContext(app)
it('should create a new user record', async () => { const res = await api .post('/users') .set('authorization', 'Bearer test-token') .send({ name: 'Jane Doe', email: '[email protected]' }) .expect(201)
expect(res.body).toMatchObject({ success: true, data: { id: expect.any(String), name: 'Jane Doe', email: '[email protected]' } }) })
it('should return 400 when payload validation fails', async () => { const res = await api .post('/users') .send({ name: 'Jane Doe' }) // missing email
expect(res.status).toBe(400) expect(res.body.success).toBe(false) expect(res.body.error).toBe('Validation failed') })})Class-Based (OOP) Application Test
createTestContext natively supports @Server decorated entry classes:
import { describe, it, expect, createTestContext } from 'exisjs/testing'import ServerApp from '../src/http/server'
describe('OOP Server API Integration', () => { const api = createTestContext(ServerApp)
it('should retrieve status check', async () => { const res = await api.get('/health').expect(200)
expect(res.body).toEqual({ status: 'ok', uptime: expect.any(Number) }) })})Fluid Request Builder (TestRequest)
The client returned by createTestContext and createTestApp provides request builder methods for standard HTTP verbs:
const res = await api .get(path) // .post(path), .put(path), .patch(path), .delete(path), .head(path), .options(path) .set('x-api-key', 'secret-key') .send({ key: 'value' }) .expect(200) .expect('content-type', /json/) .execute()Two more builder methods:
.query({ page: 2, tags: ['a', 'b'] })appends a query string..timeout(ms)fails the request aftermsmilliseconds (default 5000, orEXIS_TEST_TIMEOUT;0waits forever). A handler that never answers fails its own test with the route in the message instead of hanging the whole run.
Response Object Properties
The returned TestResponse contains:
export interface TestResponse { status: number // HTTP response status code (e.g. 200, 404) headers: http.IncomingHttpHeaders // Normalized response headers object body: any // Parsed JSON payload or raw buffer/string text: string // Raw response string representation}Testing Over Real Sockets (startTestServer)
In-memory requests cover most tests. For what only a real connection shows (CORS and security headers as a browser sees them, keep-alive, malformed HTTP), start the app on a free port:
import { describe, it, expect, startTestServer } from 'exisjs/testing'import app from '../src/http/server'
describe('headers', () => { it('sends a Content-Security-Policy', async () => { const server = await startTestServer(app) try { const res = await server.request('/health') expect(res.headers.get('content-security-policy')).toBeDefined() } finally { await server.close() } })})exisjs test runs your source files. To test the production build itself, run exisjs build, then start it with exisjs start (which always sets NODE_ENV=production) and send requests to it.
Cron Jobs in Tests
Under NODE_ENV=test (what exisjs test sets) cron jobs and timeout tasks are registered but never started, so a test run cannot fire a real job. Run one on demand, or opt in for a test file:
await app.cron.trigger('daily-cleanup') // runs it once, now
const api = createTestContext(app, { cron: true }) // schedules jobs as in productionUnit Testing Controllers & Middlewares
For fast unit tests that do not require booting a full HTTP server pipeline, use createMockRequest and createMockResponse:
import { describe, it, expect } from 'exisjs/testing'import { createMockRequest, createMockResponse, createMockNext } from 'exisjs/testing'import { authGuard } from '../../src/common/guards/auth'
describe('Auth Guard Unit Test', () => { it('should reject requests missing Authorization header', async () => { const req = createMockRequest({ method: 'GET', url: '/protected', headers: {} }) const res = createMockResponse() const next = createMockNext()
await authGuard(req, res, next)
expect(res._statusCode).toBe(401) expect(next.called).toBe(false) })
it('should proceed when Authorization header is present', async () => { const req = createMockRequest({ method: 'GET', url: '/protected', headers: { authorization: 'Bearer valid-jwt-token' } }) const res = createMockResponse() const next = createMockNext()
await authGuard(req, res, next)
expect(next.called).toBe(true) })})Mocking & Spies (ex)
ExisJS includes a built-in mocking engine via ex:
import { describe, it, expect, ex } from 'exisjs/testing'import { PaymentService } from '../src/http/billing/service'
describe('PaymentService Unit Test', () => { it('should record transactions through payment gateway', async () => { const mockGateway = { charge: ex.fn().mockResolvedValue({ id: 'tx_123', status: 'paid' }) }
const service = new PaymentService(mockGateway as any) const result = await service.processOrder('order_456', 5000)
expect(mockGateway.charge).toHaveBeenCalledTimes(1) expect(mockGateway.charge).toHaveBeenCalledWith(5000) expect(result.id).toBe('tx_123') })})Fake Timers
import { describe, it, expect, ex } from 'exisjs/testing'
describe('Timer operations', () => { it('advances timer controlled tasks', () => { ex.useFakeTimers() const spy = ex.fn()
setTimeout(spy, 10_000)
ex.advanceTimersByTime(10_000) expect(spy).toHaveBeenCalledTimes(1)
ex.useRealTimers() })})Running Tests via CLI
Run your test suites with the built-in exisjs test command:
# Run all test files matching tests/**/*.test.ts or tests/**/*.spec.tsnpx exisjs test
# Run a specific test filenpx exisjs test tests/users.test.ts
# Run tests in watch modenpx exisjs test --watch
# Run with test coveragenpx exisjs test --coveragepackage.json Configuration
Add the test command to your scripts:
{ "scripts": { "test": "exisjs test", "test:watch": "exisjs test --watch" }}Test Configuration Options (exis.config.ts)
You can customize test runner behaviors in exis.config.ts:
import { defineConfig } from 'exisjs/config'
export default defineConfig({ test: { include: ['tests/**/*.test.ts', 'src/**/*.spec.ts'], concurrency: true, coverage: false, setupFiles: ['./tests/setup.ts'] }})