Last active
May 19, 2025 18:28
-
-
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/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
}; | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | |
}); | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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