Skip to content

Test Runner Adapters

The package ships adapters for every major TypeScript test runner and E2E framework. Each provides zero-boilerplate scenario setup with automatic cleanup — pick the one matching your runner.

Quick comparison

RunnerImportAPI shape
Jest@gfargo/git-scenarios/jestdescribeWithScenario + describeEachScenario
Vitest@gfargo/git-scenarios/vitestdescribeWithScenario + describeEachScenario + repoFixture
node:test@gfargo/git-scenarios/node-testdescribeWithScenario + describeEachScenario + it
Mocha@gfargo/git-scenarios/mochadescribeWithScenario + describeEachScenario
AVA@gfargo/git-scenarios/avawithScenario + withScenarios
Playwright@gfargo/git-scenarios/playwrighttest.extend fixture
Cypress@gfargo/git-scenarios/cypresscy.task registration

The first four share the same describeWithScenario pattern. AVA uses a different shape because it has no describe blocks. Vitest additionally exports repoFixture for the idiomatic test.extend fixture pattern.

Jest / Vitest / node:test / Mocha

All four use the same API — only the import path changes:

// Pick your runner:
import { describeWithScenario } from '@gfargo/git-scenarios/jest'
// import { describeWithScenario } from '@gfargo/git-scenarios/vitest'
// import { describeWithScenario } from '@gfargo/git-scenarios/node-test'
// import { describeWithScenario } from '@gfargo/git-scenarios/mocha'
describeWithScenario('feature-pr-ready', (getRepo) => {
it('is on a feature branch', async () => {
const repo = getRepo()
const status = await repo.git.status()
expect(status.current).toBe('feat/widget-v2')
})
it('has a clean worktree', async () => {
const repo = getRepo()
const status = await repo.git.status()
expect(status.isClean()).toBe(true)
})
})

The getRepo() accessor returns the live TempGitRepo instance. Call it inside it() blocks.

Options

describeWithScenario('submodule-with-history', (getRepo) => {
// tests...
}, {
timeout: 60_000, // increase for slow scenarios (submodules, large repos)
extraSteps: [ // apply additional atoms after the scenario setup
writeFiles({ 'extra.ts': 'extra\n' }),
],
})

describeEachScenario

Run the same tests against multiple scenarios:

import { describeEachScenario } from '@gfargo/git-scenarios/jest'
describeEachScenario(
['feature-pr-ready', 'two-commit-feature', 'multi-commit-branch'],
(getRepo, scenarioName) => {
it(`has a clean worktree in ${scenarioName}`, async () => {
const repo = getRepo()
const status = await repo.git.status()
expect(status.isClean()).toBe(true)
})
},
)

Vitest: test.extend fixture

Vitest users can also use repoFixture — a factory that returns a test.extend-compatible fixture map. Each test gets a fresh repo; cleanup is automatic after every test.

import { test as base, expect } from 'vitest'
import { repoFixture } from '@gfargo/git-scenarios/vitest'
const test = base.extend(repoFixture('feature-pr-ready'))
test('on feature branch', async ({ repo }) => {
const status = await repo.git.status()
expect(status.current).toBe('feat/widget-v2')
})

Compose it with your own fixtures:

const test = base.extend({
...repoFixture('feature-pr-ready'),
myFixture: [async ({}, use) => { await use('value') }, { auto: false }],
})

See Testing Recipes — Vitest test.extend fixture for more examples.

Runner-specific notes

node:test — also re-exports it for single-import convenience:

import { describeWithScenario, it } from '@gfargo/git-scenarios/node-test'
import assert from 'node:assert/strict'
describeWithScenario('feature-pr-ready', (getRepo) => {
it('is on a feature branch', async () => {
const repo = getRepo()
const status = await repo.git.status()
assert.notStrictEqual(status.current, 'main')
})
})

Run with: node --import tsx --test src/**/*.test.ts

Mocha — timeout is set via this.timeout(ms) internally (Mocha’s convention). Pass timeout in options and the adapter handles it:

describeWithScenario('large-repo', (getRepo) => {
// ...
}, { timeout: 120_000 })

AVA

AVA doesn’t have describe blocks — tests are flat. The adapter exports withScenario which returns a handle for use with AVA’s test.before() and test.after.always():

import test from 'ava'
import { withScenario } from '@gfargo/git-scenarios/ava'
const scenario = withScenario('feature-pr-ready')
test.before(scenario.setup)
test.after.always(scenario.cleanup)
test('is on a feature branch', async (t) => {
const repo = scenario.getRepo()
const status = await repo.git.status()
t.not(status.current, 'main')
})
test('has a clean worktree', async (t) => {
const repo = scenario.getRepo()
const status = await repo.git.status()
t.true(status.isClean())
})

With extra steps

import { withScenario } from '@gfargo/git-scenarios/ava'
import { writeFiles } from '@gfargo/git-scenarios/atoms'
const scenario = withScenario('mid-merge-conflict', {
extraSteps: [writeFiles({ 'extra.ts': 'extra\n' })],
})

Multiple scenarios (withScenarios)

import test from 'ava'
import { withScenarios } from '@gfargo/git-scenarios/ava'
const scenarios = withScenarios(['feature-pr-ready', 'two-commit-feature'])
for (const [name, scenario] of Object.entries(scenarios)) {
test.before(scenario.setup)
test.after.always(scenario.cleanup)
test(`${name}: has a clean worktree`, async (t) => {
const repo = scenario.getRepo()
const status = await repo.git.status()
t.true(status.isClean())
})
}

Concurrency note

AVA runs tests concurrently by default. Since all tests in a withScenario group share a single repo instance, use test.serial if your tests mutate the repo state. If tests are read-only (status checks, log queries), concurrent is fine.

Assertion matchers (Jest & Vitest)

Alongside the lifecycle wrappers, the library ships expect(...) matchers for asserting repo state directly — register them once in your setup file:

import { matchers } from '@gfargo/git-scenarios/matchers'
expect.extend(matchers)
await expect(repo).toBeOnBranch('feat/widget-v2')
await expect(repo).toHaveCleanWorktree()
await expect(repo).toBeMidMerge()
await expect(repo).toHaveConflictIn('src/widget.ts')
await expect(repo).toBeAheadBy(2)
await expect(repo).not.toHaveDirtyWorktree()

Jest types ship automatically. Vitest users add a 4-line Assertion augmentation (no vitest dependency required of this package) — see the Testing Recipes guide for the snippet, the full matcher list, and the assertRepo(...) fluent API that the other three runners (node:test, Mocha, AVA) use instead.

Custom scenarios

All adapters search the full mutable registry, so custom-registered scenarios work everywhere:

import { registerScenario, defineScenario, chain, addCommit } from '@gfargo/git-scenarios'
registerScenario(defineScenario({
name: 'my-custom',
summary: 'custom state',
description: '...',
kind: 'branch',
setup: chain(addCommit({ message: 'custom', files: { 'x.ts': 'x\n' } })),
}))
// Now works in any adapter:
describeWithScenario('my-custom', (getRepo) => { /* ... */ })
// or:
const scenario = withScenario('my-custom')

Playwright

The Playwright adapter uses test.extend to provide a scenarioRepo fixture that materializes a scenario per test and cleans up automatically:

import { createScenarioTest } from '@gfargo/git-scenarios/playwright'
const test = createScenarioTest('mid-merge-conflict')
test('my git GUI shows conflict markers', async ({ scenarioRepo, page }) => {
// scenarioRepo.path — path to the materialized repo
// scenarioRepo.git — simple-git instance
// Use page to drive your web-based git tool against the repo
await page.goto(`http://localhost:3000?repo=${scenarioRepo.path}`)
await expect(page.locator('.conflict-marker')).toBeVisible()
})

Each test gets its own isolated repo instance. Cleanup runs automatically after the test completes.

Cypress

The Cypress adapter registers scenario materialization as a cy.task:

cypress.config.ts
import { registerScenarioTasks } from '@gfargo/git-scenarios/cypress'
export default defineConfig({
e2e: {
setupNodeEvents(on, config) {
registerScenarioTasks(on)
return config
},
},
})

Then in your tests:

describe('git tool against merge conflict', () => {
let repoPath: string
before(() => {
cy.task('createScenario', 'mid-merge-conflict').then((path) => {
repoPath = path as string
})
})
after(() => {
cy.task('cleanupScenario', repoPath)
})
it('shows the conflict file', () => {
cy.visit(`/?repo=${repoPath}`)
cy.get('.conflict-indicator').should('exist')
})
})

Tasks: createScenario (returns path), cleanupScenario (removes the dir).