Test, build, and ship
Test an HTTP server
Start a Bun server on a temporary port, make a real HTTP request, and stop it reliably after the test.
Unit tests cover functions like normalizeTitle(). But the thing clients talk to is the HTTP server, and an HTTP test should check what a client receives: the status code and the body.
The plan is to start a real server inside the test, send a real request with fetch(), and stop the server afterwards. For that we need two things: a function that creates the server, and a port that’s always free.
Make the server creatable
Right now index.ts starts the server as a side effect of being imported. A test can’t control that. Move server creation into server.ts:
export function createServer(port = 0) {
return Bun.serve({
port,
routes: {
'/health': Response.json({ ok: true }),
},
fetch() {
return new Response('Not found', { status: 404 })
},
})
}
Two things to notice. The /health route is a plain Response, not a function. Bun accepts static responses for routes that always return the same thing. And the default port is 0. That’s a special value: it asks the operating system for any free port. Every time the server starts it gets a fresh one, so two servers can never fight over 3000.
Use it from index.ts, where we still want the real port:
import { createServer } from './server'
const port = Number(Bun.env.PORT ?? 3000)
const server = createServer(port)
console.log(`Listening on ${server.url}`)
Write the test
Now create server.test.ts:
import { expect, test } from 'bun:test'
import { createServer } from './server'
test('reports that the server is healthy', async () => {
const server = createServer()
try {
const url = new URL('/health', server.url)
const response = await fetch(url)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ ok: true })
} finally {
server.stop(true)
}
})
Run bun test and both test files pass. The test uses the real HTTP stack, the same code path a browser or curl would hit. server.url tells us which port the OS picked, and new URL('/health', server.url) builds the full address.
Now imagine we had hard-coded port 3000 in the test. It passes on a clean machine. Then you run it while bun run dev is up, and it fails with an address-in-use error, or worse, it talks to the dev server and passes for the wrong reason. Port 0 removes that whole class of problems.
The finally block matters just as much. Bun stops the server even when an assertion fails. The true argument closes any open connections instead of waiting for them. Without cleanup, a failed test leaves a server listening, the test process may not exit, and the next test inherits the mess.
Use this pattern for a few important request paths, like health, listing notes, and creating a note. Don’t route every behavior through a network request. Keep validation and database logic in small functions you can test directly, and save the HTTP tests for checking that the pieces are wired together.
Lesson completed