@northbridge-security/secureai 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/README.md +122 -0
- package/.claude/commands/architect/clean.md +978 -0
- package/.claude/commands/architect/kiss.md +762 -0
- package/.claude/commands/architect/review.md +704 -0
- package/.claude/commands/catchup.md +90 -0
- package/.claude/commands/code.md +115 -0
- package/.claude/commands/commit.md +1218 -0
- package/.claude/commands/cover.md +1298 -0
- package/.claude/commands/fmea.md +275 -0
- package/.claude/commands/kaizen.md +312 -0
- package/.claude/commands/pr.md +503 -0
- package/.claude/commands/todo.md +99 -0
- package/.claude/commands/worktree.md +738 -0
- package/.claude/commands/wrapup.md +103 -0
- package/LICENSE +183 -0
- package/README.md +108 -0
- package/dist/cli.js +75634 -0
- package/docs/agents/devops-reviewer.md +889 -0
- package/docs/agents/kiss-simplifier.md +1088 -0
- package/docs/agents/typescript.md +8 -0
- package/docs/guides/README.md +109 -0
- package/docs/guides/agents.clean.arch.md +244 -0
- package/docs/guides/agents.clean.arch.ts.md +1314 -0
- package/docs/guides/agents.gotask.md +1037 -0
- package/docs/guides/agents.markdown.md +1209 -0
- package/docs/guides/agents.onepassword.md +285 -0
- package/docs/guides/agents.sonar.md +857 -0
- package/docs/guides/agents.tdd.md +838 -0
- package/docs/guides/agents.tdd.ts.md +1062 -0
- package/docs/guides/agents.typesript.md +1389 -0
- package/docs/guides/github-mcp.md +1075 -0
- package/package.json +130 -0
- package/packages/secureai-cli/src/cli.ts +21 -0
- package/tasks/README.md +880 -0
- package/tasks/aws.yml +64 -0
- package/tasks/bash.yml +118 -0
- package/tasks/bun.yml +738 -0
- package/tasks/claude.yml +183 -0
- package/tasks/docker.yml +420 -0
- package/tasks/docs.yml +127 -0
- package/tasks/git.yml +1336 -0
- package/tasks/gotask.yml +132 -0
- package/tasks/json.yml +77 -0
- package/tasks/markdown.yml +95 -0
- package/tasks/onepassword.yml +350 -0
- package/tasks/security.yml +102 -0
- package/tasks/sonar.yml +437 -0
- package/tasks/template.yml +74 -0
- package/tasks/vscode.yml +103 -0
- package/tasks/yaml.yml +121 -0
|
@@ -0,0 +1,1389 @@
|
|
|
1
|
+
# TypeScript Design Patterns for AI Agents
|
|
2
|
+
|
|
3
|
+
This guide establishes TypeScript design patterns for AI agents working with codebases. These patterns ensure maintainable, testable, and scalable TypeScript code following clean architecture principles.
|
|
4
|
+
|
|
5
|
+
## Target Audience
|
|
6
|
+
|
|
7
|
+
AI agents (Claude Code, Cursor, GitHub Copilot, etc.) writing production TypeScript code that requires clear structure, type safety, and long-term maintainability.
|
|
8
|
+
|
|
9
|
+
## Principles Checklist
|
|
10
|
+
|
|
11
|
+
Quick reference of TypeScript design principles covered in this guide:
|
|
12
|
+
|
|
13
|
+
1. [Bounded Context-Based Folder Structure](#bounded-context-based-folder-structure): Organize src/domains/ by business domain (bounded contexts), not by technical layers
|
|
14
|
+
2. [Expose Business Capabilities, Not Dependencies](#expose-business-capabilities-not-dependencies): Name APIs by business capability, not implementation tool; use Adapter Pattern to enable swapping vendors/tools without breaking consumers
|
|
15
|
+
3. [Prefer Class-Based Structures](#prefer-class-based-structures): Organize exports by function domain using classes with grouped operations for ease of understanding by consumers
|
|
16
|
+
4. [Prefer Barrel Exports](#prefer-barrel-exports): Use index.ts to create consumer-focused public APIs and hide implementation details (including adapters)
|
|
17
|
+
5. [Separate System Interactions](#separate-system-interactions): Isolate external system calls in `systems.ts` files excluded from code coverage; adapters use these for vendor-specific operations
|
|
18
|
+
6. [Test-Driven Development (TDD)](#test-driven-development-tdd): Write tests first using RED → GREEN → REFACTOR; design skeleton code with architecture-first approach
|
|
19
|
+
7. [Unit Tests Without Mocks](#unit-tests-without-mocks): Unit tests use real implementations (no mocks); mocks are for integration tests only; test structure shadows source folders
|
|
20
|
+
8. [Atomic Tests](#atomic-tests): Create isolated test instances in each `it()` or `test()` call, avoid module-level mocks and shared state to enable parallel and random-order execution
|
|
21
|
+
|
|
22
|
+
## Core Principles
|
|
23
|
+
|
|
24
|
+
### Expose Business Capabilities, Not Dependencies
|
|
25
|
+
|
|
26
|
+
**Problem**: Naming APIs after implementation tools (dependencies) creates vendor lock-in and requires consumer changes when swapping implementations.
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
// ❌ BAD - API exposes implementation dependency (vendor lock-in)
|
|
30
|
+
import { OnePasswordSecretsManager } from "@src/security";
|
|
31
|
+
|
|
32
|
+
class OnePasswordSecretsManager {
|
|
33
|
+
async resolveFromOnePassword(ref: string): Promise<string>;
|
|
34
|
+
async authenticateToOnePassword(): Promise<void>;
|
|
35
|
+
async queryOnePasswordVault(vault: string): Promise<OnePasswordItem[]>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const secrets = new OnePasswordSecretsManager();
|
|
39
|
+
await secrets.resolveFromOnePassword("op://vault/item/field");
|
|
40
|
+
|
|
41
|
+
// Problem: Consumer code is coupled to 1Password
|
|
42
|
+
// Migrating to Bitwarden requires changing ALL consumer code
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Solution**: Name classes, methods, and types by business capability, not by implementation tool.
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
// ✓ GOOD - API exposes business capability (vendor-neutral)
|
|
49
|
+
import { SecretsManager } from "@src/security";
|
|
50
|
+
|
|
51
|
+
class SecretsManager {
|
|
52
|
+
async resolve(reference: string): Promise<string>;
|
|
53
|
+
async authenticate(): Promise<void>;
|
|
54
|
+
async list(category?: string): Promise<Secret[]>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const secrets = SecretsManager.create();
|
|
58
|
+
await secrets.resolve("secret-reference");
|
|
59
|
+
|
|
60
|
+
// Consumer doesn't know or care about 1Password
|
|
61
|
+
// Bounded context can swap to Bitwarden without breaking consumers
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Why this approach is better:**
|
|
65
|
+
|
|
66
|
+
1. **No vendor lock-in**: Swap tools (1Password → Bitwarden → Vault) without consumer changes
|
|
67
|
+
2. **Domain language**: API speaks business terms ("resolve secret") not tool terms ("query 1Password CLI")
|
|
68
|
+
3. **Future-proof**: Add new implementations without breaking existing code
|
|
69
|
+
4. **Clear intent**: Consumers understand business purpose, not technical details
|
|
70
|
+
5. **Implementation hiding**: Bounded context internals can change freely
|
|
71
|
+
|
|
72
|
+
**Apply to ALL aspects of public API:**
|
|
73
|
+
|
|
74
|
+
**Class names:**
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
// ❌ BAD - Named after tool
|
|
78
|
+
class OnePasswordManager
|
|
79
|
+
class BitwardenClient
|
|
80
|
+
class AwsSecretsManagerService
|
|
81
|
+
|
|
82
|
+
// ✓ GOOD - Named after capability
|
|
83
|
+
class SecretsManager
|
|
84
|
+
class SecretStore
|
|
85
|
+
class SecretResolver
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Method signatures:**
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
// ❌ BAD - Methods expose tool
|
|
92
|
+
async resolveFromOnePassword(reference: string)
|
|
93
|
+
async fetchFromBitwarden(itemId: string)
|
|
94
|
+
async getFromVault(path: string)
|
|
95
|
+
|
|
96
|
+
// ✓ GOOD - Methods express capability
|
|
97
|
+
async resolve(reference: string)
|
|
98
|
+
async fetch(identifier: string)
|
|
99
|
+
async get(path: string)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Return types:**
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
// ❌ BAD - Types expose tool
|
|
106
|
+
interface OnePasswordItem {
|
|
107
|
+
vault: string;
|
|
108
|
+
item: string;
|
|
109
|
+
field: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ✓ GOOD - Types express domain concept
|
|
113
|
+
interface Secret {
|
|
114
|
+
category: string;
|
|
115
|
+
name: string;
|
|
116
|
+
value: string;
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Complete example:**
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
// Public API (src/security/index.ts) - Business capability-focused
|
|
124
|
+
export class SecretsManager {
|
|
125
|
+
private constructor(private adapter: ISecretsProvider) {}
|
|
126
|
+
|
|
127
|
+
static create(adapter?: ISecretsProvider): SecretsManager {
|
|
128
|
+
// Default adapter can change without breaking consumers
|
|
129
|
+
return new SecretsManager(adapter || new OnePasswordAdapter());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async resolve(reference: string): Promise<string> {
|
|
133
|
+
return this.adapter.resolve(reference);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async authenticate(): Promise<void> {
|
|
137
|
+
return this.adapter.authenticate();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async setup(services: ServiceConfig[]): Promise<void> {
|
|
141
|
+
return this.adapter.setup(services);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Internal implementation (src/security/adapters/onepassword/) - Hidden
|
|
146
|
+
class OnePasswordAdapter implements ISecretsProvider {
|
|
147
|
+
// 1Password-specific implementation
|
|
148
|
+
// Consumers never see this class
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Future: Add Bitwarden without breaking changes
|
|
152
|
+
class BitwardenAdapter implements ISecretsProvider {
|
|
153
|
+
// Bitwarden-specific implementation
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Consumer code never changes:
|
|
157
|
+
const secrets = SecretsManager.create();
|
|
158
|
+
await secrets.resolve("secret-ref");
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**Design patterns used:**
|
|
162
|
+
|
|
163
|
+
This principle combines several architectural patterns:
|
|
164
|
+
|
|
165
|
+
1. **Adapter Pattern**: Wrap vendor-specific tools behind a common interface
|
|
166
|
+
- **Purpose**: Convert vendor-specific API to your business interface
|
|
167
|
+
- **Benefit**: Swap vendors without changing consumer code
|
|
168
|
+
- **Example**: `OnePasswordAdapter`, `BitwardenAdapter`, `VaultAdapter` all implement `ISecretsProvider`
|
|
169
|
+
|
|
170
|
+
2. **Strategy Pattern**: Swap implementations at runtime via dependency injection
|
|
171
|
+
- **Purpose**: Define a family of interchangeable algorithms/providers
|
|
172
|
+
- **Benefit**: Choose provider at runtime or configuration time
|
|
173
|
+
- **Example**: `SecretsManager.create(new BitwardenAdapter())` vs `SecretsManager.create(new OnePasswordAdapter())`
|
|
174
|
+
|
|
175
|
+
3. **Hexagonal Architecture (Ports & Adapters)**:
|
|
176
|
+
- **Port**: `ISecretsProvider` interface (business capability - what you need)
|
|
177
|
+
- **Adapters**: `OnePasswordAdapter`, `BitwardenAdapter`, `VaultAdapter` (tool implementations - how it's done)
|
|
178
|
+
- **Benefit**: Domain core depends only on interfaces, not implementations
|
|
179
|
+
|
|
180
|
+
4. **Anti-Corruption Layer**: Protect domain from external system details
|
|
181
|
+
- **Purpose**: Prevent vendor-specific concepts from leaking into your domain
|
|
182
|
+
- **Example**: Vendor uses "vault/item/field", you expose "category/item/field"
|
|
183
|
+
|
|
184
|
+
**When to use this pattern:**
|
|
185
|
+
|
|
186
|
+
- External services (APIs, databases, message queues)
|
|
187
|
+
- Third-party libraries (payment processors, email providers)
|
|
188
|
+
- Infrastructure tools (secret managers, cloud providers)
|
|
189
|
+
- Any dependency that might change or be swapped
|
|
190
|
+
|
|
191
|
+
**When NOT needed:**
|
|
192
|
+
|
|
193
|
+
- Core language features (Array, Map, Set)
|
|
194
|
+
- Stable, universal standards (HTTP, JSON)
|
|
195
|
+
- Internal domain entities (User, Order, Invoice)
|
|
196
|
+
|
|
197
|
+
**How adapters enable vendor swapping:**
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
// Phase 1: Using 1Password
|
|
201
|
+
const secrets = SecretsManager.create(new OnePasswordAdapter());
|
|
202
|
+
await secrets.resolve("op://Private/API/token");
|
|
203
|
+
|
|
204
|
+
// Phase 2: Migrate to Bitwarden (zero consumer code changes)
|
|
205
|
+
const secrets = SecretsManager.create(new BitwardenAdapter());
|
|
206
|
+
await secrets.resolve("bw://Private/API/token"); // Different reference format
|
|
207
|
+
|
|
208
|
+
// Phase 3: Hybrid approach (multiple providers)
|
|
209
|
+
class MultiProviderAdapter implements ISecretsProvider {
|
|
210
|
+
constructor(
|
|
211
|
+
private primary: ISecretsProvider,
|
|
212
|
+
private fallback: ISecretsProvider
|
|
213
|
+
) {}
|
|
214
|
+
|
|
215
|
+
async resolve(ref: string): Promise<string> {
|
|
216
|
+
try {
|
|
217
|
+
return await this.primary.resolve(ref);
|
|
218
|
+
} catch {
|
|
219
|
+
return await this.fallback.resolve(ref);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const secrets = SecretsManager.create(
|
|
225
|
+
new MultiProviderAdapter(new OnePasswordAdapter(), new BitwardenAdapter())
|
|
226
|
+
);
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
**Connection to other principles:**
|
|
230
|
+
|
|
231
|
+
- **Bounded Context-Based Folder Structure** (#1): Organize by business domain in `src/domains/`, not by tools; adapters live in `domains/security/secrets/adapters/`
|
|
232
|
+
- **Class-Based Structures** (#3): How to group operations around business capabilities
|
|
233
|
+
- **Barrel Exports** (#4): Hide implementation adapters in subdirectories, only export business API from main index.ts
|
|
234
|
+
- **Separate System Interactions** (#5): Adapters use `systems.ts` files for vendor-specific CLI calls, keeping business logic clean
|
|
235
|
+
|
|
236
|
+
This principle is the foundation of a maintainable architecture. By naming APIs after business capabilities and using the Adapter Pattern, your bounded context becomes a stable interface that can evolve its implementation without breaking consumers.
|
|
237
|
+
|
|
238
|
+
### Prefer Class-Based Structures
|
|
239
|
+
|
|
240
|
+
**Problem**: Exporting 30-40 functions from a module creates cognitive overhead and unclear boundaries.
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
// ❌ BAD - Function soup
|
|
244
|
+
export function validateContract(contract: Contract): ValidationResult {}
|
|
245
|
+
export function analyzeContractRisk(contract: Contract): RiskScore {}
|
|
246
|
+
export function checkContractCompliance(contract: Contract): boolean {}
|
|
247
|
+
export function generateContractSummary(contract: Contract): Summary {}
|
|
248
|
+
export function compareContracts(a: Contract, b: Contract): Diff {}
|
|
249
|
+
export function archiveContract(contract: Contract): void {}
|
|
250
|
+
// ... 30 more functions
|
|
251
|
+
|
|
252
|
+
// Consumer imports and uses:
|
|
253
|
+
import { validateContract, analyzeContractRisk, checkCompliance /* ... */ } from "./utils";
|
|
254
|
+
const result = validateContract(contract); // Unclear relationships
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
**Solution**: Organize related functionality into classes with grouped operations.
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
// ✓ GOOD - Class with nested operation groups
|
|
261
|
+
export class ContractModeler {
|
|
262
|
+
private constructor(private contract: Contract) {}
|
|
263
|
+
|
|
264
|
+
static create(data: ContractData): ContractModeler {
|
|
265
|
+
return new ContractModeler(parseContract(data));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
readonly analysis = {
|
|
269
|
+
risk: () => analyzeRisk(this.contract),
|
|
270
|
+
compliance: () => checkCompliance(this.contract),
|
|
271
|
+
value: () => calculateValue(this.contract),
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
readonly review = {
|
|
275
|
+
validate: () => validateContract(this.contract),
|
|
276
|
+
summary: () => generateSummary(this.contract),
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
readonly lifecycle = {
|
|
280
|
+
archive: () => archiveContract(this.contract),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Consumer uses grouped operations:
|
|
285
|
+
import { ContractModeler } from "./contract-modeler";
|
|
286
|
+
const modeler = ContractModeler.create(contractData);
|
|
287
|
+
modeler.analysis.risk(); // Clear grouping
|
|
288
|
+
modeler.review.validate(); // Self-documenting
|
|
289
|
+
modeler.lifecycle.archive(); // Easy to discover
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Why this approach is better:**
|
|
293
|
+
|
|
294
|
+
1. **Clear context**: Consumer immediately understands `analysis`, `review`, and `lifecycle` are distinct areas of concern
|
|
295
|
+
2. **Discoverability**: IDE autocomplete shows grouped operations (`.analysis.` reveals all analysis functions)
|
|
296
|
+
3. **Single import**: One import statement instead of 40
|
|
297
|
+
4. **Encapsulation**: Private methods hidden from consumers, only public API exposed
|
|
298
|
+
5. **Testability**: Can mock entire groups (`modeler.analysis`) or individual operations
|
|
299
|
+
6. **Maintainability**: Adding new operations is clear (which group does it belong to?)
|
|
300
|
+
7. **Type safety**: TypeScript understands class structure better than loose functions
|
|
301
|
+
8. **Clean architecture**: Class boundaries align with business domains
|
|
302
|
+
|
|
303
|
+
**When to use class-based design:**
|
|
304
|
+
|
|
305
|
+
- Module exports more than 5-10 related functions
|
|
306
|
+
- Functions share common state or dependencies
|
|
307
|
+
- Clear groupings exist (analysis, validation, transformation, etc.)
|
|
308
|
+
- Business logic with multiple steps or workflows
|
|
309
|
+
- Code that benefits from dependency injection
|
|
310
|
+
|
|
311
|
+
**When functions are acceptable:**
|
|
312
|
+
|
|
313
|
+
- Pure utility functions (string manipulation, math operations)
|
|
314
|
+
- Single-purpose helpers with no shared state
|
|
315
|
+
- Top-level facade functions that delegate to classes
|
|
316
|
+
- Framework integration points (Next.js API routes, React components)
|
|
317
|
+
|
|
318
|
+
### Bounded Context-Based Folder Structure
|
|
319
|
+
|
|
320
|
+
**Problem**: Organizing by technical layers (controllers/, services/, models/) obscures business purpose and doesn't scale.
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
// ❌ BAD - Layer-based (screams "I use Express")
|
|
324
|
+
src/
|
|
325
|
+
├── controllers/
|
|
326
|
+
│ ├── UserController.ts
|
|
327
|
+
│ └── OrderController.ts
|
|
328
|
+
├── services/
|
|
329
|
+
└── models/
|
|
330
|
+
|
|
331
|
+
// Hard to understand business capabilities
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
**Solution**: Organize by bounded contexts (business domains) with clean architecture layers inside.
|
|
335
|
+
|
|
336
|
+
```typescript
|
|
337
|
+
// ✓ GOOD - Bounded context-based (screams "security and git workflow")
|
|
338
|
+
src/domains/
|
|
339
|
+
├── security/ // Security & Secrets bounded context
|
|
340
|
+
│ ├── secrets/ // Secrets management subdomain
|
|
341
|
+
│ │ ├── interfaces.ts // Business capability interfaces
|
|
342
|
+
│ │ ├── manager.ts // SecretsManager (main API)
|
|
343
|
+
│ │ ├── resolver.ts // Secret resolution with caching
|
|
344
|
+
│ │ ├── systems.ts // System interactions (excluded from coverage)
|
|
345
|
+
│ │ ├── adapters/ // Vendor-specific implementations
|
|
346
|
+
│ │ │ ├── onepassword/ // 1Password adapter
|
|
347
|
+
│ │ │ └── bitwarden/ // Bitwarden adapter (future)
|
|
348
|
+
│ │ └── index.ts // Public API
|
|
349
|
+
│ ├── scanning/ // Security scanning subdomain
|
|
350
|
+
│ └── index.ts // Security domain API
|
|
351
|
+
└── shared/ // Cross-cutting concerns (shared kernel)
|
|
352
|
+
├── process/
|
|
353
|
+
└── logging/
|
|
354
|
+
|
|
355
|
+
// Consumer imports from public API:
|
|
356
|
+
import { SecretsManager } from '@domains/security/secrets'; // Public API
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
**Why this approach is better:**
|
|
360
|
+
|
|
361
|
+
1. **Screaming architecture**: Folder structure reveals business capabilities, not framework
|
|
362
|
+
2. **Bounded contexts**: Each domain represents a distinct business domain (DDD)
|
|
363
|
+
3. **Easy extraction**: Lift entire domain into separate service/microservice
|
|
364
|
+
4. **Parallel development**: Teams work on different domains without conflicts
|
|
365
|
+
5. **Dependency control**: Clean architecture enforces testability
|
|
366
|
+
6. **Adapter isolation**: Vendor-specific code hidden in `adapters/` subdirectories
|
|
367
|
+
|
|
368
|
+
**When to use bounded context-based:**
|
|
369
|
+
|
|
370
|
+
- Projects beyond simple prototypes
|
|
371
|
+
- Multiple business domains
|
|
372
|
+
- Planning for microservice extraction
|
|
373
|
+
- Teams larger than 3 developers
|
|
374
|
+
|
|
375
|
+
**When layer-based is acceptable:**
|
|
376
|
+
|
|
377
|
+
- Small projects (<1000 lines)
|
|
378
|
+
- Learning projects
|
|
379
|
+
- Simple CRUD with no complex business logic
|
|
380
|
+
|
|
381
|
+
### Prefer Barrel Exports
|
|
382
|
+
|
|
383
|
+
**Problem**: Exporting everything directly from source files creates tight coupling and no API boundaries.
|
|
384
|
+
|
|
385
|
+
```typescript
|
|
386
|
+
// ❌ BAD - No public API boundary
|
|
387
|
+
src/domains/security/secrets/
|
|
388
|
+
├── manager.ts // Consumers import directly
|
|
389
|
+
├── resolver.ts // Internal implementation exposed
|
|
390
|
+
└── adapters/
|
|
391
|
+
└── onepassword/
|
|
392
|
+
└── adapter.ts // Internal implementation exposed
|
|
393
|
+
|
|
394
|
+
// Consumer sees all internals:
|
|
395
|
+
import { SecretsManager } from '@domains/security/secrets/manager';
|
|
396
|
+
import { SecretResolver } from '@domains/security/secrets/resolver';
|
|
397
|
+
import { OnePasswordAdapter } from '@domains/security/secrets/adapters/onepassword/adapter'; // Should be private
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
**Solution**: Use index.ts barrel files to define consumer-focused public APIs.
|
|
401
|
+
|
|
402
|
+
```typescript
|
|
403
|
+
// ✓ GOOD - Explicit public API via index.ts
|
|
404
|
+
src/domains/security/secrets/
|
|
405
|
+
├── manager.ts // Implementation
|
|
406
|
+
├── resolver.ts // Implementation (optional to export)
|
|
407
|
+
├── adapters/ // Internal (not exported from main index)
|
|
408
|
+
│ └── onepassword/
|
|
409
|
+
│ ├── adapter.ts // Internal implementation
|
|
410
|
+
│ └── index.ts // Adapter barrel (not exported from main)
|
|
411
|
+
└── index.ts // Public API
|
|
412
|
+
|
|
413
|
+
// index.ts - Explicitly defines what's public:
|
|
414
|
+
export { SecretsManager } from './manager.js';
|
|
415
|
+
export type { ISecretsProvider } from './interfaces.js';
|
|
416
|
+
// Adapters NOT exported - consumers use dependency injection
|
|
417
|
+
|
|
418
|
+
// Consumer uses clean API:
|
|
419
|
+
import { SecretsManager } from '@domains/security/secrets'; // Simple, controlled
|
|
420
|
+
const secrets = SecretsManager.create(); // Uses default adapter internally
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
**Why this approach is better:**
|
|
424
|
+
|
|
425
|
+
1. **Consumer-focused**: API designed for ease of use, not implementation convenience
|
|
426
|
+
2. **Encapsulation**: Hide internal classes, only expose what consumers need
|
|
427
|
+
3. **Refactoring safety**: Change internals without breaking consumers
|
|
428
|
+
4. **Clear boundaries**: index.ts documents what's public vs internal
|
|
429
|
+
5. **Code coverage exclusion**: Barrel files excluded from coverage (no logic)
|
|
430
|
+
|
|
431
|
+
**Barrel file guidelines:**
|
|
432
|
+
|
|
433
|
+
```typescript
|
|
434
|
+
// index.ts patterns:
|
|
435
|
+
|
|
436
|
+
// ✓ GOOD - Explicit named exports
|
|
437
|
+
export { UserService } from "./user-service";
|
|
438
|
+
export { CreateUserUseCase } from "./application/create-user.usecase";
|
|
439
|
+
export type { User, UserDTO } from "./domain/user.entity";
|
|
440
|
+
|
|
441
|
+
// ❌ BAD - Wildcard exports (kills tree-shaking)
|
|
442
|
+
export * from "./user-service"; // Exports everything, including internals
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
**Code coverage exclusion:**
|
|
446
|
+
|
|
447
|
+
```javascript
|
|
448
|
+
// jest.config.js
|
|
449
|
+
collectCoverageFrom: [
|
|
450
|
+
"src/**/*.ts",
|
|
451
|
+
"!src/**/index.ts", // Exclude barrel files (no logic to test)
|
|
452
|
+
"!src/**/*.spec.ts",
|
|
453
|
+
];
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
**When to use barrel exports:**
|
|
457
|
+
|
|
458
|
+
- Module public APIs (always)
|
|
459
|
+
- Package entry points (package.json main field)
|
|
460
|
+
- Bounded context boundaries
|
|
461
|
+
|
|
462
|
+
**When NOT to use barrel exports:**
|
|
463
|
+
|
|
464
|
+
- Internal organization within a module
|
|
465
|
+
- Deep folder hierarchies (use direct imports)
|
|
466
|
+
- Performance-critical paths (direct imports faster)
|
|
467
|
+
|
|
468
|
+
### Separate System Interactions
|
|
469
|
+
|
|
470
|
+
**Problem**: Mixing business logic with system calls (file I/O, HTTP, shell commands) makes code untestable and dilutes coverage metrics.
|
|
471
|
+
|
|
472
|
+
```typescript
|
|
473
|
+
// ❌ BAD - Business logic mixed with system calls
|
|
474
|
+
export async function processConfig(path: string): Promise<Config> {
|
|
475
|
+
// System call - hard to test
|
|
476
|
+
const content = await readFile(path, "utf-8");
|
|
477
|
+
|
|
478
|
+
// Business logic
|
|
479
|
+
const parsed = JSON.parse(content);
|
|
480
|
+
if (!parsed.version) throw new Error("Missing version");
|
|
481
|
+
|
|
482
|
+
return { ...parsed, validated: true };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Cannot test without real files, coverage includes untestable system code
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
**Solution**: Use interface-based dependency injection with `systems.ts` files for system operations.
|
|
489
|
+
|
|
490
|
+
```typescript
|
|
491
|
+
// ✓ GOOD - Clean separation via interface
|
|
492
|
+
// interfaces.ts
|
|
493
|
+
export interface IFileReader {
|
|
494
|
+
read(path: string): Promise<string>;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// systems.ts (excluded from coverage by glob pattern: **/systems.ts)
|
|
498
|
+
import { readFile } from "node:fs/promises";
|
|
499
|
+
export class FileReaderSystem implements IFileReader {
|
|
500
|
+
async read(path: string): Promise<string> {
|
|
501
|
+
return readFile(path, "utf-8"); // Thin wrapper, no logic
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
export const defaultFileReader = new FileReaderSystem();
|
|
505
|
+
|
|
506
|
+
// config-loader.ts (testable business logic)
|
|
507
|
+
import type { IFileReader } from "./interfaces.js";
|
|
508
|
+
import { defaultFileReader } from "./systems.js";
|
|
509
|
+
|
|
510
|
+
export async function processConfig(
|
|
511
|
+
path: string,
|
|
512
|
+
reader: IFileReader = defaultFileReader
|
|
513
|
+
): Promise<Config> {
|
|
514
|
+
const content = await reader.read(path); // Injected dependency
|
|
515
|
+
const parsed = JSON.parse(content); // Business logic
|
|
516
|
+
if (!parsed.version) throw new Error("Missing version");
|
|
517
|
+
return { ...parsed, validated: true };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// tests/mocks.ts (or tests/mocks/config.ts)
|
|
521
|
+
export class FileReaderMock implements IFileReader {
|
|
522
|
+
private files = new Map<string, string>();
|
|
523
|
+
setFile(path: string, content: string) {
|
|
524
|
+
this.files.set(path, content);
|
|
525
|
+
}
|
|
526
|
+
async read(path: string) {
|
|
527
|
+
return this.files.get(path) || "";
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
**Why this approach is better:**
|
|
533
|
+
|
|
534
|
+
1. **Testable**: Business logic tested with mocks, no real file I/O
|
|
535
|
+
2. **Fast tests**: All in-memory, deterministic
|
|
536
|
+
3. **Accurate coverage**: Only business logic measured, system wrappers excluded
|
|
537
|
+
4. **Clear boundaries**: `systems.ts` file self-documents all system interactions in module
|
|
538
|
+
5. **Type-safe**: Interface contract enforced at compile time
|
|
539
|
+
|
|
540
|
+
**File naming convention:**
|
|
541
|
+
|
|
542
|
+
```typescript
|
|
543
|
+
src/domains/config/
|
|
544
|
+
├── interfaces.ts // All interface definitions for this domain
|
|
545
|
+
├── systems.ts // All system implementations (excluded from coverage)
|
|
546
|
+
├── config-loader.ts // Business logic (testable)
|
|
547
|
+
├── adapters/ // Vendor-specific implementations (if any)
|
|
548
|
+
│ └── yaml/ // YAML config adapter
|
|
549
|
+
│ ├── adapter.ts
|
|
550
|
+
│ └── systems.ts // YAML library system wrapper
|
|
551
|
+
└── index.ts // Public API
|
|
552
|
+
|
|
553
|
+
tests/mocks/
|
|
554
|
+
└── config.ts // All test doubles for config domain
|
|
555
|
+
|
|
556
|
+
// Coverage exclusion:
|
|
557
|
+
collectCoverageFrom: [
|
|
558
|
+
'src/**/*.ts',
|
|
559
|
+
'!src/**/systems.ts', // Exclude all system interaction files
|
|
560
|
+
'!src/**/index.ts', // Exclude all barrel files
|
|
561
|
+
'!src/**/adapters/**', // Optionally exclude adapter implementations
|
|
562
|
+
]
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
**When to use this pattern:**
|
|
566
|
+
|
|
567
|
+
- File system operations (read, write, delete)
|
|
568
|
+
- Shell command execution
|
|
569
|
+
- HTTP/network requests
|
|
570
|
+
- Database queries
|
|
571
|
+
- External service calls
|
|
572
|
+
|
|
573
|
+
**When NOT needed:**
|
|
574
|
+
|
|
575
|
+
- Pure functions with no I/O
|
|
576
|
+
- In-memory data transformations
|
|
577
|
+
- Business rule calculations
|
|
578
|
+
|
|
579
|
+
For comprehensive examples and patterns, see [Clean Architecture in TypeScript](./agents.clean.arch.ts.md).
|
|
580
|
+
|
|
581
|
+
### Test-Driven Development (TDD)
|
|
582
|
+
|
|
583
|
+
**Problem**: Writing implementation first and tests later leads to untestable code, missed edge cases, and poor API design.
|
|
584
|
+
|
|
585
|
+
```typescript
|
|
586
|
+
// ❌ BAD - Implementation first approach
|
|
587
|
+
// Step 1: Write full implementation
|
|
588
|
+
export class UserService {
|
|
589
|
+
async createUser(data: UserData): Promise<User> {
|
|
590
|
+
// Complex implementation with no tests guiding design
|
|
591
|
+
const user = await this.db.insert({
|
|
592
|
+
name: data.name,
|
|
593
|
+
email: data.email,
|
|
594
|
+
// Missing validation, error handling
|
|
595
|
+
});
|
|
596
|
+
return user;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Step 2: Try to write tests (discover problems too late)
|
|
601
|
+
describe("UserService", () => {
|
|
602
|
+
it("should create user", async () => {
|
|
603
|
+
// Hard to test - tightly coupled to real database
|
|
604
|
+
// Missing validation wasn't caught until now
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
**Solution**: Write tests first using RED → GREEN → REFACTOR, designing skeleton code with architecture-first approach.
|
|
610
|
+
|
|
611
|
+
```typescript
|
|
612
|
+
// ✓ GOOD - TDD approach with architecture-first design
|
|
613
|
+
// Step 1: Design architecture skeleton (focus on structure)
|
|
614
|
+
export interface IUserRepository {
|
|
615
|
+
insert(user: User): Promise<User>;
|
|
616
|
+
findByEmail(email: string): Promise<User | null>;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export class UserService {
|
|
620
|
+
constructor(private repository: IUserRepository) {}
|
|
621
|
+
|
|
622
|
+
async createUser(data: UserData): Promise<User> {
|
|
623
|
+
throw new Error("Not implemented yet"); // Architecture complete, logic pending
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Step 2: Write failing test (RED)
|
|
628
|
+
import { describe, it, expect } from "bun:test";
|
|
629
|
+
import { UserService } from "./user-service";
|
|
630
|
+
import { InMemoryUserRepository } from "../../../tests/mocks/user-repository";
|
|
631
|
+
|
|
632
|
+
describe("UserService.createUser", () => {
|
|
633
|
+
it("should create user with valid data", async () => {
|
|
634
|
+
// Arrange
|
|
635
|
+
const repository = new InMemoryUserRepository();
|
|
636
|
+
const service = new UserService(repository);
|
|
637
|
+
const userData = { name: "Alice", email: "alice@example.com" };
|
|
638
|
+
|
|
639
|
+
// Act
|
|
640
|
+
const user = await service.createUser(userData);
|
|
641
|
+
|
|
642
|
+
// Assert
|
|
643
|
+
expect(user.name).toBe("Alice");
|
|
644
|
+
expect(user.email).toBe("alice@example.com");
|
|
645
|
+
expect(user.id).toBeDefined();
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
// Test fails: Error: Not implemented yet (RED)
|
|
649
|
+
|
|
650
|
+
// Step 3: Write minimal implementation (GREEN)
|
|
651
|
+
export class UserService {
|
|
652
|
+
constructor(private repository: IUserRepository) {}
|
|
653
|
+
|
|
654
|
+
async createUser(data: UserData): Promise<User> {
|
|
655
|
+
const user: User = {
|
|
656
|
+
id: crypto.randomUUID(),
|
|
657
|
+
name: data.name,
|
|
658
|
+
email: data.email,
|
|
659
|
+
createdAt: new Date(),
|
|
660
|
+
};
|
|
661
|
+
return this.repository.insert(user);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
// Test passes (GREEN)
|
|
665
|
+
|
|
666
|
+
// Step 4: Add validation test (RED)
|
|
667
|
+
it("should reject invalid email", async () => {
|
|
668
|
+
const repository = new InMemoryUserRepository();
|
|
669
|
+
const service = new UserService(repository);
|
|
670
|
+
const invalidData = { name: "Bob", email: "not-an-email" };
|
|
671
|
+
|
|
672
|
+
await expect(service.createUser(invalidData)).rejects.toThrow("Invalid email");
|
|
673
|
+
});
|
|
674
|
+
// Test fails (RED)
|
|
675
|
+
|
|
676
|
+
// Step 5: Add validation (GREEN)
|
|
677
|
+
export class UserService {
|
|
678
|
+
async createUser(data: UserData): Promise<User> {
|
|
679
|
+
if (!this.isValidEmail(data.email)) {
|
|
680
|
+
throw new Error("Invalid email");
|
|
681
|
+
}
|
|
682
|
+
// ... rest of implementation
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private isValidEmail(email: string): boolean {
|
|
686
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// Test passes (GREEN)
|
|
690
|
+
|
|
691
|
+
// Step 6: Refactor (REFACTOR)
|
|
692
|
+
// Extract validation to separate validator class
|
|
693
|
+
// Improve error messages
|
|
694
|
+
// All tests still pass
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
**Why this approach is better:**
|
|
698
|
+
|
|
699
|
+
1. **Better API design**: Tests force you to think about how consumers will use your code
|
|
700
|
+
2. **Complete test coverage**: Every line of code has a corresponding test
|
|
701
|
+
3. **Faster feedback**: Catch design problems in minutes, not days
|
|
702
|
+
4. **Regression protection**: Tests document expected behavior and catch regressions
|
|
703
|
+
5. **Confidence to refactor**: Comprehensive tests enable safe refactoring
|
|
704
|
+
6. **No dead code**: Only write code needed to pass tests
|
|
705
|
+
7. **Architecture clarity**: Skeleton design separates structure from implementation
|
|
706
|
+
|
|
707
|
+
**TDD Workflow:**
|
|
708
|
+
|
|
709
|
+
1. **Design skeleton (architecture first)**:
|
|
710
|
+
- Define interfaces for dependencies (`IUserRepository`)
|
|
711
|
+
- Define public API signatures (`createUser(data: UserData): Promise<User>`)
|
|
712
|
+
- Use `throw new Error('Not implemented yet')` in method bodies
|
|
713
|
+
- Focus on bounded context structure and domain separation
|
|
714
|
+
|
|
715
|
+
2. **RED - Write failing test**:
|
|
716
|
+
- Write test for single behavior
|
|
717
|
+
- Test should fail (proves test is valid)
|
|
718
|
+
- Use mocks/test doubles for dependencies
|
|
719
|
+
|
|
720
|
+
3. **GREEN - Write minimal implementation**:
|
|
721
|
+
- Write simplest code to pass the test
|
|
722
|
+
- Don't optimize or add extra features
|
|
723
|
+
- Get to working state quickly
|
|
724
|
+
|
|
725
|
+
4. **REFACTOR - Improve code quality**:
|
|
726
|
+
- Extract duplicated code
|
|
727
|
+
- Improve naming and structure
|
|
728
|
+
- Optimize algorithms
|
|
729
|
+
- All tests must still pass
|
|
730
|
+
|
|
731
|
+
5. **Repeat**: Add next test, implement, refactor
|
|
732
|
+
|
|
733
|
+
**When to use TDD:**
|
|
734
|
+
|
|
735
|
+
- New features or modules
|
|
736
|
+
- Bug fixes (write failing test that reproduces bug first)
|
|
737
|
+
- Complex business logic
|
|
738
|
+
- Public APIs (test-first ensures good design)
|
|
739
|
+
- Code that requires high reliability
|
|
740
|
+
|
|
741
|
+
**When TDD may be skipped:**
|
|
742
|
+
|
|
743
|
+
- Prototypes or spikes (exploration phase)
|
|
744
|
+
- UI layout (visual testing more appropriate)
|
|
745
|
+
- Configuration files
|
|
746
|
+
- Simple data transformations with obvious implementation
|
|
747
|
+
|
|
748
|
+
**Example: TDD for secrets management refactoring**
|
|
749
|
+
|
|
750
|
+
```typescript
|
|
751
|
+
// Step 1: Architecture skeleton
|
|
752
|
+
// src/domains/security/secrets/manager.ts
|
|
753
|
+
export class SecretsManager {
|
|
754
|
+
private constructor(private provider: ISecretsProvider) {}
|
|
755
|
+
|
|
756
|
+
static create(provider?: ISecretsProvider): SecretsManager {
|
|
757
|
+
throw new Error("Not implemented yet");
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async resolve(reference: string): Promise<string> {
|
|
761
|
+
throw new Error("Not implemented yet");
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Step 2: Write failing tests (RED)
|
|
766
|
+
describe("SecretsManager", () => {
|
|
767
|
+
it("should create instance with default provider", () => {
|
|
768
|
+
const manager = SecretsManager.create();
|
|
769
|
+
expect(manager).toBeInstanceOf(SecretsManager);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it("should resolve secret reference", async () => {
|
|
773
|
+
const mockProvider = new MockSecretsProvider();
|
|
774
|
+
mockProvider.setSecret("op://vault/item/field", "secret-value");
|
|
775
|
+
const manager = SecretsManager.create(mockProvider);
|
|
776
|
+
|
|
777
|
+
const result = await manager.resolve("op://vault/item/field");
|
|
778
|
+
expect(result).toBe("secret-value");
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
// Step 3: Implement (GREEN)
|
|
783
|
+
export class SecretsManager {
|
|
784
|
+
private constructor(private provider: ISecretsProvider) {}
|
|
785
|
+
|
|
786
|
+
static create(provider?: ISecretsProvider): SecretsManager {
|
|
787
|
+
return new SecretsManager(provider || new OnePasswordAdapter());
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async resolve(reference: string): Promise<string> {
|
|
791
|
+
return this.provider.resolve(reference);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Step 4: Refactor (add error handling, caching, etc.)
|
|
796
|
+
// All tests still pass
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
**Connection to other principles:**
|
|
800
|
+
|
|
801
|
+
- **Bounded Context-Based Folder Structure** (#1): TDD encourages thinking about domain boundaries upfront
|
|
802
|
+
- **Expose Business Capabilities** (#2): Tests validate that API expresses business intent
|
|
803
|
+
- **Class-Based Structures** (#3): Tests drive towards cohesive class design
|
|
804
|
+
- **Barrel Exports** (#4): Tests validate public API is sufficient and well-designed
|
|
805
|
+
- **Separate System Interactions** (#5): TDD with mocks naturally separates testable logic from system calls
|
|
806
|
+
- **Unit Tests Without Mocks** (#7): TDD skeleton design enables testing real implementations
|
|
807
|
+
|
|
808
|
+
### Unit Tests Without Mocks
|
|
809
|
+
|
|
810
|
+
**Problem**: Using mocks in unit tests creates brittle tests that pass even when real implementations are broken.
|
|
811
|
+
|
|
812
|
+
```typescript
|
|
813
|
+
// ❌ BAD - Mocks in unit tests
|
|
814
|
+
// tests/unit/user-service.test.ts
|
|
815
|
+
import { jest } from "@jest/globals";
|
|
816
|
+
|
|
817
|
+
describe("UserService", () => {
|
|
818
|
+
it("should create user", async () => {
|
|
819
|
+
// Mocking in unit test
|
|
820
|
+
const mockRepository = {
|
|
821
|
+
insert: jest.fn().mockResolvedValue({ id: "123", name: "Alice" }),
|
|
822
|
+
findByEmail: jest.fn().mockResolvedValue(null),
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
const service = new UserService(mockRepository);
|
|
826
|
+
const user = await service.createUser({ name: "Alice", email: "alice@example.com" });
|
|
827
|
+
|
|
828
|
+
expect(user.name).toBe("Alice");
|
|
829
|
+
// Test passes, but real PostgresRepository might be broken
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
**Solution**: Unit tests use real implementations; mocks only in integration tests to bypass external systems.
|
|
835
|
+
|
|
836
|
+
```typescript
|
|
837
|
+
// ✓ GOOD - Real implementations in unit tests
|
|
838
|
+
// tests/unit/domains/security/secrets/manager.test.ts
|
|
839
|
+
import { describe, it, expect } from "bun:test";
|
|
840
|
+
import { SecretsManager } from "@src/domains/security/secrets/manager";
|
|
841
|
+
import { InMemorySecretsProvider } from "../../../../mocks/secrets-provider";
|
|
842
|
+
|
|
843
|
+
describe("SecretsManager", () => {
|
|
844
|
+
it("should resolve secret from provider", async () => {
|
|
845
|
+
// Real implementation, just in-memory instead of external system
|
|
846
|
+
const provider = new InMemorySecretsProvider();
|
|
847
|
+
provider.addSecret("op://vault/item/field", "secret-value");
|
|
848
|
+
|
|
849
|
+
const manager = SecretsManager.create(provider);
|
|
850
|
+
const result = await manager.resolve("op://vault/item/field");
|
|
851
|
+
|
|
852
|
+
expect(result).toBe("secret-value");
|
|
853
|
+
// Test verifies real business logic with real data structures
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it("should cache resolved secrets", async () => {
|
|
857
|
+
const provider = new InMemorySecretsProvider();
|
|
858
|
+
provider.addSecret("op://vault/api/token", "abc123");
|
|
859
|
+
|
|
860
|
+
const manager = SecretsManager.create(provider);
|
|
861
|
+
|
|
862
|
+
// First call - hits provider
|
|
863
|
+
await manager.resolve("op://vault/api/token");
|
|
864
|
+
|
|
865
|
+
// Second call - should use cache
|
|
866
|
+
await manager.resolve("op://vault/api/token");
|
|
867
|
+
|
|
868
|
+
// Verify caching behavior with real implementation
|
|
869
|
+
expect(provider.getCallCount("op://vault/api/token")).toBe(1);
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
// tests/mocks/secrets-provider.ts - Real implementation for testing
|
|
874
|
+
export class InMemorySecretsProvider implements ISecretsProvider {
|
|
875
|
+
private secrets = new Map<string, string>();
|
|
876
|
+
private callCounts = new Map<string, number>();
|
|
877
|
+
|
|
878
|
+
addSecret(reference: string, value: string): void {
|
|
879
|
+
this.secrets.set(reference, value);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async resolve(reference: string): Promise<string> {
|
|
883
|
+
this.callCounts.set(reference, (this.callCounts.get(reference) || 0) + 1);
|
|
884
|
+
const value = this.secrets.get(reference);
|
|
885
|
+
if (!value) throw new Error(`Secret not found: ${reference}`);
|
|
886
|
+
return value;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
getCallCount(reference: string): number {
|
|
890
|
+
return this.callCounts.get(reference) || 0;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// ✓ Integration tests use mocks for external systems
|
|
895
|
+
// tests/integration/secrets/onepassword-integration.test.ts
|
|
896
|
+
describe("OnePasswordAdapter integration", () => {
|
|
897
|
+
it("should authenticate with 1Password CLI", async () => {
|
|
898
|
+
// Mock external system (1Password CLI) in integration test
|
|
899
|
+
const mockCLI = new MockOnePasswordCLI();
|
|
900
|
+
mockCLI.setAuthResponse({ success: true, account: "test-account" });
|
|
901
|
+
|
|
902
|
+
const adapter = new OnePasswordAdapter(mockCLI);
|
|
903
|
+
const result = await adapter.authenticate();
|
|
904
|
+
|
|
905
|
+
expect(result.success).toBe(true);
|
|
906
|
+
// Integration test verifies adapter interacts correctly with CLI
|
|
907
|
+
});
|
|
908
|
+
});
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
**Why this approach is better:**
|
|
912
|
+
|
|
913
|
+
1. **Catch real bugs**: Tests exercise actual business logic, not mock behavior
|
|
914
|
+
2. **Fast execution**: In-memory implementations run at compile speed (< 1ms per test)
|
|
915
|
+
3. **Deterministic**: No flakiness from external systems or timing issues
|
|
916
|
+
4. **Refactoring confidence**: Tests verify real behavior, not implementation details
|
|
917
|
+
5. **Clear test boundaries**: Unit (real implementations) vs Integration (mocked systems) vs UAT (real systems)
|
|
918
|
+
6. **Better test coverage**: Exercise actual code paths, not mock return values
|
|
919
|
+
|
|
920
|
+
**Test structure:**
|
|
921
|
+
|
|
922
|
+
```typescript
|
|
923
|
+
tests/
|
|
924
|
+
├── unit/ // Real implementations, no mocks
|
|
925
|
+
│ └── domains/ // Shadows src/domains/
|
|
926
|
+
│ ├── security/
|
|
927
|
+
│ │ └── secrets/ // Shadows src/domains/security/secrets/
|
|
928
|
+
│ │ ├── manager.test.ts
|
|
929
|
+
│ │ ├── resolver.test.ts
|
|
930
|
+
│ │ └── reference.test.ts
|
|
931
|
+
│ └── git/
|
|
932
|
+
│ └── pr-template/ // Shadows src/domains/git/pr-template/
|
|
933
|
+
│ └── parser.test.ts
|
|
934
|
+
├── integration/ // Mocks for external systems
|
|
935
|
+
│ ├── secrets/
|
|
936
|
+
│ │ └── onepassword-integration.test.ts // Mocks 1Password CLI
|
|
937
|
+
│ └── installers/
|
|
938
|
+
│ └── github-mcp.test.ts // Mocks GitHub API
|
|
939
|
+
├── uat/ // End-to-end, real systems
|
|
940
|
+
│ └── deployment-workflow.test.ts
|
|
941
|
+
└── mocks/ // Real implementations for testing
|
|
942
|
+
├── secrets-provider.ts // In-memory ISecretsProvider
|
|
943
|
+
├── user-repository.ts // In-memory IUserRepository
|
|
944
|
+
└── file-reader.ts // In-memory IFileReader
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
**Test folder structure shadows source:**
|
|
948
|
+
|
|
949
|
+
```typescript
|
|
950
|
+
// Source structure
|
|
951
|
+
src/domains/security/secrets/
|
|
952
|
+
├── manager.ts
|
|
953
|
+
├── resolver.ts
|
|
954
|
+
├── reference.ts
|
|
955
|
+
└── adapters/
|
|
956
|
+
└── onepassword/
|
|
957
|
+
└── adapter.ts
|
|
958
|
+
|
|
959
|
+
// Test structure mirrors domain organization
|
|
960
|
+
tests/unit/domains/security/secrets/
|
|
961
|
+
├── manager.test.ts // Tests manager.ts
|
|
962
|
+
├── resolver.test.ts // Tests resolver.ts
|
|
963
|
+
├── reference.test.ts // Tests reference.ts
|
|
964
|
+
└── adapters/
|
|
965
|
+
└── onepassword/
|
|
966
|
+
└── adapter.test.ts // Tests adapter.ts
|
|
967
|
+
|
|
968
|
+
// Benefits:
|
|
969
|
+
// 1. Easy to find tests for any source file
|
|
970
|
+
// 2. Clear relationship between source and tests
|
|
971
|
+
// 3. Refactoring moves tests with source
|
|
972
|
+
// 4. New developers understand test organization instantly
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
**Performance targets:**
|
|
976
|
+
|
|
977
|
+
| Test Type | Execution Time | External Dependencies | Mocks Used |
|
|
978
|
+
| ----------- | -------------- | --------------------- | ------------------------------- |
|
|
979
|
+
| Unit | < 1ms per test | None | None (real in-memory impl) |
|
|
980
|
+
| Integration | < 100ms | Minimal (local only) | External systems (API, CLI, DB) |
|
|
981
|
+
| UAT/E2E | < 30 seconds | Real systems | None (full end-to-end) |
|
|
982
|
+
|
|
983
|
+
**Creating test doubles (real implementations):**
|
|
984
|
+
|
|
985
|
+
```typescript
|
|
986
|
+
// ✓ GOOD - Real implementation for testing
|
|
987
|
+
// tests/mocks/user-repository.ts
|
|
988
|
+
export class InMemoryUserRepository implements IUserRepository {
|
|
989
|
+
private users = new Map<string, User>();
|
|
990
|
+
|
|
991
|
+
async insert(user: User): Promise<User> {
|
|
992
|
+
this.users.set(user.id, user);
|
|
993
|
+
return user;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
async findByEmail(email: string): Promise<User | null> {
|
|
997
|
+
return Array.from(this.users.values()).find((u) => u.email === email) || null;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
async delete(id: string): Promise<void> {
|
|
1001
|
+
this.users.delete(id);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Test helper methods
|
|
1005
|
+
clear(): void {
|
|
1006
|
+
this.users.clear();
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
count(): number {
|
|
1010
|
+
return this.users.size;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Usage in tests
|
|
1015
|
+
describe("UserService", () => {
|
|
1016
|
+
it("should prevent duplicate emails", async () => {
|
|
1017
|
+
const repository = new InMemoryUserRepository();
|
|
1018
|
+
const service = new UserService(repository);
|
|
1019
|
+
|
|
1020
|
+
await service.createUser({ name: "Alice", email: "alice@example.com" });
|
|
1021
|
+
|
|
1022
|
+
// Test real validation logic
|
|
1023
|
+
await expect(service.createUser({ name: "Bob", email: "alice@example.com" })).rejects.toThrow(
|
|
1024
|
+
"Email already exists"
|
|
1025
|
+
);
|
|
1026
|
+
});
|
|
1027
|
+
});
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
**When to use mocks:**
|
|
1031
|
+
|
|
1032
|
+
**Integration tests** - Mock external systems:
|
|
1033
|
+
|
|
1034
|
+
- Database connections (use in-memory DB or docker container instead when possible)
|
|
1035
|
+
- HTTP APIs (mock server responses)
|
|
1036
|
+
- File system (mock file operations for external files)
|
|
1037
|
+
- CLI tools (mock command output)
|
|
1038
|
+
- Cloud services (mock AWS SDK calls)
|
|
1039
|
+
|
|
1040
|
+
**Unit tests** - Use real implementations:
|
|
1041
|
+
|
|
1042
|
+
- Business logic (always use real code)
|
|
1043
|
+
- Domain entities (always use real objects)
|
|
1044
|
+
- Data transformations (always test real functions)
|
|
1045
|
+
- Validation rules (always test real validators)
|
|
1046
|
+
- Internal dependencies (use real in-memory implementations)
|
|
1047
|
+
|
|
1048
|
+
**Never mock:**
|
|
1049
|
+
|
|
1050
|
+
- Language built-ins (Array, Map, Set, String, etc.)
|
|
1051
|
+
- Your own domain code in unit tests
|
|
1052
|
+
- Pure functions with no side effects
|
|
1053
|
+
- Value objects and DTOs
|
|
1054
|
+
|
|
1055
|
+
**Example: Unit vs Integration testing**
|
|
1056
|
+
|
|
1057
|
+
```typescript
|
|
1058
|
+
// Unit test - Real implementations
|
|
1059
|
+
// tests/unit/domains/security/secrets/resolver.test.ts
|
|
1060
|
+
describe("SecretResolver", () => {
|
|
1061
|
+
it("should cache resolved secrets", async () => {
|
|
1062
|
+
const provider = new InMemorySecretsProvider(); // Real implementation
|
|
1063
|
+
provider.addSecret("op://vault/api/token", "abc123");
|
|
1064
|
+
|
|
1065
|
+
const resolver = new SecretResolver(provider);
|
|
1066
|
+
|
|
1067
|
+
const first = await resolver.resolve("op://vault/api/token");
|
|
1068
|
+
const second = await resolver.resolve("op://vault/api/token");
|
|
1069
|
+
|
|
1070
|
+
expect(first).toBe("abc123");
|
|
1071
|
+
expect(second).toBe("abc123");
|
|
1072
|
+
expect(provider.getCallCount("op://vault/api/token")).toBe(1); // Cached
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
// Integration test - Mocked external system
|
|
1077
|
+
// tests/integration/secrets/onepassword-integration.test.ts
|
|
1078
|
+
describe("OnePasswordAdapter", () => {
|
|
1079
|
+
it("should resolve secret from 1Password CLI", async () => {
|
|
1080
|
+
const mockCLI = new MockOnePasswordCLI(); // Mocks external system
|
|
1081
|
+
mockCLI.setSecret("op://vault/api/token", "real-secret-123");
|
|
1082
|
+
|
|
1083
|
+
const adapter = new OnePasswordAdapter(mockCLI);
|
|
1084
|
+
const result = await adapter.resolve("op://vault/api/token");
|
|
1085
|
+
|
|
1086
|
+
expect(result).toBe("real-secret-123");
|
|
1087
|
+
expect(mockCLI.wasCalledWith("op", "read", "op://vault/api/token")).toBe(true);
|
|
1088
|
+
});
|
|
1089
|
+
});
|
|
1090
|
+
```
|
|
1091
|
+
|
|
1092
|
+
**Connection to other principles:**
|
|
1093
|
+
|
|
1094
|
+
- **Test-Driven Development** (#6): TDD skeleton design enables real implementations for unit testing
|
|
1095
|
+
- **Separate System Interactions** (#5): System wrappers tested in integration, business logic in unit tests
|
|
1096
|
+
- **Expose Business Capabilities** (#2): Unit tests validate business API works with real domain logic
|
|
1097
|
+
- **Bounded Context-Based Folder Structure** (#1): Test folders mirror domain organization for clarity
|
|
1098
|
+
|
|
1099
|
+
### Atomic Tests
|
|
1100
|
+
|
|
1101
|
+
**Problem**: Tests that share state (module-level mocks, describe-scoped instances, or module singletons) fail when Bun runs them in parallel or random order, even though they pass individually.
|
|
1102
|
+
|
|
1103
|
+
```typescript
|
|
1104
|
+
// ❌ BAD - Shared mock instance causes race conditions
|
|
1105
|
+
describe("git-utils (integration)", () => {
|
|
1106
|
+
let mockExecutor: ProcessExecutorMock; // ← SHARED across all tests
|
|
1107
|
+
|
|
1108
|
+
beforeEach(() => {
|
|
1109
|
+
mockExecutor = new ProcessExecutorMock();
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
it("should get staged files", async () => {
|
|
1113
|
+
mockExecutor.setCommand("git diff --cached", { stdout: "file1.ts\n" });
|
|
1114
|
+
const result = await getStagedFiles(mockExecutor);
|
|
1115
|
+
expect(result).toEqual(["file1.ts"]);
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
it("should return empty array when no files", async () => {
|
|
1119
|
+
mockExecutor.setCommand("git diff --cached", { stdout: "" });
|
|
1120
|
+
const result = await getStagedFiles(mockExecutor);
|
|
1121
|
+
expect(result).toEqual([]);
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
// When running in parallel:
|
|
1125
|
+
// - Test 1 sets stdout to 'file1.ts\n'
|
|
1126
|
+
// - Test 2 sets stdout to '' at the same time
|
|
1127
|
+
// - Both tests use the SAME mockExecutor instance
|
|
1128
|
+
// - Race condition: which value wins?
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
// Symptom: Individual file passes, full suite fails
|
|
1132
|
+
// $ FILES=tests/unit/utils/git-utils.test.ts bun test ✓ 13 pass
|
|
1133
|
+
// $ bun test ✗ 8 failures in git-utils.test.ts
|
|
1134
|
+
```
|
|
1135
|
+
|
|
1136
|
+
**Even worse - Module-level mocks pollute ALL test files:**
|
|
1137
|
+
|
|
1138
|
+
```typescript
|
|
1139
|
+
// ❌ VERY BAD - Global pollution across entire test process
|
|
1140
|
+
// tests/unit/security/eslint-linter.test.ts
|
|
1141
|
+
const mockIsCommandAvailable = mock();
|
|
1142
|
+
|
|
1143
|
+
mock.module("../../../src/utils/git-utils.js", () => ({
|
|
1144
|
+
isCommandAvailable: mockIsCommandAvailable,
|
|
1145
|
+
getStagedFiles: mock(() => []),
|
|
1146
|
+
}));
|
|
1147
|
+
|
|
1148
|
+
describe("eslint-linter", () => {
|
|
1149
|
+
it("should check if ESLint is installed", async () => {
|
|
1150
|
+
mockIsCommandAvailable.mockReturnValue(true);
|
|
1151
|
+
const result = await isESLintInstalled();
|
|
1152
|
+
expect(result).toBe(true);
|
|
1153
|
+
});
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
// Problem: mock.module() creates GLOBAL mock for ALL test files
|
|
1157
|
+
// When git-utils.test.ts runs (in parallel or after), it imports git-utils
|
|
1158
|
+
// But it gets the MOCKED version instead of real implementation
|
|
1159
|
+
// Result: git-utils.test.ts fails with unexpected mock behavior
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
**Solution**: Create new mock instances in each `it()` block, avoid module-level mocks for internal code, and always pass explicit dependencies.
|
|
1163
|
+
|
|
1164
|
+
```typescript
|
|
1165
|
+
// ✓ GOOD - Test-scoped mocks (isolated per test)
|
|
1166
|
+
describe("git-utils (integration)", () => {
|
|
1167
|
+
it("should get staged files", async () => {
|
|
1168
|
+
const mockExecutor = new ProcessExecutorMock(); // ← NEW instance per test
|
|
1169
|
+
mockExecutor.setCommand("git diff --cached --name-only --diff-filter=ACM", {
|
|
1170
|
+
stdout: "file1.ts\n",
|
|
1171
|
+
exitCode: 0,
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
const result = await getStagedFiles(mockExecutor);
|
|
1175
|
+
expect(result).toEqual(["file1.ts"]);
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
it("should return empty array when no files", async () => {
|
|
1179
|
+
const mockExecutor = new ProcessExecutorMock(); // ← NEW instance per test
|
|
1180
|
+
mockExecutor.setCommand("git diff --cached --name-only --diff-filter=ACM", {
|
|
1181
|
+
stdout: "",
|
|
1182
|
+
exitCode: 0,
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
const result = await getStagedFiles(mockExecutor);
|
|
1186
|
+
expect(result).toEqual([]);
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
// Each test has its own isolated mock
|
|
1190
|
+
// No race conditions in parallel execution
|
|
1191
|
+
// Tests can run in any order
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
// ✓ GOOD - Use dependency injection instead of mock.module()
|
|
1195
|
+
// src/security/eslint-linter.ts
|
|
1196
|
+
export async function isESLintInstalled(
|
|
1197
|
+
commandChecker: (cmd: string) => Promise<boolean> = isCommandAvailable
|
|
1198
|
+
): Promise<boolean> {
|
|
1199
|
+
return commandChecker("eslint");
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// tests/unit/security/eslint-linter.test.ts
|
|
1203
|
+
it("should check if ESLint is installed", async () => {
|
|
1204
|
+
const mockChecker = mock(() => Promise.resolve(true)); // ← Test-scoped
|
|
1205
|
+
const result = await isESLintInstalled(mockChecker);
|
|
1206
|
+
expect(result).toBe(true);
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
// No module mocking needed - clean dependency injection
|
|
1210
|
+
```
|
|
1211
|
+
|
|
1212
|
+
**Why this approach is better:**
|
|
1213
|
+
|
|
1214
|
+
1. **Parallel execution**: Tests run concurrently without race conditions
|
|
1215
|
+
2. **Random order**: Tests can run in any order (Bun default behavior)
|
|
1216
|
+
3. **Fast debugging**: Individual test failures are isolated, not cascade failures
|
|
1217
|
+
4. **No pollution**: Module-level mocks don't leak between test files
|
|
1218
|
+
5. **Deterministic**: Same results every run, regardless of execution order
|
|
1219
|
+
6. **Maintainable**: Tests don't mysteriously fail when unrelated tests are added
|
|
1220
|
+
|
|
1221
|
+
**The Four Rules of Atomic Tests:**
|
|
1222
|
+
|
|
1223
|
+
**Rule 1: Test-Scoped Mocks Only**
|
|
1224
|
+
|
|
1225
|
+
```typescript
|
|
1226
|
+
// ✗ BAD - Describe-scoped (shared instance)
|
|
1227
|
+
describe('my tests', () => {
|
|
1228
|
+
let mockExecutor: ProcessExecutorMock; // Shared
|
|
1229
|
+
|
|
1230
|
+
beforeEach(() => {
|
|
1231
|
+
mockExecutor = new ProcessExecutorMock();
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
it('test 1', () => { mockExecutor.setCommand(...); }); // Race
|
|
1235
|
+
it('test 2', () => { mockExecutor.setCommand(...); }); // Race
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
// ✓ GOOD - Test-scoped (isolated instances)
|
|
1239
|
+
describe('my tests', () => {
|
|
1240
|
+
it('test 1', () => {
|
|
1241
|
+
const mockExecutor = new ProcessExecutorMock(); // Isolated
|
|
1242
|
+
mockExecutor.setCommand(...);
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
it('test 2', () => {
|
|
1246
|
+
const mockExecutor = new ProcessExecutorMock(); // Isolated
|
|
1247
|
+
mockExecutor.setCommand(...);
|
|
1248
|
+
});
|
|
1249
|
+
});
|
|
1250
|
+
```
|
|
1251
|
+
|
|
1252
|
+
**Rule 2: Avoid Module-Level Mocks for Internal Code**
|
|
1253
|
+
|
|
1254
|
+
```typescript
|
|
1255
|
+
// ✗ BAD - Module mock for internal code (global pollution)
|
|
1256
|
+
mock.module("../../../src/utils/git-utils.js", () => ({
|
|
1257
|
+
isCommandAvailable: mock(),
|
|
1258
|
+
}));
|
|
1259
|
+
|
|
1260
|
+
// ✓ GOOD - Dependency injection (no module mocking)
|
|
1261
|
+
export async function isESLintInstalled(commandChecker = isCommandAvailable): Promise<boolean> {
|
|
1262
|
+
return commandChecker("eslint");
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Test with injected mock (test-scoped)
|
|
1266
|
+
it("should check if ESLint is installed", async () => {
|
|
1267
|
+
const mockChecker = mock(() => Promise.resolve(true));
|
|
1268
|
+
const result = await isESLintInstalled(mockChecker);
|
|
1269
|
+
expect(result).toBe(true);
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
// ✓ ACCEPTABLE - Module mock for external dependencies only
|
|
1273
|
+
mock.module("node:child_process", () => ({
|
|
1274
|
+
execSync: mock(),
|
|
1275
|
+
}));
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
**Rule 3: Minimize Module-Level Singletons**
|
|
1279
|
+
|
|
1280
|
+
```typescript
|
|
1281
|
+
// ⚠️ RISKY - Module-level singleton (shared state)
|
|
1282
|
+
export const defaultProcessExecutor = new ProcessExecutorSystem();
|
|
1283
|
+
|
|
1284
|
+
export async function getStagedFiles(
|
|
1285
|
+
executor: IProcessExecutor = defaultProcessExecutor // ← Shared singleton
|
|
1286
|
+
): Promise<string[]> {
|
|
1287
|
+
return executor.exec("git diff --cached");
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// ✓ BETTER - Factory function (new instance per call)
|
|
1291
|
+
export function createProcessExecutor(): IProcessExecutor {
|
|
1292
|
+
return new ProcessExecutorSystem();
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// ✓ ACCEPTABLE - Immutable singleton (no state)
|
|
1296
|
+
export const DEFAULT_TIMEOUT = 30000; // Primitive, can't mutate
|
|
1297
|
+
```
|
|
1298
|
+
|
|
1299
|
+
**Rule 4: Always Pass Dependencies in Tests**
|
|
1300
|
+
|
|
1301
|
+
```typescript
|
|
1302
|
+
// ✗ BAD - Relies on default parameter (uses shared singleton)
|
|
1303
|
+
it('should get staged files', async () => {
|
|
1304
|
+
const result = await getStagedFiles(); // No executor passed
|
|
1305
|
+
expect(result).toEqual(['file1.ts']);
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
// ✓ GOOD - Explicit mock instance (isolated)
|
|
1309
|
+
it('should get staged files', async () => {
|
|
1310
|
+
const mockExecutor = new ProcessExecutorMock();
|
|
1311
|
+
mockExecutor.setCommand(...);
|
|
1312
|
+
|
|
1313
|
+
const result = await getStagedFiles(mockExecutor);
|
|
1314
|
+
expect(result).toEqual(['file1.ts']);
|
|
1315
|
+
});
|
|
1316
|
+
```
|
|
1317
|
+
|
|
1318
|
+
**Detection Methods:**
|
|
1319
|
+
|
|
1320
|
+
When tests fail only in full suite but pass individually:
|
|
1321
|
+
|
|
1322
|
+
```bash
|
|
1323
|
+
# Individual file passes
|
|
1324
|
+
FILES=tests/unit/utils/git-utils.test.ts bun test
|
|
1325
|
+
# ✓ 13 pass, 0 fail
|
|
1326
|
+
|
|
1327
|
+
# Full suite fails
|
|
1328
|
+
bun test
|
|
1329
|
+
# ✗ 995 pass, 14 fail (including 8 git-utils failures)
|
|
1330
|
+
|
|
1331
|
+
# Conclusion: Global state pollution
|
|
1332
|
+
```
|
|
1333
|
+
|
|
1334
|
+
**Finding antipatterns:**
|
|
1335
|
+
|
|
1336
|
+
```bash
|
|
1337
|
+
# Find describe-scoped mocks (potential shared state)
|
|
1338
|
+
grep -r "let mock.*:" tests/ | grep -v "const mock"
|
|
1339
|
+
|
|
1340
|
+
# Find module-level mocks (global pollution)
|
|
1341
|
+
grep -r "mock.module" tests/
|
|
1342
|
+
|
|
1343
|
+
# Find module-level singletons (shared state)
|
|
1344
|
+
grep -r "^export const.*=.*new " src/
|
|
1345
|
+
```
|
|
1346
|
+
|
|
1347
|
+
**Quick checklist before committing:**
|
|
1348
|
+
|
|
1349
|
+
- [ ] No `let mock*` declarations at describe scope
|
|
1350
|
+
- [ ] No `mock.module()` calls for internal code (only external deps)
|
|
1351
|
+
- [ ] All mocks created with `const` inside individual `it()` blocks
|
|
1352
|
+
- [ ] No reliance on default parameters in tests (always pass explicit mocks)
|
|
1353
|
+
- [ ] Tests pass individually: `FILES=path/to/test.ts bun test`
|
|
1354
|
+
- [ ] Tests pass in full suite: `bun test`
|
|
1355
|
+
|
|
1356
|
+
**Performance impact:**
|
|
1357
|
+
|
|
1358
|
+
| Approach | Parallel | Random Order | Speed | Reliability |
|
|
1359
|
+
| ------------------ | -------- | ------------ | ----------- | ----------- |
|
|
1360
|
+
| Shared mocks (bad) | ✗ Fails | ✗ Fails | Fast | Unreliable |
|
|
1361
|
+
| Test-scoped mocks | ✓ Works | ✓ Works | Fast | Reliable |
|
|
1362
|
+
| Serial execution | N/A | ✓ Works | Slow (10x+) | Reliable |
|
|
1363
|
+
|
|
1364
|
+
**When to use atomic test pattern:**
|
|
1365
|
+
|
|
1366
|
+
- Always (all unit and integration tests)
|
|
1367
|
+
- Especially when using Bun (parallel by default)
|
|
1368
|
+
- Required for CI/CD reliability
|
|
1369
|
+
- Critical for test-driven development
|
|
1370
|
+
|
|
1371
|
+
**When NOT to worry:**
|
|
1372
|
+
|
|
1373
|
+
- End-to-end tests with real systems (serial execution acceptable)
|
|
1374
|
+
- Prototype/spike code (no tests yet)
|
|
1375
|
+
|
|
1376
|
+
**Connection to other principles:**
|
|
1377
|
+
|
|
1378
|
+
- **Unit Tests Without Mocks** (#7): Real implementations eliminate need for module mocking
|
|
1379
|
+
- **Test-Driven Development** (#6): TDD naturally creates test-scoped dependencies
|
|
1380
|
+
- **Separate System Interactions** (#5): Dependency injection enables atomic tests
|
|
1381
|
+
- **Expose Business Capabilities** (#2): Business APIs designed for testability support atomic tests
|
|
1382
|
+
|
|
1383
|
+
By following these four rules, tests become truly independent units that can execute in any order, at any time, in parallel, without cascading failures or mysterious bugs.
|
|
1384
|
+
|
|
1385
|
+
## Related Documentation
|
|
1386
|
+
|
|
1387
|
+
For general clean architecture principles, see [Clean Architecture Guide](./agents.clean.arch.md)
|
|
1388
|
+
|
|
1389
|
+
For test-driven development patterns, see [TDD Guide](./agents.tdd.md)
|