Testing

Test your application with @justscale/testing

The @justscale/testing package provides utilities for testing JustScale applications with full type safety. Test your controllers, services, and middleware using Node.js test runner or your preferred test framework.

Installation

Bash
pnpm add --save-dev @justscale/testing

Test Client

Create a test client to interact with your application:

test/api.test.tsTypeScript
import { describe, it, after } from 'node:test';
import assert from 'node:assert';
import { createTestClient } from '@justscale/testing';
import { httpTransport } from '@justscale/http/testing';
import { app } from '../src/app';

describe('API Tests', async () => {
  const client = await createTestClient(app, {
    transports: { http: httpTransport },
    transportOptions: { http: { port: 0 } }
  });

  after(() => client.close());

  it('should create a player', async () => {
    const { status, data } = await client.http.post('/players', {
      name: 'Alice',
      chips: 1000,
    });

    assert.strictEqual(status, 200);
    assert.strictEqual(data.player.name, 'Alice');
  });
});

Typed Controller Access

Get type-safe access to your controllers for better DX:

test/typed-api.test.tsTypeScript
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { createTestClient } from '@justscale/testing';
import { httpTransport } from '@justscale/http/testing';
import { app } from '../src/app';
import { PlayersController } from '../src/controllers/players';

describe('Typed API', async () => {
  const client = await createTestClient(app, {
    transports: { http: httpTransport },
    transportOptions: { http: { port: 0 } }
  });

  const api = client.http.controllers({
    player: PlayersController,
  });

  it('supports typed requests', async () => {
    // GET /players/:playerId
    await api.player.getOne({ playerId: '123' });

    // POST /players with body
    await api.player.create({ name: 'Bob', chips: 500 });

    // PATCH /players/:playerId with body
    const { data } = await api.player.update({
      playerId: '123',
      name: 'Updated Name',
    });

    assert.strictEqual(data.player.name, 'Updated Name');
  });
});

Service Access

Access services directly for unit testing:

test/services.test.tsTypeScript
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { createTestClient } from '@justscale/testing';
import { httpTransport } from '@justscale/http/testing';
import { app } from '../src/app';
import { PlayerRepository } from '../src/services/player-repository';

describe('Service Tests', async () => {
  const client = await createTestClient(app, {
    transports: { http: httpTransport },
  });

  const { players } = client.services({
    players: PlayerRepository,
  });

  it('should test services directly', async () => {
    const allPlayers = await players.find();
    assert.ok(Array.isArray(allPlayers));

    const player = await players.save({ name: 'Alice', chips: 1000 });
    assert.strictEqual(player.name, 'Alice');
  });
});

Mocking and Spies

Spy on Service Methods

Use spyOn() to track calls to service methods:

test/spy.test.tsTypeScript
import { describe, it } from 'node:test';
import { createTestClient, spyOn, assertCallCount } from '@justscale/testing';
import { httpTransport } from '@justscale/http/testing';
import { app } from '../src/app';
import { PlayerRepository } from '../src/services/player-repository';

describe('Spy Tests', async () => {
  const client = await createTestClient(app, {
    transports: { http: httpTransport },
  });

  it('should spy on service methods', async () => {
    const { players } = client.services({ players: PlayerRepository });

    // Use 'using' for automatic cleanup
    using spy = spyOn(players);

    await players.find();

    assertCallCount(spy.spied.find, 1);
    // Original methods automatically restored when block exits
  });
});

Mock Functions

Create mock functions with mockFn():

test/mock-fn.test.tsTypeScript
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { mockFn, assertCallCount } from '@justscale/testing';

describe('Mock Function Tests', () => {
  it('should create mock functions', async () => {
    const mockFind = mockFn()
      .returns(Promise.resolve([
        { id: '1', name: 'Alice', chips: 1000 }
      ]));

    // Use in service mock
    const mockService = {
      find: mockFind,
    };

    const result = await mockService.find();
    assert.strictEqual(result[0].name, 'Alice');
    assertCallCount(mockFind, 1);
  });
});

Mock Service Methods

test/mock-service.test.tsTypeScript
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { mockService, mockFn } from '@justscale/testing';
import { PlayerRepository } from '../src/services/player-repository';

describe('Mock Service Tests', () => {
  it('should mock service methods', async () => {
    const mockedPlayers = mockService(PlayerRepository, {
      findById: mockFn().returns(Promise.resolve({
        id: '1',
        name: 'Mocked Player',
        chips: 500,
      })),
      save: mockFn().returns(Promise.resolve({
        id: '2',
        name: 'Saved Player',
        chips: 1000,
      })),
    });

    const player = await mockedPlayers.findById('1');
    assert.strictEqual(player.name, 'Mocked Player');
  });
});

Testing with createTestApp

For lower-level testing without HTTP, use createTestApp:

test/create-test-app.test.tsTypeScript
import { createTestApp, mockFn } from '@justscale/testing';
import { UserRepository } from '../src/repositories/user-repository';
import { UserService } from '../src/services/user-service';
import { UsersController } from '../src/controllers/users';

const app = createTestApp({
  services: [UserRepository, UserService],
  controllers: [UsersController],
});

// Mock a dependency
app.mock(UserRepository, {
  findById: mockFn().returns(Promise.resolve({ id: '1' })),
});

// Access services
const userService = app.service(UserService);

// Use the app for testing
const matched = app.match('GET', '/users/1');

Testing Validation

Test that validation errors are handled correctly:

test/validation.test.tsTypeScript
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { createTestClient } from '@justscale/testing';
import { httpTransport } from '@justscale/http/testing';
import { app } from '../src/app';
import { PlayersController } from '../src/controllers/players';

describe('Validation Tests', async () => {
  const client = await createTestClient(app, {
    transports: { http: httpTransport },
  });

  const api = client.http.controllers({
    player: PlayersController,
  });

  it('should reject invalid input', async () => {
    const { status, data } = await api.player.create({
      name: '', // Invalid: empty name
      chips: -100, // Invalid: negative chips
    } as any);

    assert.strictEqual(status, 400);
    assert.ok(data.error);
  });

  it('should accept valid input', async () => {
    const { status, data } = await api.player.create({
      name: 'Valid Player',
      chips: 1000,
    });

    assert.strictEqual(status, 200);
    assert.strictEqual(data.player.name, 'Valid Player');
  });
});

Testing Authentication

Use createUserSession helper for auth testing:

test/auth.test.tsTypeScript
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { createTestClient } from '@justscale/testing';
import { createUserSession } from '@justscale/http/testing';
import { httpTransport } from '@justscale/http/testing';
import { app } from '../src/app';
import { AuthController } from '../src/controllers/auth';
import { ProtectedController } from '../src/controllers/protected';

describe('Auth Tests', async () => {
  const client = await createTestClient(app, {
    transports: { http: httpTransport },
  });

  const api = client.http.controllers({
    auth: AuthController,
    protected: ProtectedController,
  });

  it('should handle user sessions', async () => {
    const user = createUserSession(api, {
      captureToken: (route, res) => {
        if (route === 'auth.register') {
          return res.data?.token;
        }
      },
    });

    // Register - token is auto-captured
    await user.api.auth.register({
      email: 'test@example.com',
      password: 'password123',
    });

    // Already authenticated
    const { status, data } = await user.api.protected.profile();
    assert.strictEqual(status, 200);
    assert.strictEqual(data.email, 'test@example.com');
  });
});

Multi-User Testing

Test interactions between multiple users:

test/multi-user.test.tsTypeScript
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { createTestClient } from '@justscale/testing';
import { createUserSession } from '@justscale/http/testing';
import { httpTransport } from '@justscale/http/testing';
import { app } from '../src/app';
import { AuthController } from '../src/controllers/auth';
import { ProtectedController } from '../src/controllers/protected';

describe('Multi-User Tests', async () => {
  const client = await createTestClient(app, {
    transports: { http: httpTransport },
  });

  const api = client.http.controllers({
    auth: AuthController,
    protected: ProtectedController,
  });

  it('should handle multiple users independently', async () => {
    const alice = createUserSession(api);
    const bob = createUserSession(api);

    // Alice registers
    const aliceRes = await alice.api.auth.register({
      email: 'alice@example.com',
      password: 'password123',
    });
    alice.setToken(aliceRes.data.token);

    // Bob registers
    const bobRes = await bob.api.auth.register({
      email: 'bob@example.com',
      password: 'password123',
    });
    bob.setToken(bobRes.data.token);

    // Each sees their own profile
    const aliceProfile = await alice.api.protected.profile();
    const bobProfile = await bob.api.protected.profile();

    assert.strictEqual(aliceProfile.data.email, 'alice@example.com');
    assert.strictEqual(bobProfile.data.email, 'bob@example.com');
  });
});

Assertion Helpers

Use built-in assertion helpers for cleaner tests:

test/assertions.test.tsTypeScript
import { describe, it } from 'node:test';
import {
  mockFn,
  assertCalledWith,
  assertCallCount,
  assertNotCalled,
} from '@justscale/testing';

describe('Assertion Helpers', () => {
  it('should verify mock function calls', async () => {
    const mockFindById = mockFn().returns(Promise.resolve(null));

    await mockFindById('123');
    await mockFindById('456');

    // Verify call count
    assertCallCount(mockFindById, 2);

    // Verify called with specific args
    assertCalledWith(mockFindById, ['123']);

    // Verify not called
    const mockDelete = mockFn();
    assertNotCalled(mockDelete);
  });
});

Cleanup

Always clean up test clients to avoid port conflicts:

test/cleanup.test.tsTypeScript
import { describe, it, after } from 'node:test';
import { createTestClient } from '@justscale/testing';
import { httpTransport } from '@justscale/http/testing';
import { app } from '../src/app';

describe('Tests', async () => {
  const client = await createTestClient(app, {
    transports: { http: httpTransport },
  });

  // Clean up after all tests
  after(() => client.close());

  it('test case 1', async () => {
    // Test code
  });
});

Use using keyword for automatic cleanup:

test/using-cleanup.test.tsTypeScript
import { spyOn } from '@justscale/testing';

it('should spy on methods', async () => {
  using spy = spyOn(service);

  // Test code

  // Spy automatically cleaned up when block exits
});

Best Practices

  • Use typed controllers - Better DX with autocomplete
  • Test at multiple levels - Unit tests for services, integration tests for controllers
  • Mock external dependencies - Keep tests fast and isolated
  • Clean up resources - Always close test clients
  • Use assertion helpers - More readable test code
  • Test error cases - Verify validation and error handling

Example Test Suite

test/players.test.tsTypeScript
import { describe, it, after } from 'node:test';
import assert from 'node:assert';
import { createTestClient, spyOn, assertCallCount } from '@justscale/testing';
import { httpTransport } from '@justscale/http/testing';
import { app } from '../src/app';
import { PlayersController } from '../src/controllers/players';
import { PlayerRepository } from '../src/services/player-repository';

describe('Players API', async () => {
  const client = await createTestClient(app, {
    transports: { http: httpTransport },
    transportOptions: { http: { port: 0 } }
  });

  const api = client.http.controllers({
    player: PlayersController,
  });

  after(() => client.close());

  it('should list all players', async () => {
    const { status, data } = await api.player.list();
    assert.strictEqual(status, 200);
    assert.ok(Array.isArray(data.players));
  });

  it('should create a player', async () => {
    const { status, data } = await api.player.create({
      name: 'Alice',
      chips: 2000,
    });

    assert.strictEqual(status, 200);
    assert.strictEqual(data.player.name, 'Alice');
    assert.strictEqual(data.player.chips, 2000);
  });

  it('should get a player by ID', async () => {
    const createRes = await api.player.create({ name: 'Bob', chips: 500 });
    const playerId = createRes.data.player.id;

    const { status, data } = await api.player.getOne({ playerId });

    assert.strictEqual(status, 200);
    assert.strictEqual(data.player.name, 'Bob');
  });

  it('should spy on repository calls', async () => {
    const { players } = client.services({ players: PlayerRepository });

    using spy = spyOn(players);

    await players.find();
    assertCallCount(spy.spied.find, 1);
  });
});