Skip to content

Instantly share code, notes, and snippets.

@nickdirienzo
Last active May 19, 2025 18:28
Show Gist options
  • Save nickdirienzo/73d6cfb6caf29c56219340a7546ebd9c to your computer and use it in GitHub Desktop.
Save nickdirienzo/73d6cfb6caf29c56219340a7546ebd9c to your computer and use it in GitHub Desktop.
typed integration testing with ts-rest, supertest, and express: https://nickdirienzo.com/typed-integration-testing-with-ts-rest-supertest-and-express/
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
/** Internally create the Drizzle client. **/
const makeDB = (databaseUrl: string) => {
const db = postgres(databaseUrl);
const client = drizzle(db, { schema });
return { client, db };
};
/** Helper to create a Drizzle client given some database URL **/
export const createDatabaseClient = (dbUrl) => {
const { db, client } = makeDB(dbUrl);
return { db, client };
};
export type DBClient = ReturnType<typeof makeDB>["client"];
export type DBTransaction = Parameters<Parameters<typeof client.transaction>[0]>[0];
import express from "express";
import { DBClient } from "./database";
interface CreateAppParams {
db: { client: DBClient };
}
/**
* App factory function that creates an express app with the necessary middleware.
* Inspired by https://flask.palletsprojects.com/en/stable/patterns/appfactories/.
*/
export const createApp = ({ db }: CreateAppParams) => {
const app = express();
app.use(cors());
app.use(express.json());
app.use(attachDatabaseClient(db.client));
return { app };
}
import { DBClient } from "./database"
import { NextFunction, Request, Response } from "express";
/**
* Request type so our handlers can access the database
* client with `req.db.client.query...`
*/
namespace Express {
interface Request {
db: { client: DBClient; };
}
}
/**
* Middleware to take in the instantiated Drizzle client.
*/
export const attachDatabaseClient = (client: DBClient) => {
return (req: Request, _res: Response, next: NextFunction) => {
// Expects the AppContext to be attached to the request object before this middleware is called.
req.db = { client };
next();
};
};
import request from "supertest";
import { Express } from "express";
import{ ApiFetcher, ApiFetcherArgs, initClient } from "@ts-rest/core";
// Wherever your ts-rest contract is
import { apiContract } from "./contract";
/**
* Wrap an Express app in a ts-rest-compatible ApiFetcher.
*
* const api = createClient(contract, {
* baseUrl: 'http://local', // ignored
* api: supertestApiFetcher(app), // drop-in here
* });
*/
const supertestApiFetcher =
(app: Express): ApiFetcher =>
async ({
method,
path,
headers,
body,
fetchOptions,
}: ApiFetcherArgs): Promise<{
status: number;
body: unknown;
headers: Headers;
}> => {
// Create a supertest request based on the method.
const methodMap: Record<string, (path: string) => request.Request> = {
GET: (p) => request(app).get(p),
POST: (p) => request(app).post(p),
PUT: (p) => request(app).put(p),
PATCH: (p) => request(app).patch(p),
DELETE: (p) => request(app).delete(p),
HEAD: (p) => request(app).head(p),
OPTIONS: (p) => request(app).options(p),
};
const methodFn = methodMap[method.toUpperCase()];
if (!methodFn) {
throw new Error(`Unsupported HTTP method: ${method}`);
}
let testRequest = methodFn(path);
// Add headers to the request as needed.
for (const [k, v] of Object.entries(headers ?? {})) {
testRequest = testRequest.set(k, v);
}
// Signal / abort support.
if (fetchOptions?.signal) {
fetchOptions.signal.addEventListener("abort", () => {
testRequest.abort?.();
});
}
// Include the body in the request if it exists.
if (body !== null) testRequest = testRequest.send(body);
// Send the request and wait for the response.
const res = await testRequest;
// Supertest gives body parsed when JSON, otherwise .text
const responseBody = res.type?.includes("application/json") ? res.body : res.text;
return {
status: res.status,
body: responseBody,
headers: new Headers(Object.entries(res.headers).map(([k, v]) => [k, String(v)]) as [string, string][]),
};
};
export const createTestApiClient = (app: Express) => {
return initClient(apiContract, {
baseUrl: "", // Needs to be empty so ts-rest doesn't append it to the path
api: supertestApiFetcher(app),
});
};
import { DBClient, createDatabaseClient } from "./database";
import { createApp } from "./factory"
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
interface GetTestAppParams {
dbClient: DBClient;
}
export const getTestApp = ({ dbClient }: GetTestAppParams): Express => {
const { app } = createApp({
db: { client: dbClient }
});
setupTsRestRoutes(app);
return app;
};
export const initializeTestEnvironment = async () => {
const container = await new PostgreSqlContainer().start();
// Create our test database client and setup the test app.
const db = createDatabaseClient(container.getConnectionUri());
// There's some other code in here around
// log levels and running migrations too.
return { dbClient: db.client, container };
};
import assert from "node:assert";
import { before, after, afterEach, describe, it } from "node:test";
import { createTestApiClient } from "./tsRest";
import {
initializeTestEnvironment, // spins up the app + test DB container
truncateTables, // helper to DELETE FROM all tables
getTestApp, // returns an Express app wired to the test DB
} from "./utils";
import { createTestApiClient } from "./tsRest";
import { widgets } from "@/schema";
describe("@integration widgets.get", () => {
/** Holds DB client + container refs so we can tear everything down */
let testEnv: Awaited<ReturnType<typeof initializeTestEnvironment>>;
// Start the database container once per file
before(async () => {
testEnv = await initializeTestEnvironment();
});
// Stop the container when the suite finishes
after(async () => {
await testEnv.container.stop();
});
// Truncate every table after each test so we start fresh
afterEach(async () => {
await truncateTables(testEnv.dbClient);
});
it("returns the widget for the given id", async () => {
const [widget] = await testEnv.dbClient
.insert(widgets)
.values({ name: "Super Widget", description: "All-purpose widget" })
.returning(); // → [{ id, name, description }]
const client = createTestApiClient(getTestApp({ dbClient: testEnv.dbClient }));
const res = await client.widgets.get({
params: { widgetId: widget.id },
headers: {
Authorization: "Bearer fake-token"
}
})
assert.strictEqual(res.status, 200);
// Body is now typed to the contract
assert.deepStrictEqual(res.body.data, {
id: widget.id,
name: "Super Widget",
description: "All-purpose widget",
});
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment