Virtual Test Container
What this covers: How Suites creates lightweight test environments without full framework initialization
Time to read: ~10 minutes
Prerequisites: Unit Testing Fundamentals
Best for: Understanding how Suites achieves speed and type safety under the hood
The Virtual Test Container analyzes classes and creates minimal test environments, removing full framework
initialization and manual mock setup while maintaining speed and type safety. Currently optimized for dependency
injection patterns, with support for additional IoC implementations coming soon with TestBed.manual.
Overview
This guide covers:
- What the Virtual Test Container is and its purpose
- The problems it solves (slow full containers vs brittle manual mocking)
- How it works under the hood with automatic mock generation
- Token injections as natural boundaries between logic and I/O
- Performance and type safety benefits
- Framework support across dependency injection systems
Concept
The Virtual Test Container analyzes class dependencies and creates minimal test environments. Instead of initializing the full application framework, it generates only the required mocks and wiring.
The Problem It Solves
Traditional IoC testing has two approaches, each with significant drawbacks:
Full container initialization:
- Slow (initializes entire application)
- Brittle (breaks when unrelated dependencies change)
- Complex (extensive mocking configuration)
Manual mocking:
- Massive boilerplate (many lines per test)
- No type safety (
as any/as unknown as ..casts everywhere) - Maintenance nightmare (update constructor = update every test)
How It Works
Suites reads class metadata and auto-generates only the required mocks:
@Injectable()
class UserService {
constructor(
private readonly userApi: UserApi,
private readonly database: Database
) {}
async generateRandomUser(): Promise<number> {
const user = await this.userApi.getRandomUser();
return this.database.saveUser(user);
}
}
import { type Mocked, TestBed } from '@suites/unit';
import { describe } from 'node:test';
let userApi: Mocked<UserApi>;
let database: Mocked<Database>;
describe('User Service Unit Spec', () => {
beforeAll(async () => {
// One call creates everything
const { unit, unitRef } = await TestBed.solitary(UserService).compile();
// Virtual container created:
// - UserService instance
// - Mocked UserApi
// - Mocked Database
userApi = unitRef.get(UserApi);
database = unitRef.get(Database);
});
describe('it should save a random user from the api in the database', async () => {
const dummyUser = { id: 1, name: 'John' };
userApi.getRandomUser.mockResolvedValue(dummyUser);
await unit.generateRandomUser();
expect(database.saveUser).toHaveBeenCalledWith(dummyUser);
});
})
What happened:
- Read
UserServiceconstructor metadata - Found
UserApiandDatabasedependencies - Auto-generated mocks
- Injected mocks into
UserService - Provided typed access via
unitRef
Token Injections Are Natural Boundaries
Token-injected dependencies are always mocked, regardless of test mode.
@Injectable()
class PaymentService {
constructor(
private validator: PaymentValidator, // Class (mode decides)
@Inject('STRIPE_API') private stripe: StripeApi, // Token - ALWAYS mocked
@Inject('APP_CONFIG') private config: AppConfig // Token - ALWAYS mocked
) {}
}
Token types and mocking behavior:
Class-based tokens are auto-mocked (all methods become stubs):
@Inject('STRIPE_API') private stripe: StripeApi
// ✅ Auto-mocked, methods are stubs
Interface/Type/Primitive tokens need explicit values:
Use .final() to provide values for interface/type/primitive tokens:
export interface DatabaseConfig {
readonly uri: string;
}
public contructor(@Inject('DB_CONFIG') private config: DatabaseConfig) {}
// In test - provide value using .final():
await TestBed.solitary(UserService)
.mock<DatabaseConfig>('DB_CONFIG')
.final({ uri: 'mongodb://localhost' })
.compile();
Why this matters:
Tokens typically represent external boundaries:
- Databases
- HTTP clients
- External APIs
- File systems
- Configuration objects
Mocking these dependencies keeps sociable tests as unit tests - they never touch external systems.
// Even in sociable mode with multiple real classes
await TestBed.sociable(OrderService)
.expose(PriceCalculator)
.expose(TaxCalculator)
.compile();
// But @Inject('DATABASE') is still auto-mocked
// Tests run fast, never hit real database
This creates natural separation between business logic (real) and external I/O (mocked) without manual configuration.
Reasons for Using Virtual Test Container
- Speed: Creates only required dependencies
- Full IoC/DI container: 500+ classes, 2-5 seconds
- Virtual container: 1-10 classes, 50-100ms
- Type Safety: Full TypeScript support throughout
- Automatic: No manual mock creation or wiring
- Framework Agnostic: Same approach for all supported frameworks
Next Steps
Explore how to use the virtual container in different testing modes:
- Solitary Unit Tests: Use the virtual container for complete isolation with automatic mocking
- Sociable Unit Tests: Use
.expose()for controlled collaboration