Skip to main content
HLS Downloader uses Vitest for fast, modern unit testing across all packages. This guide covers running tests, coverage reporting, and writing effective tests.

Quick reference

Run all tests

pnpm test
Runs test suites for all packages

Generate coverage

pnpm test:coverage
Creates coverage reports and badge

Test a package

pnpm --filter ./src/core run test
Runs tests for a specific package

Watch mode

pnpm --filter ./src/core run test -- --watch
Re-runs tests on file changes

Running tests

All packages

Run the complete test suite:
pnpm test
This executes tests in all workspace packages in parallel:
  • core → Business logic and use cases
  • background → Service implementations
  • design-system → UI component tests
  • popup → React component and integration tests
The root package.json defines:
{
  "scripts": {
    "test": "run-p test:core test:background test:design-system test:popup"
  }
}
run-p (from npm-run-all) runs all test scripts in parallel for faster execution.
Tests run in Node.js environment via Vitest. No browser is required.

Individual packages

Run tests for a specific package:
pnpm run test:core
# or
pnpm --filter ./src/core run test
Use pnpm --filter to run any script in a workspace package without navigating to its directory.

Coverage reporting

Generate coverage

Run tests with coverage collection:
pnpm test:coverage
This command:
  1. Runs each package’s test suite with coverage enabled
  2. Generates JSON summary reports
  3. Combines coverage from all packages
  4. Creates coverage-badge.svg in the repository root
After running coverage, you’ll find:
hls-downloader/
├── coverage-badge.svg         # Combined coverage badge
├── src/
│   ├── core/
│   │   └── coverage/          # Core package coverage
│   │       ├── coverage-summary.json
│   │       └── index.html     # HTML coverage report
│   ├── background/
│   │   └── coverage/          # Background package coverage
│   ├── design-system/
│   │   └── coverage/          # Design system coverage
│   └── popup/
│       └── coverage/          # Popup package coverage
Open any index.html file in a browser to explore line-by-line coverage.
The coverage/ directories are git-ignored. Only coverage-badge.svg is committed to the repository.

Individual coverage reports

Generate coverage for a specific package:
pnpm run test:core:coverage
Each command creates a coverage/ directory in the respective package.

Coverage badge

The coverage badge is automatically generated by:
pnpm coverage:badge
This script:
  1. Reads combined coverage from all packages
  2. Calculates overall percentage
  3. Generates coverage-badge.svg using badge-maker
The badge appears in the repository README:
![Test Coverage](./coverage-badge.svg)

Testing framework

Vitest configuration

Each package has a Vitest configuration in its vite.config.ts or vitest.config.ts:
// src/core/vite.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['json-summary', 'text', 'html'],
    },
  },
});
  • globals: Enables describe, it, expect without imports
  • environment: node for non-browser code, jsdom for React components
  • coverage.provider: v8 for fast, native coverage collection
  • coverage.reporter: Output formats (JSON, text, HTML)

Test file organization

Test files are co-located with source files or in dedicated test directories:
src/core/
├── src/
│   ├── use-cases/
│   │   ├── download.use-case.ts
│   │   └── download.use-case.test.ts  # Co-located
│   └── ...
└── test/                               # Shared test utilities
    └── test-helpers.ts
File naming convention: *.test.ts or *.test.tsx for test files. Vitest automatically discovers them.

Writing tests

Unit tests (Core package)

Test pure business logic:
// src/core/src/use-cases/download.use-case.test.ts
import { describe, it, expect } from 'vitest';
import { calculateDownloadSize } from './download.use-case';

describe('calculateDownloadSize', () => {
  it('should sum segment sizes', () => {
    const segments = [
      { size: 1024 },
      { size: 2048 },
      { size: 512 },
    ];
    
    const result = calculateDownloadSize(segments);
    
    expect(result).toBe(3584);
  });

  it('should return 0 for empty segments', () => {
    expect(calculateDownloadSize([])).toBe(0);
  });
});
Use cases: Test input/output behavior
it('should parse playlist successfully', () => {
  const input = 'm3u8PlaylistString';
  const result = parsePlaylist(input);
  expect(result).toMatchObject({ segments: expect.any(Array) });
});
Epics: Test observable streams
import { TestScheduler } from 'rxjs/testing';

it('should dispatch success action on completion', () => {
  const scheduler = new TestScheduler((actual, expected) => {
    expect(actual).toEqual(expected);
  });

  scheduler.run(({ hot, expectObservable }) => {
    const action$ = hot('-a', { a: startAction });
    const state$ = hot('s', { s: mockState });
    
    const output$ = myEpic(action$, state$);
    
    expectObservable(output$).toBe('-b', { b: successAction });
  });
});

Service tests (Background package)

Test implementations with mocks:
// src/background/src/services/IndexedDBFS.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import 'fake-indexeddb/auto';  // Mock IndexedDB
import { IndexedDBFS } from './IndexedDBFS';

describe('IndexedDBFS', () => {
  let fs: IndexedDBFS;

  beforeEach(async () => {
    fs = new IndexedDBFS('test-db');
    await fs.init();
  });

  it('should write and read files', async () => {
    const data = new Blob(['test content']);
    
    await fs.writeFile('test.txt', data);
    const result = await fs.readFile('test.txt');
    
    expect(result.size).toBe(data.size);
  });
});
Service implementations:
import { vi } from 'vitest';

it('should call fetch with correct URL', async () => {
  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    blob: () => Promise.resolve(new Blob(['data'])),
  });

  await fetchLoader.load('https://example.com/segment.ts');

  expect(fetch).toHaveBeenCalledWith('https://example.com/segment.ts');
});
Extension API mocks:
import browser from 'webextension-polyfill';

vi.mock('webextension-polyfill', () => ({
  default: {
    tabs: {
      query: vi.fn().mockResolvedValue([{ id: 1 }]),
    },
  },
}));

Component tests (Design System & Popup)

Test React components:
// src/design-system/src/components/ui/button.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Button } from './button';

describe('Button', () => {
  it('should render children', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  it('should apply variant classes', () => {
    render(<Button variant="primary">Primary</Button>);
    const button = screen.getByRole('button');
    expect(button).toHaveClass('bg-primary');
  });
});
User interactions:
import { render, screen, fireEvent } from '@testing-library/react';

it('should call onClick when clicked', () => {
  const handleClick = vi.fn();
  render(<Button onClick={handleClick}>Click</Button>);
  
  fireEvent.click(screen.getByRole('button'));
  
  expect(handleClick).toHaveBeenCalledTimes(1);
});
Redux-connected components:
import { render } from './test-utils';  // Custom render with Provider

it('should display data from store', () => {
  const { getByText } = render(
    <ConnectedComponent />,
    { preloadedState: { playlists: [{ id: '1', name: 'Test' }] } }
  );
  
  expect(getByText('Test')).toBeInTheDocument();
});

Watch mode

Run tests continuously during development:
# Watch all packages
pnpm dev:test  # if configured

# Watch a specific package
pnpm --filter ./src/core run test -- --watch
When in watch mode, Vitest offers:
  • Automatic re-run: Tests re-run when files change
  • Interactive CLI: Press keys to filter tests, update snapshots, etc.
  • Fast feedback: Only related tests run on change
Useful commands in watch mode:
  • Press a to run all tests
  • Press f to run only failed tests
  • Press t to filter by test name pattern
  • Press q to quit

Test organization guidelines

Follow these practices for maintainable tests:
  1. One concern per test: Each test should verify a single behavior
  2. Descriptive names: Use it('should ...') format
  3. Arrange-Act-Assert: Structure tests clearly
  4. Avoid implementation details: Test behavior, not internals
  5. Use factories: Create test data with helper functions
Example test structure:
describe('Feature name', () => {
  // Setup
  beforeEach(() => {
    // Arrange: Common setup
  });

  it('should handle success case', () => {
    // Arrange: Prepare test data
    const input = createTestInput();
    
    // Act: Execute the behavior
    const result = functionUnderTest(input);
    
    // Assert: Verify the outcome
    expect(result).toBe(expectedValue);
  });

  it('should handle error case', () => {
    // ...
  });
});

Continuous integration

In CI/CD pipelines:
  1. Run pnpm install --frozen-lockfile for reproducible dependencies
  2. Execute pnpm test to run all tests
  3. Run pnpm test:coverage to generate coverage reports
  4. Fail the build if coverage drops below threshold
Example GitHub Actions workflow:
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '20'
      
      - name: Enable Corepack
        run: corepack enable
      
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
      
      - name: Run tests
        run: pnpm test
      
      - name: Generate coverage
        run: pnpm test:coverage
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/coverage-final.json

Troubleshooting

Ensure the core package is built:
pnpm run build:core
The core package compiles TypeScript to lib/, which other packages import.
Make sure you’re using the coverage scripts:
pnpm test:coverage
Not:
pnpm test  # doesn't collect coverage
React component tests may be flaky due to async rendering. Use waitFor or findBy* queries:
import { waitFor } from '@testing-library/react';

await waitFor(() => {
  expect(screen.getByText('Loaded')).toBeInTheDocument();
});
Ensure fake-indexeddb is imported before tests:
import 'fake-indexeddb/auto';
Or configure it globally in vitest.config.ts:
export default defineConfig({
  test: {
    setupFiles: ['fake-indexeddb/auto'],
  },
});

Next steps

Contributing

Learn how to submit changes with tests

Architecture

Understand where to add tests for new features

Build docs developers (and LLMs) love