Skip to main content
Ooneex applications are tested with Bun’s built-in test runner — there is no separate test framework to install or configure. Tests import from bun:test, live in a tests/ directory next to the code they cover, and follow the *.spec.ts naming convention. Because every service, middleware, and controller is a plain class resolved through the dependency injection container, you test units in isolation: construct them directly (or resolve them from a fresh Container) and hand them a mock context.

Why this approach

  • No extra tooling. bun test discovers and runs specs out of the box — Jest-compatible describe/test/expect with native speed.
  • Plain classes. Middleware, controllers, and services are constructor-injected classes. You can new them with fakes, or resolve them from a container — no app boot required.
  • Container-driven doubles. The DI container lets you register test doubles in place of real services, so a unit under test sees a fake DB, cache, or mailer.
  • A dedicated environment. The testing environment (APP_ENV=testing) isolates configuration and behavior from local, staging, and production.
  • Generated stubs. The CLI scaffolds a matching test file next to every artifact it generates, so new code starts with a test in place.

How it works

ConventionDetail
Test runnerBun’s built-in runner — bun test, no config file needed.
LocationA tests/ directory inside each package or module.
File naming*.spec.ts (e.g. CorsMiddleware.spec.ts).
Test APIimport { describe, expect, test, beforeEach, afterEach } from "bun:test".
ImportsUse the @/ path alias for the unit under test, @ooneex/* for framework packages.
IsolationA fresh new Container() and/or mock context per test; reset env in afterEach.

Running tests

Run the whole suite from the repository or package root:
# Run every *.spec.ts in the project
bun test

# Re-run on file changes
bun test --watch

# Run a single file
bun test packages/middleware/tests/CorsMiddleware.spec.ts

# Filter by test name (substring or pattern)
bun test --test-name-pattern "CORS_ORIGINS"

# Only run tests under a directory
bun test packages/middleware
bun test automatically loads .env files, so the same configuration loading your app uses is available to tests. Set APP_ENV=testing to run against the testing environment (see Using the testing environment).

Test file conventions

A spec imports the helpers it needs from bun:test, the unit under test through the @/ alias, and any framework packages it depends on:
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { CorsMiddleware } from "@/CorsMiddleware";
Group related cases in a describe block, write one assertion-focused test per behavior, and use beforeEach/afterEach to set up and tear down shared state:
describe("CorsMiddleware", () => {
  afterEach(() => {
    // Clean up any env vars a test set, so the next test starts clean
    delete Bun.env.CORS_ORIGINS;
  });

  test("defaults to wildcard origin when CORS_ORIGINS is unset", async () => {
    // ...
  });
});

Unit testing a middleware

Middleware and controllers receive a context object. In a unit test you don’t boot the HTTP server — you build a minimal mock context exposing only the fields the unit reads (method, header.get, response.header.*, response.json, …) and assert on what the handler wrote back. This pattern is adapted from the real CorsMiddleware spec. A small factory builds the mock context and records the headers the middleware sets:
import { afterEach, describe, expect, test } from "bun:test";
import { AppEnv } from "@ooneex/app-env";
import { CorsMiddleware } from "@/CorsMiddleware";

function createMockContext(options: { origin?: string; method?: string } = {}) {
  const responseHeaders = new Map<string, string>();

  return {
    method: options.method ?? "GET",
    header: {
      get: (name: string) => (name === "Origin" ? (options.origin ?? null) : null),
    },
    response: {
      header: {
        setAccessControlAllowOrigin(origin: string) {
          responseHeaders.set("Access-Control-Allow-Origin", origin);
          return this;
        },
        set(name: string, value: string) {
          responseHeaders.set(name, value);
          return this;
        },
      },
      // biome-ignore lint/suspicious/noExplicitAny: mock
      json(_data: any, _status?: number) {},
    },
    _responseHeaders: responseHeaders,
    // biome-ignore lint/suspicious/noExplicitAny: mock
  } as any;
}

describe("CorsMiddleware", () => {
  afterEach(() => {
    delete Bun.env.CORS_ORIGINS;
  });

  test("defaults to wildcard origin when CORS_ORIGINS is unset", async () => {
    const middleware = new CorsMiddleware(new AppEnv());
    const context = createMockContext({ origin: "https://anything.com" });

    await middleware.handler(context);

    expect(context._responseHeaders.get("Access-Control-Allow-Origin")).toBe("*");
  });

  test("restricts to configured origins", async () => {
    Bun.env.CORS_ORIGINS = "https://example.com";
    const middleware = new CorsMiddleware(new AppEnv());

    const disallowed = createMockContext({ origin: "https://evil.com" });
    await middleware.handler(disallowed);

    expect(disallowed._responseHeaders.size).toBe(0);
  });
});
The same shape works for controllers: build a mock context with the request fields the handler reads, call the handler, and assert on the response it produced. See Middleware and Controllers for the real context interface.

Testing with the container

When a class is registered through dependency injection, resolve it from a fresh container per test so registrations from one test never leak into another. Create the container in beforeEach, register the class, and get it back:
import { beforeEach, describe, expect, test } from "bun:test";
import { Container, EContainerScope } from "@ooneex/container";

describe("container resolution", () => {
  let container: Container;

  beforeEach(() => {
    container = new Container();
  });

  test("resolves a transient class as a new instance each time", () => {
    class ReportService {}

    container.add(ReportService, EContainerScope.Transient);

    expect(container.get(ReportService)).not.toBe(container.get(ReportService));
  });
});

Registering test doubles

To isolate a unit from external systems (database, cache, mailer), register a fake under the same key the real binding uses. container.add(...) rebinds a class, and container.addConstant(key, value) binds a ready-made value:
import { beforeEach, describe, expect, test } from "bun:test";
import { Container } from "@ooneex/container";
import { UserService } from "@/UserService";

class FakeMailer {
  public readonly sent: string[] = [];
  public async send(to: string) {
    this.sent.push(to);
  }
}

describe("UserService", () => {
  let container: Container;
  let mailer: FakeMailer;

  beforeEach(() => {
    container = new Container();
    mailer = new FakeMailer();
    // Bind the fake in place of the real mailer
    container.addConstant("Mailer", mailer);
    container.add(UserService);
  });

  test("emails the user on registration", async () => {
    const service = container.get(UserService);

    await service.register("apps@ooneex.com");

    expect(mailer.sent).toContain("apps@ooneex.com");
  });
});
For the full container API — scopes, constants, and injection — see Dependency injection.

Using the testing environment

Ooneex recognizes a dedicated testing environment alongside local, development, staging, and production. It is selected by the APP_ENV variable:
APP_ENV=testing bun test
With APP_ENV=testing, an AppEnv instance reports isTesting === true, and any environment-specific configuration loads its testing values. Drive a code path that checks the environment by setting APP_ENV before constructing AppEnv:
import { afterEach, expect, test } from "bun:test";
import { AppEnv } from "@ooneex/app-env";

afterEach(() => {
  delete Bun.env.APP_ENV;
});

test("AppEnv reports the testing environment", () => {
  Bun.env.APP_ENV = "testing";
  const env = new AppEnv();

  expect(env.isTesting).toBe(true);
  expect(env.APP_ENV).toBe("testing");
});
Config and env load in tests exactly as they do at runtime — Bun reads .env files automatically — so prefer a dedicated .env.testing (or test-only values) over hardcoding secrets in specs. See Configuration for how environment values are resolved.

Generated test stubs

Every CLI generator scaffolds a matching test next to the artifact it creates. For example, ooneex middleware:create --name=Auth writes the middleware class and a companion spec in the module’s tests/ directory, pre-wired with the bun:test imports and a describe block. New code starts testable — fill in the cases rather than setting up the file from scratch.
ooneex middleware:create --name=Auth
# -> src/middlewares/AuthMiddleware.ts
# -> tests/AuthMiddleware.spec.ts

Best practices

  • Isolate every test. Use beforeEach to build fresh state and afterEach to tear it down; never let one test’s env vars or container bindings reach the next.
  • Fresh container per test. Create new Container() in beforeEach so registrations don’t leak across cases.
  • Mock external services. Replace the database, cache, mailer, and HTTP clients with fakes registered in the container — tests must not hit real systems.
  • Mock the context minimally. Build only the context fields the unit reads, and record what it writes so assertions stay focused.
  • Use the testing environment. Run with APP_ENV=testing and a test-only env file rather than reusing local or production configuration.
  • Keep tests meaningful. One behavior per test, named for what it proves; remove redundant or trivial cases so the suite stays a fast, trustworthy signal.