| name | testing-conventions |
| description | Vitest 4 + Playwright testing conventions: query priority (getByRole > getByText > getByTestId), seed-resilient patterns, realistic user flows (happy path & early finish), virtualized lists, fake-indexeddb isolation, factories, and gotchas (database reset, userEvent, expect.poll). Triggers: "query priority", "getByRole", "getByTestId", "getByLabelText", "querySelector", "seed data", "test invariants", "test isolation", "fake-indexeddb", "database reset", "expect.poll", "expect.element", "assertion", "userEvent", "realistic flows", "early finish", "virtualized list", "virtual scroll", "scoped query", "animation test", "visual state", "factory", "workoutBuilder", "flaky test", "navigation test", "exercise selection", "data attribute query", "test cleanup", "test gotchas". |
Testing Conventions
Complements the vue-integration-testing skill with project-specific conventions.
Stack
Framework: Vitest 4 with Playwright browser mode (NOT jsdom)
Test isolation: fake-indexeddb (NOT real IndexedDB)
Query Priority
Use page from vitest/browser:
page.getByRole (best) - Accessible queries
page.getByLabelText - Form fields
page.getByText - Non-interactive elements
page.getByTestId (last resort)
import { page } from 'vitest/browser'
page.getByRole('button', { name: /start workout/i })
page.getByTestId('workout-timer')
When querySelector Is Acceptable
Vitest 4.x lacks locators.extend(). Use querySelector with eslint-disable for:
1. CSS class assertions (animation/visual state):
await expect.poll(() => {
return document.querySelector('.animate-ping') !== null
}).toBe(true)
2. Scoped queries within located elements:
const card = page.getByRole('article', { name: 'Bench Press' })
const removeBtn = card.getByRole('button', { name: /remove/i })
const removeBtn = card.querySelector('button[aria-label*="remove" i]')
3. Raw DOM element tests (video, hidden file inputs):
expect(document.querySelector('video')).toBeTruthy()
4. Data attribute queries:
const completedSets = dialog.querySelectorAll('[data-set-state="completed"]')
Assertions
await expect.element(page.getByText(/block 1/i)).toBeVisible()
await expect.poll(() => app.router.currentRoute.value.path).toBe('/workout')
await expect.poll(async () => {
const template = await db.templates.get('id')
return template?.name
}).toBe('My Template')
Seed Data Resilience (IMPORTANT)
Seed data evolves. Tests that assume specific seed data break unexpectedly.
Pattern: Test Invariants, Not Specific Data
const matches = buttons.filter(btn => btn.textContent?.includes('Deadlift'))
expect(matches.length).toBe(1)
const names = buttons.map(btn => btn.textContent?.trim())
const uniqueNames = new Set(names)
expect(names.length).toBe(uniqueNames.size)
Pattern: Create Controlled Test Data
await userEvent.fill(searchInput, 'Deadlift')
expect(results.length).toBe(1)
await db.exercises.add({
id: 'test-unique-exercise',
name: 'Zzzz Unique Test Exercise',
muscle: 'chest',
equipment: 'barbell',
})
await userEvent.fill(searchInput, 'Zzzz Unique')
await expect.element(page.getByText('Zzzz Unique Test Exercise')).toBeVisible()
Pattern: Test Behavior, Not Implementation
expect(exercises.length).toBe(134)
expect(exercises.length).toBeGreaterThan(0)
expect(exercises.every(e => e.name && e.muscle)).toBe(true)
Pattern: Use Exact Matches When Filtering
const deadlifts = exercises.filter(e => e.name.includes('Deadlift'))
const deadlift = exercises.find(e => e.name === 'Deadlift')
Exercise Selection in Tests
The exercise list has 130+ items and is virtualized. Tests can break when:
- Partial name matching: "Squat" might match "Belt Squat Machine" before "Bodyweight Squat"
- Virtualized lists: Exercises may scroll off-screen
await userEvent.click(common.getDialogButton('Squat'))
await userEvent.click(common.getDialogButton('Bodyweight Squat'))
await expect.element(page.getByText('Assisted Pull-up Machine')).toBeVisible()
Test Realistic User Flows
Don't just test happy paths. Real users often finish early.
await workout.completeMultipleSets(3, { weight: '80', reps: '10', rir: '2' })
const setRow = workout.getSet(0)
await setRow.fill({ kg: 80, reps: 10, rir: 2 })
await workout.openMenu()
await page.getByRole('menuitem', { name: /end workout/i }).click()
Key flows to test:
- Complete all sets → finish (happy path)
- Enter data → finish early via menu (realistic)
- No data entered → finish early (edge case)
Navigation Reliability
UI button clicks for navigation can be flaky. Prefer direct router navigation:
await userEvent.click(page.getByRole('button', { name: /go back/i }))
await navigateTo('/exercises')
Use UI navigation only when testing the navigation behavior itself.
Factory Usage
import { workoutBuilder } from '@/__tests__/factories/workout.builder'
const workout = workoutBuilder()
.withStrengthBlock({ exerciseName: 'Squat' })
.build()
import { dbWorkoutBuilder } from '@/__tests__/factories/dbWorkout.factory'
const dbWorkout = await dbWorkoutBuilder()
.withExercise('Deadlift', 3)
.build()
Core Gotchas
- Always reset database:
await resetDatabase() in beforeEach
- Always cleanup:
app.cleanup() at end of test
- Use userEvent: NOT fireEvent
- Locators work directly: Don't use
.element() for userEvent clicks