Skip to main content

Test Framework

Emdash uses Vitest for all tests, configured in vite.config.ts with environment: 'node'.

Test Locations

Tests are organized by process type:
src/test/
├── main/                  # Main process tests (15+ service tests)
│   ├── WorktreeService.test.ts
│   ├── DatabaseService.test.ts
│   ├── ptyManager.test.ts
│   └── ...
├── renderer/              # Renderer tests (UI, hooks)
│   ├── useProjectManagement.test.ts
│   ├── TaskTerminalPanel.test.ts
│   └── ...
└── main/utils/__tests__/  # Utility tests
    └── ...

Running Tests

Run All Tests

pnpm exec vitest run
This runs the full test suite once and exits.

Run Specific Test File

pnpm exec vitest run src/test/main/WorktreeService.test.ts

Watch Mode

pnpm exec vitest
Runs tests in watch mode, re-running on file changes.

Run Tests with Coverage

pnpm exec vitest run --coverage

Test Structure

Emdash tests follow a per-file mocking pattern. There’s no shared test setup file — each test file declares its own mocks.

Basic Test Example

import { describe, it, expect, beforeEach, vi } from 'vitest';

// Mock electron before importing anything that depends on it
vi.mock('electron', () => ({
  app: {
    getPath: vi.fn().mockReturnValue('/tmp/test'),
    getName: vi.fn().mockReturnValue('emdash-test'),
  },
}));

// Mock DatabaseService
vi.mock('../../main/services/DatabaseService', () => ({
  databaseService: {
    getDatabase: vi.fn(),
  },
}));

// Mock logger
vi.mock('../../main/lib/logger', () => ({
  log: {
    info: vi.fn(),
    debug: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
  },
}));

describe('MyService', () => {
  it('should do something', () => {
    expect(true).toBe(true);
  });
});

Common Mocking Patterns

Mocking Electron

vi.mock('electron', () => ({
  app: {
    getPath: vi.fn().mockReturnValue(os.tmpdir()),
    getName: vi.fn().mockReturnValue('emdash-test'),
    getVersion: vi.fn().mockReturnValue('0.0.0-test'),
  },
  ipcMain: {
    handle: vi.fn(),
    on: vi.fn(),
  },
}));

Mocking DatabaseService

vi.mock('../../main/services/DatabaseService', () => ({
  databaseService: {
    getDatabase: vi.fn(),
    getAllProjects: vi.fn().mockResolvedValue([]),
    createTask: vi.fn().mockResolvedValue({ id: 'test-id' }),
  },
}));

Mocking Logger

vi.mock('../../main/lib/logger', () => ({
  log: {
    info: vi.fn(),
    debug: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
  },
}));

Mocking File System Operations

import fs from 'fs';
import { vi } from 'vitest';

vi.mock('fs', () => ({
  existsSync: vi.fn().mockReturnValue(true),
  readFileSync: vi.fn().mockReturnValue('file contents'),
  writeFileSync: vi.fn(),
}));

Integration Tests

Integration tests create real Git repositories in temporary directories:
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { execSync } from 'child_process';

describe('WorktreeService integration', () => {
  let tempDir: string;
  let repoPath: string;

  beforeEach(() => {
    // Create temp directory
    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-test-'));
    repoPath = path.join(tempDir, 'repo');
    
    // Initialize Git repo
    fs.mkdirSync(repoPath);
    execSync('git init', { cwd: repoPath });
    execSync('git config user.email "[email protected]"', { cwd: repoPath });
    execSync('git config user.name "Test User"', { cwd: repoPath });
    
    // Create initial commit
    fs.writeFileSync(path.join(repoPath, 'README.md'), '# Test');
    execSync('git add .', { cwd: repoPath });
    execSync('git commit -m "Initial commit"', { cwd: repoPath });
  });

  afterEach(() => {
    // Clean up temp directory
    fs.rmSync(tempDir, { recursive: true, force: true });
  });

  it('creates a worktree', async () => {
    // Test implementation
  });
});
Integration tests use os.tmpdir() for temporary repos. Never create test files in the actual project directory.

Testing Best Practices

Always Mock External Dependencies

// Good: Mock electron, database, logger
vi.mock('electron', () => ({ /* ... */ }));
vi.mock('../../main/services/DatabaseService', () => ({ /* ... */ }));
vi.mock('../../main/lib/logger', () => ({ /* ... */ }));

Use Descriptive Test Names

// Good
it('creates a worktree with the correct branch prefix', async () => {
  // ...
});

// Bad
it('test worktree', async () => {
  // ...
});

Test Error Cases

it('throws when project path does not exist', async () => {
  await expect(
    service.createWorktree('/nonexistent/path', 'task-name')
  ).rejects.toThrow('Project path does not exist');
});

Clean Up Resources

afterEach(async () => {
  // Close database connections
  await db.close();
  
  // Remove temp files
  fs.rmSync(tempDir, { recursive: true, force: true });
  
  // Kill PTY processes
  ptyManager.removeAllPtys();
});

Vitest Configuration

From vite.config.ts:
export default defineConfig({
  test: {
    dir: '.',
    environment: 'node',
    include: ['src/**/*.test.ts'],
  },
});

Key Settings

  • environment: 'node': Tests run in Node.js environment (not jsdom)
  • include: ['src/**/*.test.ts']: Only files matching *.test.ts are tested
  • Test files must be in src/ directory

Database Testing

Schema Contract Tests

Tests that verify database schema structure:
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DatabaseService } from '../../main/services/DatabaseService';

vi.mock('../../main/db/path', () => ({
  resolveDatabasePath: () => '/tmp/emdash-test.db',
  resolveMigrationsPath: () => '/tmp/drizzle',
}));

describe('DatabaseService schema contract', () => {
  let service: DatabaseService;

  beforeEach(() => {
    service = new DatabaseService();
  });

  it('validates all required schema invariants', async () => {
    vi.spyOn(service as any, 'tableExists').mockResolvedValue(true);
    vi.spyOn(service as any, 'tableHasColumn')
      .mockResolvedValue(true);

    await expect(
      (service as any).validateSchemaContract()
    ).resolves.toBeUndefined();
  });

  it('throws when required column is missing', async () => {
    vi.spyOn(service as any, 'tableExists').mockResolvedValue(true);
    vi.spyOn(service as any, 'tableHasColumn')
      .mockResolvedValue(false);

    await expect(
      (service as any).validateSchemaContract()
    ).rejects.toMatchObject({
      name: 'DatabaseSchemaMismatchError',
      code: 'DB_SCHEMA_MISMATCH',
    });
  });
});

PTY Testing

Tests for PTY (pseudo-terminal) management:
describe('ptyManager', () => {
  beforeEach(() => {
    process.env.EMDASH_DISABLE_PTY = '1'; // Disable actual PTY creation
  });

  afterEach(() => {
    delete process.env.EMDASH_DISABLE_PTY;
  });

  it('tracks PTY lifecycle', () => {
    const ptyId = 'test-pty-id';
    
    // Mock PTY creation
    const mockPty = {
      pid: 1234,
      write: vi.fn(),
      kill: vi.fn(),
      on: vi.fn(),
    };
    
    // Test PTY operations
    ptyManager.addPty(ptyId, mockPty);
    expect(ptyManager.getPty(ptyId)).toBe(mockPty);
    
    ptyManager.removePty(ptyId);
    expect(ptyManager.getPty(ptyId)).toBeUndefined();
  });
});

Pre-Commit Testing

Before committing, always run the quality checks:
pnpm run format       # Format with Prettier
pnpm run lint         # ESLint
pnpm run type-check   # TypeScript
pnpm exec vitest run  # Tests
These checks run automatically in CI. Running them locally catches issues early.

CI Testing

Tests run automatically in GitHub Actions on every PR via .github/workflows/code-consistency-check.yml:
  1. Format check (prettier --check)
  2. Type check (tsc --noEmit)
  3. Lint check (eslint)
  4. Test suite (vitest run)
All checks must pass before merging.

Writing New Tests

1

Create test file

Place test files next to the code they test:
  • Main process: src/test/main/ServiceName.test.ts
  • Renderer: src/test/renderer/ComponentName.test.ts
2

Set up mocks

Mock all external dependencies at the top of the file:
vi.mock('electron', () => ({ /* ... */ }));
vi.mock('../../main/services/DatabaseService', () => ({ /* ... */ }));
vi.mock('../../main/lib/logger', () => ({ /* ... */ }));
3

Write tests

Use describe blocks to group related tests:
describe('MyService', () => {
  describe('methodName', () => {
    it('should handle success case', () => { /* ... */ });
    it('should handle error case', () => { /* ... */ });
  });
});
4

Run tests

pnpm exec vitest run src/test/main/MyService.test.ts

Next Steps

Development Setup

Set up your development environment

Contributing

Learn the contribution workflow

Build docs developers (and LLMs) love