testing-strategy
Executive Summary
This document analyzes Lattice’s current testing approach and recommends a strategy that produces meaningful tests without brittle mocking.
Key Findings:
- Current tests are comprehensive (195 tests, 1395 assertions) but heavily mocked
- Mocks are tightly coupled to implementation details, making refactoring risky
- The codebase already has good foundations (MockEmbeddingProvider, temp-file manifest tests)
- Architecture changes can significantly reduce mock requirements
Core Recommendation: Adopt a Testing Layers approach with distinct strategies per layer, leveraging real implementations where possible.
Current State Analysis
Test Coverage Overview
| Test File | Tests | Focus | Mock Intensity |
|---|---|---|---|
graph.service.test.ts | 30+ | FalkorDB wrapper | High - MockRedis |
embedding.service.test.ts | 45+ | Embedding generation | Low - Uses MockProvider |
sync.service.test.ts | 35+ | Document sync orchestration | High - 5 service mocks |
manifest.service.test.ts | 15+ | File-based tracking | Low - Real file I/O |
cascade.service.test.ts | 25+ | Entity change detection | Medium - 2 service mocks |
ontology.service.test.ts | 10+ | Schema derivation | Low - Minimal mocking |
sync.command.test.ts | 20+ | CLI output | High - Full mock stack |
query.command.test.ts | 15+ | Query commands | Medium - Service mocks |
validate.command.test.ts | 5 | Validation logic | None - Tests inline logic |
Identified Problems
1. Mock Implementation Coupling
Tests verify internal implementation details rather than behavior:
// Brittle: Tests HOW the code worksconst cypher = calls[calls.length - 1][2];expect(cypher).toContain("MERGE");expect(cypher).toContain("TypeScript");This test breaks if the MERGE syntax changes, even if functionality remains correct.
2. Mock Sprawl
Each test file recreates similar mock factories:
const createMockManifestService = () => ({ ... });const createMockDocumentParserService = () => ({ ... });const createMockGraphService = () => ({ ... });
// cascade.service.test.tsconst createMockGraphService = () => ({ ... }); // Duplicated!const createMockDocumentParserService = () => ({ ... }); // Duplicated!3. Type Casting Hell
Frequent use of as any as Type breaks type safety:
service = new SyncService( mockManifest as unknown as ManifestService, mockParser as unknown as DocumentParserService, mockGraph as unknown as GraphService, // ...);4. Process.exit Mocking Pattern
Commands mock process.exit which throws errors, leading to awkward try-catch patterns:
try { await command.run([], {});} catch (e) { // Expected - process.exit mock throws}5. validate.command.test.ts Anti-Pattern
This test doesn’t test the command at all - it tests inline validation logic:
it('should detect entity type inconsistencies', () => { const issues: any[] = []; const entityTypes = new Map<string, string>(); // Tests reimplemented logic, not the actual command});Recommended Testing Strategy
The Testing Layers Approach
┌─────────────────────────────────────────────────────────┐│ E2E Tests (5%) ││ Full CLI invocation, real/containerized services │├─────────────────────────────────────────────────────────┤│ Integration Tests (25%) ││ Service combinations with real file I/O, mocked DB │├─────────────────────────────────────────────────────────┤│ Unit Tests (70%) ││ Pure functions, isolated logic, minimal mocking │└─────────────────────────────────────────────────────────┘Layer 1: Unit Tests (Pure Functions)
Goal: Test pure logic without ANY mocking.
Extract these pure functions from services:
// sync.service.ts - Currently embedded in classexport function collectUniqueEntities(docs: ParsedDocument[]): Map<string, UniqueEntity>;export function composeEmbeddingText(doc: ParsedDocument): string;export function composeEntityEmbeddingText(entity: UniqueEntity): string;export function validateDocuments(docs: ParsedDocument[]): ValidationError[];export function getChangeReason(changeType: ChangeType): string;
// graph.service.ts - Currently private methods// EXTRACT TO: src/graph/cypher-builder.ts
export function escapeCypher(value: string): string;export function escapeCypherValue(value: any): string;export function parseStats(result: unknown): CypherStats | undefined;export function buildUpsertNodeCypher(label: string, props: Record<string, any>): string;export function buildUpsertRelationshipCypher(...params): string;
// manifest.service.ts - Keep as is, already isolatedexport function getContentHash(content: string): string; // Already exists!Test Example (No Mocks):
import { escapeCypher, buildUpsertNodeCypher } from './cypher-builder.js';
describe('escapeCypher', () => { it('escapes single quotes', () => { expect(escapeCypher("O'Reilly")).toBe("O\\'Reilly"); });
it('escapes backslashes', () => { expect(escapeCypher('C:\\path')).toBe('C:\\\\path'); });});
describe('buildUpsertNodeCypher', () => { it('generates valid MERGE statement', () => { const cypher = buildUpsertNodeCypher('Technology', { name: 'TypeScript', version: '5.0' }); // Test the OUTPUT, not implementation expect(cypher).toMatch(/^MERGE \(n:`Technology` \{ name: 'TypeScript' \}\)/); expect(cypher).toContain("n.`version` = '5.0'"); });});Layer 2: Integration Tests (Service Combinations)
Goal: Test service interactions with minimal mocking, using real implementations where practical.
What to Use Real vs Mock
| Component | Approach | Rationale |
|---|---|---|
| ManifestService | Real (temp directory) | Already doing this - it works! |
| DocumentParserService | Real (temp files) | File I/O is deterministic |
| EmbeddingService | Real MockEmbeddingProvider | Built-in mock provider exists! |
| GraphService | Mock OR Testcontainers | External dependency |
| CascadeService | Real (with mocked GraphService) | Logic depends on graph queries |
| OntologyService | Real | Already pure-ish |
Using the Built-in MockEmbeddingProvider
The codebase already has MockEmbeddingProvider - use it properly:
// BEFORE: Over-mockedconst mockEmbeddingService = { generateEmbedding: mock(() => [0.1, 0.2, 0.3]), getDimensions: mock(() => 1536),};
// AFTER: Use real service with mock providerconst mockConfig = { get: (key: string) => ({ EMBEDDING_PROVIDER: 'mock', EMBEDDING_DIMENSIONS: 1536, }[key]),};const embeddingService = new EmbeddingService(mockConfig);// Now you get deterministic embeddings that are ACTUALLY normalized!Testcontainers for FalkorDB
For true integration tests with FalkorDB:
import { GenericContainer, StartedTestContainer } from 'testcontainers';
describe('GraphService Integration', () => { let container: StartedTestContainer; let graphService: GraphService;
beforeAll(async () => { container = await new GenericContainer('falkordb/falkordb:latest') .withExposedPorts(6379) .start();
const config = { get: (key: string) => ({ FALKORDB_HOST: container.getHost(), FALKORDB_PORT: container.getMappedPort(6379), GRAPH_NAME: 'test_graph', }[key]), };
graphService = new GraphService(config as ConfigService); await graphService.connect(); }, 60000); // Container startup can be slow
afterAll(async () => { await graphService.disconnect(); await container.stop(); });
it('should upsert and query nodes', async () => { await graphService.upsertNode('Technology', { name: 'TypeScript' }); const nodes = await graphService.findNodesByLabel('Technology'); expect(nodes).toHaveLength(1); expect(nodes[0].name).toBe('TypeScript'); });});File-Based Integration Tests
For sync workflows, use temp directories:
import { mkdtemp, rm, writeFile } from 'fs/promises';import { tmpdir } from 'os';import { join } from 'path';
describe('SyncService Integration', () => { let tempDir: string; let docsDir: string;
beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'lattice-test-')); docsDir = join(tempDir, 'docs'); await mkdir(docsDir, { recursive: true }); });
afterEach(async () => { await rm(tempDir, { recursive: true }); });
it('should detect new documents', async () => { // Create a real markdown file const docPath = join(docsDir, 'test.md'); await writeFile(docPath, `---title: Test Docentities: - name: TypeScript type: Technology---# Test DocumentContent here.`);
// Use real parser, real manifest, mocked graph const parser = new DocumentParserService(/* real config pointing to tempDir */); const manifest = new ManifestService(); const mockGraph = createMockGraphService();
const sync = new SyncService(manifest, parser, mockGraph, ...); const changes = await sync.detectChanges();
expect(changes).toHaveLength(1); expect(changes[0].changeType).toBe('new'); });});Layer 3: E2E Tests (Full CLI)
Goal: Verify the CLI works end-to-end, not implementation details.
import { $ } from 'bun';
describe('lattice sync E2E', () => { let tempDir: string;
beforeEach(async () => { tempDir = await setupTestProject(); // Creates docs, env, etc. });
it('should sync documents and report results', async () => { const result = await $`cd ${tempDir} && bun run src/main.ts sync --dry-run`.text();
expect(result).toContain('Dry run mode'); expect(result).toMatch(/Added: \d+/); expect(result).not.toContain('Error'); });
it('should show help', async () => { const result = await $`bun run src/main.ts --help`.text(); expect(result).toContain('sync'); expect(result).toContain('query'); expect(result).toContain('validate'); });});Specific Refactoring Recommendations
1. Extract Pure Functions
Priority: HIGH - Immediate improvement with zero risk
Create src/pure/ directory:
src/├── pure/│ ├── cypher.ts # escapeCypher, buildCypher functions│ ├── embedding-text.ts # composeEmbeddingText functions│ ├── validation.ts # validateDocuments (already exported!)│ └── hashing.ts # getContentHash (move from ManifestService)2. Create Shared Test Fixtures
Priority: HIGH - Reduces duplication
export function createTestDocument(overrides: Partial<ParsedDocument> = {}): ParsedDocument { return { path: 'docs/test.md', title: 'Test Document', content: '# Test', contentHash: 'abc123', frontmatterHash: 'def456', entities: [], relationships: [], tags: [], ...overrides, };}
export function createTestEntity(overrides: Partial<Entity> = {}): Entity { return { name: 'TestEntity', type: 'Technology', ...overrides, };}3. Replace process.exit Mocking
Priority: MEDIUM - Better command testing pattern
// Change commands to return exit codes instead of calling process.exitclass SyncCommand { async run(args: string[], opts: Options): Promise<number> { // ... logic ... return 0; // Success }}
// main.ts handles the actual exitconst exitCode = await command.run(args, opts);process.exit(exitCode);
// Tests become cleanerit('should sync successfully', async () => { const exitCode = await command.run([], {}); expect(exitCode).toBe(0);});4. Add Testcontainers Integration Suite
Priority: LOW - Nice to have for confidence
Add as separate test suite that runs optionally:
{ "scripts": { "test": "bun test", "test:integration": "bun test test/integration/", "test:e2e": "bun test test/e2e/", "test:all": "bun test && bun test:integration && bun test:e2e" }}NestJS Testing Utilities
Lattice uses NestJS but isn’t leveraging its built-in testing utilities. These would significantly reduce mock boilerplate and eliminate type casting issues.
Current Approach (Manual Construction)
// 80+ lines of manual mock setup per test fileconst mockGraph = createMockGraphService();const mockManifest = createMockManifestService();const mockParser = createMockDocumentParserService();
service = new SyncService( mockManifest as unknown as ManifestService, // Type casting! mockParser as unknown as DocumentParserService, mockGraph as unknown as GraphService,);Recommended: Test.createTestingModule()
import { Test, TestingModule } from '@nestjs/testing';
describe('SyncService', () => { let service: SyncService; let graphService: GraphService;
beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [SyncModule], }) .overrideProvider(GraphService) .useValue({ upsertNode: mock(() => Promise.resolve()), query: mock(() => Promise.resolve({ resultSet: [] })), deleteNode: mock(() => Promise.resolve()), }) .overrideProvider(ConfigService) .useValue({ get: (key: string) => ({ EMBEDDING_PROVIDER: 'mock', // Use real EmbeddingService with MockProvider! FALKORDB_HOST: 'localhost', }[key]), }) .compile();
service = module.get(SyncService); graphService = module.get(GraphService); });});Key NestJS Testing Features
| Feature | Purpose | Example |
|---|---|---|
Test.createTestingModule() | Create isolated test module | Test.createTestingModule({ providers: [...] }) |
.overrideProvider() | Swap real provider for mock | .overrideProvider(GraphService).useValue(mock) |
.useValue() | Provide mock object | .useValue({ method: mock() }) |
.useClass() | Provide mock class | .useClass(MockGraphService) |
.useFactory() | Dynamic mock creation | .useFactory({ factory: () => createMock() }) |
module.get() | Get provider instance | module.get(SyncService) |
Benefits Over Manual Construction
| Aspect | Manual | NestJS Testing Module |
|---|---|---|
| Type safety | as any as Type casts | Proper DI types |
| DI wiring | Not verified | Framework validates |
| Mock setup | 50+ lines duplicated | Chainable, reusable |
| Real + Mock mix | Awkward | Natural |
| Module imports | Can’t test | Full module testing |
Mixing Real and Mock Providers
The key advantage: use real providers where practical, mock only external dependencies:
const module = await Test.createTestingModule({ imports: [SyncModule],})// Mock external database.overrideProvider(GraphService).useValue(createMockGraphService())// Use REAL EmbeddingService with mock provider (built-in!).overrideProvider(ConfigService).useValue({ get: () => 'mock' }) // Forces MockEmbeddingProvider// ManifestService, DocumentParserService stay REAL.compile();Bun Compatibility
NestJS testing module works with Bun test runner. The useMocker() auto-mock feature requires Jest internals, but overrideProvider() works perfectly:
// Bun-compatible pattern.overrideProvider(GraphService).useValue(createMockGraphService()) // Your existing factory works!Design Patterns for Testable Code
1. Functional Core, Imperative Shell (Primary Pattern)
Separate pure logic (testable without mocks) from I/O operations (requires mocks/integration):
┌──────────────────────────────────────────────────┐│ Imperative Shell (Service) ││ • Handles I/O, async, side effects ││ • Thin orchestration layer ││ • Requires mocking for unit tests ││ ││ ┌────────────────────────────────────────┐ ││ │ Functional Core │ ││ │ • Pure functions │ ││ │ • All business logic │ ││ │ • Zero mocks needed! │ ││ └────────────────────────────────────────┘ │└──────────────────────────────────────────────────┘Apply to Lattice:
// FUNCTIONAL CORE - pure, testable without mocksexport function buildSyncPlan( diskDocs: string[], manifestDocs: string[]): { toAdd: string[], toDelete: string[], toUpdate: string[] } { // Pure logic - easy to test!}
// IMPERATIVE SHELL - thin orchestrationclass SyncService { async sync() { const diskDocs = await this.parser.discoverDocuments(); // I/O const manifestDocs = this.manifest.getTrackedPaths(); // I/O const plan = buildSyncPlan(diskDocs, manifestDocs); // PURE! for (const path of plan.toAdd) { await this.syncDocument(path); // I/O } }}2. When to Extract Pure Functions
| Extract When | Keep Inline When |
|---|---|
| Logic is complex (>5 lines) | Simple delegation |
| Logic is reusable | One-off operation |
| Hard to test in context | Easy to verify |
| You want to test “just this part” | Orchestration logic |
3. Ports and Adapters (Already Partial)
Lattice already does this for embeddings - extend to other dependencies:
// Port (interface)interface EmbeddingProvider { generateEmbedding(text: string): Promise<number[]>;}
// Adapters (swappable implementations)class VoyageProvider implements EmbeddingProvider { ... } // Productionclass OpenAIProvider implements EmbeddingProvider { ... } // Alternativeclass MockProvider implements EmbeddingProvider { ... } // Testing!4. Command Query Separation (CQS)
Methods should either change state OR return data, not both:
// GOOD - Separatedasync sync(): Promise<void> { } // Command: changes stateasync getStats(): Promise<Stats> { } // Query: returns data
// BAD - Mixedasync syncAndReturnStats(): Promise<Stats> { } // Does bothTesting benefit: Queries are easy to test (check return), commands are easy to verify (check side effects).
TDD Workflow with Testing Layers
TDD Works Best at Unit Level
The Testing Layers approach makes TDD easier, not harder:
Red → Write test for pure function (no mocks needed!)Green → Implement the functionRefactor → Clean upFast feedback loop: Pure function tests run in milliseconds.
TDD Friction Comes From Over-Mocking
// Before you write ANY test, you need 50+ lines of setup:const mockManifest = createMockManifestService();const mockParser = createMockDocumentParserService();const mockGraph = createMockGraphService();// ... This kills TDD momentum!TDD-Friendly Patterns for Lattice
| Scenario | TDD Approach |
|---|---|
| New business logic | Start with pure function test (no mocks) |
| New service method | Write integration test with NestJS testing module |
| Bug fix | Write failing test at lowest possible layer |
| Refactoring | Run existing tests, don’t write new ones first |
Practical Example
Adding new Cypher escape logic:
// 1. RED - Write failing test (no mocks!)describe('escapeCypher', () => { it('should escape newlines', () => { expect(escapeCypher('line1\nline2')).toBe('line1\\nline2'); });});
// 2. GREEN - Implementexport function escapeCypher(value: string): string { return value .replace(/\\/g, '\\\\') .replace(/'/g, "\\'") .replace(/\n/g, '\\n'); // Added!}
// 3. REFACTOR - Clean up if neededTotal mock setup: 0 lines. Feedback time: ~10ms.
Mock Reduction Roadmap
Phase 1: Extract Pure Functions (Week 1)
- Extract
escapeCypher,buildCypher*functions - Extract
composeEmbeddingTextfunctions - Move
getContentHashto pure module - Write unit tests for all pure functions
- Update services to use extracted functions
Phase 2: Consolidate Mocks (Week 2)
- Create shared mock factory module
- Deduplicate mock implementations
- Add type-safe mock builders
- Remove
as any as Typecasts
Phase 3: Integration Tests (Week 3)
- Add file-based sync integration tests
- Use real MockEmbeddingProvider in tests
- Add temp directory test utilities
- Convert suitable unit tests to integration tests
Phase 4: E2E & Testcontainers (Week 4+)
- Add basic CLI E2E tests
- Set up Testcontainers for FalkorDB
- Add optional integration test suite
- Document test running procedures
What NOT to Mock
Following Enterprise Craftsmanship guidance:
| Don’t Mock | Reason | Alternative |
|---|---|---|
| Pure functions | No side effects | Test directly |
| Data structures | Deterministic | Use real objects |
| Value objects | Immutable, testable | Use real objects |
| File I/O | Deterministic with temp dirs | Use temp directories |
| Built-in mock providers | They exist for testing! | Use MockEmbeddingProvider |
What TO Mock
| Do Mock | Reason |
|---|---|
| External APIs (Voyage, OpenAI) | Network, non-deterministic, costs money |
| FalkorDB (unless using Testcontainers) | External database |
| Time/dates | Non-deterministic |
| Random values | Non-deterministic |
Type-Safe Mocking with Strict TypeScript
You’re right to question the earlier overrideProvider example - with strictNullChecks and no as any casts allowed, that pattern would produce type errors.
The Problem
With strict TypeScript, this won’t compile:
// ERROR: Type '{ upsertNode: Mock<Promise<void>>; }' is missing properties.overrideProvider(GraphService).useValue({ upsertNode: mock(() => Promise.resolve()), // Missing: query, deleteNode, connect, disconnect... 20+ methods})Solution 1: DeepMockProxy with bun-mock-extended
For Bun projects, use @tkoehlerlg/bun-mock-extended:
bun add -d @tkoehlerlg/bun-mock-extendedimport { mockDeep, DeepMockProxy } from '@tkoehlerlg/bun-mock-extended';import { Test, TestingModule } from '@nestjs/testing';
describe('SyncService', () => { let service: SyncService; let graphService: DeepMockProxy<GraphService>;
beforeEach(async () => { graphService = mockDeep<GraphService>();
const module: TestingModule = await Test.createTestingModule({ imports: [SyncModule], }) .overrideProvider(GraphService) .useValue(graphService) // Type-safe! No cast needed .compile();
service = module.get(SyncService); });
it('should sync document', async () => { // Type-safe: calledWith validates argument types graphService.upsertNode .calledWith('Document', expect.objectContaining({ title: 'Test' })) .mockResolvedValue(undefined);
await service.syncDocument(testDoc);
expect(graphService.upsertNode).toHaveBeenCalled(); });});Benefits:
mockDeep<T>()creates a fully-typed proxy implementing all methods- No
as unknown as Typecasts required calledWith()validates argument types at compile time- Works with Bun’s test runner
Solution 2: Jest-Mock-Extended (for Jest)
For Jest projects, use jest-mock-extended:
npm install -D jest-mock-extendedimport { mock, mockDeep, DeepMockProxy } from 'jest-mock-extended';
// Shallow mock - only top-level methodsconst userService = mock<UserService>();
// Deep mock - nested objects also mockedconst graphService = mockDeep<GraphService>();graphService.deepProp.getNumber.calledWith(1).mockReturnValue(4);Solution 3: Custom DeepPartial Pattern
If you can’t use external libraries, create a type-safe partial mock utility:
export type DeepPartial<T> = T extends object ? { [P in keyof T]?: DeepPartial<T[P]> } : T;
export function createTypedMock<T>(partial: DeepPartial<T>): T { return partial as T; // Single controlled cast point}// test fileimport { createTypedMock } from './types/testing';
const mockGraphService = createTypedMock<GraphService>({ upsertNode: mock(() => Promise.resolve()), query: mock(() => Promise.resolve({ resultSet: [] })), // Only mock what you need - other methods are undefined});Trade-off: Methods not mocked return undefined. Your tests must only call mocked methods.
Solution 4: Interface-Based Mocking with Bun
For Bun’s native mocking without external deps:
interface GraphServicePort { upsertNode(label: string, props: object): Promise<void>; query(cypher: string): Promise<QueryResult>;}
// Create mock that satisfies the interfaceconst createMockGraphService = (): GraphServicePort => ({ upsertNode: mock(() => Promise.resolve()), query: mock(() => Promise.resolve({ resultSet: [] })),});
// Use in NestJS.overrideProvider(GRAPH_SERVICE_TOKEN) // Use token-based injection.useValue(createMockGraphService())This requires refactoring to interface-based dependency injection with tokens rather than class-based injection.
Comparison: Which Pattern to Use?
| Pattern | Pros | Cons |
|---|---|---|
| bun-mock-extended | Full type safety, calledWith matchers, deep mocking | External dependency |
| jest-mock-extended | Mature, full-featured | Jest-only |
| DeepPartial utility | No deps, simple | Unmocked methods throw at runtime |
| Interface + tokens | Pure DI, testable design | Requires refactoring |
Recommended Approach for Lattice
- Install
@tkoehlerlg/bun-mock-extended - Use
mockDeep<T>()for service mocks - Combine with NestJS
overrideProvider()for full type safety - Keep functional core pattern - pure functions need no mocks
// The golden pattern for Latticeimport { mockDeep } from '@tkoehlerlg/bun-mock-extended';
beforeEach(async () => { const module = await Test.createTestingModule({ imports: [SyncModule], }) .overrideProvider(GraphService) .useValue(mockDeep<GraphService>()) .overrideProvider(ConfigService) .useValue({ get: (key: string) => ({ EMBEDDING_PROVIDER: 'mock' }[key]), }) .compile();});Sources
Testing Philosophy
- Enterprise Craftsmanship - When to Mock
- Stack Overflow Blog - Favor real dependencies for unit testing
NestJS Testing
- NestJS Testing Documentation
- NestJS overrideProvider vs provider - Stack Overflow
- Overriding providers in NestJS Jest tests - Stack Overflow
- Testing NestJS Apps Best Practices
- jmcdo29/testing-nestjs - GitHub Examples
Type-Safe Mocking
- jest-mock-extended - GitHub
- @tkoehlerlg/bun-mock-extended - npm
- Partial Mocking in TypeScript - Stack Overflow
- Bun Test Mocks Documentation