Skip to main content
Zequel uses a comprehensive testing strategy with three test types:
  • Unit tests - Fast, isolated tests with mocked dependencies
  • Integration tests - Tests against real databases via Docker
  • E2E tests - Full application tests with Playwright

Test Stack

Vitest

Unit and integration test runner with Vite integration

Playwright

End-to-end testing framework for Electron

Docker Compose

Test database containers with seed data

Running Tests

Quick Commands

npm run test
# Runs unit + integration tests in watch mode
# Re-runs on file changes

Pre-commit Hook

Husky runs these commands before each commit:
npm run typecheck  # Type-check all code
npm run test:unit  # Run unit tests
Commits are blocked if either fails.

Unit Tests

Overview

Location: src/tests/unit/ Characteristics:
  • Fast (no I/O, no database connections)
  • Isolated (all dependencies mocked)
  • Run on every commit (pre-commit hook)
  • Mirror source structure:
    • unit/main/ - Main process tests
    • unit/renderer/ - Renderer process tests

Configuration

File: vitest.config.ts
export default defineConfig({
  test: {
    globals: true,              // No need to import describe, it, expect
    environment: 'node',        // Node.js environment
    include: ['src/tests/**/*.{test,spec}.{ts,mts,js,mjs}'],
    exclude: ['node_modules', 'src/tests/e2e/**'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      exclude: ['node_modules/', 'src/tests/']
    },
    alias: {
      '@': resolve(__dirname, './src/renderer'),
      '@main': resolve(__dirname, './src/main')
    }
  }
})

Writing Unit Tests

Example: Testing a service (main process)
// src/tests/unit/main/connections.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DatabaseType } from '@main/types';
import type { ConnectionConfig } from '@main/types';

// Mock dependencies
vi.mock('@main/utils/logger', () => ({
  logger: {
    debug: vi.fn(),
    info: vi.fn(),
    error: vi.fn(),
  },
}));

// Mock database
const mockPrepare = vi.fn(() => ({
  run: vi.fn(() => ({ changes: 1 })),
  get: vi.fn(),
  all: vi.fn(() => []),
}));

vi.mock('@main/services/database', () => ({
  appDatabase: {
    getDatabase: vi.fn(() => ({
      prepare: mockPrepare,
    })),
  },
}));

// Import after mocks
import { ConnectionsService } from '@main/services/connections';

describe('ConnectionsService', () => {
  let service: ConnectionsService;
  
  beforeEach(() => {
    vi.clearAllMocks();
    service = new ConnectionsService();
  });
  
  it('should save a connection', async () => {
    const config: ConnectionConfig = {
      id: 'test-1',
      name: 'Test DB',
      type: DatabaseType.PostgreSQL,
      host: 'localhost',
      port: 5432,
      database: 'testdb',
      username: 'user',
    };
    
    await service.save(config);
    
    expect(mockPrepare).toHaveBeenCalledWith(expect.stringContaining('INSERT'));
  });
  
  it('should list connections', async () => {
    const mockConnections = [
      { id: '1', name: 'DB1', type: 'postgresql' },
      { id: '2', name: 'DB2', type: 'mysql' },
    ];
    
    mockPrepare.mockReturnValueOnce({
      all: vi.fn(() => mockConnections),
    });
    
    const result = await service.list();
    
    expect(result).toHaveLength(2);
    expect(result[0].name).toBe('DB1');
  });
});
Example: Testing a composable (renderer)
// src/tests/unit/renderer/formatters.test.ts
import { describe, it, expect } from 'vitest';
import { formatBytes, formatDuration } from '@/lib/formatters';

describe('formatBytes', () => {
  it('should format bytes correctly', () => {
    expect(formatBytes(0)).toBe('0 B');
    expect(formatBytes(1024)).toBe('1 KB');
    expect(formatBytes(1024 * 1024)).toBe('1 MB');
    expect(formatBytes(1024 * 1024 * 1024)).toBe('1 GB');
  });
});

describe('formatDuration', () => {
  it('should format milliseconds', () => {
    expect(formatDuration(500)).toBe('500ms');
    expect(formatDuration(1500)).toBe('1.50s');
    expect(formatDuration(65000)).toBe('1m 5s');
  });
});

Mocking Guidelines

Mock file system, databases, network calls, and third-party libraries:
vi.mock('@main/utils/logger');
vi.mock('@main/services/database');
vi.mock('fs/promises');
Mocks must be declared before importing the module under test:
// Good
vi.mock('@main/utils/logger');
import { service } from '@main/services/myService';

// Bad
import { service } from '@main/services/myService';
vi.mock('@main/utils/logger');  // Too late!
Use beforeEach(() => vi.clearAllMocks()) to reset mock state:
beforeEach(() => {
  vi.clearAllMocks();
});

Integration Tests

Overview

Location: src/tests/integration/main/ Characteristics:
  • Connect to real databases via Docker
  • Verify seed data integrity
  • Test database-specific features
  • Gracefully skip when containers unavailable

Setup

1

Start Docker Containers

docker-compose up -d
Starts all database containers with seed data.
2

Run Integration Tests

npm run test:integration

Writing Integration Tests

Example: Testing PostgreSQL seed data
// src/tests/integration/main/postgres-seed.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { PostgresDriver } from '@main/db/postgres';
import { DatabaseType } from '@main/types';

const config = {
  id: 'test',
  name: 'Test',
  type: DatabaseType.PostgreSQL,
  host: 'localhost',
  port: 54320,
  database: 'zequel',
  username: 'zequel',
  password: 'zequel',
};

describe('PostgreSQL seed data', () => {
  let driver: PostgresDriver;
  
  beforeAll(async () => {
    driver = new PostgresDriver();
    try {
      await driver.connect(config);
    } catch (err) {
      console.warn('PostgreSQL container not available, skipping tests');
      return;
    }
  });
  
  afterAll(async () => {
    if (driver?.isConnected) {
      await driver.disconnect();
    }
  });
  
  it('should have seed tables', async () => {
    if (!driver?.isConnected) return;
    
    const tables = await driver.getTables('zequel');
    
    expect(tables.find(t => t.name === 'users')).toBeDefined();
    expect(tables.find(t => t.name === 'products')).toBeDefined();
  });
  
  it('should have seed data in users table', async () => {
    if (!driver?.isConnected) return;
    
    const result = await driver.execute('SELECT COUNT(*) as count FROM users');
    
    expect(Number(result.rows[0].count)).toBeGreaterThan(0);
  });
  
  it('should have sequences', async () => {
    if (!driver?.isConnected) return;
    
    const sequences = await driver.getSequences();
    
    expect(sequences.length).toBeGreaterThan(0);
  });
});

Database Connection Details

See Development Setup - Database Ports for all connection details.

Skip Pattern

Integration tests gracefully skip when Docker containers aren’t running:
beforeAll(async () => {
  try {
    await driver.connect(config);
  } catch (err) {
    console.warn('Database not available, skipping tests');
    return;
  }
});

it('should test feature', async () => {
  if (!driver?.isConnected) return;  // Skip if not connected
  
  // Test logic...
});

End-to-End Tests

Overview

Location: src/tests/e2e/ Characteristics:
  • Test the full Electron app (main + renderer)
  • Playwright controls the application
  • Tests user workflows (connect, query, export, etc.)
  • Requires production build

Configuration

File: playwright.config.ts
export default defineConfig({
  tsconfig: './tsconfig.e2e.json',
  testDir: './src/tests/e2e/tests',
  timeout: 60_000,
  expect: { timeout: 30_000 },
  fullyParallel: false,
  workers: 1,
  retries: 1,
  reporter: 'list',
  use: {
    actionTimeout: 10_000,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
})

Running E2E Tests

1

Build the App

E2E tests require a production build:
npm run build
2

Run Tests

npm run test:e2e

# Or run a specific test:
npx playwright test src/tests/e2e/tests/connection.test.ts

Writing E2E Tests

Example: Testing connection workflow
// src/tests/e2e/tests/connection.test.ts
import { test, expect } from '@playwright/test';
import { launchApp } from '@e2e/utils/electron';

test.describe('Connection Management', () => {
  test('should create and connect to SQLite database', async () => {
    const { app, page } = await launchApp();
    
    try {
      // Click "New Connection" button
      await page.click('button:has-text("New Connection")');
      
      // Fill connection form
      await page.fill('input[name="name"]', 'Test SQLite');
      await page.selectOption('select[name="type"]', 'sqlite');
      await page.fill('input[name="filepath"]', '/tmp/test.db');
      
      // Save connection
      await page.click('button:has-text("Save")');
      
      // Verify connection appears in list
      await expect(page.locator('text=Test SQLite')).toBeVisible();
      
      // Connect
      await page.click('button:has-text("Connect")');
      
      // Verify connected state
      await expect(page.locator('.connection-status:has-text("Connected")')).toBeVisible();
    } finally {
      await app.close();
    }
  });
});

Helpers

Electron launcher (src/tests/e2e/utils/electron.ts):
import { _electron as electron } from '@playwright/test';

export const launchApp = async () => {
  const app = await electron.launch({
    args: ['.'],
    env: {
      ...process.env,
      E2E: '1',  // Prevent auto-showing windows
    },
  });
  
  const page = await app.firstWindow();
  
  return { app, page };
};

Best Practices

Test Behavior, Not Implementation

Test what users see and do, not internal implementation details.

Use Descriptive Test Names

it('should display error when connection fails') is better than it('works').

Keep Tests Independent

Each test should run in isolation. Use beforeEach for setup.

Mock External Services

Don’t depend on external APIs or services in unit tests.

Test Edge Cases

Test error conditions, empty states, and boundary values.

Use AAA Pattern

Arrange (setup), Act (execute), Assert (verify).

Coverage

Generate coverage reports:
npm run test:coverage
Output: coverage/index.html Open in browser to see line-by-line coverage:
open coverage/index.html  # macOS
xdg-open coverage/index.html  # Linux
start coverage/index.html  # Windows
Coverage excludes src/tests/ and node_modules/ by default.

Debugging Tests

Vitest UI

npm run test:ui
Opens interactive UI at http://localhost:51204 with:
  • Test file tree
  • Console output
  • Coverage visualization
  • Re-run on file save

Playwright Inspector

PWDEBUG=1 npx playwright test src/tests/e2e/tests/connection.test.ts
Opens Playwright Inspector for step-by-step debugging.

VS Code Debugging

Add to .vscode/launch.json:
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Debug Vitest Tests",
      "runtimeExecutable": "npm",
      "runtimeArgs": ["run", "test:unit"],
      "console": "integratedTerminal"
    }
  ]
}
Set breakpoints and press F5 to debug.

Continuous Integration

GitHub Actions runs tests on every PR:
- name: Run type checking
  run: npm run typecheck

- name: Run unit tests
  run: npm run test:unit

- name: Run integration tests
  run: |
    docker-compose up -d
    npm run test:integration

- name: Build app
  run: npm run build

- name: Run E2E tests
  run: npm run test:e2e

Next Steps

Database Adapters

Learn how to add support for new databases

Project Structure

Navigate the codebase

Build docs developers (and LLMs) love