Chapter 16: Testing

Testing is where OriJS’s design philosophy pays its biggest dividends. Because every component is a plain TypeScript class with explicit constructor dependencies — no decorators, no metadata, no module system — testing is dramatically simpler than in decorator-based frameworks.

This chapter covers OriJS’s three-layer testing strategy, the built-in mock factories, and patterns for testing every part of your application, from individual services to full HTTP request flows.

Testing Philosophy

The Modified Testing Pyramid

Traditional testing pyramids put unit tests at the bottom and E2E tests at the top. OriJS modifies this by emphasizing functional tests — tests that use real class instances with controlled dependencies — as the most valuable layer.

         /    E2E     \        ← Real HTTP, real Redis, Testcontainers
        /  (few, slow)  \
       /                  \
      /    Functional      \   ← Real instances, controlled deps, DI container
     /  (many, medium)      \
    /                        \
   /        Unit              \  ← Single class, all deps mocked
  /    (many, fast)            \
 /______________________________\

Unit tests verify isolated logic — a single class with all dependencies mocked. They are fast but can miss integration issues.

Functional tests wire together real instances with controlled dependencies. They catch interface mismatches, incorrect dependency injection, and integration bugs that unit tests miss. In OriJS, because everything is a plain class, functional tests are almost as easy to write as unit tests.

E2E tests send real HTTP requests to a running server. They verify the full request lifecycle — routing, guards, interceptors, validation, handlers, and responses. Use them for critical paths and complex interactions.

Why OriJS is More Testable Than NestJS

In NestJS, testing a controller or service requires bootstrapping the NestJS testing module:

// NestJS — the TestingModule ceremony
const module = await Test.createTestingModule({
  controllers: [UserController],
  providers: [
    { provide: UserService, useValue: mockUserService },
    { provide: AuthGuard, useValue: mockAuthGuard },
  ],
}).compile();

const controller = module.get<UserController>(UserController);

This is necessary because NestJS uses decorators and reflect-metadata to wire dependencies at runtime. Without the testing module, the DI system doesn’t work.

In OriJS, testing is just… constructing classes:

// OriJS — plain class instantiation
const mockUserService = { findById: mock(() => testUser) };
const controller = new UserController(mockUserService as UserService);

No testing module. No framework bootstrapping. No decorator metadata. Just new and your mocks. This is possible because OriJS services are plain TypeScript classes with explicit constructor parameters — there is nothing framework-specific about them.

Test Setup

Bun Test Runner

OriJS uses Bun’s built-in test runner. Tests use the bun:test module:

import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';

Preload File

For tests that need infrastructure (Redis, containers), create a preload file:

// __tests__/preload.ts
import { createBunTestPreload } from '@orijs/test-utils';
import { Logger } from '@orijs/logging';
import { afterAll } from 'bun:test';

// Enable debug mode so framework errors throw instead of exiting
process.env.ORIJS_DEBUG = 'true';

const preload = createBunTestPreload({
  packageName: 'my-app',
  dependencies: ['redis'],
});

await preload();

// Clean up Logger timer after each test file
afterAll(async () => {
  await Logger.shutdown();
});

Configure the preload in bunfig.toml:

[test]
preload = ["./__tests__/preload.ts"]

Disable Signal Handling

When testing OriJS applications, always disable signal handling. Without this, your tests will intercept SIGINT/SIGTERM and interfere with the test runner:

const app = Ori.create()
  .disableSignalHandling()
  // ... rest of configuration
  .listen(0); // Port 0 = random available port

Running Tests

# Run all tests
bun test

# Run a specific test file
bun test __tests__/services/user-service.test.ts

# Run tests matching a pattern
bun test --grep "should create user"

# Run with coverage
bun test --coverage

# Run with timeout (useful for E2E tests)
bun test --timeout 30000

Unit Testing

Unit tests verify a single class in isolation. All dependencies are mocked.

Testing Services

Services are the simplest to test — they are plain classes with constructor dependencies.

// src/services/user-service.ts
class UserService {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly emailService: EmailService
  ) {}

  public async createUser(data: CreateUserInput): Promise<User> {
    const user = await this.userRepository.create(data);
    await this.emailService.sendWelcome(user.email);
    return user;
  }

  public async findById(id: string): Promise<User | null> {
    return this.userRepository.findById(id);
  }
}
// __tests__/services/user-service.test.ts
import { describe, test, expect, mock, beforeEach } from 'bun:test';
import { UserService } from '../../src/services/user-service';
import type { UserRepository } from '../../src/repositories/user-repository';
import type { EmailService } from '../../src/services/email-service';

describe('UserService', () => {
  let service: UserService;
  let mockRepo: UserRepository;
  let mockEmail: EmailService;

  const testUser = {
    id: 'user-123',
    name: 'Alice',
    email: 'alice@example.com',
  };

  beforeEach(() => {
    mockRepo = {
      create: mock(() => Promise.resolve(testUser)),
      findById: mock(() => Promise.resolve(testUser)),
    } as unknown as UserRepository;

    mockEmail = {
      sendWelcome: mock(() => Promise.resolve()),
    } as unknown as EmailService;

    service = new UserService(mockRepo, mockEmail);
  });

  describe('createUser', () => {
    test('should create user and send welcome email', async () => {
      const result = await service.createUser({
        name: 'Alice',
        email: 'alice@example.com',
      });

      expect(result).toEqual(testUser);
      expect(mockRepo.create).toHaveBeenCalledTimes(1);
      expect(mockEmail.sendWelcome).toHaveBeenCalledWith('alice@example.com');
    });

    test('should propagate repository errors', async () => {
      (mockRepo.create as ReturnType<typeof mock>).mockRejectedValueOnce(
        new Error('Database connection failed')
      );

      expect(
        service.createUser({ name: 'Alice', email: 'alice@example.com' })
      ).rejects.toThrow('Database connection failed');
    });
  });

  describe('findById', () => {
    test('should return user when found', async () => {
      const result = await service.findById('user-123');
      expect(result).toEqual(testUser);
    });

    test('should return null when user not found', async () => {
      (mockRepo.findById as ReturnType<typeof mock>).mockResolvedValueOnce(null);

      const result = await service.findById('nonexistent');
      expect(result).toBeNull();
    });
  });
});

The key insight: there is nothing OriJS-specific about this test. The service is a plain class, the mocks are plain objects, and the test is a standard Bun test. This is the direct benefit of OriJS’s no-decorator, explicit-dependency approach.

Testing Guards

Guards implement the Guard interface with a single canActivate method that receives a RequestContext:

// src/guards/auth-guard.ts
import type { Guard } from '@orijs/orijs';
import type { RequestContext } from '@orijs/orijs';

class AuthGuard implements Guard {
  constructor(private readonly authService: AuthService) {}

  public async canActivate(ctx: RequestContext): Promise<boolean> {
    const token = ctx.request.headers.get('authorization');
    if (!token) return false;

    const user = await this.authService.validateToken(token);
    if (!user) return false;

    ctx.set('user', user);
    return true;
  }
}

To unit test a guard, you need a mock RequestContext. You can construct a minimal one:

// __tests__/guards/auth-guard.test.ts
import { describe, test, expect, mock, beforeEach } from 'bun:test';
import { AuthGuard } from '../../src/guards/auth-guard';
import type { AuthService } from '../../src/services/auth-service';

function createMockRequestContext(options: {
  headers?: Record<string, string>;
} = {}): any {
  const state: Record<string, unknown> = {};
  return {
    request: {
      headers: {
        get: (key: string) => options.headers?.[key] ?? null,
      },
    },
    state,
    set: (key: string, value: unknown) => { state[key] = value; },
    get: (key: string) => state[key],
  };
}

describe('AuthGuard', () => {
  let guard: AuthGuard;
  let mockAuthService: AuthService;

  const testUser = { id: 'user-123', role: 'admin' };

  beforeEach(() => {
    mockAuthService = {
      validateToken: mock(() => Promise.resolve(testUser)),
    } as unknown as AuthService;

    guard = new AuthGuard(mockAuthService);
  });

  test('should allow request with valid token', async () => {
    const ctx = createMockRequestContext({
      headers: { authorization: 'Bearer valid-token' },
    });

    const result = await guard.canActivate(ctx);

    expect(result).toBe(true);
    expect(ctx.state.user).toEqual(testUser);
  });

  test('should deny request without token', async () => {
    const ctx = createMockRequestContext();

    const result = await guard.canActivate(ctx);

    expect(result).toBe(false);
  });

  test('should deny request with invalid token', async () => {
    (mockAuthService.validateToken as ReturnType<typeof mock>)
      .mockResolvedValueOnce(null);

    const ctx = createMockRequestContext({
      headers: { authorization: 'Bearer invalid' },
    });

    const result = await guard.canActivate(ctx);

    expect(result).toBe(false);
  });
});

Testing Interceptors

Interceptors follow the onion model — they wrap handler execution:

// src/interceptors/timing-interceptor.ts
import type { Interceptor, RequestContext } from '@orijs/orijs';

class TimingInterceptor implements Interceptor {
  public async intercept(ctx: RequestContext, next: () => Promise<Response>): Promise<Response> {
    const start = performance.now();
    const response = await next();
    const duration = Math.round(performance.now() - start);

    ctx.log.info('Request completed', {
      method: ctx.request.method,
      duration,
    });

    return new Response(response.body, {
      status: response.status,
      headers: {
        ...Object.fromEntries(response.headers),
        'X-Response-Time': `${duration}ms`,
      },
    });
  }
}
// __tests__/interceptors/timing-interceptor.test.ts
import { describe, test, expect, mock } from 'bun:test';
import { TimingInterceptor } from '../../src/interceptors/timing-interceptor';

describe('TimingInterceptor', () => {
  test('should add X-Response-Time header', async () => {
    const interceptor = new TimingInterceptor();
    const mockCtx = {
      request: { method: 'GET' },
      log: { info: mock(() => {}) },
    } as any;

    const next = mock(() => Promise.resolve(
      Response.json({ ok: true })
    ));

    const response = await interceptor.intercept(mockCtx, next);

    expect(response.headers.get('X-Response-Time')).toMatch(/^\d+ms$/);
    expect(next).toHaveBeenCalledTimes(1);
    expect(mockCtx.log.info).toHaveBeenCalled();
  });

  test('should propagate handler errors', async () => {
    const interceptor = new TimingInterceptor();
    const mockCtx = {
      request: { method: 'GET' },
      log: { info: mock(() => {}) },
    } as any;

    const next = mock(() => Promise.reject(new Error('Handler failed')));

    expect(
      interceptor.intercept(mockCtx, next)
    ).rejects.toThrow('Handler failed');
  });
});

Mock Factories

For more realistic unit tests, you can build helper factories that create properly structured mock contexts. These are lightweight functions you keep in your test utilities:

createMockRequestContext

// __tests__/helpers/mock-factories.ts

export function createMockRequestContext(options: {
  method?: string;
  url?: string;
  headers?: Record<string, string>;
  params?: Record<string, string>;
  query?: Record<string, string | string[]>;
  body?: unknown;
} = {}): any {
  const state: Record<string, unknown> = {};
  const {
    method = 'GET',
    url = 'http://localhost:3000/test',
    headers = {},
    params = {},
    query = {},
    body = undefined,
  } = options;

  return {
    request: new Request(url, {
      method,
      headers: new Headers(headers),
      body: body ? JSON.stringify(body) : undefined,
    }),
    params,
    query,
    state,
    set: (key: string, value: unknown) => { state[key] = value; },
    get: (key: string) => state[key],
    json: async () => body,
    text: async () => JSON.stringify(body),
    log: {
      info: mock(() => {}),
      warn: mock(() => {}),
      error: mock(() => {}),
      debug: mock(() => {}),
    },
    correlationId: 'test-correlation-id',
    signal: new AbortController().signal,
    app: {
      config: {},
      log: { info: mock(() => {}), warn: mock(() => {}), error: mock(() => {}), debug: mock(() => {}) },
    },
  };
}

createMockAppContext

export function createMockAppContext(options: {
  config?: Record<string, unknown>;
} = {}): any {
  return {
    config: options.config ?? {},
    log: {
      info: mock(() => {}),
      warn: mock(() => {}),
      error: mock(() => {}),
      debug: mock(() => {}),
      child: () => ({
        info: mock(() => {}),
        warn: mock(() => {}),
        error: mock(() => {}),
        debug: mock(() => {}),
      }),
    },
    onStartup: mock(() => {}),
    onReady: mock(() => {}),
    onShutdown: mock(() => {}),
    resolve: mock(() => null),
    phase: 'ready',
  };
}

createMockEventContext

export function createMockEventContext<T>(options: {
  data: T;
  eventName?: string;
  eventId?: string;
}): any {
  return {
    eventId: options.eventId ?? crypto.randomUUID(),
    eventName: options.eventName ?? 'test.event',
    data: options.data,
    timestamp: Date.now(),
    correlationId: crypto.randomUUID(),
    log: {
      info: mock(() => {}),
      warn: mock(() => {}),
      error: mock(() => {}),
      debug: mock(() => {}),
    },
    emit: mock(() => ({ wait: () => Promise.resolve() })),
  };
}

createMockWorkflowContext

export function createMockWorkflowContext<T>(options: {
  data: T;
  flowId?: string;
  results?: Record<string, unknown>;
}): any {
  return {
    flowId: options.flowId ?? crypto.randomUUID(),
    data: options.data,
    results: options.results ?? {},
    correlationId: crypto.randomUUID(),
    meta: {},
    log: {
      info: mock(() => {}),
      warn: mock(() => {}),
      error: mock(() => {}),
      debug: mock(() => {}),
    },
  };
}

Functional Testing

Functional tests use real class instances wired together with controlled dependencies. This catches integration issues that unit tests miss — incorrect dependency types, interface mismatches, and wiring errors.

Using the DI Container

OriJS’s Container class can be used directly in tests to wire up real dependency graphs:

// __tests__/functional/user-flow.test.ts
import { describe, test, expect, beforeEach } from 'bun:test';
import { Container } from '@orijs/core';
import { UserService } from '../../src/services/user-service';
import { UserRepository } from '../../src/repositories/user-repository';
import { InMemoryDatabase } from '../../src/testing/in-memory-database';

describe('User creation flow (functional)', () => {
  let container: Container;
  let userService: UserService;

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

    // Use real implementations with controlled deps
    const db = new InMemoryDatabase();
    container.registerInstance(InMemoryDatabase, db);
    container.register(UserRepository, [InMemoryDatabase]);
    container.register(UserService, [UserRepository]);

    userService = container.resolve(UserService);
  });

  test('should create and retrieve a user', async () => {
    const created = await userService.createUser({
      name: 'Alice',
      email: 'alice@example.com',
    });

    expect(created.id).toBeDefined();
    expect(created.name).toBe('Alice');

    const found = await userService.findById(created.id);
    expect(found).toEqual(created);
  });

  test('should return null for nonexistent user', async () => {
    const found = await userService.findById('nonexistent');
    expect(found).toBeNull();
  });
});

Testing with Swapped Providers

The provider architecture makes functional tests powerful. Swap Redis for InMemory, BullMQ for InProcess:

import { CacheService, InMemoryCacheProvider } from '@orijs/cache';

describe('Cached user lookup (functional)', () => {
  let userService: UserService;
  let cacheService: CacheService;

  beforeEach(() => {
    // InMemory cache provider — no Redis needed
    cacheService = new CacheService(new InMemoryCacheProvider());

    const mockRepo = new InMemoryUserRepository();
    userService = new UserService(mockRepo, cacheService);
  });

  test('should cache user after first lookup', async () => {
    // First call — cache miss, hits repository
    const user1 = await userService.findById('user-123');

    // Second call — cache hit, does not hit repository
    const user2 = await userService.findById('user-123');

    expect(user1).toEqual(user2);
    // Verify repository was only called once (second call was cached)
  });
});

Testing Controllers Functionally

You can test controllers without starting an HTTP server by instantiating them directly and calling handlers:

import { describe, test, expect, beforeEach } from 'bun:test';
import { UserController } from '../../src/controllers/user-controller';
import { UserService } from '../../src/services/user-service';

describe('UserController (functional)', () => {
  let controller: UserController;
  let userService: UserService;

  beforeEach(() => {
    // Use a real UserService with an in-memory repository
    const repo = new InMemoryUserRepository();
    userService = new UserService(repo);
    controller = new UserController(userService);
  });

  test('should return user by ID', async () => {
    // Seed data
    await userService.createUser({ name: 'Alice', email: 'alice@example.com' });

    const ctx = createMockRequestContext({
      params: { id: 'user-123' },
    });

    // Call the handler directly (it is a public arrow function property)
    // This tests the controller logic without HTTP overhead
  });
});

E2E Testing

E2E tests start a real server, send real HTTP requests, and verify real responses. They test the entire request lifecycle.

Basic E2E Test

// __tests__/e2e/user-api.test.ts
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { Ori } from '@orijs/orijs';
import type { OriApplication } from '@orijs/orijs';
import { UserController } from '../../src/controllers/user-controller';
import { UserService } from '../../src/services/user-service';
import { InMemoryUserRepository } from '../../src/testing/in-memory-user-repository';

describe('User API (E2E)', () => {
  let app: OriApplication;
  let baseUrl: string;

  beforeAll(async () => {
    const repo = new InMemoryUserRepository();

    app = Ori.create()
      .disableSignalHandling()     // Required for tests
      .providerInstance(InMemoryUserRepository, repo)
      .provider(UserService, [InMemoryUserRepository])
      .controller('/users', UserController, [UserService]);

    const server = await app.listen(0);  // Port 0 = random available port
    baseUrl = `http://localhost:${server.port}`;
  });

  afterAll(async () => {
    await app.stop();
  });

  test('should create a user via POST', async () => {
    const response = await fetch(`${baseUrl}/users`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        name: 'Alice',
        email: 'alice@example.com',
      }),
    });

    expect(response.status).toBe(201);

    const body = await response.json();
    expect(body.name).toBe('Alice');
    expect(body.email).toBe('alice@example.com');
    expect(body.id).toBeDefined();
  });

  test('should get a user via GET', async () => {
    // Create first
    const createRes = await fetch(`${baseUrl}/users`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: 'Bob', email: 'bob@example.com' }),
    });
    const created = await createRes.json();

    // Then fetch
    const getRes = await fetch(`${baseUrl}/users/${created.id}`);
    expect(getRes.status).toBe(200);

    const fetched = await getRes.json();
    expect(fetched.name).toBe('Bob');
  });

  test('should return 404 for nonexistent user', async () => {
    const response = await fetch(`${baseUrl}/users/nonexistent-id`);
    expect(response.status).toBe(404);
  });

  test('should return 422 for invalid body', async () => {
    const response = await fetch(`${baseUrl}/users`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: '' }), // Missing required email
    });

    expect(response.status).toBe(422);
    const body = await response.json();
    expect(body.error).toBe('Validation Error');
  });
});

Port 0 for Random Ports

Always use port 0 in E2E tests. Bun assigns a random available port, preventing conflicts when tests run in parallel:

const server = await app.listen(0);
const port = server.port; // Bun gives you the assigned port
const baseUrl = `http://localhost:${port}`;

E2E with Testcontainers

For tests that need real Redis (cache, events, WebSockets), use Testcontainers via the @orijs/test-utils preload:

// __tests__/e2e/cached-api.test.ts
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { Ori } from '@orijs/orijs';
import { createRedisTestHelper } from '@orijs/test-utils';
import { createRedisCacheProvider } from '@orijs/cache-redis';

describe('Cached API (E2E with Redis)', () => {
  const redisHelper = createRedisTestHelper('my-app');
  let app: any;
  let baseUrl: string;

  beforeAll(async () => {
    // Redis container is started by the preload file
    const connectionConfig = redisHelper.getConnectionConfig();
    const cacheProvider = createRedisCacheProvider({
      connection: { host: connectionConfig.host, port: connectionConfig.port }
    });

    app = Ori.create()
      .disableSignalHandling()
      .cache(cacheProvider)
      .provider(UserService, [UserRepository, CacheService])
      .controller('/users', UserController, [UserService]);

    const server = await app.listen(0);
    baseUrl = `http://localhost:${server.port}`;
  });

  afterAll(async () => {
    await app.stop();
  });

  test('should serve cached response on second request', async () => {
    // First request — cache miss
    const res1 = await fetch(`${baseUrl}/users/user-123`);
    expect(res1.status).toBe(200);

    // Second request — cache hit (faster)
    const res2 = await fetch(`${baseUrl}/users/user-123`);
    expect(res2.status).toBe(200);

    const body1 = await res1.json();
    const body2 = await res2.json();
    expect(body1).toEqual(body2);
  });
});

Testing Events

Unit Testing Event Consumers

Event consumers are plain classes. Test them by creating mock event contexts:

// src/consumers/user-created-consumer.ts
import type { EventConsumer } from '@orijs/orijs';
import type { UserCreated } from '../events/user-events';

class UserCreatedConsumer implements EventConsumer<typeof UserCreated> {
  constructor(private readonly emailService: EmailService) {}

  onEvent = async (ctx) => {
    await this.emailService.sendWelcome(ctx.data.email);
    return { welcomeEmailSent: true };
  };

  onError = async (ctx, error) => {
    ctx.log.error('Failed to process user.created', { error, userId: ctx.data.userId });
  };
}
// __tests__/consumers/user-created-consumer.test.ts
import { describe, test, expect, mock, beforeEach } from 'bun:test';
import { UserCreatedConsumer } from '../../src/consumers/user-created-consumer';

describe('UserCreatedConsumer', () => {
  let consumer: UserCreatedConsumer;
  let mockEmailService: any;

  beforeEach(() => {
    mockEmailService = {
      sendWelcome: mock(() => Promise.resolve()),
    };
    consumer = new UserCreatedConsumer(mockEmailService);
  });

  test('should send welcome email when user is created', async () => {
    const ctx = createMockEventContext({
      data: { userId: 'user-123', email: 'alice@example.com' },
      eventName: 'user.created',
    });

    const result = await consumer.onEvent(ctx);

    expect(result).toEqual({ welcomeEmailSent: true });
    expect(mockEmailService.sendWelcome).toHaveBeenCalledWith('alice@example.com');
  });

  test('should log error on failure', async () => {
    mockEmailService.sendWelcome.mockRejectedValueOnce(new Error('SMTP down'));

    const ctx = createMockEventContext({
      data: { userId: 'user-123', email: 'alice@example.com' },
    });

    // onError is a separate lifecycle hook
    await consumer.onError!(ctx, new Error('SMTP down'));

    expect(ctx.log.error).toHaveBeenCalled();
  });
});

Testing Event Emission in E2E

To test that events are emitted correctly in an E2E scenario, use the InProcess event provider:

import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { Ori, Event } from '@orijs/orijs';
import { Type } from '@orijs/validation';
import { waitFor } from '@orijs/test-utils';

const UserCreated = Event.define({
  name: 'user.created',
  data: Type.Object({ userId: Type.String(), email: Type.String() }),
  result: Type.Object({ welcomeEmailSent: Type.Boolean() }),
});

describe('Event emission (E2E)', () => {
  let app: any;
  let baseUrl: string;
  const processedEvents: unknown[] = [];

  class TestConsumer {
    onEvent = async (ctx: any) => {
      processedEvents.push(ctx.data);
      return { welcomeEmailSent: true };
    };
  }

  beforeAll(async () => {
    app = Ori.create()
      .disableSignalHandling()
      .event(UserCreated).consumer(TestConsumer)
      .controller('/users', UserController, [UserService]);

    const server = await app.listen(0);
    baseUrl = `http://localhost:${server.port}`;
  });

  afterAll(async () => {
    await app.stop();
  });

  test('should emit event when user is created', async () => {
    await fetch(`${baseUrl}/users`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }),
    });

    // Wait for async event processing
    await waitFor(() => processedEvents.length > 0);

    expect(processedEvents[0]).toMatchObject({
      email: 'alice@example.com',
    });
  });
});

Testing WebSockets

WebSocket tests use Bun’s native WebSocket client. Start a server on port 0 and connect:

import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { Ori } from '@orijs/orijs';
import { waitFor } from '@orijs/test-utils';

describe('WebSocket (E2E)', () => {
  let app: any;
  let wsUrl: string;

  beforeAll(async () => {
    app = Ori.create()
      .disableSignalHandling()
      .websocket()
      .onWebSocket({
        open: (ws) => {
          ws.subscribe('global');
        },
        message: (ws, message) => {
          // Echo back
          ws.send(`echo: ${message}`);
        },
      });

    const server = await app.listen(0);
    wsUrl = `ws://localhost:${server.port}/ws`;
  });

  afterAll(async () => {
    await app.stop();
  });

  test('should receive echo response', async () => {
    const messages: string[] = [];

    const ws = new WebSocket(wsUrl);

    ws.onmessage = (event) => {
      messages.push(event.data);
    };

    // Wait for connection
    await waitFor(() => ws.readyState === WebSocket.OPEN);

    ws.send('hello');

    // Wait for response
    await waitFor(() => messages.length > 0);

    expect(messages[0]).toBe('echo: hello');

    ws.close();
  });
});

Testing Socket Routers

Socket routers with connection guards and message handlers:

describe('Socket Router (E2E)', () => {
  let app: any;
  let wsUrl: string;

  beforeAll(async () => {
    app = Ori.create()
      .disableSignalHandling()
      .websocket()
      .socketRouter(PresenceRouter, [PresenceService]);

    const server = await app.listen(0);
    wsUrl = `ws://localhost:${server.port}/ws`;
  });

  afterAll(async () => {
    await app.stop();
  });

  test('should handle heartbeat message', async () => {
    const messages: any[] = [];
    const ws = new WebSocket(wsUrl);

    ws.onmessage = (event) => {
      messages.push(JSON.parse(event.data));
    };

    await waitFor(() => ws.readyState === WebSocket.OPEN);

    // Send typed message
    ws.send(JSON.stringify({
      type: 'heartbeat',
      data: { timestamp: Date.now() },
      correlationId: 'test-123',
    }));

    await waitFor(() => messages.length > 0);

    expect(messages[0].type).toBe('heartbeat');
    expect(messages[0].correlationId).toBe('test-123');

    ws.close();
  });
});

Test Naming Conventions

Use the pattern: should {expected behavior} when {condition}

// Good
test('should return 401 when token is missing', async () => { ... });
test('should cache result when cache miss occurs', async () => { ... });
test('should retry three times when connection fails', async () => { ... });
test('should emit UserCreated event when user is created', async () => { ... });

// Bad — vague, doesn't describe behavior
test('test user creation', async () => { ... });
test('auth works', async () => { ... });
test('handles errors', async () => { ... });

Async Test Helpers

The @orijs/test-utils package provides helpers for deterministic async testing:

waitFor

Polls a condition until it becomes true, or times out:

import { waitFor } from '@orijs/test-utils';

// Wait for messages to arrive
await waitFor(() => messages.length >= 2);

// Custom timeout and interval
await waitFor(() => db.isConnected(), {
  timeout: 10000,
  interval: 100,
  message: 'Database did not connect in time',
});

waitForAsync

Same as waitFor but the condition function can be async:

import { waitForAsync } from '@orijs/test-utils';

await waitForAsync(async () => {
  const count = await redis.llen('events');
  return count >= 3;
});

withTimeout

Wraps a promise with a timeout to prevent hanging tests:

import { withTimeout } from '@orijs/test-utils';

const result = await withTimeout(
  workflow.waitForCompletion(),
  30000,
  'Workflow did not complete in time'
);

delay

Simple delay for cases where a fixed wait is actually appropriate (use sparingly):

import { delay } from '@orijs/test-utils';

await provider.stop();
await delay(50); // Allow background cleanup

Testing Best Practices

1. Test One Behavior Per Test

Each test should verify a single behavior. If a test has multiple unrelated assertions, split it:

// Bad — testing multiple behaviors
test('should handle user operations', async () => {
  const user = await service.create({ name: 'Alice' });
  expect(user.name).toBe('Alice');

  const found = await service.findById(user.id);
  expect(found).toEqual(user);

  await service.delete(user.id);
  const deleted = await service.findById(user.id);
  expect(deleted).toBeNull();
});

// Good — one behavior per test
test('should create user with correct name', async () => { ... });
test('should find user by ID after creation', async () => { ... });
test('should return null after deletion', async () => { ... });

2. Prefer Specific Assertions

Use specific assertions instead of weak ones:

// Bad — weak assertions
expect(result).toBeTruthy();
expect(result).toBeDefined();
expect(typeof result).toBe('object');

// Good — specific assertions
expect(result).toEqual({ id: 'user-123', name: 'Alice' });
expect(result.id).toBe('user-123');
expect(result.items).toHaveLength(3);

3. Use beforeEach for Isolation

Every test should start with a clean state:

let service: UserService;

beforeEach(() => {
  // Fresh instances for every test
  const repo = new InMemoryUserRepository();
  service = new UserService(repo);
});

4. Always Clean Up

E2E tests must clean up their servers and connections:

afterAll(async () => {
  await app.stop(); // Stop the server
});

5. No .only or .skip in Committed Code

Never commit .only() or .skip() on tests. These are debugging aids that should not reach the repository.

Summary

OriJS’s testing story is built on a simple foundation: plain classes with explicit dependencies are inherently testable. There is no TestingModule to bootstrap, no decorator metadata to configure, and no framework ceremony to satisfy. Services, guards, interceptors, and controllers are all just classes — instantiate them, pass in mocks or real implementations, and test.

The three-layer strategy — unit tests for isolated logic, functional tests for integration, E2E tests for full request flows — gives you confidence at every level. And the provider architecture means you can swap production infrastructure (Redis, BullMQ) for test-friendly alternatives (InMemory, InProcess) without changing your business logic.


Previous: Logging ← | Next: Advanced Patterns →