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.