Database Access & Integration Testing
Learning Objectives
- You can execute parameterized queries and interpret returned rows.
- You can add a migration for a new practice resource and distinguish its schema from test fixtures.
- You can establish controlled PostgreSQL fixtures and close test resources.
- You can distinguish database integration evidence from pure or browser tests.
The existing todo routes use the shared client in api/src/database.ts.
The room routes still use their in-memory array; this chapter moves the
room reads to PostgreSQL.
One application pool, separate test data
The shared sql client manages a pool of connections. Query functions reuse it;
they do not create or close a pool per request. The listener owns shutdown in the
running application. A database test closes its client after all related steps.
When running tests, the DATABASE_URL environment variable points to a separate test database. The test process imports the same application code as development, but the shared client connects to a different database.
Figure 1 shows the configuration boundary. Development and tests use the same application and repository code, but run in separate processes with separately configured clients and databases. Test fixtures still need cleanup within the shared test database.
Set DATABASE_URL before starting the test process. Modules can create their
shared client when they are first imported; changing the environment afterward
does not reconfigure an existing client.
Configure before importing
0 / 5 points
A test imports app.ts, then changes DATABASE_URL to its test URL. The repository imported by app.ts already created its client. What repairs the setup?
Keep data values out of SQL syntax
A query with a value parameter has this form:
const rows = await sql` SELECT id, name, capacity FROM lab_rooms WHERE capacity >= ${minimumCapacity} ORDER BY id LIMIT 100`;The syntax sql`...` is a tagged template literal. The embedded minimumCapacity is a value parameter. The database receives the query text and the parameter value separately, so it cannot be interpreted as SQL syntax. This prevents SQL injection attacks.
Do not first build a string using user input and then pass that string to an unsafe execution method.
Keep a value separate from SQL
0 / 5 points
The supplied name is Reader's lamp. Which expression uses postgres.js to search for that complete value without turning it into SQL syntax?
Use the isolated Compose test database
Use the two-file Compose test setup for running tests. Run tests with compose.yaml
and compose.test.yaml under a distinct project name such as wsd-practice-test.
The practice application’s development and test projects both use the service
hostname database and database name walking_skeleton. Their separate Compose
networks and the test project’s temporary database storage keep the data separate.
Keep the test override’s network, port, and storage settings. When migrations
change, remove the disposable test project so the next test run recreates it and
runs the migrations again. From practice/:
docker compose -p wsd-practice-test -f compose.yaml -f compose.test.yaml downUse the same project name and both Compose files for the reset and the test run. This keeps the reset scoped to the test project. Tests still need their own fixture setup and cleanup: a separate database protects development data, but does not prevent tests from affecting each other’s records. Run database and browser suites sequentially when they share this test project.
Locate the isolation mistake
0 / 5 points
A student runs development and tests under the same Compose project name, using only compose.yaml. Both URLs contain database:5432/walking_skeleton. Here, compose.test.yaml overrides database storage with disposable temporary storage and removes published API/client ports. Compose project names isolate their containers and networks. Which change separates test state from development state?
Add a room table to the practice application
Keep the existing 001_create_todos.sql. Add database-migrations/002_rooms.sql
in the practice application, using the next unused number if you already added a
practice migration:
-- migrate:upCREATE TABLE lab_rooms ( id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name TEXT NOT NULL, capacity INTEGER NOT NULL CHECK (capacity BETWEEN 1 AND 500));
-- migrate:downDROP TABLE lab_rooms;Run the migration through the practice application’s existing Compose migration
service in development. The test project applies the same migrations to its own
database. The migration establishes the schema; each test supplies its own records. With the development database running, apply it from practice/:
docker compose run --rm database-migrationsPut room queries in a repository
Create api/src/repositories/room-repository.ts. This is the same persistence
responsibility as the existing todo repository, now structured in a folder called repositories.
The module imports the shared client; individual query functions do not create or close pools. A simple read route may call a repository directly. We introduce a service when there is a business decision to express:
import { sql } from "../database.ts";
export type Room = { id: number; name: string; capacity: number };
export const insertRoom = async (name: string, capacity: number) => { const [room] = await sql<Room[]>` INSERT INTO lab_rooms (name, capacity) VALUES (${name}, ${capacity}) RETURNING id, name, capacity `;
return room;};
export const roomsForGroup = async (minimumCapacity: number) => { return await sql<Room[]>` SELECT id, name, capacity FROM lab_rooms WHERE capacity >= ${minimumCapacity} ORDER BY id LIMIT 100 `;};
export const findAll = () => roomsForGroup(1);
export const findById = async (id: number): Promise<Room | null> => { const rows = await sql<Room[]>` SELECT id, name, capacity FROM lab_rooms WHERE id = ${id} `;
return rows[0] ?? null;};These are ordinary exported functions. They use the same shared client as the existing todo repository. The result type describes the selected columns to TypeScript; the database still enforces its own constraints.
An awaited query returns an array of rows, even when it selects one ID. const [room] takes the first returned row; this insert returns one because RETURNING follows one successful insertion. A lookup can return no rows, so findById converts rows[0] from undefined to the service’s explicit null result. A collection query returns an empty array when nothing matches.
sql<Room[]>describes selected columns to the type checker; it does not convert or validate database values at runtime.INTEGERcolumns arrive as JavaScript numbers.
Query real equipment stock
0 / 15 points
Files in the editor
Edit api/src/repositories/equipment-repository.ts in the editor below.
This exercise provides one editable file.
The imported @src/database.ts module and PostgreSQL database are provided
by the grader when you submit. They do not appear in the editor, and you do not
need to create them or add migrations for this submission.
Task
Complete api/src/repositories/equipment-repository.ts. Import sql from @src/database.ts. This is the shared postgres.js tagged-template client; @src/ maps to api/src/.
The grader supplies that database module and an isolated lab_equipment table
with the following schema:
CREATE TABLE lab_equipment ( id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, stock INTEGER NOT NULL CHECK (stock >= 0));This schema is provided for reference; the test setup creates the table for you.
PostgreSQL generates id when you insert a row, so supply only code, name,
and stock in the insert.
Requirements
Export insertEquipment({ code, name, stock }), returning the inserted
{ id, code, name, stock }, and equipmentForQuantity(quantity), returning
at most 100 rows whose stock is at least the requested quantity, ordered by code
ascending. Inputs are already validated. Use parameterized value queries, keep
quoted or SQL-looking names as data, and let database errors reject the call.
Do not create or close a database client in the repository. Its owner handles
shutdown after all requests or test steps finish.
Check and submit
Choose Submit solution below the editor to run the grading checks and read
their feedback. The editor submits api/src/repositories/equipment-repository.ts.
This editor has no Run control.
The checks award 5 points for insert values, 5 for filtering/order, 2 for database constraint behavior, and 3 for the collection bound and shared-client lifetime. Cases include a quote in a name, duplicate code rejection, the exact stock boundary, and an empty result.
Use the repository in the existing room application
In api/src/routes/room-routes.ts, remove the fixed rooms array and import the
repository:
import * as roomRepository from "@src/repositories/room-repository.ts";Replace the two route handlers with the database-backed versions. Keep the
exported app, logging middleware, and unknown-route handler. Also keep schema imports and validateRoomId middleware:
app.get("/api/rooms", async (c) => { const rooms = await roomRepository.findAll(); return c.json(rooms);});
app.get("/api/rooms/:id", validateRoomId, async (c) => { const { id } = c.req.valid("param"); const room = await roomRepository.findById(id); if (!room) { return c.json({ error: { code: "NOT_FOUND", message: "Room not found" } }, 404); }
return c.json(room);});The request paths and JSON fields stay the same; the records now come from
PostgreSQL. To expose these routes in the running practice API, import the
example as roomsApp in its main api/src/app.ts and mount it once with
app.route('/', roomsApp).
Keep the existing mount if you already added it in Chapter 3.2; do not register it twice.
The room router already includes /api, so its collection
is available at /api/rooms both here and in direct application tests.
The separate example instance is also what the following test imports.
Test the application against persisted rows
A fixture is the starting state prepared for a test. Here, it is one inserted room; the migration has already created the table. The test uses that room to check that a request reads the persisted values.
Replace api/tests/room-routes_test.ts with this focused database test:
import { assertEquals } from "@std/assert";import { app } from "@src/routes/room-routes.ts";import { sql } from "@src/database.ts";
Deno.test("a room request reads a persisted record", async () => { let id: number | undefined; try { const [room] = await sql` INSERT INTO lab_rooms (name, capacity) VALUES (${"Reader's room"}, ${6}) RETURNING id, name, capacity `; id = room.id; const response = await app.request(`/api/rooms/${id}`); assertEquals(response.status, 200); assertEquals(await response.json(), room); } finally { try { if (id !== undefined) await sql`DELETE FROM lab_rooms WHERE id = ${id}`; } finally { await sql.end(); } }});The test owns one inserted row and the imported pool. It uses the returned ID and
cleans up even when an assertion fails. The nested finally closes the pool even
if deleting the row fails.
Run this from practice/ with the isolated Compose test project:
docker compose -p wsd-practice-test -f compose.yaml -f compose.test.yaml run --build --rm api deno test --cached-only --allow-env --allow-net tests/room-routes_test.tsFrom this chapter onward, the room app imports the database repository, so use the above database-enabled command instead of the no-dependency command from earlier. The test removes its own row: a test database is separate from development, but records still need isolation between tests and suites.
The request passes through the real room route and repository. Changing that route incorrectly can now make this test fail; no route implementation is copied into the test.
Check that an equipment request reads the database
0 / 10 points
Files in the editor
Edit api/src/checks/equipment-check.ts, the single file in the editor.
Complete assertEquipmentResponse(app, equipment). Its parameters and imports
are supplied. This function is the request-and-assertion part of an integration
test; it does not implement the route.
Supplied test setup
The grader supplies a Hono application, its repository, and an isolated
PostgreSQL database. These files are not shown in the editor. The fixture helper
inserts equipment records, passes the application and one inserted record to
your function, and removes its records and closes the database client in
finally, even when an assertion fails. You do not need to write SQL, create
tables, start a listener, or manage the client.
Your function fits into this supplied test structure:
Deno.test("an equipment request reads a persisted record", async () => { await withEquipmentFixture(async (app, equipment) => { await assertEquipmentResponse(app, equipment); });});The test wrapper and withEquipmentFixture are supplied by the grader; do not
add them to your submission. The helper waits for your function before cleanup.
Response contract and task
equipment is the record that was inserted, with numeric id and stock,
and string code and name. For this existing record,
GET /api/equipment/:id must return status 200 and a JSON object containing
exactly { id, code, name, stock }, with every value matching equipment.
The object is returned directly, without a surrounding equipment property.
The database assigns IDs; the values can differ between grading runs.
In the marked function body:
- Make one GET request with
await app.request(...), usingequipment.idin the path.app.requestdefaults to GET and needs no network listener. - Assert that the response status is 200.
- Parse the response with
await response.json()and assert that the whole object equalsequipment.
Use the imported assertEquals(actual, expected) from @std/assert. Compare
the response with the supplied record instead of hardcoding fixture values.
Keep the function asynchronous and await the request and JSON parsing.
Do not replace the app, change equipment, catch and discard assertion
failures, or inspect source files or the grading environment.
Check and submit
Choose Submit solution to run the checks and read their feedback. The editor
submits only api/src/checks/equipment-check.ts and has no Run control.
Your check must pass for the correct application (4 points). It must also fail through an assertion when an application returns the wrong status, an incorrect stock value, or a different equipment record (2 points each). Credit for detecting defects requires passing the correct application first. A function that always throws, or a syntax/import error, does not demonstrate a useful test.
Check Your Understanding
- Why is a tagged parameterized query different from building a raw SQL string?
- Who should own and close a database client in a test?
- Why should a database integration test use IDs returned by its fixture inserts instead of assuming fixed IDs?
- How do migrations and fixtures serve different purposes?
- Why does a separate test database not automatically make every test independent?