Files
obsidian-mcp-server/docs/plans/2025-10-19-100-percent-test-coverage-design.md
Bill b89d0912c2 docs: design document for 100% test coverage via dependency injection
Add comprehensive design for achieving 100% test coverage through
dependency injection refactoring. The design introduces adapter
interfaces to decouple tool classes from Obsidian API dependencies,
enabling cleaner, more maintainable tests.

Key elements:
- IVaultAdapter, IMetadataCacheAdapter, IFileManagerAdapter interfaces
- Factory pattern for production usage
- Phased implementation approach (adapters → VaultTools → NoteTools → integration)
- Mock adapter pattern for simplified test setup
- Coverage strategy organized by feature areas

Goal: Codebase confidence for future refactoring and feature work.
2025-10-19 22:54:08 -04:00

11 KiB

100% Test Coverage via Dependency Injection

Date: 2025-10-19 Goal: Achieve 100% test coverage through dependency injection refactoring Current Coverage: 90.58% overall (VaultTools: 71.72%, NoteTools: 92.77%)

Motivation

We want codebase confidence for future refactoring and feature work. The current test suite has good coverage but gaps remain in:

  • Error handling paths
  • Edge cases (type coercion, missing data)
  • Complex conditional branches

The current testing approach directly mocks Obsidian's App object, leading to:

  • Complex, brittle mock setups
  • Duplicated mocking code across test files
  • Difficulty isolating specific behaviors
  • Hard-to-test error conditions

Solution: Dependency Injection Architecture

Core Principle

Extract interfaces for Obsidian API dependencies, allowing tools to depend on abstractions rather than concrete implementations. This enables clean, simple mocks in tests while maintaining production functionality.

Architecture Overview

Current State:

class NoteTools {
  constructor(private app: App) {}
  // Methods use: this.app.vault.X, this.app.metadataCache.Y, etc.
}

Target State:

class NoteTools {
  constructor(
    private vault: IVaultAdapter,
    private metadata: IMetadataCacheAdapter,
    private fileManager: IFileManagerAdapter
  ) {}
  // Methods use: this.vault.X, this.metadata.Y, etc.
}

// Production usage via factory:
function createNoteTools(app: App): NoteTools {
  return new NoteTools(
    new VaultAdapter(app.vault),
    new MetadataCacheAdapter(app.metadataCache),
    new FileManagerAdapter(app.fileManager)
  );
}

Interface Design

IVaultAdapter

Wraps file system operations from Obsidian's Vault API.

interface IVaultAdapter {
  // File reading
  read(path: string): Promise<string>;

  // File existence and metadata
  exists(path: string): boolean;
  stat(path: string): { ctime: number; mtime: number; size: number } | null;

  // File retrieval
  getAbstractFileByPath(path: string): TAbstractFile | null;
  getMarkdownFiles(): TFile[];

  // Directory operations
  getRoot(): TFolder;
}

IMetadataCacheAdapter

Wraps metadata and link resolution from Obsidian's MetadataCache API.

interface IMetadataCacheAdapter {
  // Cache access
  getFileCache(file: TFile): CachedMetadata | null;

  // Link resolution
  getFirstLinkpathDest(linkpath: string, sourcePath: string): TFile | null;

  // Backlinks
  getBacklinksForFile(file: TFile): { [key: string]: any };

  // Additional metadata methods as needed
}

IFileManagerAdapter

Wraps file modification operations from Obsidian's FileManager API.

interface IFileManagerAdapter {
  // File operations
  rename(file: TAbstractFile, newPath: string): Promise<void>;
  delete(file: TAbstractFile): Promise<void>;
  create(path: string, content: string): Promise<TFile>;
  modify(file: TFile, content: string): Promise<void>;
}

Implementation Strategy

Directory Structure

src/
├── adapters/
│   ├── interfaces.ts          # Interface definitions
│   ├── vault-adapter.ts       # VaultAdapter implementation
│   ├── metadata-adapter.ts    # MetadataCacheAdapter implementation
│   └── file-manager-adapter.ts # FileManagerAdapter implementation
├── tools/
│   ├── note-tools.ts          # Refactored to use adapters
│   └── vault-tools.ts         # Refactored to use adapters
tests/
├── __mocks__/
│   ├── adapters.ts            # Mock adapter factories
│   └── obsidian.ts            # Existing Obsidian mocks (minimal usage going forward)

Migration Approach

Step 1: Create Adapters

  • Define interfaces in src/adapters/interfaces.ts
  • Implement concrete adapters (simple pass-through wrappers initially)
  • Create mock adapter factories in tests/__mocks__/adapters.ts

Step 2: Refactor VaultTools

  • Update constructor to accept adapter interfaces
  • Replace all this.app.X calls with this.X (using injected adapters)
  • Create createVaultTools(app: App) factory function
  • Update tests to use mock adapters

Step 3: Refactor NoteTools

  • Same pattern as VaultTools
  • Create createNoteTools(app: App) factory function
  • Update tests to use mock adapters

Step 4: Integration

  • Update ToolRegistry to use factory functions
  • Update main.ts to use factory functions
  • Verify all existing functionality preserved

Backward Compatibility

Plugin Code (main.ts, ToolRegistry):

  • Uses factory functions: createNoteTools(app), createVaultTools(app)
  • No awareness of adapters - just passes the App object
  • Public API unchanged

Tool Classes:

  • Constructors accept adapters (new signature)
  • All methods work identically (internal implementation detail)
  • External callers use factory functions

Test Suite Overhaul

Mock Adapter Pattern

Centralized Mock Creation:

// tests/__mocks__/adapters.ts
export function createMockVaultAdapter(overrides?: Partial<IVaultAdapter>): IVaultAdapter {
  return {
    read: jest.fn(),
    exists: jest.fn(),
    stat: jest.fn(),
    getAbstractFileByPath: jest.fn(),
    getMarkdownFiles: jest.fn(),
    getRoot: jest.fn(),
    ...overrides
  };
}

export function createMockMetadataCacheAdapter(overrides?: Partial<IMetadataCacheAdapter>): IMetadataCacheAdapter {
  return {
    getFileCache: jest.fn(),
    getFirstLinkpathDest: jest.fn(),
    getBacklinksForFile: jest.fn(),
    ...overrides
  };
}

export function createMockFileManagerAdapter(overrides?: Partial<IFileManagerAdapter>): IFileManagerAdapter {
  return {
    rename: jest.fn(),
    delete: jest.fn(),
    create: jest.fn(),
    modify: jest.fn(),
    ...overrides
  };
}

Test Setup Simplification:

// Before: Complex App mock with nested properties
const mockApp = {
  vault: { read: jest.fn(), ... },
  metadataCache: { getFileCache: jest.fn(), ... },
  fileManager: { ... },
  // Many more properties...
};

// After: Simple, targeted mocks
const vaultAdapter = createMockVaultAdapter({
  read: jest.fn().mockResolvedValue('file content')
});
const tools = new VaultTools(vaultAdapter, mockMetadata, mockFileManager);

Coverage Strategy by Feature Area

1. Frontmatter Operations

  • Test string tags → array conversion
  • Test array tags → preserved as array
  • Test missing frontmatter → base metadata only
  • Test frontmatter parsing errors → error handling path
  • Test all field types (title, aliases, custom fields)

2. Wikilink Validation

  • Test resolved links → included in results
  • Test unresolved links → included with error details
  • Test missing file → error path
  • Test heading links ([[note#heading]])
  • Test alias links ([[note|alias]])

3. Backlinks

  • Test includeSnippets: true → snippets included
  • Test includeSnippets: false → snippets removed
  • Test includeUnlinked: true → unlinked mentions included
  • Test includeUnlinked: false → only linked mentions
  • Test error handling paths

4. Search Utilities

  • Test glob pattern filtering
  • Test regex search with matches
  • Test regex search with no matches
  • Test invalid regex → error handling
  • Test edge cases (empty results, malformed patterns)

5. Note CRUD Operations

  • Test all conflict strategies: error, overwrite, rename
  • Test version mismatch → conflict error
  • Test missing file on update → error path
  • Test permission errors → error handling
  • Test all edge cases in uncovered lines

6. Path Validation Edge Cases

  • Test all PathUtils error conditions
  • Test leading/trailing slash handling
  • Test .. traversal attempts
  • Test absolute path rejection

Implementation Phases

Phase 1: Foundation (Adapters)

Deliverables:

  • src/adapters/interfaces.ts - All interface definitions
  • src/adapters/vault-adapter.ts - VaultAdapter implementation
  • src/adapters/metadata-adapter.ts - MetadataCacheAdapter implementation
  • src/adapters/file-manager-adapter.ts - FileManagerAdapter implementation
  • tests/__mocks__/adapters.ts - Mock adapter factories
  • Tests for adapters (basic pass-through verification)

Success Criteria:

  • All adapters compile without errors
  • Mock adapters available for test usage
  • Simple adapter tests pass

Phase 2: VaultTools Refactoring

Deliverables:

  • Refactored VaultTools class using adapters
  • createVaultTools() factory function
  • Updated vault-tools.test.ts using mock adapters
  • New tests for uncovered lines:
    • Frontmatter extraction (lines 309-352)
    • Wikilink validation error path (lines 716-735)
    • Backlinks snippet removal (lines 824-852)
    • Other uncovered paths

Success Criteria:

  • VaultTools achieves 100% coverage (all metrics)
  • All existing tests pass
  • No breaking changes to public API

Phase 3: NoteTools Refactoring

Deliverables:

  • Refactored NoteTools class using adapters
  • createNoteTools() factory function
  • Updated note-tools.test.ts using mock adapters
  • New tests for uncovered error paths and edge cases

Success Criteria:

  • NoteTools achieves 100% coverage (all metrics)
  • All existing tests pass
  • No breaking changes to public API

Phase 4: Integration & Verification

Deliverables:

  • Updated ToolRegistry using factory functions
  • Updated main.ts using factory functions
  • Full test suite passing
  • Coverage report showing 100% across all files
  • Build succeeding with no errors

Success Criteria:

  • 100% test coverage: statements, branches, functions, lines
  • All 400+ tests passing
  • npm run build succeeds
  • Manual smoke test in Obsidian confirms functionality

Risk Mitigation

Risk: Breaking existing functionality

  • Mitigation: Incremental refactoring, existing tests updated alongside code changes
  • Factory pattern keeps plugin code nearly unchanged

Risk: Incomplete interface coverage

  • Mitigation: Start with methods actually used by tools, add to interfaces as needed
  • Adapters are simple pass-throughs, easy to extend

Risk: Complex migration

  • Mitigation: Phased approach allows stopping after any phase
  • Git worktree isolates changes from main branch

Risk: Test maintenance burden

  • Mitigation: Centralized mock factories reduce duplication
  • Cleaner mocks are easier to maintain than complex App mocks

Success Metrics

Coverage Goals:

  • Statement coverage: 100%
  • Branch coverage: 100%
  • Function coverage: 100%
  • Line coverage: 100%

Quality Goals:

  • All existing tests pass
  • No type errors in build
  • Plugin functions correctly in Obsidian
  • Test code is cleaner and more maintainable

Timeline:

  • Phase 1: ~2-3 hours (adapters + mocks)
  • Phase 2: ~3-4 hours (VaultTools refactor + tests)
  • Phase 3: ~2-3 hours (NoteTools refactor + tests)
  • Phase 4: ~1 hour (integration + verification)
  • Total: ~8-11 hours of focused work

Future Benefits

After this refactoring:

  • Adding new tools is easier (use existing adapters)
  • Testing new features is trivial (mock only what you need)
  • Obsidian API changes isolated to adapter layer
  • Confidence in comprehensive test coverage enables fearless refactoring
  • New team members can understand test setup quickly