npm package: @open-draft/test-server
Description: HTTP/HTTPS testing server for your tests.
Author: Artem Zakharchenko
Latest version: 0.7.2

# Test Server

Spawns HTTP/HTTPS/WS/WSS local servers for testing.

## Motivation

I happened to do a lot of network testing. In my test suites, I often need to perform requests against actual HTTP/WebSocket servers, but need the flexibility to model whatever scenarios I want, including on a per-test basis. Traditional server packages likes `express` or `fastify` are great but too verbose for this particular use case.

So I've built this utility. I've been using this library for years to help me perfect the test suite for [Mock Service Worker](https://mswjs.io). But why build it at all?

- **Compact**. I can spawn actual servers in tests while keeping the test setup to the minimum.
- **Practical**. I can _configure_ test servers first, _spawn_ later. This adds a ton of reusability while also keeping my tests clean. I can also use the test servers as _disposables_, which means they will automatically clean after themselves even if I establish a server within a particular test.
- **Universal**. This library gives you the means to create test HTTP and WebSocket servers to test the network to your heart's content.

> It's also a bit silly that in 2024 some server libraries don't return a connection promise and don't give you any adequate means to get the server's actual URL. Those two are quite essential when testing the network.

This library is built on top of [Hono](https://hono.dev/), and you should definitely check it out! For once, it's an incredible environment-agnostic server library. But also, because you can attach existing Hono middleware to your test serves to send them to the moon.

## Install

```bash
npm install @open-draft/test-server
```

## Usage

### HTTP server

```js
import { createTestHttpServer } from '@open-draft/test-server/http'

const server = createTestHttpServer({
  defineRoutes(router) {
    router.get('/numbers', () => {
      return Response.json([1, 2, 3])
    })
  },
})

beforeAll(async () => {
  await server.listen()
})

afterAll(async () => {
  await server.close()
})

test('fetches all the numbers', async () => {
  const response = await fetch(server.http.url('/numbers'))
  expect(response.status).toBe(200)
  await expect(response.json()).resolves.toEqual([1, 2, 3])
})
```

### WebSocket server

```js
import { createTestHttpServer } from '@open-draft/test-server/http'
import { createTestWebSocketServer } from '@open-draft/test-server/ws'

const server = createTestHttpServer()
const wss = createTestWebSocketServer({
  // Attach the test WebSocket server to an existing HTTP server.
  server,
  pathname: '/ws',
})

// Running the HTTP server will establish the WebSocket middleware as well.
await server.listen()

console.log('WebSocket server is running at:', wss.ws.url())

wss.ws.on('connect', (socket) => {
  console.log('new connection:', socket.id)
})
```

### As disposable utilities

Both the `createTestHttpServer()` and `createTestWebSocketServer()` utilities are [disposable](https://tc39.es/proposal-explicit-resource-management/), which means they can be created within function closures (e.g. your test cases) and will be automatically cleaned up once that closure is garbage-collected.

```js
// my.test.js
import { createTestHttpServer } from '@open-draft/test-server/http'

it('fetches a resource by id', async () => {
  await using server = createTestHttpServer({
    defineRoutes(router) {
      router.get('/resource/:id', () => {
        return Response.json({ a: 1 })
      })
    }
  })

  await fetch(server.http.url('/resource/abc-123'))
})
```

> In the test case above, the test HTTP server will automatically be closed once the test case is done!

The "rooms" you can create in the test HTTP server are also disposable, which allows you to append additional behaviors to an already existing test server instance.

```js
const server = createTestHttpServer()

beforeAll(async () => await server.listen())
afterAll(async () => await server.listen())

it('handles errors when fetching a resource', async () => {
  await using room = server.http.createRoom({
    defineRoutes(router) {
      router.get('/resource', () => {
        return new Response(null, { status: 404 })
      })
    }
  })

  await fetch(room.url('/resource'))
})
```

> Note that the request references the `/resource` pathname nested under the room (`room.url()`).

## API

### `createTestHttpServer([options])`

Creates a test HTTP server instance. The server instance is _not running_ until you call `server.listen()`

- `options` (optional)
  - `protocols`, (optional) `Array<'http' | 'htts'`, the list of protocols to spawn corresponding test servers. For example, providing `['http', 'https']` will spawn _two_ test servers (HTTP and HTTPS) with the same route handlers.
  - `defineRoutes`, (optional) `(router: Hono) => void`, a function describing the initial route handlers.

```ts
const server = createTestHttpServer({
  protocols: ['http', 'https'],
  defineRoutes(router) {
    router.get('/user', () => {
      return Response.json(userFixture)
    })
  },
})
```

### `TestHttpServer`

#### `TestHttpServer.listen([options])`

- `options` (optional)
  - `hostname`, (optional) `string`, the exact hostname to use for this server.

#### `TestHttpServer.close()`

Closes the HTTP server, aborting any pending requests.

#### `TestHttpServer.http.url([pathname])`

- `pathname` (optional) `string`, a pathname to rebase against the server URL.

```ts
const server = createTestHttpServer()
await server.listen()

server.http.url() // "http://localhost:57834"
server.http.url('/resource') // "http://localhost:57834/resource"
```

#### `TestHttpServer.http.createRoom([options])`

Create a new room on this server. Room is a set of route handlers scoped under a random base path.

- `options` (optional)
  - `defineRoutes`, (optional) `(router: Hono) => void`, a function describing the routes for this room. Note that all room routes are scoped to the random room base path.

```ts
const server = createTestHttpServer()
await server.listen()

const room = server.http.creaetRoom({
  defineRouter(router) {
    router.get('/resource', () => new Response())
  },
})
room.url() // "http://localhost:57834/48e34ef7-13d0-4b6b-862f-4a29475c3396"

await fetch(room.url('/resource'))
```

> Creating rooms with their own route handlers allows you to reuse the same running server instance while imbuing it with different functionality on a per-test basis.

### `createTestWebSocketServer(options)`

- `options`
  - `server`, `TestHttpServer`, a reference to the server instance created by calling the `createTestHttpServer()` utility.
  - `pathname`, `string`, a pathname of the given HTTP `server` where the WebSocket connection must be established.

#### `TestWebSocketServer.ws.url()`

#### `TestWebSocketServer.wss.url()`

Returns an absolute URL to this WebSocket connection.

> Note that the `.url()` method on the WebSocket server does _not_ accept a `pathname`. The WebSocket protocol does not define the concept of sub-resources, so there is no point in referencing nested paths that point at nothing.

#### `TestWebSocketServer.on(type, listener)`

Adds an event listener to the underlying WebSocket connection.

```js
const wss = createTestWebSocketServer({
  server,
  pathname: '/ws',
})

wss.ws.on('connection', (socket) => {
  // Handle any incoming WebSocket client connections
  // to this test WebSocket server under HTTP.
})
```

#### `TestWebSocketServer.once(type, listener)`

Adds a one-time event listener to this WebSocket connection.

#### `TestWebSocketServer.off(type, listener)`

Removes the event listener for the given event type from this WebSocket connection.

#### `TestWebSocketServer.close()`

Closes the WebSocket server, closing any active clients.

> [!IMPORTANT]
> Note that closing the WebSocket server does _not_ close the parent HTTP server. Closing the parent HTTP server, however, will automatically close any attached WebSocket servers.