Testing

Test Through the Same Boundary Production Uses

IoC is useful because construction is a seam. Tests should replace collaborators through container registration, then resolve and exercise the real class under test — no mocking framework needed.

Co-Located Mock

Keep reusable fakes next to the real boundary. Export a registration function that receives the test container.

payment_processor.mock.ts
import type { IContainer } from "@noego/ioc";
import { LoadAs } from "@noego/ioc";
import { PaymentProcessor } from "./payment_processor";

class MockPaymentProcessor extends PaymentProcessor {
  async charge(amount: number): Promise<string> {
    if (amount <= 0) {
      throw new Error("amount must be positive");
    }

    return "mock-payment-id";
  }
}

export function mockPaymentProcessor(container: IContainer): void {
  container.registerFunction(
    PaymentProcessor,
    () => new MockPaymentProcessor(),
    { loadAs: LoadAs.Singleton },
  );
}

Test Shape

Create a fresh container, register the fake, resolve the class under test, and exercise it. No mocks, no module-level vi.mock() — just container registration.

order_service.test.ts
import createContainer from "@noego/ioc";
import { OrderService } from "./order_service";
import { mockPaymentProcessor } from "./payment_processor.mock";

it("charges through the configured processor", async () => {
  const container = createContainer();
  mockPaymentProcessor(container);

  const service = await container.instance(OrderService);

  await expect(service.placeOrder({ amount: 25 })).resolves.toEqual({
    status: "paid",
    paymentId: "mock-payment-id",
  });
});

Testing Scoped Services

Services that consume runtime parameters need a child scope. Use container.extend() to create one, pass parameter values, and verify isolation between scopes.

current_user.test.ts
import createContainer from "@noego/ioc";
import { CurrentUser } from "./current_user";
import { USER_ID, USER_ROLE } from "./tokens";

it("reads permissions based on role", async () => {
  const root = createContainer();
  const scope = root.extend();

  const user = await scope.instance(CurrentUser, [
    USER_ID.value("user-123"),
    USER_ROLE.value("admin"),
  ]);

  expect(user.role).toBe("admin");
});

it("isolates state between scopes", async () => {
  const root = createContainer();
  const scope1 = root.extend();
  const scope2 = root.extend();

  const user1 = await scope1.instance(CurrentUser, [
    USER_ID.value("user-1"),
    USER_ROLE.value("member"),
  ]);
  const user2 = await scope2.instance(CurrentUser, [
    USER_ID.value("user-2"),
    USER_ROLE.value("admin"),
  ]);

  expect(user1.role).toBe("member");
  expect(user2.role).toBe("admin");
});

Rules

  • Create a fresh container per test.
  • Register fakes before resolving the class under test.
  • Resolve the real class through the container.
  • Put reusable test fakes in co-located .mock.ts files.
  • Make fakes enforce the same boundary rules as real implementations.
  • Avoid vi.mock() for internal IoC-managed services.
  • Use container.extend() to test scoped services with runtime parameters.
NoEgo

© 2025 NoEgo. All rights reserved.