JCC Express

Testing

Feature Testing

Feature tests validate end-to-end application behavior through real HTTP requests. They run with bun test (not Vitest) and live in tests/Feature/.


Basic structure

TypeScript
import { describe, it, beforeEach, afterEach } from "bun:test";
import { TestCase } from "../TestCase";

describe("Users API", () => {
  const test = new (class extends TestCase {})();

  beforeEach(async () => await test.setUp());
  afterEach(async () => await test.tearDown());

  it("lists users", async () => {
    const response = await test.get("/api/users");
    response.assertOk();
  });

  it("creates a user", async () => {
    const response = await test.post("/api/users", {
      name: "Alice",
      email: "alice@example.com",
      password: "secret123",
    });
    response.assertCreated();
    response.assertJsonFragment({ name: "Alice" });
  });
});

HTTP methods

All methods accept an optional headers object as the last argument.

TypeScript
await test.get("/api/users")
await test.post("/api/users", { name: "Alice", email: "a@b.com" })
await test.put("/api/users/1", { name: "Updated" })
await test.patch("/api/users/1", { name: "Patched" })
await test.delete("/api/users/1")

Sending custom headers

Pass headers as the last argument — useful for sending auth tokens:

TypeScript
const response = await test.get("/api/profile", {
  Authorization: "Bearer eyJ...",
  "X-Custom-Header": "value",
});

TestResponse — all assertions

Status

TypeScript
response.assertStatus(200)      // any status code
response.assertOk()             // 200
response.assertCreated()        // 201
response.assertNoContent()      // 204
response.assertUnauthorized()   // 401
response.assertForbidden()      // 403
response.assertNotFound()       // 404
response.assertUnprocessable()  // 422

Redirects

TypeScript
response.assertRedirect()           // any 3xx
response.assertRedirect("/login")   // specific location header

JSON body

TypeScript
// Exact deep match — the entire body must equal the given object
response.assertJson({ id: 1, name: "Alice" })

// Subset match — checks only the listed keys
response.assertJsonFragment({ name: "Alice" })

// String present anywhere in the body
response.assertSee("Welcome")

Headers

TypeScript
response.assertHeader("content-type")                        // header exists
response.assertHeader("content-type", "application/json")   // exact value

Chaining

All assert* methods return this, so you can chain:

TypeScript
response
  .assertCreated()
  .assertJsonFragment({ name: "Alice" })
  .assertHeader("content-type", "application/json");

Accessing the body

TypeScript
response.json()   // parsed JSON (object/array)
response.text()   // raw string body

Database isolation with withTransaction

Wrap any test that writes to the database in withTransaction. The callback runs inside a Knex transaction that is always rolled back — keeping the database clean between tests.

TypeScript
it("creates a user", async () => {
  await test.withTransaction(async () => {
    const response = await test.post("/api/users", {
      name: "Alice",
      email: "alice@example.com",
    });

    response.assertCreated();

    await test.assertDatabaseHas("users", { email: "alice@example.com" });
  });
  // The INSERT is rolled back here — other tests see a clean DB
});

All database queries made inside the callback (including those inside your controllers and models) automatically use the same transaction because the framework uses AsyncLocalStorage to propagate it.


Using factories to seed test data

Factory and Faker live in Testing/Factory.ts:

TypeScript
import { describe, it, beforeEach, afterEach } from "bun:test";
import { TestCase } from "../TestCase";
import { Factory, Faker } from "../../Testing/Factory";
import { User } from "../../app/Models/User";

const userFactory = Factory.define(User as any, () => ({
  name: Faker.fullName(),
  email: Faker.email(),
  password: "password123",
}));

describe("Users API", () => {
  const test = new (class extends TestCase {})();

  beforeEach(async () => await test.setUp());
  afterEach(async () => await test.tearDown());

  it("deletes a user", async () => {
    await test.withTransaction(async () => {
      const user = await userFactory.create();

      const response = await test.delete(`/api/users/${user.id}`);
      response.assertNoContent();

      await test.assertDatabaseMissing("users", { id: user.id });
    });
  });
});

Factory methods

MethodDescription
Factory.define(Model, () => attrs)Create a factory
factory.create(attrs?)Insert one record, return model instance
factory.createMany(n, attrs?)Insert n records
factory.make(attrs?)Build instance without saving
factory.makeMany(n, attrs?)Build n instances without saving
factory.state(name, attrs)Define a named state
factory.withState(...names)Apply states before create/make

Faker helpers

TypeScript
Faker.fullName()          // "Alice Johnson"
Faker.email()             // "user4291@example.com"
Faker.string(16)          // random alphanumeric
Faker.number(1, 100)      // random integer
Faker.boolean()           // true / false
Faker.date()              // random Date between 2020 and now
Faker.text(10)            // lorem ipsum words

For richer data generation, install @faker-js/faker and use it in your factory definitions.


Testing authentication

Most authenticated routes require a valid auth_token cookie or Authorization header. The cleanest approach in tests is to log in first:

TypeScript
it("shows the profile for an authenticated user", async () => {
  await test.withTransaction(async () => {
    // Create the user
    const user = await userFactory.create();

    // Log in to get a token
    const login = await test.post("/api/login", {
      email: user.email,
      password: "password123",
    });
    login.assertOk();
    const { tokens } = login.json();

    // Use the token in subsequent requests
    const profile = await test.get("/api/profile", {
      Authorization: `Bearer ${tokens.accessToken}`,
    });
    profile.assertOk();
    profile.assertJsonFragment({ email: user.email });
  });
});

Run feature tests

Bash
bun test tests/Feature
bun test tests/Feature/UsersTest.test.ts   # single file