Skip to main content
userTourKit
Testing Library

Recipes

Copy-paste test patterns for Tour Kit.

domidex01Published

Patterns that come up over and over in tour testing. Copy, adapt, ship.

The snippets below assume this tiny harness:

import { HookProbe, render } from '@tour-kit/testing-library'
import { TourProvider, TourCard, useTour, type TourConfig } from '@tour-kit/react'
import * as React from 'react'

function AutoStart({ tourId }: { tourId: string }) {
  const { start } = useTour()
  const started = React.useRef(false)
  // Start exactly once. `useTour().start` changes identity as provider state
  // updates, so guard against re-running the effect and restarting the tour.
  React.useEffect(() => {
    if (started.current) return
    started.current = true
    void start(tourId)
  }, [start, tourId])
  return null
}

function renderTour(tours: TourConfig[], tourId: string, targets: React.ReactNode = null) {
  return render(
    <>
      {targets}
      <TourProvider tours={tours}>
        <AutoStart tourId={tourId} />
        <TourCard />
        <HookProbe />
      </TourProvider>
    </>,
  )
}

// A two-step demo tour, reused by several recipes below.
const tours: TourConfig[] = [
  {
    id: 'demo',
    steps: [
      { id: 's1', target: '#a', content: 'First' },
      { id: 's2', target: '#b', content: 'Second' },
    ],
  },
]

// A five-step tour for the "jump to a non-adjacent step" recipe.
const longTour: TourConfig = {
  id: 'long',
  steps: ['s1', 's2', 's3', 's4', 's5'].map((id) => ({
    id,
    target: `#${id}`,
    content: id,
  })),
}

Each recipe below renders DOM targets for the steps it drives — the #a / #b / #s1<div>s you'll see passed to renderTour — so the card has a real element to anchor to.

1. Asserting that a tour starts on mount

import { expectStepVisible } from '@tour-kit/testing-library'

it('autostarts the welcome tour', async () => {
  renderTour(
    [{ id: 'welcome', steps: [{ id: 'hi', target: '#hi', content: 'Hi' }] }],
    'welcome',
    <div id="hi" />,
  )

  await expectStepVisible('hi')
})

2. Driving a tour through every step

import { advanceTour, completeTour, expectStepVisible } from '@tour-kit/testing-library'

it('walks step 1 → 2 → done', async () => {
  renderTour(tours, 'demo', <>
    <div id="a" />
    <div id="b" />
  </>)

  await expectStepVisible('s1')
  await advanceTour()
  await expectStepVisible('s2')
  await completeTour('demo')

  // Tour is done — no step should be visible:
  expect(document.querySelector('[data-tour-step]')).toBeNull()
})

3. Skipping mid-tour

import { vi } from 'vitest'
import { expectStepVisible, skipTour } from '@tour-kit/testing-library'

it('skip mid-tour invokes onSkip', async () => {
  const onSkip = vi.fn()
  renderTour([{ ...tours[0], onSkip }], 'demo', <>
    <div id="a" />
    <div id="b" />
  </>)

  // Wait for the card (and its Skip button) to mount before skipping —
  // skipTour() queries synchronously and start() settles asynchronously.
  await expectStepVisible('s1')
  await skipTour()
  expect(onSkip).toHaveBeenCalledOnce()
})

4. Jumping to a non-adjacent step

goToStep requires the <HookProbe /> because it needs direct access to the tour handle.

import { goToStep, expectStepVisible } from '@tour-kit/testing-library'

it('jumps from step 1 to step 5', async () => {
  renderTour(
    [longTour],
    'long',
    <>
      {['s1', 's2', 's3', 's4', 's5'].map((id) => (
        <div key={id} id={id} />
      ))}
    </>,
  )

  await expectStepVisible('s1')
  await goToStep('s5')
  await expectStepVisible('s5')
})

5. Testing target positioning helpers

Use virtualTarget for low-level Floating UI positioning tests. It does not create a selector-backed DOM element for TourStep.target; for full TourCard tests, render a real DOM target or pass a ref/getter.

import { virtualTarget } from '@tour-kit/testing-library'

it('creates a stable virtual rect', () => {
  const target = virtualTarget({ width: 320, height: 180 })
  expect(target.getBoundingClientRect().width).toBe(320)
})

6. Asserting against the active tour handle directly

For complex assertions, drop to the handle:

import { getActiveTourHandle, waitFor } from '@tour-kit/testing-library'

it('exposes step metadata via the handle', async () => {
  renderTour(tours, 'demo')

  // `start()` settles asynchronously, so wait for the handle to reflect it
  // before reading. (`waitFor` is re-exported from the testing library.)
  await waitFor(() => {
    expect(getActiveTourHandle()?.currentStep?.id).toBe('s1')
  })
  expect(getActiveTourHandle()?.totalSteps).toBe(2)
})

7. Mocking storage between tests

Tour Kit persists progress to localStorage by default. If your tests care about a fresh state per test, clear it in beforeEach:

import { beforeEach } from 'vitest'

beforeEach(() => {
  localStorage.clear()
})

Or pass an in-memory storage adapter to the provider — see the persistence guide.

8. Testing portal-rendered cards

Cards render into a portal mounted on document.body. The helpers handle this for you, but if you bypass them and use screen.getByText, scope your query to the portal root:

import { within } from '@testing-library/react'

const text = within(document.body).getByText(/welcome/i)
expect(text).toBeVisible()
Free & open source

Ship onboarding, not config.

npm i @tour-kit/core is MIT and free. The Pro packages work unlicensed too — a one-time $99 license removes the production watermark when you ship.

MIT-licensed — no signup, no credit card. Pay once, only when you ship.