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:
- โ Install and configure Vitest with jsdom and Testing Library
- โ Test route loaders without a browser
- โ Render components that use typed router hooks in tests
- โ Add Playwright smoke tests for critical paths
- โ Wire lint โ type-check โ test โ build into the pipeline
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
- Land the Vitest config + first five tests (Button, KB splat loader, changelog loader) this week.
- Add
teststep to the pipeline next to lint/type-check (manual-vs-automated explains why it must be automated). - Create the nightly Playwright job once three smoke specs exist.
- Revisit flaky tests immediately โ quarantine is where suites go to die.
Sources & Related
External references consulted:
- https://vitest.dev/guide/
- https://testing-library.com/docs/react-testing-library/intro
- https://playwright.dev/docs/intro
Related knowledge-base articles:
Change Log
2026-08-26
- Initial creation covering Vitest/Testing Library setup, loader tests, Playwright option, and CI wiring.