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
pnpm add --save-dev @justscale/testingTest Client
Create a test client to interact with your application:
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:
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:
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:
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():
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
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:
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:
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:
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:
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:
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:
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:
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
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);
});
});