<!-- Markdown mirror of https://justscale.sh/docs/techniques/testing -->

# 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

```bash
pnpm add --save-dev @justscale/testing
```

## Test Client

Create a test client to interact with your application:

test/api.test.tsTypeScript

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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([
        { 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

```typescript
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, {
      get: mockFn().returns(Promise.resolve({
        name: 'Mocked Player',
        chips: 500,
      })),
      save: mockFn().returns(Promise.resolve({
        name: 'Saved Player',
        chips: 1000,
      })),
    });

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

## Testing with createTestApp

For lower-level testing without HTTP, use `createTestApp`:

test/create-test-app.test.tsTypeScript

```typescript
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, {
  get: mockFn().returns(Promise.resolve({ name: 'Test User' })),
});

// 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

```typescript
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

```typescript
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

```typescript
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

```typescript
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 mockGet = mockFn().returns(Promise.resolve(null));

    await mockGet(Player.ref`123`);
    await mockGet(Player.ref`456`);

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

    // Verify called with specific args
    assertCalledWith(mockGet, [Player.ref`123`]);

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

## Cleanup

Always clean up test clients to avoid port conflicts:

test/cleanup.test.tsTypeScript

```typescript
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

```typescript
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

```typescript
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', async () => {
    const { status, data } = await api.player.getOne({ playerId: '123' });

    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);
  });
});
```

## Next Steps

- Validation
- Error Handling
- Controllers
