This skill provides Testing Library best practices for the fitness app. Use when writing component tests, API tests, or database tests. Covers user-centric testing patterns, query strategies, and async state handling.
Provides Testing Library best practices for user-centric component, API, and database tests.
/plugin marketplace add josephanson/claude-plugin/plugin install ja@josephansonThis skill inherits all available tools. When active, it can use any tool Claude has access to.
This skill provides guidance for writing user-centric tests using Vitest, Testing Library, and Nuxt Test Utils.
User-Centric Philosophy: Test what users see and do, not implementation details.
Never Test Implementation:
wrapper.vm or internal component stateAlways Test Behavior:
Use @nuxt/test-utils/runtime for Nuxt components:
import { renderSuspended } from '@nuxt/test-utils/runtime'
import { screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, beforeEach } from 'vitest'
import MyComponent from './MyComponent.vue'
describe('MyComponent', () => {
let user: ReturnType<typeof userEvent.setup>
beforeEach(() => {
user = userEvent.setup()
})
describe('form rendering', () => {
it('should display all form fields', async () => {
await renderSuspended(MyComponent)
expect(screen.getByLabelText(/email/i)).toBeDefined()
expect(screen.getByLabelText(/password/i)).toBeDefined()
expect(screen.getByRole('button', { name: /submit/i })).toBeDefined()
})
})
describe('form input', () => {
it('should allow user to type in fields', async () => {
await renderSuspended(MyComponent)
const emailInput = screen.getByLabelText(/email/i) as HTMLInputElement
await user.type(emailInput, 'test@example.com')
expect(emailInput.value).toBe('test@example.com')
})
})
})
Key imports:
renderSuspended - Render component in full Nuxt environmentscreen - Query rendered outputuserEvent - Simulate realistic user interactions*.nuxt.test.ts for Nuxt componentsFile Naming Convention:
*.nuxt.test.ts (e.g., Login.nuxt.test.ts)*.nuxt.test.ts (e.g., login.nuxt.test.ts)*.test.ts (e.g., index.get.test.ts)*.test.ts (e.g., format.test.ts)*.spec.ts (e.g., login.spec.ts)Test server routes using $fetch:
import { describe, it, expect, beforeAll } from 'vitest'
describe('/api/workouts', () => {
let authCookie: string
beforeAll(async () => {
// Setup test user session
const response = await $fetch('/api/auth/login', {
method: 'POST',
body: { email: 'test@example.com', password: 'password' }
})
authCookie = response.headers.get('set-cookie') || ''
})
it('should return user workouts', async () => {
const workouts = await $fetch('/api/workouts', {
headers: { cookie: authCookie }
})
expect(Array.isArray(workouts)).toBe(true)
expect(workouts[0]).toHaveProperty('id')
expect(workouts[0]).toHaveProperty('name')
})
it('should return 401 without auth', async () => {
await expect($fetch('/api/workouts')).rejects.toThrow('401')
})
})
Key points:
$fetch within tests to call API routes*.test.ts for API testsTest query functions directly:
import { describe, it, expect, beforeEach } from 'vitest'
import { db } from '~~/server/database'
import { queryUserWorkouts } from '~~/server/database/queries/workouts'
import { users } from '~~/server/database/schema'
describe('queryUserWorkouts', () => {
let userId: string
beforeEach(async () => {
// Create test user
const [user] = await db.insert(users).values({
email: 'test@example.com',
name: 'Test User'
}).returning()
userId = user.id
})
it('should return empty array when user has no workouts', async () => {
const workouts = await queryUserWorkouts(userId)
expect(workouts).toEqual([])
})
it('should enforce RLS - only return user\'s workouts', async () => {
// Create workout for another user
const [otherUser] = await db.insert(users).values({
email: 'other@example.com',
name: 'Other'
}).returning()
// Create workout for other user
await db.insert(workouts).values({
userId: otherUser.id,
name: 'Other\'s Workout'
})
// Query should return empty for original user
const userWorkouts = await queryUserWorkouts(userId)
expect(userWorkouts).toEqual([])
})
})
Key points:
*.test.ts for database testsUse queries in this order (from Testing Library docs):
screen.getByRole('button', { name: /sign in/i })
screen.getByRole('link', { name: /forgot password/i })
screen.getByRole('heading', { name: /welcome/i })
screen.getByRole('textbox', { name: /email/i })
screen.getByLabelText(/email/i)
screen.getByLabelText(/password/i)
screen.getByPlaceholderText(/enter your email/i)
screen.getByText(/welcome back/i)
screen.getByText(/successfully logged in/i)
screen.getByTestId('submit-button') // Use getByRole instead
Query variants:
getBy* - Throws if not found, throws if multiplequeryBy* - Returns null if not foundfindBy* - Async, waits for element to appearRegex matching: Always use case-insensitive regex for text matching:
// ✅ Good
screen.getByText(/sign in/i)
screen.getByRole('button', { name: /submit/i })
// ❌ Bad
screen.getByText('Sign In') // Breaks if text changes
Test user filling out and submitting a form:
describe('login form', () => {
let user: ReturnType<typeof userEvent.setup>
beforeEach(() => {
user = userEvent.setup()
})
it('should submit form with valid credentials', async () => {
const navigateTo = vi.fn()
vi.stubGlobal('navigateTo', navigateTo)
await renderSuspended(LoginForm)
// Fill form
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
// Submit
await user.click(screen.getByRole('button', { name: /sign in/i }))
// Assert navigation
await waitFor(() => {
expect(navigateTo).toHaveBeenCalledWith('/dashboard')
})
})
})
Test buttons, toggles, and other interactive elements:
describe('password visibility toggle', () => {
it('should toggle password visibility', async () => {
await renderSuspended(LoginForm)
const passwordInput = screen.getByLabelText(/password/i) as HTMLInputElement
const toggleButton = screen.getByRole('button', { name: /show password/i })
expect(passwordInput.type).toBe('password')
await user.click(toggleButton)
expect(passwordInput.type).toBe('text')
await user.click(toggleButton)
expect(passwordInput.type).toBe('password')
})
})
Test components that show/hide based on state:
it('should show success message after submission', async () => {
await renderSuspended(ForgotPasswordForm)
// Initially no success message
expect(screen.queryByText(/check your email/i)).toBeNull()
// Submit form
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.click(screen.getByRole('button', { name: /send/i }))
// Success message appears
await waitFor(() => {
expect(screen.getByText(/check your email/i)).toBeDefined()
})
// Form is hidden
expect(screen.queryByLabelText(/email/i)).toBeNull()
})
Test links and navigation behavior:
it('should navigate to signup page', async () => {
await renderSuspended(LoginPage)
const signupLink = screen.getByRole('link', { name: /create account/i })
expect(signupLink.getAttribute('href')).toBe('/signup')
})
it('should redirect if already logged in', async () => {
const navigateTo = vi.fn()
vi.stubGlobal('navigateTo', navigateTo)
// Mock logged in state
mockNuxtImport('useUserSession', () => ({
loggedIn: ref(true)
}))
await renderSuspended(LoginPage)
await waitFor(() => {
expect(navigateTo).toHaveBeenCalledWith('/')
})
})
Test wizards and multi-step forms:
it('should progress through email and OTP steps', async () => {
await renderSuspended(EmailOtpPage)
// Step 1: Email form visible
expect(screen.getByLabelText(/email/i)).toBeDefined()
expect(screen.queryByLabelText(/verification code/i)).toBeNull()
// Submit email
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.click(screen.getByRole('button', { name: /send code/i }))
// Step 2: OTP form visible
await waitFor(() => {
expect(screen.queryByLabelText(/email/i)).toBeNull()
expect(screen.getByLabelText(/verification code/i)).toBeDefined()
})
})
Always test all four states: loading, error, empty, and success.
it('should show loading state during submission', async () => {
await renderSuspended(LoginForm)
const submitButton = screen.getByRole('button', { name: /sign in/i })
// Initially not disabled
expect(submitButton.disabled).toBe(false)
// Fill and submit
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password')
await user.click(submitButton)
// Button disabled during loading
expect(submitButton.disabled).toBe(true)
expect(screen.getByText(/signing in/i)).toBeDefined()
})
it('should show error message on API failure', async () => {
// Mock API error
vi.mocked($fetch).mockRejectedValueOnce(new Error('Invalid credentials'))
await renderSuspended(LoginForm)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'wrong')
await user.click(screen.getByRole('button', { name: /sign in/i }))
await waitFor(() => {
expect(screen.getByText(/invalid credentials/i)).toBeDefined()
})
})
it('should show empty state when no data', async () => {
// Mock empty response
vi.mocked($fetch).mockResolvedValueOnce([])
await renderSuspended(WorkoutList)
await waitFor(() => {
expect(screen.getByText(/no workouts yet/i)).toBeDefined()
expect(screen.getByRole('button', { name: /create workout/i })).toBeDefined()
})
})
it('should display data when loaded', async () => {
// Mock successful response
vi.mocked($fetch).mockResolvedValueOnce([
{ id: '1', name: 'Morning Workout' },
{ id: '2', name: 'Evening Workout' }
])
await renderSuspended(WorkoutList)
await waitFor(() => {
expect(screen.getByText(/morning workout/i)).toBeDefined()
expect(screen.getByText(/evening workout/i)).toBeDefined()
})
})
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
mockNuxtImport('useUserSession', () => ({
user: ref({ id: '1', email: 'test@example.com' }),
loggedIn: ref(true),
clear: vi.fn()
}))
mockNuxtImport('useToast', () => ({
message: vi.fn()
}))
import { vi } from 'vitest'
vi.mock('#app', () => ({
$fetch: vi.fn()
}))
// In test
vi.mocked($fetch).mockResolvedValueOnce({ success: true, data: {} })
const navigateTo = vi.fn()
vi.stubGlobal('navigateTo', navigateTo)
// Assert
expect(navigateTo).toHaveBeenCalledWith('/dashboard')
Group tests with describe blocks using lowercase naming:
describe('component name', () => {
let user: ReturnType<typeof userEvent.setup>
beforeEach(() => {
user = userEvent.setup()
})
describe('form rendering', () => {
it('should display heading', async () => { /* ... */ })
it('should display all form fields', async () => { /* ... */ })
it('should display submit button', async () => { /* ... */ })
})
describe('form input', () => {
it('should allow user to type email', async () => { /* ... */ })
it('should allow user to type password', async () => { /* ... */ })
})
describe('form validation', () => {
it('should show error for invalid email', async () => { /* ... */ })
it('should show error for short password', async () => { /* ... */ })
})
describe('form submission', () => {
it('should submit with valid data', async () => { /* ... */ })
it('should show error on API failure', async () => { /* ... */ })
it('should redirect on success', async () => { /* ... */ })
})
})
IMPORTANT: Naming Convention
describe() block: Use lowercase with spaces (e.g., describe('component name', ...) or describe('page name', ...))describe() blocks: Use lowercase with spaces (e.g., describe('form rendering', ...))Common group names (lowercase):
// Wrong
it('accepts email prop', () => {
const wrapper = mount(Component, { props: { email: 'test@example.com' } })
expect(wrapper.props('email')).toBe('test@example.com')
})
// Right
it('should display email in message', async () => {
await renderSuspended(Component, { props: { email: 'test@example.com' } })
expect(screen.getByText(/test@example\.com/i)).toBeDefined()
})
// Wrong
it('has loading state', () => {
const wrapper = mount(Component)
expect(wrapper.vm.isLoading).toBe(false)
})
// Right
it('should show loading spinner', async () => {
await renderSuspended(Component)
// Trigger loading state
await user.click(screen.getByRole('button', { name: /submit/i }))
expect(screen.getByRole('status', { name: /loading/i })).toBeDefined()
})
// Wrong
it('renders', () => {
const wrapper = mount(Component)
expect(wrapper.exists()).toBe(true)
})
// Right
it('should display form heading and inputs', async () => {
await renderSuspended(Component)
expect(screen.getByRole('heading', { name: /sign in/i })).toBeDefined()
expect(screen.getByLabelText(/email/i)).toBeDefined()
expect(screen.getByLabelText(/password/i)).toBeDefined()
})
// Wrong
const button = wrapper.find('.submit-btn')
const input = wrapper.find('#email-input')
// Right
const button = screen.getByRole('button', { name: /submit/i })
const input = screen.getByLabelText(/email/i)
// Wrong
it('should show success message', async () => {
await renderSuspended(Component)
await user.click(screen.getByRole('button'))
expect(screen.getByText(/success/i)).toBeDefined() // May fail
})
// Right
it('should show success message', async () => {
await renderSuspended(Component)
await user.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText(/success/i)).toBeDefined()
})
})
# Run all tests
pnpm test
# Run tests in watch mode
pnpm test:watch
# Run tests with UI
pnpm test:ui
# Run tests with coverage
pnpm test:coverage
# Run specific test file
pnpm test path/to/file.test.ts
# Run specific project (unit or nuxt)
pnpm test --project=unit
pnpm test --project=nuxt
# Run tests matching pattern
pnpm test --grep "should submit form"
# Run all E2E tests
pnpm test:e2e
# Run E2E tests in headed mode (browser visible)
HEADED=true pnpm test:e2e
# Run specific E2E test file
pnpm test:e2e tests/e2e/auth/login.spec.ts
# View E2E test report
pnpm exec playwright show-report
vitest.config.tsvitest.setup.tsunit: Node environment for server/shared utilsnuxt: Nuxt environment for components/pagesplaywright.config.ts./tests/e2ehttp://localhost:3000Current thresholds (as of latest config):
Check coverage:
pnpm test:coverage
Use shared helper functions for common operations:
Auth Helpers (tests/helpers/auth.ts):
import { loginAsTestUser, logout, registerNewUser } from './helpers/auth'
test('should login successfully', async ({ page }) => {
await loginAsTestUser(page, 'josephanson@hotmail.co.uk', 'Testtest1')
await expect(page).toHaveURL(/\/feed/)
})
test('should logout successfully', async ({ page }) => {
await loginAsTestUser(page)
await logout(page)
await expect(page).toHaveURL('/')
})
Test Fixtures (tests/fixtures/users.ts):
import { testUser, adminUser, newUser } from '../../fixtures/users'
test('should login with test user', async ({ page }) => {
await loginAsTestUser(page, testUser.email, testUser.password)
})
import { expect, test } from '@playwright/test'
import { testUser } from '../../fixtures/users'
import { loginAsTestUser } from '../../helpers/auth'
test.describe('Feature Name', () => {
test('should perform user action', async ({ page }) => {
// Setup
await loginAsTestUser(page, testUser.email, testUser.password)
// Action
await page.getByRole('button', { name: /click me/i }).click()
// Assert
await expect(page).toHaveURL(/\/expected-url/)
})
})
loginAsTestUser, logout, and other helperstests/fixtures/users.tswaitForLoadState('networkidle') and waitForURL()getByRole, getByPlaceholder, getByText when possible$fetchResultFor components that use $fetchResult (custom fetch wrapper):
const mockFetchResult = vi.fn()
vi.stubGlobal('$fetchResult', mockFetchResult)
beforeEach(() => {
vi.clearAllMocks()
mockFetchResult.mockResolvedValue({ success: true })
})
// In test
await waitFor(() => {
expect($fetchResult).toHaveBeenCalledWith(
'/api/endpoint',
expect.objectContaining({
method: 'PUT',
body: expect.objectContaining({
field: 'value',
}),
}),
)
})
When testing Shadcn UISelect components that render options in portals:
it('should show options when clicked', async () => {
await renderSuspended(Component)
const selects = screen.getAllByRole('combobox')
await user.click(selects[0])
// Wait for portal rendering - options render in body
await waitFor(() => {
const options = screen.queryAllByRole('option')
expect(options.length).toBeGreaterThan(0)
}, { timeout: 1000 })
expect(screen.getByRole('option', { name: /option text/i })).toBeDefined()
})
For detailed migration examples, see:
.claude/specs/05-test-coverage-improvement/MIGRATION-PATTERNS.md - Before/after examples.claude/specs/05-test-coverage-improvement/design.md - Test patterns and architectureActivates when the user asks about AI prompts, needs prompt templates, wants to search for prompts, or mentions prompts.chat. Use for discovering, retrieving, and improving prompts.
Search, retrieve, and install Agent Skills from the prompts.chat registry using MCP tools. Use when the user asks to find skills, browse skill catalogs, install a skill for Claude, or extend Claude's capabilities with reusable AI agent components.
Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.