Testing Components Together (Sociable)
A sociable test is a unit test where selected dependencies are real. Use it to check how your class works with a collaborator’s actual business logic while keeping external dependencies mocked.
Start with Solitary Unit Tests or your framework quick start. Keep the same adapter and runner configuration. Unit Testing Fundamentals and Test Doubles are optional background.
Step 1: Define the service and its collaborators
Choose your framework’s version of src/user.service.ts. This example checks a user’s email through a real EmailValidator and saves the user through a database dependency injected with the 'DATABASE' token.
import 'reflect-metadata';import { Injectable, Inject } from '@nestjs/common';
export interface SavedUser { id: number; email: string;}
export interface DatabaseClient { users: { save(user: { email: string }): Promise<SavedUser>; };}
@Injectable()export class EmailValidator { isValid(email: string): boolean { return email.includes('@') && email.includes('.'); }}
@Injectable()export class UserService { constructor( private readonly validator: EmailValidator, @Inject('DATABASE') private readonly database: DatabaseClient, ) {}
async createUser(email: string): Promise<SavedUser> { if (!this.validator.isValid(email)) { throw new Error('Invalid email'); } return this.database.users.save({ email }); }}import 'reflect-metadata';import { injectable, inject } from 'inversify';
export interface SavedUser { id: number; email: string;}
export interface DatabaseClient { users: { save(user: { email: string }): Promise<SavedUser>; };}
@injectable()export class EmailValidator { isValid(email: string): boolean { return email.includes('@') && email.includes('.'); }}
@injectable()export class UserService { constructor( @inject(EmailValidator) private readonly validator: EmailValidator, @inject('DATABASE') private readonly database: DatabaseClient, ) {}
async createUser(email: string): Promise<SavedUser> { if (!this.validator.isValid(email)) { throw new Error('Invalid email'); } return this.database.users.save({ email }); }}import 'reflect-metadata';import { Injectable, Inject } from 'injection-js';
export interface SavedUser { id: number; email: string;}
export interface DatabaseClient { users: { save(user: { email: string }): Promise<SavedUser>; };}
@Injectable()export class EmailValidator { isValid(email: string): boolean { return email.includes('@') && email.includes('.'); }}
@Injectable()export class UserService { constructor( private readonly validator: EmailValidator, @Inject('DATABASE') private readonly database: DatabaseClient, ) {}
async createUser(email: string): Promise<SavedUser> { if (!this.validator.isValid(email)) { throw new Error('Invalid email'); } return this.database.users.save({ email }); }}NestJS and InjectionJS use @Inject() for the database token; InversifyJS uses @inject(). All three use the same test below. The validator performs a small illustrative format check, not full email address validation.
Step 2: Select the real collaborator
Call .expose(EmailValidator) to use its real implementation. The database remains mocked, so the test does not connect to a database.
import { beforeAll, beforeEach, describe, expect, it } from '@jest/globals';import { TestBed, type Mocked } from '@suites/unit';import { EmailValidator, UserService, type DatabaseClient } from './user.service';
describe('UserService, sociable', () => { let unit: UserService; let database: Mocked<DatabaseClient>;
beforeAll(async () => { const testBed = await TestBed.sociable(UserService) .expose(EmailValidator) .compile(); unit = testBed.unit; database = testBed.unitRef.get<DatabaseClient>('DATABASE'); });
beforeEach(() => { database.users.save.mockReset(); });
it('saves an email accepted by the real validator', async () => { database.users.save.mockResolvedValue({ id: 1, email: 'ada@example.com' });
await expect(unit.createUser('ada@example.com')).resolves.toEqual({ id: 1, email: 'ada@example.com', }); expect(database.users.save).toHaveBeenCalledWith({ email: 'ada@example.com' }); });
it('rejects an invalid email before accessing the database', async () => { await expect(unit.createUser('invalid')).rejects.toThrow('Invalid email'); expect(database.users.save).not.toHaveBeenCalled(); });
it('propagates a database error', async () => { database.users.save.mockRejectedValue(new Error('Connection failed'));
await expect(unit.createUser('ada@example.com')).rejects.toThrow('Connection failed'); });});For Vitest, change the runner import from @jest/globals to vitest. Keep the rest of the test unchanged.
beforeAll compiles the test bed once. beforeEach resets the database stub’s behavior and call history so each test configures its own scenario.
Step 3: Run the tests
Use your configured runner:
# Jestpnpm exec jest
# Vitestpnpm exec vitest runExpect three passing tests. They exercise real validation logic and service behavior while controlling database results and failures through the mock.
Choose which dependencies stay real
Select each real class explicitly with .expose(). For multiple real collaborators, chain another .expose(OtherCollaborator) before .compile() and check every exposed class’s dependencies for I/O.
Exposed classes must be class dependencies that the adapter can discover. For InjectionJS, they must use supported constructor injection. Token-injected dependencies remain mocked by default; use .mock<Dependency>(TOKEN).final(value) when you need a supplied value or implementation instead.
Neither exposed real classes nor values supplied with .final() can be retrieved through unitRef. See UnitReference for lookup rules.
Configure mocks before construction
Use .mock(Dependency).impl(...) inside beforeAll when the class needs a configured mock during construction. Use the returned unitRef for behavior configured after compilation, as in the complete test above.
A .final() replacement is injected as supplied. If you use it for the database, remove the unitRef lookup and the reset hook for that dependency. See Mock Configuration for complete setup examples.
Decision Framework
Choose the boundary for each dependency. Suites does not classify dependencies by whether they perform I/O.
- External I/O: Keep the dependency mocked or supply a controlled test double.
- Selected real classes: Use
.expose()when their real behavior belongs in the test. - Token values: Use
.mock(token).final(value)for supplied values or implementations. - Other injected dependencies: Leave them mocked by default.
Suites replaces dependencies resolved through the adapter. It does not intercept direct imports, detect I/O inside exposed code, or validate production module and container registrations. Use your framework’s application tests when those behaviors belong in the test.
Summary
- Compile the test bed in
beforeAlland select real classes with.expose(). - Leave external dependencies mocked and retrieve their mocks through
unitRef. - Reset shared stub state between independent scenarios.
- Check real business behavior together while keeping database and network behavior controlled.
Next steps
- How Suites works: Understand dependency discovery and test bed construction.
- Mock Configuration: Configure mocks before compilation or supply values.
- UnitReference: Retrieve generated mocks by class or token.