Testing Components Together (Sociable Testing)
What this covers: Testing real component (class) interactions while controlling external dependencies
Time to read: ~15 minutes
Prerequisites: Unit Testing Fundamentals, Solitary Unit Tests, Test Doubles
Best for: Verifying components (classes) work together correctly, catching integration bugs while maintaining test speed
Sociable unit tests verify how components interact with their real dependencies while keeping external I/O operations under control. This approach identifies bugs in how business logic components work together - bugs that solitary tests miss.
Sociable Tests Are Unit Tests
Sociable tests are unit tests, not integration tests. They keep external I/O (databases, APIs, file systems) mocked to stay fast and side-effect-free. The integration bugs they catch are issues in how business logic components work with each other - not issues with real external systems.
For the distinction, see Unit Testing Fundamentals: Quick Reference.
Overview
This tutorial covers:
- Setting up a sociable test with real dependencies
- Handling external dependencies and I/O operations
- Managing multiple dependencies using
.expose() - Choosing when to configure mocks (before vs after compilation)
- Using a decision framework to choose what to mock
Step 1: Set Up the First Sociable Test
This example tests a UserService that depends on an EmailValidator.
1.1 Create the Services
@Injectable()
export class EmailValidator {
isValid(email: string): boolean {
return email.includes('@') && email.includes('.');
}
}
@Injectable()
export class UserService {
constructor(private validator: EmailValidator) {}
createUser(email: string) {
if (!this.validator.isValid(email)) {
throw new Error('Invalid email');
}
return { email };
}
}
1.2 Write the Sociable Test
The .expose() method tells Suites to use the real implementation instead of a mock.
import { TestBed } from '@suites/unit';
import { UserService, EmailValidator } from './services';
describe('UserService', () => {
let userService: UserService;
beforeAll(async () => {
const { unit } = await TestBed.sociable(UserService)
.expose(EmailValidator) // Use real EmailValidator
.compile();
userService = unit;
});
it('validates email using real logic', () => {
const result = userService.createUser('test@example.com');
expect(result.email).toBe('test@example.com');
});
it('rejects invalid email', () => {
expect(() => userService.createUser('invalid'))
.toThrow('Invalid email');
});
});
The real EmailValidator runs its actual validation logic. If the validator has a bug, this test will detect it.
Step 2: Handle External Dependencies
Most services interact with external systems like databases. This example extends the previous one.
2.1 Add Database Dependency
@Injectable()
export class UserService {
constructor(
private validator: EmailValidator,
@Inject('DATABASE') private db: DatabaseClient // Token injection
) {}
async createUser(email: string) {
if (!this.validator.isValid(email)) {
throw new Error('Invalid email');
}
return this.db.users.save({ email });
}
}
2.2 Test with Mocked I/O
Dependencies injected using tokens are automatically mocked.
import { TestBed, Mocked } from '@suites/unit';
describe('UserService', () => {
let userService: UserService;
let database: Mocked<DatabaseClient>;
beforeAll(async () => {
const { unit, unitRef } = await TestBed.sociable(UserService)
.expose(EmailValidator)
.compile();
userService = unit;
database = unitRef.get<DatabaseClient>('DATABASE');
});
it('saves valid user', async () => {
database.users.save.mockResolvedValue({ id: 1, email: 'test@example.com' });
const result = await userService.createUser('test@example.com');
expect(result.id).toBe(1);
expect(database.users.save).toHaveBeenCalledWith({ email: 'test@example.com' });
});
});
Third-party packages use @Inject('TOKEN') because they're not @Injectable() classes. Dependencies injected this way are always mocked automatically.
Step 3: Manage Multiple Dependencies
As services grow, you need to manage more dependencies. Here's how to handle them:
3.1 Service with Many Dependencies
@Injectable()
export class OrderService {
constructor(
private pricingService: PricingService,
private taxCalculator: TaxCalculator,
private inventoryChecker: InventoryChecker,
private discountEngine: DiscountEngine,
@Inject('DATABASE') private db: DatabaseClient,
@Inject('EMAIL_SERVICE') private email: EmailClient
) {}
async processOrder(items: OrderItem[], region: string) {
const subtotal = this.pricingService.calculateTotal(items);
const tax = this.taxCalculator.calculateTax(subtotal, region);
const total = subtotal + tax;
const order = await this.db.orders.create({ items, subtotal, tax, total });
await this.email.send({ to: order.customerEmail, template: 'order-confirmation' });
return order;
}
}
3.2 Using .expose()
List each dependency that should use its real implementation.
describe('OrderService', () => {
let orderService: OrderService;
let database: Mocked<DatabaseClient>;
beforeAll(async () => {
const { unit, unitRef } = await TestBed.sociable(OrderService)
.expose(PricingService) // Use real implementation
.expose(TaxCalculator) // Use real implementation
.expose(InventoryChecker) // Use real implementation
.expose(DiscountEngine) // Use real implementation
.compile();
orderService = unit;
database = unitRef.get<DatabaseClient>('DATABASE');
});
it('processes order with real calculations', async () => {
const items = [{ price: 10, quantity: 6 }];
database.orders.create.mockResolvedValue({ id: 123 });
await orderService.processOrder(items, 'US');
// All exposed services run real code
expect(database.orders.create).toHaveBeenCalledWith({
items,
subtotal: 54, // Real discount calculation
tax: 4.32, // Real tax calculation
total: 58.32
});
});
});
Suites can only control explicit dependencies (passed through constructors). It cannot control implicit dependencies (direct imports). For more, see Test Doubles.
Step 4: Choose When to Configure Mocks
You can configure mocks before compilation (for consistent values) or after compilation (for test-specific scenarios).
- Before Compilation
- After Compilation
When to use: Set default values that all tests in the suite need.
Best for: Shared test data, reducing repetitive setup code
beforeAll(async () => {
const { unit, unitRef } = await TestBed.sociable(UserService)
.expose(EmailValidator)
.mock('DATABASE')
.final({
users: {
save: async () => ({ id: 42, email: 'test@example.com' })
}
})
.compile();
userService = unit;
// Database always returns the same value
});
it('creates user', async () => {
const result = await userService.createUser('test@example.com');
expect(result.id).toBe(42); // Always 42
});
When to use: Configure different behaviors for individual tests.
Best for: Test-specific scenarios, error cases, varying responses
beforeAll(async () => {
const { unit, unitRef } = await TestBed.sociable(UserService)
.expose(EmailValidator)
.compile();
userService = unit;
database = unitRef.get<DatabaseClient>('DATABASE');
});
it('handles save error', async () => {
// Configure for this specific test
database.users.save.mockRejectedValue(new Error('Connection failed'));
await expect(userService.createUser('test@example.com'))
.rejects.toThrow('Connection failed');
});
it('handles successful save', async () => {
// Different configuration for different test
database.users.save.mockResolvedValue({ id: 99 });
const result = await userService.createUser('test@example.com');
expect(result.id).toBe(99);
});
Decision Framework
Use this flowchart to decide how to handle each dependency:
Rules:
- External I/O (databases, APIs, file systems) → Use token injection → Auto-mocked
- Business logic to test → Use
.expose()→ Runs real code - Everything else → Leave as default → Auto-mocked
Summary
Sociable tests work alongside solitary tests to provide comprehensive coverage:
- Solitary tests: Verify individual class behavior in isolation
- Sociable tests: Verify components work together correctly
Takeaways
- Sociable tests verify how components interact using real implementations for business logic
- External systems (I/O) are always mocked using token injection (
@Inject('TOKEN')) - Use
.expose()to specify which dependencies should use real implementations - Configure mocks before compilation for consistent values, or after for test-specific scenarios
- Follow the decision tree: External I/O → token injection, business logic → expose, everything else stays mocked
Next Steps
After understanding sociable testing, explore these resources:
- Test Doubles: Core concepts of mocking and stubbing
- Suites Examples Repository: Working examples of sociable testing patterns