Skip to main content

Testing

Testing is a crucial part of software development. Rikta's architecture makes testing straightforward thanks to its dependency injection system.

Introduction​

Rikta applications are designed to be testable:

  • Dependency Injection allows easy mocking
  • No complex module system simplifies test setup
  • Type safety catches errors at compile time

Unit Testing​

Testing Services​

Services are plain classes that can be tested in isolation:

import { describe, it, expect, beforeEach } from 'vitest';
import { UserService } from './user.service';

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

beforeEach(() => {
userService = new UserService();
});

it('should create a user', () => {
const user = userService.create({ name: 'John', email: '[email protected]' });

expect(user.id).toBeDefined();
expect(user.name).toBe('John');
expect(user.email).toBe('[email protected]');
});

it('should find a user by id', () => {
const created = userService.create({ name: 'Jane', email: '[email protected]' });
const found = userService.findById(created.id);

expect(found).toEqual(created);
});
});

Mocking Dependencies​

When a service has dependencies, mock them:

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { UserService } from './user.service';
import { DatabaseService } from './database.service';
import { LoggerService } from './logger.service';

describe('UserService', () => {
let userService: UserService;
let mockDatabase: DatabaseService;
let mockLogger: LoggerService;

beforeEach(() => {
// Create mocks
mockDatabase = {
query: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
} as unknown as DatabaseService;

mockLogger = {
log: vi.fn(),
error: vi.fn(),
} as unknown as LoggerService;

// Create service with mocked dependencies
userService = new UserService();
(userService as any).database = mockDatabase;
(userService as any).logger = mockLogger;
});

it('should find user from database', async () => {
const mockUser = { id: '1', name: 'John', email: '[email protected]' };
(mockDatabase.query as any).mockResolvedValue([mockUser]);

const user = await userService.findById('1');

expect(mockDatabase.query).toHaveBeenCalledWith(
'SELECT * FROM users WHERE id = $1',
['1']
);
expect(user).toEqual(mockUser);
});

it('should log when creating user', async () => {
const userData = { name: 'Jane', email: '[email protected]' };
(mockDatabase.insert as any).mockResolvedValue({ id: '2', ...userData });

await userService.create(userData);

expect(mockLogger.log).toHaveBeenCalledWith(
expect.stringContaining('Creating user')
);
});
});

Testing Controllers​

Controllers can be unit tested similarly:

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { UserController } from './user.controller';
import { UserService } from './user.service';

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

beforeEach(() => {
mockUserService = {
findAll: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
} as unknown as UserService;

controller = new UserController();
(controller as any).userService = mockUserService;
});

describe('findAll', () => {
it('should return all users', async () => {
const mockUsers = [
{ id: '1', name: 'John' },
{ id: '2', name: 'Jane' },
];
(mockUserService.findAll as any).mockResolvedValue(mockUsers);

const result = await controller.findAll();

expect(result).toEqual(mockUsers);
expect(mockUserService.findAll).toHaveBeenCalled();
});
});

describe('findOne', () => {
it('should return a user by id', async () => {
const mockUser = { id: '1', name: 'John' };
(mockUserService.findById as any).mockResolvedValue(mockUser);

const result = await controller.findOne('1');

expect(result).toEqual(mockUser);
expect(mockUserService.findById).toHaveBeenCalledWith('1');
});
});
});

Integration Testing​

Testing with the Container​

Test how components work together:

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { container } from '@riktajs/core';
import { UserService } from './user.service';
import { DatabaseService } from './database.service';

describe('UserService Integration', () => {
beforeEach(async () => {
// Setup test database
const database = container.resolve(DatabaseService);
await database.migrate();
});

afterEach(async () => {
// Cleanup
const database = container.resolve(DatabaseService);
await database.truncate('users');
});

it('should create and retrieve user', async () => {
const userService = container.resolve(UserService);

const created = await userService.create({
name: 'John',
email: '[email protected]',
});

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

expect(found).toEqual(created);
});
});

E2E Testing​

Test the full HTTP stack:

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Rikta } from '@riktajs/core';

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

beforeAll(async () => {
app = await Rikta.create({
port: 0, // Random port
autowired: ['./src'],
});
await app.listen();
baseUrl = `http://localhost:${app.getPort()}`;
});

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

describe('GET /users', () => {
it('should return empty array initially', async () => {
const response = await fetch(`${baseUrl}/users`);
const data = await response.json();

expect(response.status).toBe(200);
expect(data).toEqual([]);
});
});

describe('POST /users', () => {
it('should create a new user', async () => {
const response = await fetch(`${baseUrl}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'John Doe',
email: '[email protected]',
}),
});
const data = await response.json();

expect(response.status).toBe(201);
expect(data.id).toBeDefined();
expect(data.name).toBe('John Doe');
});

it('should validate input', async () => {
const response = await fetch(`${baseUrl}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: '', // Invalid
}),
});

expect(response.status).toBe(400);
});
});
});

Testing Utilities​

Creating Test Fixtures​

// test/fixtures/user.fixture.ts
export function createUserFixture(overrides = {}) {
return {
id: crypto.randomUUID(),
name: 'Test User',
email: '[email protected]',
createdAt: new Date(),
...overrides,
};
}

// Usage in tests
const user = createUserFixture({ name: 'Custom Name' });

Mock Factory​

// test/mocks/index.ts
import { vi } from 'vitest';

export function createMockUserService() {
return {
findAll: vi.fn().mockResolvedValue([]),
findById: vi.fn().mockResolvedValue(null),
create: vi.fn().mockImplementation((data) => ({
id: crypto.randomUUID(),
...data,
})),
update: vi.fn().mockResolvedValue(null),
delete: vi.fn().mockResolvedValue(true),
};
}

export function createMockLogger() {
return {
log: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
};
}

Test Configuration​

Vitest Configuration​

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['**/*.test.ts', '**/*.spec.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: ['node_modules', 'dist', '**/*.test.ts'],
},
setupFiles: ['./test/setup.ts'],
},
});

Test Setup File​

// test/setup.ts
import { beforeAll, afterAll, afterEach } from 'vitest';
import { Container, registry } from '@riktajs/core';

beforeAll(async () => {
// Global test setup
process.env.NODE_ENV = 'test';
});

afterEach(() => {
// Reset mocks between tests
vi.clearAllMocks();
});

afterAll(async () => {
// Global cleanup
Container.reset();
});

Resetting Singletons for Testing​

Rikta uses the Singleton pattern for its Container and Registry. This can cause test isolation issues if not handled properly.

The Problem​

// ❌ Problem: Tests share state
describe('Test Suite A', () => {
it('registers a service', () => {
@Injectable()
class ServiceA {}
// ServiceA is now in the global container
});
});

describe('Test Suite B', () => {
it('may see ServiceA from previous test', () => {
// ServiceA might still be registered!
});
});

The Solution: Reset Between Tests​

Use Container.reset() and Registry.reset() to create fresh instances:

import { Container, registry } from '@riktajs/core';
import { beforeEach, afterEach } from 'vitest';

describe('MyService', () => {
beforeEach(() => {
// Reset to fresh state before each test
Container.reset();
registry.reset();
});

it('test 1', () => {
// Fresh container and registry
});

it('test 2', () => {
// Another fresh container and registry
});
});

Important: Re-register After Reset​

When you call Container.reset(), all registered providers are cleared. If your test defines classes with @Injectable(), they register at decoration time (when the file is loaded), not at test time.

import { Injectable, Container, container } from '@riktajs/core';

// This registers when the file loads (before reset!)
@Injectable()
class MyService {
getValue() { return 42; }
}

describe('MyService', () => {
beforeEach(() => {
Container.reset();
// MyService is no longer registered!
});

it('should work', () => {
// ❌ This fails - MyService not in the new container
const service = container.resolve(MyService);
});
});

Solution: Define Test Classes Inside Tests​

import { Injectable, Container, container } from '@riktajs/core';

describe('MyService', () => {
beforeEach(() => {
Container.reset();
});

it('should work', () => {
// ✅ Define AFTER reset
@Injectable()
class MyService {
getValue() { return 42; }
}

const service = container.resolve(MyService);
expect(service.getValue()).toBe(42);
});
});

Or: Manually Register​

import { Container, container } from '@riktajs/core';

class MyService {
getValue() { return 42; }
}

describe('MyService', () => {
beforeEach(() => {
Container.reset();
// Manually register after reset
container.register(MyService);
});

it('should work', () => {
const service = container.resolve(MyService);
expect(service.getValue()).toBe(42);
});
});

Reset Methods Reference​

ClassMethodDescription
ContainerContainer.reset()Creates new container, clears all singletons and providers
Containercontainer.clearSingletons()Clears singleton instances only (keeps registrations)
RegistryRegistry.reset()Creates new registry, clears all registered classes
Registryregistry.clearConfigProviders()Clears config provider registrations only

Complete Test Setup Example​

// test/setup.ts
import { beforeEach, afterEach, vi } from 'vitest';
import { Container, Registry } from '@riktajs/core';

// Reset state before each test for isolation
beforeEach(() => {
Container.reset();
Registry.reset();
});

// Clear mocks after each test
afterEach(() => {
vi.clearAllMocks();
});
// user.service.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { Injectable, Autowired, container } from '@riktajs/core';

describe('UserService', () => {
// Define test classes inside describe block
@Injectable()
class MockDatabase {
query = vi.fn().mockResolvedValue([]);
}

@Injectable()
class UserService {
@Autowired()
private db!: MockDatabase;

async findAll() {
return this.db.query('SELECT * FROM users');
}
}

it('should query database', async () => {
const service = container.resolve(UserService);
await service.findAll();

expect(container.resolve(MockDatabase).query).toHaveBeenCalled();
});
});
Important

Always reset both Container AND Registry when testing code that uses decorators like @Injectable() or @Controller(). The registry tracks which classes have been decorated.

Clearing Router Caches​

The Router caches guard and middleware instances for performance. In testing scenarios with hot-reload or when testing different guard configurations, you may need to clear these caches.

Using the Application Method​

import { describe, it, afterEach } from 'vitest';
import { Rikta } from '@riktajs/core';

describe('API Tests', () => {
let app: RiktaApplication;

beforeAll(async () => {
app = await Rikta.create({ port: 0, silent: true });
});

afterEach(() => {
// Clear cached guard and middleware instances
app.clearRouterCaches();
});

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

Using the Router Directly​

For more granular control, access the router directly:

const router = app.getRouter();

// Clear only guard cache
router.clearGuardCache();

// Clear only middleware cache
router.clearMiddlewareCache();

// Clear both
router.clearAllCaches();

// Check cache sizes (for debugging)
console.log('Guards cached:', router.getGuardCacheSize());
console.log('Middleware cached:', router.getMiddlewareCacheSize());

When to Clear Caches​

ScenarioAction
Between test suitesapp.clearRouterCaches()
After modifying guardsrouter.clearGuardCache()
Hot-reload in developmentapp.clearRouterCaches()
Memory debuggingCheck cache sizes first

Best Practices​

1. Test One Thing at a Time​

// ✅ Good - focused test
it('should return 404 when user not found', async () => {
mockUserService.findById.mockResolvedValue(null);

await expect(controller.findOne('999')).rejects.toThrow(NotFoundException);
});

// ❌ Avoid - testing too much
it('should handle users', async () => {
// Creates, finds, updates, deletes... too much!
});

2. Use Descriptive Names​

// ✅ Good - clear what's being tested
describe('UserService.create', () => {
it('should hash password before saving', async () => {});
it('should throw if email already exists', async () => {});
it('should emit UserCreated event', async () => {});
});

3. Arrange-Act-Assert Pattern​

it('should update user name', async () => {
// Arrange
const user = createUserFixture();
mockDatabase.query.mockResolvedValue([user]);

// Act
const result = await userService.update(user.id, { name: 'New Name' });

// Assert
expect(result.name).toBe('New Name');
expect(mockDatabase.update).toHaveBeenCalled();
});

4. Don't Test Implementation Details​

// ✅ Good - tests behavior
it('should return user with hashed password', async () => {
const user = await userService.create({ password: 'plain' });
expect(user.password).not.toBe('plain');
});

// ❌ Avoid - tests implementation
it('should call bcrypt.hash with 10 rounds', async () => {
await userService.create({ password: 'plain' });
expect(bcrypt.hash).toHaveBeenCalledWith('plain', 10);
});