Skip to content

Commit f3da831

Browse files
authored
feat(core.unit,unit): boundaries mode and fail fast (#905)
Introduces boundaries mode and fail-fast behavior for sociable unit tests in v4.0.0. Boundaries mode provides a blacklist strategy where all dependencies are real by default, and only specified boundaries (external services, databases) are mocked. This simplifies configuration when most dependencies should execute real business logic. Fail-fast behavior is now enabled by default, throwing DependencyNotConfiguredError with helpful mode-specific error messages when accessing unconfigured dependencies. This catches configuration errors early in the development cycle. Key features: - boundaries([...]) API for blacklist-style mocking - Auto-exposure of leaf classes (no dependencies) in boundaries mode - Tokens/primitives always mocked as natural boundaries - 7-level resolution priority system for predictable behavior - Mode-specific error messages with actionable suggestions - .disableFailFast() migration helper (deprecated, removed in v5) Resolution priority: 1. Explicit mocks (.mock().impl()) 2. Boundaries (in boundaries mode) 3. Tokens/Primitives (always mocked, leaf classes auto-exposed in boundaries) 4. Auto-expose (boundaries mode) 5. Explicit expose (expose mode) 6. Fail-fast or auto-mock
1 parent bf3aacf commit f3da831

18 files changed

+2307
-866
lines changed
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { TestBed } from '@suites/unit';
2+
import type { Repository } from './e2e-assets-sociable';
3+
import {
4+
UserService,
5+
UserApiService,
6+
UserDal,
7+
ApiService,
8+
DatabaseService,
9+
UserVerificationService,
10+
UserDigestService,
11+
Logger,
12+
HttpClient,
13+
type User,
14+
} from './e2e-assets-sociable';
15+
16+
/**
17+
* E2E tests for Boundaries and Fail-Fast features (v4.0.0)
18+
*
19+
* These tests simulate real-world usage from the user's perspective.
20+
* Goal: Verify that boundaries mode works as intended for QozbroQqn's use case:
21+
* - Most dependencies are real (business logic executes)
22+
* - Only expensive/external services are mocked (boundaries)
23+
* - Tokens are auto-mocked (don't need boundaries declaration)
24+
*/
25+
describe('Boundaries and Fail-Fast - Real World E2E', () => {
26+
describe('Real-world boundaries usage: Mock only expensive services', () => {
27+
it('should execute real business logic while mocking only expensive ApiService', async () => {
28+
// ARRANGE: Mock only the expensive HTTP service, everything else real
29+
const { unit, unitRef } = await TestBed.sociable(UserService)
30+
.boundaries([ApiService]) // Only mock expensive HTTP calls
31+
.mock<Repository>('Repository') // Token - must be explicitly mocked for verification
32+
.impl((stub) => ({
33+
find: stub().mockResolvedValue([]),
34+
create: stub().mockResolvedValue(undefined),
35+
}))
36+
.compile();
37+
38+
// ACT: Call business logic
39+
const validUser: User = { name: 'John', email: '[email protected]' };
40+
const result = await unit.create(validUser);
41+
42+
// ASSERT: Real business logic executed
43+
expect(result).toEqual(validUser);
44+
45+
// ASSERT: Real UserVerificationService.verify() was called (has @ in email)
46+
// (proven by the fact that create succeeded - real validation logic ran)
47+
48+
// ASSERT: Token (Repository) was mocked and used
49+
const mockRepo = unitRef.get<Repository>('Repository');
50+
expect(mockRepo.create).toHaveBeenCalledWith(JSON.stringify(validUser));
51+
});
52+
53+
it('should execute real validation logic and throw for invalid users', async () => {
54+
// ARRANGE
55+
const { unit } = await TestBed.sociable(UserService)
56+
.boundaries([ApiService]) // Only mock expensive service
57+
.mock<Repository>('Repository')
58+
.impl((stub) => ({
59+
find: stub().mockResolvedValue([]),
60+
create: stub().mockResolvedValue(undefined),
61+
}))
62+
.compile();
63+
64+
// ACT & ASSERT: Real validation logic executes and throws
65+
const invalidUser: User = { name: 'Jane', email: 'invalid-email' }; // No @ sign
66+
67+
await expect(unit.create(invalidUser)).rejects.toThrow('invalid user data');
68+
69+
// This proves UserVerificationService.verify() actually ran with real logic
70+
});
71+
72+
it('should work with mocked boundary when calling API methods', async () => {
73+
// ARRANGE: ApiService is boundary (mocked)
74+
const { unit, unitRef } = await TestBed.sociable(UserService)
75+
.boundaries([ApiService])
76+
.mock(ApiService)
77+
.impl((stub) => ({
78+
fetchData: stub().mockResolvedValue('mocked-api-response'),
79+
}))
80+
.compile();
81+
82+
// ACT: Call method that uses ApiService
83+
const result = await unit.getUserInfo('user-123');
84+
85+
// ASSERT: Mocked ApiService was used
86+
expect(result).toBe('user data: mocked-api-response');
87+
88+
const mockApi = unitRef.get(ApiService);
89+
expect(mockApi.fetchData).toHaveBeenCalledWith('https://api.example.com/users/user-123');
90+
});
91+
});
92+
93+
describe('Leaf class auto-exposure: Leaf classes are auto-exposed in boundaries mode', () => {
94+
it('should auto-expose leaf classes that are not in boundaries array', async () => {
95+
// ARRANGE: UserVerificationService is a leaf class (no dependencies)
96+
// It's NOT in boundaries array, so it should be auto-exposed (made real)
97+
const { unit } = await TestBed.sociable(UserService)
98+
.boundaries([ApiService, UserApiService, UserDigestService])
99+
// UserDal is NOT in boundaries - it's real, uses real UserVerificationService
100+
// UserVerificationService is a leaf - should be auto-exposed
101+
.mock<Repository>('Repository')
102+
.impl((stub) => ({
103+
find: stub().mockResolvedValue([]),
104+
create: stub().mockResolvedValue(undefined),
105+
}))
106+
.compile();
107+
108+
// ACT: Create user with valid email
109+
const validUser: User = { name: 'Test', email: '[email protected]' };
110+
const result = await unit.create(validUser);
111+
112+
// ASSERT: Success proves UserVerificationService was REAL
113+
// If it was mocked, verify() would return undefined → create() would fail
114+
// But it's real, so verify() runs: email.includes('@') → true → create succeeds
115+
expect(result).toEqual(validUser);
116+
117+
// ACT: Create user with invalid email
118+
const invalidUser: User = { name: 'Test', email: 'invalid' };
119+
120+
// ASSERT: Real UserVerificationService.verify() logic runs
121+
// email.includes('@') → false → create() throws
122+
await expect(unit.create(invalidUser)).rejects.toThrow('invalid user data');
123+
124+
// This proves UserVerificationService is REAL (not mocked) even though
125+
// it wasn't explicitly exposed and wasn't in boundaries array
126+
});
127+
});
128+
129+
describe('Token auto-mocking: Tokens are natural boundaries', () => {
130+
it('should auto-mock token injections without declaring them as boundaries', async () => {
131+
// ARRANGE: Don't declare 'Repository' or 'SOME_VALUE_TOKEN' as boundaries
132+
const { unit, unitRef } = await TestBed.sociable(UserService)
133+
.boundaries([ApiService]) // Only this is a boundary
134+
.mock<Repository>('Repository') // Mock token for verification
135+
.impl((stub) => ({
136+
find: stub().mockResolvedValue([]),
137+
create: stub().mockResolvedValue(undefined),
138+
}))
139+
.compile();
140+
141+
// ACT: Call method that uses token-injected dependency
142+
const user: User = { name: 'Alice', email: '[email protected]' };
143+
await unit.create(user);
144+
145+
// ASSERT: Token 'Repository' was auto-mocked (not real)
146+
const mockRepo = unitRef.get<Repository>('Repository');
147+
expect(mockRepo.create).toHaveBeenCalled();
148+
expect(typeof mockRepo.create).toBe('function');
149+
150+
// This proves tokens are automatically mocked regardless of boundaries
151+
});
152+
153+
it('should auto-mock SOME_VALUE_TOKEN without boundaries declaration', async () => {
154+
// ARRANGE: SOME_VALUE_TOKEN is injected in UserService and UserDigestService
155+
const { unit } = await TestBed.sociable(UserService)
156+
.boundaries([ApiService]) // Don't declare SOME_VALUE_TOKEN
157+
.mock<string[]>('SOME_VALUE_TOKEN')
158+
.final(['mocked', 'token', 'value'])
159+
.mock<Repository>('Repository')
160+
.impl((stub) => ({
161+
find: stub().mockResolvedValue([]),
162+
create: stub().mockResolvedValue(undefined),
163+
}))
164+
.compile();
165+
166+
// ACT & ASSERT: UserService constructor runs with mocked token
167+
// (constructor logs token values - if it throws, token wasn't mocked)
168+
expect(unit).toBeInstanceOf(UserService);
169+
170+
// The fact that constructor didn't throw proves token was auto-mocked
171+
});
172+
});
173+
174+
// NOTE: Fail-fast in boundaries mode is NOT tested here because auto-expose handles
175+
// all class dependencies automatically. In boundaries mode, fail-fast rarely triggers
176+
// since non-boundary classes are auto-exposed. The fail-fast safety net is primarily
177+
// valuable in expose mode (tested below).
178+
179+
describe('Fail-fast with expose mode', () => {
180+
it('should fail-fast when accessing non-exposed dependency', async () => {
181+
await expect(
182+
TestBed.sociable(UserService).expose(UserApiService).compile()
183+
).rejects.toThrow(/not configured/);
184+
});
185+
186+
it('should provide helpful error message for expose mode', async () => {
187+
await expect(
188+
TestBed.sociable(UserService).expose(UserApiService).compile()
189+
).rejects.toThrow(/In expose mode/);
190+
});
191+
192+
it('should allow disabling for migration', async () => {
193+
const { unit } = await TestBed.sociable(UserService)
194+
.failFast({ enabled: false })
195+
.expose(UserApiService)
196+
.compile();
197+
198+
expect(unit).toBeInstanceOf(UserService);
199+
});
200+
});
201+
202+
describe('Comparing boundaries vs expose for same test', () => {
203+
const validUser: User = { name: 'Test', email: '[email protected]' };
204+
205+
it('EXPOSE MODE: Tedious - must whitelist every dependency', async () => {
206+
const { unit, unitRef } = await TestBed.sociable(UserService)
207+
.expose(UserApiService)
208+
.expose(UserDal)
209+
.expose(UserVerificationService)
210+
.expose(DatabaseService)
211+
.expose(UserDigestService)
212+
.expose(Logger)
213+
.expose(ApiService)
214+
.expose(HttpClient)
215+
// Still need to mock tokens
216+
.mock<Repository>('Repository')
217+
.impl((stub) => ({
218+
find: stub().mockResolvedValue([]),
219+
create: stub().mockResolvedValue(undefined),
220+
}))
221+
.compile();
222+
223+
const result = await unit.create(validUser);
224+
225+
expect(result).toEqual(validUser);
226+
227+
const mockRepo = unitRef.get<Repository>('Repository');
228+
expect(mockRepo.create).toHaveBeenCalled();
229+
});
230+
231+
it('BOUNDARIES MODE: Simple - just declare what to mock', async () => {
232+
const { unit, unitRef } = await TestBed.sociable(UserService)
233+
.boundaries([ApiService]) // Just mock expensive service
234+
.mock<Repository>('Repository')
235+
.impl((stub) => ({
236+
find: stub().mockResolvedValue([]),
237+
create: stub().mockResolvedValue(undefined),
238+
}))
239+
.compile();
240+
241+
const result = await unit.create(validUser);
242+
243+
expect(result).toEqual(validUser);
244+
245+
const mockRepo = unitRef.get<Repository>('Repository');
246+
expect(mockRepo.create).toHaveBeenCalled();
247+
});
248+
249+
// Same test result, but boundaries mode is MUCH simpler configuration
250+
});
251+
});

e2e/jest/nestjs/suites-jest-sociable-empty-constructor.e2e.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
describe('Suites Jest / NestJS E2E Test Ctor - Empty Constructor', () => {
99
it('should expose sociable service', async () => {
1010
const { unit, unitRef } = await TestBed.sociable(TestService)
11+
.failFast({ enabled: false }) // v3.x behavior - TestDependService auto-mocked
1112
.expose(TestSociableService)
1213
.compile();
1314

e2e/jest/nestjs/suites-jest-sociable.e2e.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ describe('Suites Jest / NestJS E2E Test Ctor', () => {
1717

1818
beforeAll(async () => {
1919
const { unitRef: ref, unit } = await TestBed.sociable(UserService)
20+
.failFast({ enabled: false }) // v3.x behavior - ApiService and UserVerificationService auto-mocked
2021
.expose(UserApiService)
2122
.expose(UserDal)
2223
.expose(DatabaseService)

0 commit comments

Comments
 (0)