Testing Setup - Vitest, Testing Library, and CI Wiring for the Website

Status: Active
Last Updated: 2026-08-26
Category: Frontend - Quality
Prerequisites: routing-patterns, cicd-concepts
Time: 3 hours
Tags: vitest, testing-library, playwright, e2e, tanstack-router, ci

Summary

How to set up and run tests for the fogserv.cloud TanStack Start website: Vitest + React Testing Library for components, route/loader unit tests against the generated route tree, an optional Playwright e2e layer, and wiring everything into Woodpecker CI so broken UI can't merge.

๐ŸŽฏ What You'll Learn

By the end of this article, you'll be able to:


Context / Why This Matters

The website currently ships lint and type-check scripts but no test runner (website-rebuild). For a site whose routes render database content through loaders (routing-patterns), regressions surface as blank pages or hydration errors discovered after deploy. A thin, fast test layer โ€” not a coverage-number chase โ€” catches those. Everything below assumes Bun as the runtime, matching the repo's lockfile.

Implementation / Core Content

Installing the Stack

bun add -d vitest @vitest/coverage-v8 jsdom \
  @testing-library/react @testing-library/user-event @testing-library/jest-dom \
  @playwright/test

Vitest Configuration

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    globals: true,
    include: ['src/**/*.test.{ts,tsx}'],
    coverage: { reporter: ['text'], include: ['src/**'] },
  },
})
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'

Add scripts:

// package.json
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"

Component Tests

Test behavior a user sees, not internals:

// src/components/Button.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { Button } from './Button'

describe('Button', () => {
  it('invokes onClick when clicked', async () => {
    const onClick = vi.fn()
    render(<Button variant="secondary" onClick={onClick}>Subscribe</Button>)
    await userEvent.click(screen.getByRole('button', { name: /subscribe/i }))
    expect(onClick).toHaveBeenCalledOnce()
  })
})

Query by role/name โ€” that's also your accessibility check. If getByRole fails, real users may struggle too.

Testing Components That Use the Router

Typed <Link> and Route.useLoaderData() need router context. Wrap with createMemoryHistory:

import { createMemoryHistory, createRootRoute, createRoute, createRouter, Outlet, RouterProvider } from '@tanstack/react-router'

export function renderWithRouter(ui: React.ReactNode) {
  const rootRoute = createRootRoute({ component: () => <Outlet /> })
  const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: () => ui })
  const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]), history: createMemoryHistory() })
  return render(<RouterProvider router={router} />)
}

Put renderWithRouter in src/test/utils.tsx so every test reuses it.

Route/Loader Unit Tests

Loaders are plain functions โ€” test them directly with mocked services:

// src/routes/changelog_.$slug.test.tsx
import { describe, expect, it, vi } from 'vitest'
vi.mock('../services/posts')

import Route from './changelog_.$slug'

describe('changelog loader', () => {
  it('throws notFound for unknown slug', async () => {
    vi.mocked(getPostBySlug).mockResolvedValue(null)
    await expect(
      Route.options.loader!({ params: { slug: 'nope' } } as never),
    ).rejects.toThrow()   // notFound() throws
  })
})

For SSR-sensitive logic, keep loaders pure: services injected/imported, no window access โ€” then jsdom never matters.

Playwright e2e (Thin Slice)

Only critical journeys; e2e is expensive to maintain:

// e2e/smoke.spec.ts
import { test, expect } from '@playwright/test'

test('home page renders hero CTA', async ({ page }) => {
  await page.goto('/')
  await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
})

test('kb index lists articles', async ({ page }) => {
  await page.goto('/kb/')
  await expect(page.getByRole('link', { name: /database/i }).first()).toBeVisible()
})

Run against vite preview locally (webServer entry in playwright.config.ts), not dev mode โ€” you're testing the built artifact.

CI Wiring

Pipeline stage order in Woodpecker:

steps:
  lint:     { image: oven/bun:1, commands: ['bun install --frozen-lockfile', 'bun run lint'] }
  typecheck:{ image: oven/bun:1, commands: ['bun run type-check'] }
  test:
    image: oven/bun:1
    commands: ['bun run test']
  build:
    image: oven/bun:1
    commands: ['bun run build']
depends_on: [lint, typecheck, test]   # build gates on all three

Keep Playwright out of per-commit CI initially; schedule it nightly or run manually pre-release. Database-backed e2e should point at a seeded scratch DB, mirroring the migration-check pattern from prisma-migrations-guide.

Practical Examples

Example 1: Regression test from a real bug

Symptom: /changelog/$slug rendered empty when a post had no feature image. Fix + test:

it('renders title even without featureImage', () => {
  render(<ChangelogPost {...minimalPost} />)   // minimalPost.featureImage = null
  expect(screen.getByRole('heading', { name: minimalPost.title })).toBeInTheDocument()
})

Every production bug gets exactly one such test before the fix merges.

Example 2: What NOT to test

Don't snapshot entire pages, assert Tailwind class strings, or test that useState updated. Those tests break on refactors while catching no user-visible regressions โ€” the reason teams abandon test suites.

Common Pitfalls & Troubleshooting

Problem Cause Fix
document is not defined Missing jsdom environment Set environment: 'jsdom'; confirm setup file loads
"No overload matches" on router usage in tests Router context absent Use renderWithRouter helper
Tests pass but app broken Over-mocked services diverged from reality Mock at service boundary with realistic fixtures; add one e2e for the path
Flaky e2e on CI timing No waiting for async content Use expect(...).toBeVisible() auto-waiting; never page.waitForTimeout
Slow suite creeping up Integration-style component tests hitting network All fetches must be mocked; treat unmocked fetch as test bug
Coverage pressure produces junk tests Chasing percentage Track coverage informally; gate CI on pass/fail, not thresholds

Next Steps / Ops Actions

Sources & Related

External references consulted:

Related knowledge-base articles:

Change Log

2026-08-26

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse