Testing
Jetpath ships with a built-in JetServer class designed for testing your routes, middleware, and validation without starting a real HTTP server. Tests run with Bun's test runner (bun:test).
Setup
Install Bun if you haven't already, then run tests from your project root:
bun test # run all tests
bun test test/ # run tests in a specific directory
bun test --watch # re-run on file changes
Test files follow the naming convention *.test.ts and live in a test/ directory.
JetServer — The Test Harness
JetServer lets you execute route handlers in isolation. No port binding, no network — just your handler logic and a result object.
import { describe, test, expect, beforeEach } from 'bun:test';
import { JetServer } from 'jetpath';
import type { JetRoute } from 'jetpath';
describe('My API', () => {
let jetServer: JetServer;
beforeEach(() => {
jetServer = new JetServer();
});
test('GET /health returns 200', async () => {
const GET_health: JetRoute = function (ctx) {
ctx.send({ status: 'ok' });
};
GET_health.method = 'GET';
GET_health.path = '/health';
const result = await jetServer.runBare(GET_health);
expect(result.code).toBe(200);
expect(result.body.status).toBe('ok');
});
});
Every result returned by JetServer has this shape:
{
code: number; // HTTP status code
body: any; // parsed response body (JSON auto-parsed)
headers: Record<string, string>; // response headers
}
Two Ways to Run Routes
runBare(route) — Quick and Simple
Creates a minimal mock context internally. Use this when your handler doesn't need a real Request object (no body parsing, no custom headers).
const GET_users: JetRoute = function (ctx) {
ctx.send({ users: [{ id: 1, name: 'Alice' }] });
};
GET_users.method = 'GET';
GET_users.path = '/users';
const result = await jetServer.runBare(GET_users);
expect(result.code).toBe(200);
expect(result.body.users).toHaveLength(1);
runWithCTX(route, ctx) — Full Control
Use createCTX() to build a context with a real Request, path params, and headers. Required when testing body parsing, query strings, cookies, or auth headers.
const POST_users: JetRoute = async function (ctx) {
const body = await ctx.parse();
ctx.send({ name: body.name, created: true }, 201);
};
POST_users.method = 'POST';
POST_users.path = '/users';
const req = new Request('http://localhost/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice', email: '[email protected]' }),
});
const ctx = jetServer.createCTX(req, new Response(), '/users', POST_users, {});
const result = await jetServer.runWithCTX(POST_users, ctx);
expect(result.code).toBe(201);
expect(result.body.name).toBe('Alice');
createCTX() Signature
jetServer.createCTX(
req: Request, // the incoming request
res: Response, // pass `new Response()` (placeholder for non-Node runtimes)
path: string, // the matched route path
handler: JetRoute, // the route handler
params: Record<string, any> // path parameters (e.g. { id: "123" })
): JetContext
Testing Path Parameters
const GET_users_$id: JetRoute = function (ctx) {
ctx.send({ userId: ctx.params.id });
};
GET_users_$id.method = 'GET';
GET_users_$id.path = '/users/:id';
const req = new Request('http://localhost/users/42');
const ctx = jetServer.createCTX(
req,
new Response(),
'/users/42',
GET_users_$id,
{ id: '42' }
);
const result = await jetServer.runWithCTX(GET_users_$id, ctx);
expect(result.body.userId).toBe('42');
Testing Status Codes and Content Types
test('custom status code', async () => {
const route: JetRoute = function (ctx) {
ctx.send({ brewed: true }, 418); // I'm a teapot
};
route.method = 'GET';
route.path = '/teapot';
const result = await jetServer.runBare(route);
expect(result.code).toBe(418);
});
test('redirect sets Location header', async () => {
const route: JetRoute = function (ctx) {
ctx.redirect('/new-location');
};
route.method = 'GET';
route.path = '/old';
const result = await jetServer.runBare(route);
expect(result.code).toBe(301);
expect(result.headers['Location']).toBe('/new-location');
});
test('HTML content type', async () => {
const route: JetRoute = function (ctx) {
ctx.send('<h1>Hello</h1>', 200, 'text/html');
};
route.method = 'GET';
route.path = '/page';
const result = await jetServer.runBare(route);
expect(result.headers['Content-Type']).toBe('text/html');
expect(typeof result.body).toBe('string');
});
Testing Middleware
Attach middleware to a route via the jet_middleware array. Middleware runs before the handler and can inject state, short-circuit the request, or handle errors via a returned callback.
Middleware That Injects State
import type { JetMiddleware } from 'jetpath';
const authMiddleware: JetMiddleware = function (ctx) {
ctx.state.user = { id: 'user-123', role: 'admin' };
};
const GET_profile: JetRoute = function (ctx) {
ctx.send({ user: ctx.state.user });
};
GET_profile.method = 'GET';
GET_profile.path = '/profile';
GET_profile.jet_middleware = [authMiddleware];
const result = await jetServer.runBare(GET_profile);
expect(result.body.user.id).toBe('user-123');
Middleware That Blocks Requests
const authGuard: JetMiddleware = function (ctx) {
const token = ctx.get('authorization');
if (!token || !token.startsWith('Bearer ')) {
ctx.send({ error: 'Unauthorized' }, 401);
return; // short-circuit — handler never runs
}
ctx.state.user = { id: 'user-123' };
};
const route: JetRoute = function (ctx) {
ctx.send({ message: 'Protected data' });
};
route.method = 'GET';
route.path = '/protected';
route.jet_middleware = [authGuard];
// Without auth header → blocked
const result = await jetServer.runBare(route);
expect(result.code).toBe(401);
// With auth header → allowed
const req = new Request('http://localhost/protected', {
headers: { Authorization: 'Bearer my-token' },
});
const ctx = jetServer.createCTX(req, new Response(), '/protected', route, {});
const authed = await jetServer.runWithCTX(route, ctx);
expect(authed.code).toBe(200);
Middleware Execution Order
Middleware runs in array order. Post-handler callbacks (returned functions) run in reverse order. This gives you a clean "onion" pattern:
import { describe, test, expect, beforeEach } from 'bun:test';
import { JetServer } from 'jetpath';
import type { JetRoute } from 'jetpath';
describe('My API', () => {
let jetServer: JetServer;
beforeEach(() => {
jetServer = new JetServer();
});
test('GET /health returns 200', async () => {
const GET_health: JetRoute = function (ctx) {
ctx.send({ status: 'ok' });
};
GET_health.method = 'GET';
GET_health.path = '/health';
const result = await jetServer.runBare(GET_health);
expect(result.code).toBe(200);
expect(result.body.status).toBe('ok');
});
});
```0
### Error Handling Middleware
The returned callback receives a second `error` argument when the handler throws:
```typescript
import { describe, test, expect, beforeEach } from 'bun:test';
import { JetServer } from 'jetpath';
import type { JetRoute } from 'jetpath';
describe('My API', () => {
let jetServer: JetServer;
beforeEach(() => {
jetServer = new JetServer();
});
test('GET /health returns 200', async () => {
const GET_health: JetRoute = function (ctx) {
ctx.send({ status: 'ok' });
};
GET_health.method = 'GET';
GET_health.path = '/health';
const result = await jetServer.runBare(GET_health);
expect(result.code).toBe(200);
expect(result.body.status).toBe('ok');
});
});
```1
## Testing Validation
### Body Validation with `use()`
Attach schemas via `use()`, then test that `ctx.parse()` enforces them:
```typescript
import { describe, test, expect, beforeEach } from 'bun:test';
import { JetServer } from 'jetpath';
import type { JetRoute } from 'jetpath';
describe('My API', () => {
let jetServer: JetServer;
beforeEach(() => {
jetServer = new JetServer();
});
test('GET /health returns 200', async () => {
const GET_health: JetRoute = function (ctx) {
ctx.send({ status: 'ok' });
};
GET_health.method = 'GET';
GET_health.path = '/health';
const result = await jetServer.runBare(GET_health);
expect(result.code).toBe(200);
expect(result.body.status).toBe('ok');
});
});
```2
### Response Validation
Response schemas are checked when `ctx.send()` is called with an object:
```typescript
import { describe, test, expect, beforeEach } from 'bun:test';
import { JetServer } from 'jetpath';
import type { JetRoute } from 'jetpath';
describe('My API', () => {
let jetServer: JetServer;
beforeEach(() => {
jetServer = new JetServer();
});
test('GET /health returns 200', async () => {
const GET_health: JetRoute = function (ctx) {
ctx.send({ status: 'ok' });
};
GET_health.method = 'GET';
GET_health.path = '/health';
const result = await jetServer.runBare(GET_health);
expect(result.code).toBe(200);
expect(result.body.status).toBe('ok');
});
});
```3
If the response doesn't match the schema, the handler throws and returns a 500:
```typescript
import { describe, test, expect, beforeEach } from 'bun:test';
import { JetServer } from 'jetpath';
import type { JetRoute } from 'jetpath';
describe('My API', () => {
let jetServer: JetServer;
beforeEach(() => {
jetServer = new JetServer();
});
test('GET /health returns 200', async () => {
const GET_health: JetRoute = function (ctx) {
ctx.send({ status: 'ok' });
};
GET_health.method = 'GET';
GET_health.path = '/health';
const result = await jetServer.runBare(GET_health);
expect(result.code).toBe(200);
expect(result.body.status).toBe('ok');
});
});
```4
### Query Validation
```typescript
import { describe, test, expect, beforeEach } from 'bun:test';
import { JetServer } from 'jetpath';
import type { JetRoute } from 'jetpath';
describe('My API', () => {
let jetServer: JetServer;
beforeEach(() => {
jetServer = new JetServer();
});
test('GET /health returns 200', async () => {
const GET_health: JetRoute = function (ctx) {
ctx.send({ status: 'ok' });
};
GET_health.method = 'GET';
GET_health.path = '/health';
const result = await jetServer.runBare(GET_health);
expect(result.code).toBe(200);
expect(result.body.status).toBe('ok');
});
});
```5
### Testing the Validator Directly
You can also test the `validator()` function in isolation for unit-level schema checks:
```typescript
import { describe, test, expect, beforeEach } from 'bun:test';
import { JetServer } from 'jetpath';
import type { JetRoute } from 'jetpath';
describe('My API', () => {
let jetServer: JetServer;
beforeEach(() => {
jetServer = new JetServer();
});
test('GET /health returns 200', async () => {
const GET_health: JetRoute = function (ctx) {
ctx.send({ status: 'ok' });
};
GET_health.method = 'GET';
GET_health.path = '/health';
const result = await jetServer.runBare(GET_health);
expect(result.code).toBe(200);
expect(result.body.status).toBe('ok');
});
});
```6
## Testing Cookies
```typescript
import { describe, test, expect, beforeEach } from 'bun:test';
import { JetServer } from 'jetpath';
import type { JetRoute } from 'jetpath';
describe('My API', () => {
let jetServer: JetServer;
beforeEach(() => {
jetServer = new JetServer();
});
test('GET /health returns 200', async () => {
const GET_health: JetRoute = function (ctx) {
ctx.send({ status: 'ok' });
};
GET_health.method = 'GET';
GET_health.path = '/health';
const result = await jetServer.runBare(GET_health);
expect(result.code).toBe(200);
expect(result.body.status).toBe('ok');
});
});
```7
## Testing the Trie Router
You can test route matching directly against the `Trie` data structure:
```typescript
import { describe, test, expect, beforeEach } from 'bun:test';
import { JetServer } from 'jetpath';
import type { JetRoute } from 'jetpath';
describe('My API', () => {
let jetServer: JetServer;
beforeEach(() => {
jetServer = new JetServer();
});
test('GET /health returns 200', async () => {
const GET_health: JetRoute = function (ctx) {
ctx.send({ status: 'ok' });
};
GET_health.method = 'GET';
GET_health.path = '/health';
const result = await jetServer.runBare(GET_health);
expect(result.code).toBe(200);
expect(result.body.status).toBe('ok');
});
});
```8
## Testing CORS Configuration
```typescript
import { describe, test, expect, beforeEach } from 'bun:test';
import { JetServer } from 'jetpath';
import type { JetRoute } from 'jetpath';
describe('My API', () => {
let jetServer: JetServer;
beforeEach(() => {
jetServer = new JetServer();
});
test('GET /health returns 200', async () => {
const GET_health: JetRoute = function (ctx) {
ctx.send({ status: 'ok' });
};
GET_health.method = 'GET';
GET_health.path = '/health';
const result = await jetServer.runBare(GET_health);
expect(result.code).toBe(200);
expect(result.body.status).toBe('ok');
});
});
```9
## Testing Error Scenarios
```typescript
{
code: number; // HTTP status code
body: any; // parsed response body (JSON auto-parsed)
headers: Record<string, string>; // response headers
}
```0
## Schema Builder Reference for Tests
The `v` builder (from `use()`) supports these types:
| Builder | Methods |
| ---------------------- | --------------------------------------------------------------------------------------------- |
| `v.string()` | `.required()`, `.min(n)`, `.max(n)`, `.email()`, `.url()`, `.regex(pattern)`, `.default(val)` |
| `v.number()` | `.required()`, `.min(n)`, `.max(n)`, `.integer()`, `.positive()`, `.negative()` |
| `v.boolean()` | `.required()` |
| `v.array(itemSchema?)` | `.required()`, `.min(n)`, `.max(n)`, `.nonempty()` |
| `v.object(shape?)` | `.required()`, `.shape({...})` |
| `v.date()` | `.required()`, `.min(date)`, `.max(date)`, `.future()`, `.past()` |
| `v.file()` | `.required()`, `.maxSize(bytes)`, `.mimeType(types)` |
All builders also support `.optional()`, `.default(value)`, `.validate(fn)`, and `.regex(pattern)`.
## Tips
- Use `runBare()` for simple handlers that don't need request parsing — it's faster and less boilerplate.
- Use `runWithCTX()` when you need to test body parsing, headers, cookies, or query strings.
- Middleware is attached directly to the route's `jet_middleware` array in tests — no file-system scanning needed.
- The `use()` API works the same in tests as in production code. Attach schemas before running the route.
- Tests run against the real framework internals (context pooling, validation, CORS) — not mocks. What passes in tests will behave the same in production.
- Run `bun test --watch` during development for instant feedback.