Testing

Emet’s tests run on Vitest, with jsdom standing in for the browser and Testing Library for anything that renders. There are a little over a hundred test files, sitting next to the code they test, and they cover most of the subsystems: frontmatter parsing, slugs and filenames, config loading and defaults, the sync service, shortcode and table serialisation, the SEO rules, the Hugo config parser, the AI client and providers, the GitHub client and pull request helpers and a fair few hooks and components.

Running them#

npm test                # everything, once
npm run test:watch      # re-run on change

Both are plain vitest, so its own flags work through npx:

npx vitest run src/utils/slug.test.ts     # one file
npx vitest run src/seo/                   # one directory
npx vitest run -t 'generateSlug'          # tests whose name matches

CI runs npm test on every push and pull request.

How it’s configured#

vitest.config.ts is short enough to quote in full:

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
  },
})

globals: true means describe, it, expect and vi exist without importing them, though most files import them anyway for the editor’s sake. The include pattern is why tests live beside their modules as foo.test.ts or foo.test.tsx: put one anywhere under src/ with that suffix and it’s picked up. tsconfig.json excludes the same pattern, so test files don’t go through npm run typecheck.

src/test/setup.ts runs before each file. It pulls in @testing-library/jest-dom for matchers like toBeInTheDocument(), replaces localStorage and sessionStorage with objects whose methods are vi.fn() stubs and stubs window.matchMedia so components that check for dark mode don’t blow up. Note that the storage stubs don’t store anything: getItem returns undefined whatever you set, so a test that needs persistence to actually work has to mock it more fully itself.

Helpers#

src/test/helpers.ts has four small factories for the fixtures that come up repeatedly:

import { makeSeoInput, makeSeoConfig, createMockBackend, makeConfig } from '../test/helpers'

// A SeoAnalysisInput with every field filled in; override what matters
const input = makeSeoInput({ title: 'My Page', focusKeyword: 'hugo' })

// A SeoRuleConfig, enabled, with an optional weight
const rule = makeSeoConfig(8)

// A ContentBackend where every method is a vi.fn() resolving to something empty
const backend = createMockBackend({
  readFile: vi.fn().mockResolvedValue('---\ntitle: Test\n---\nBody'),
})

// A minimal CmsConfig; sections need label, folder and fields
const config = makeConfig({
  sections: { blog: { label: 'Blog', folder: 'content/blog', fields: [] } },
})

makeConfig takes a Partial<CmsConfig>, so anything you pass has to be a complete value of its type; a section with just a label won’t type-check.

What the tests look like#

The three shapes you’ll meet are a pure function, a hook and a component. These are lifted from real test files, so if you want the full version, open the file.

A pure function, from src/utils/slug.test.ts:

import { describe, it, expect } from 'vitest'
import { generateSlug } from './slug'

describe('generateSlug', () => {
  it('converts to lowercase', () => {
    expect(generateSlug('Hello World')).toBe('hello-world')
  })

  it('handles accented characters', () => {
    expect(generateSlug('Café Résumé')).toBe('cafe-resume')
  })

  it('removes special characters', () => {
    expect(generateSlug('Hello! World?')).toBe('hello-world')
  })
})

A hook, from src/hooks/useSaveShortcut.test.ts. useSaveShortcut is what wires Ctrl+S and Vim’s :w to the save action in the entry editor, and since it only listens for events on document, testing it is a matter of rendering it and dispatching some:

import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useSaveShortcut } from './useSaveShortcut'

describe('useSaveShortcut', () => {
  it('saves on Ctrl+S and prevents the browser shortcut', () => {
    const onSave = vi.fn()
    renderHook(() => useSaveShortcut({ isDirty: true, saving: false, onSave }))
    const event = new KeyboardEvent('keydown', { key: 's', ctrlKey: true, cancelable: true })

    act(() => {
      document.dispatchEvent(event)
    })

    expect(event.defaultPrevented).toBe(true)
    expect(onSave).toHaveBeenCalledTimes(1)
  })

  it('ignores clean or currently saving editors', () => {
    const cleanSave = vi.fn()
    renderHook(() => useSaveShortcut({ isDirty: false, saving: false, onSave: cleanSave }))

    act(() => {
      document.dispatchEvent(new Event('cms:save'))
    })

    expect(cleanSave).not.toHaveBeenCalled()
  })
})

A component, from src/components/ErrorBoundary.test.tsx:

import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ErrorBoundary } from './ErrorBoundary'

function ThrowingComponent({ shouldThrow }: { shouldThrow: boolean }) {
  if (shouldThrow) throw new Error('Test render error')
  return <div>Content rendered</div>
}

describe('ErrorBoundary', () => {
  it('renders children when no error', () => {
    render(
      <ErrorBoundary label="Test">
        <ThrowingComponent shouldThrow={false} />
      </ErrorBoundary>
    )
    expect(screen.getByText('Content rendered')).toBeInTheDocument()
  })
})

Mocking#

Modules are mocked with vi.mock() at the top of the file, before any imports that depend on them are evaluated. The store is the most common target, because almost every hook reads from it. src/hooks/useDataEntries.test.ts shows the minimal form, where the mock useStore just applies the selector to a hand-built state:

vi.mock('../store', () => ({
  useStore: (selector: (s: { pullVersion: number }) => unknown) => selector({ pullVersion: 0 }),
}))

For API modules the pattern is to mock the whole module with vi.fn() per export and set return values per test; src/hooks/usePublication.test.ts does this for src/github/pulls.ts and is the one to copy if you’re testing something that talks to GitHub.

Anything that depends on the clock, from date-stamped filenames to polling intervals, uses fake timers. generateFilename in src/utils/slug.test.ts is the simplest example:

beforeEach(() => {
  vi.useFakeTimers()
  vi.setSystemTime(new Date('2024-03-15T12:00:00Z'))
})

afterEach(() => {
  vi.useRealTimers()
})

it('replaces {{year}} token', () => {
  expect(generateFilename('my-post', '{{year}}-{{slug}}')).toBe('2024-my-post.md')
})

For async code driven by timers, await vi.advanceTimersByTimeAsync(ms) runs the timers and lets promises settle; src/content/sync.test.ts and src/hooks/usePublication.test.ts both lean on it.

Where next?#