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 FileTestsFocusMock Intensity
graph.service.test.ts30+FalkorDB wrapperHigh - MockRedis
embedding.service.test.ts45+Embedding generationLow - Uses MockProvider
sync.service.test.ts35+Document sync orchestrationHigh - 5 service mocks
manifest.service.test.ts15+File-based trackingLow - Real file I/O
cascade.service.test.ts25+Entity change detectionMedium - 2 service mocks
ontology.service.test.ts10+Schema derivationLow - Minimal mocking
sync.command.test.ts20+CLI outputHigh - Full mock stack
query.command.test.ts15+Query commandsMedium - Service mocks
validate.command.test.ts5Validation logicNone - Tests inline logic

Identified Problems

1. Mock Implementation Coupling

Tests verify internal implementation details rather than behavior:

// Brittle: Tests HOW the code works
const 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:

sync.service.test.ts
const createMockManifestService = () => ({ ... });
const createMockDocumentParserService = () => ({ ... });
const createMockGraphService = () => ({ ... });
// cascade.service.test.ts
const 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
});

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:

src/sync/pure-functions.ts
// sync.service.ts - Currently embedded in class
export 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 isolated
export function getContentHash(content: string): string; // Already exists!

Test Example (No Mocks):

cypher-builder.test.ts
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

ComponentApproachRationale
ManifestServiceReal (temp directory)Already doing this - it works!
DocumentParserServiceReal (temp files)File I/O is deterministic
EmbeddingServiceReal MockEmbeddingProviderBuilt-in mock provider exists!
GraphServiceMock OR TestcontainersExternal dependency
CascadeServiceReal (with mocked GraphService)Logic depends on graph queries
OntologyServiceRealAlready pure-ish

Using the Built-in MockEmbeddingProvider

The codebase already has MockEmbeddingProvider - use it properly:

// BEFORE: Over-mocked
const mockEmbeddingService = {
generateEmbedding: mock(() => [0.1, 0.2, 0.3]),
getDimensions: mock(() => 1536),
};
// AFTER: Use real service with mock provider
const 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:

test/integration/graph.integration.test.ts
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:

test/integration/sync.integration.test.ts
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 Doc
entities:
- name: TypeScript
type: Technology
---
# Test Document
Content 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.

test/e2e/sync.e2e.test.ts
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

test/fixtures/index.ts
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.exit
class SyncCommand {
async run(args: string[], opts: Options): Promise<number> {
// ... logic ...
return 0; // Success
}
}
// main.ts handles the actual exit
const exitCode = await command.run(args, opts);
process.exit(exitCode);
// Tests become cleaner
it('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:

package.json
{
"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 file
const 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,
);
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

FeaturePurposeExample
Test.createTestingModule()Create isolated test moduleTest.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 instancemodule.get(SyncService)

Benefits Over Manual Construction

AspectManualNestJS Testing Module
Type safetyas any as Type castsProper DI types
DI wiringNot verifiedFramework validates
Mock setup50+ lines duplicatedChainable, reusable
Real + Mock mixAwkwardNatural
Module importsCan’t testFull 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 mocks
export function buildSyncPlan(
diskDocs: string[],
manifestDocs: string[]
): { toAdd: string[], toDelete: string[], toUpdate: string[] } {
// Pure logic - easy to test!
}
// IMPERATIVE SHELL - thin orchestration
class 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 WhenKeep Inline When
Logic is complex (>5 lines)Simple delegation
Logic is reusableOne-off operation
Hard to test in contextEasy 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 { ... } // Production
class OpenAIProvider implements EmbeddingProvider { ... } // Alternative
class MockProvider implements EmbeddingProvider { ... } // Testing!

4. Command Query Separation (CQS)

Methods should either change state OR return data, not both:

// GOOD - Separated
async sync(): Promise<void> { } // Command: changes state
async getStats(): Promise<Stats> { } // Query: returns data
// BAD - Mixed
async syncAndReturnStats(): Promise<Stats> { } // Does both

Testing 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 function
Refactor → Clean up

Fast 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

ScenarioTDD Approach
New business logicStart with pure function test (no mocks)
New service methodWrite integration test with NestJS testing module
Bug fixWrite failing test at lowest possible layer
RefactoringRun 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 - Implement
export function escapeCypher(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/\n/g, '\\n'); // Added!
}
// 3. REFACTOR - Clean up if needed

Total mock setup: 0 lines. Feedback time: ~10ms.


Mock Reduction Roadmap

Phase 1: Extract Pure Functions (Week 1)

  • Extract escapeCypher, buildCypher* functions
  • Extract composeEmbeddingText functions
  • Move getContentHash to 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 Type casts

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 MockReasonAlternative
Pure functionsNo side effectsTest directly
Data structuresDeterministicUse real objects
Value objectsImmutable, testableUse real objects
File I/ODeterministic with temp dirsUse temp directories
Built-in mock providersThey exist for testing!Use MockEmbeddingProvider

What TO Mock

Do MockReason
External APIs (Voyage, OpenAI)Network, non-deterministic, costs money
FalkorDB (unless using Testcontainers)External database
Time/datesNon-deterministic
Random valuesNon-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:

Terminal window
bun add -d @tkoehlerlg/bun-mock-extended
import { 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 Type casts 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:

Terminal window
npm install -D jest-mock-extended
import { mock, mockDeep, DeepMockProxy } from 'jest-mock-extended';
// Shallow mock - only top-level methods
const userService = mock<UserService>();
// Deep mock - nested objects also mocked
const 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:

types/testing.ts
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 file
import { 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 interface
const 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?

PatternProsCons
bun-mock-extendedFull type safety, calledWith matchers, deep mockingExternal dependency
jest-mock-extendedMature, full-featuredJest-only
DeepPartial utilityNo deps, simpleUnmocked methods throw at runtime
Interface + tokensPure DI, testable designRequires refactoring
  1. Install @tkoehlerlg/bun-mock-extended
  2. Use mockDeep<T>() for service mocks
  3. Combine with NestJS overrideProvider() for full type safety
  4. Keep functional core pattern - pure functions need no mocks
// The golden pattern for Lattice
import { 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

NestJS Testing

Type-Safe Mocking

Integration Testing