- name
- epic-testing
- description
- Guide on testing with Vitest and Playwright for Epic Stack
- categories
- ["testing","vitest","playwright","e2e"]
# Epic Stack: Testing
## When to use this skill
Use this skill when you need to:
- Write unit tests for utilities and components
- Create E2E tests with Playwright
- Test forms and validation
- Test routes and loaders
- Mock external services with MSW
- Test authentication and permissions
- Configure test database
## Patterns and conventions
### Testing Philosophy
Following Epic Web principles:
**Tests should resemble users** - Write tests that mirror how real users
interact with your application. Test user workflows, not implementation details.
If a user would click a button, your test should click that button. If a user
would see an error message, your test should check for that specific message.
**Make assertions specific** - Be explicit about what you're testing. Instead of
vague assertions, use specific, meaningful checks that clearly communicate the
expected behavior. This makes tests easier to understand and debug when they
fail.
**Example - Tests that resemble users:**
```typescript
// ✅ Good - Tests user workflow
test('User can sign up and create their first note', async ({ page, navigate }) => {
// User visits signup page
await navigate('/signup')
// User fills out form like a real person would
await page.getByRole('textbox', { name: /email/i }).fill('newuser@example.com')
await page.getByRole('textbox', { name: /username/i }).fill('newuser')
await page.getByRole('textbox', { name: /^password$/i }).fill('securepassword123')
await page.getByRole('textbox', { name: /confirm/i }).fill('securepassword123')
// User submits form
await page.getByRole('button', { name: /sign up/i }).click()
// User is redirected to onboarding
await expect(page).toHaveURL(/\/onboarding/)
// User creates their first note
await navigate('/notes/new')
await page.getByRole('textbox', { name: /title/i }).fill('My First Note')
await page.getByRole('textbox', { name: /content/i }).fill('This is my first note!')
await page.getByRole('button', { name: /create/i }).click()
// User sees their note
await expect(page.getByRole('heading', { name: 'My First Note' })).toBeVisible()
await expect(page.getByText('This is my first note!')).toBeVisible()
})
// ❌ Avoid - Testing implementation details
test('Signup form calls API endpoint', async ({ page }) => {
// This tests implementation, not user experience
const response = await page.request.post('/signup', { data: {...} })
expect(response.status()).toBe(200)
})
```
**Example - Specific assertions:**
```typescript
// ✅ Good - Specific assertions
test('Form shows specific validation errors', async ({ page, navigate }) => {
await navigate('/signup')
await page.getByRole('button', { name: /sign up/i }).click()
// Specific error messages that users would see
await expect(page.getByText(/email is required/i)).toBeVisible()
await expect(
page.getByText(/username must be at least 3 characters/i),
).toBeVisible()
await expect(
page.getByText(/password must be at least 6 characters/i),
).toBeVisible()
})
// ❌ Avoid - Vague assertions
test('Form shows errors', async ({ page, navigate }) => {
await navigate('/signup')
await page.getByRole('button', { name: /sign up/i }).click()
// Too vague - what errors? where?
expect(page.locator('.error')).toBeVisible()
})
```
### Two Types of Tests
Epic Stack uses two types of tests:
1. **Unit Tests with Vitest** - Tests for individual components and utilities
2. **E2E Tests with Playwright** - End-to-end tests of the complete flow
### Unit Tests with Vitest
**Basic setup:**
```typescript
// app/utils/my-util.test.ts
import { describe, expect, it } from 'vitest'
import { myUtil } from './my-util.ts'
describe('myUtil', () => {
it('should do something', () => {
expect(myUtil('input')).toBe('expected')
})
})
```
**Testing con DOM:**
```typescript
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MyComponent } from './my-component.tsx'
describe('MyComponent', () => {
it('should render correctly', () => {
render(<MyComponent />)
expect(screen.getByText('Hello')).toBeInTheDocument()
})
})
```
### E2E Tests with Playwright
**Basic setup:**
```typescript
// tests/e2e/my-feature.test.ts
import { expect, test } from '#tests/playwright-utils.ts'
test('Users can do something', async ({ page, navigate, login }) => {
const user = await login()
await navigate('/my-page')
// Interact with the page
await page.getByRole('button', { name: /Submit/i }).click()
// Verificar resultado
await expect(page).toHaveURL('/success')
})
```
### Login Fixture
Epic Stack provides a `login` fixture for authenticated tests.
**Use login fixture:**
```typescript
test('Protected route', async ({ page, navigate, login }) => {
const user = await login() // Creates user and session automatically
await navigate('/protected')
// User is authenticated
await expect(page.getByText(`Welcome ${user.username}`)).toBeVisible()
})
```
**Login with options:**
```typescript
const user = await login({
username: 'testuser',
email: 'test@example.com',
password: 'password123',
})
```
**Note:** The user is automatically deleted when the test completes.
### Insert User without Login
To create user without authentication:
```typescript
test('Public content', async ({ page, navigate, insertNewUser }) => {
const user = await insertNewUser({
username: 'publicuser',
email: 'public@example.com',
})
await navigate(`/users/${user.username}`)
await expect(page.getByText(user.username)).toBeVisible()
})
```
### Navigate Helper
Use the `navigate` helper to navigate with type-safety:
```typescript
// Type-safe navigation
await navigate('/users/:username/notes', { username: user.username })
await navigate('/users/:username/notes/:noteId', {
username: user.username,
noteId: note.id,
})
// Also works with routes without parameters
await navigate('/login')
```
### Test Database
Epic Stack uses a separate test database.
**Automatic configuration:**
- The test database is configured automatically
- It's cleaned between tests
- Data created in tests is automatically deleted
**Create data in tests:**
```typescript
import { prisma } from '#app/utils/db.server.ts'
test('User can see notes', async ({ page, navigate, login }) => {
const user = await login()
// Create note in database
const note = await prisma.note.create({
data: {
title: 'Test Note',
content: 'Test Content',
ownerId: user.id,
},
})
await navigate('/users/:username/notes/:noteId', {
username: user.username,
noteId: note.id,
})
await expect(page.getByText('Test Note')).toBeVisible()
})
```
### MSW (Mock Service Worker)
Epic Stack uses MSW to mock external services.
**Mock example:**
```typescript
// tests/mocks/github.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('https://api.github.com/user', () => {
return HttpResponse.json({
id: '123',
login: 'testuser',
email: 'test@example.com',
})
}),
]
```
**Use in tests:** Mocks are automatically applied when `MOCKS=true` is
configured.
### Testing Forms
**Test form:**
```typescript
test('User can submit form', async ({ page, navigate, login }) => {
const user = await login()
await navigate('/notes/new')
// Fill form
await page.getByRole('textbox', { name: /title/i }).fill('New Note')
await page.getByRole('textbox', { name: /content/i }).fill('Note content')
// Submit
await page.getByRole('button', { name: /submit/i }).click()
// Verificar redirect
await expect(page).toHaveURL(new RegExp('/users/.*/notes/.*'))
})
```
**Test validation:**
```typescript
test('Form shows validation errors', async ({ page, navigate }) => {
await navigate('/signup')
// Submit sin llenar
await page.getByRole('button', { name: /submit/i }).click()
// Verificar errores
await expect(page.getByText(/email is required/i)).toBeVisible()
})
```
### Testing Loaders
**Test loader:**
```typescript
// app/utils/my-util.test.ts
import { describe, expect, it } from 'vitest'
import { loader } from '../routes/my-route.ts'
import { prisma } from '../utils/db.server.ts'
describe('loader', () => {
it('should load data', async () => {
// Create data
const user = await prisma.user.create({
data: {
email: 'test@example.com',
username: 'testuser',
roles: { connect: { name: 'user' } },
},
})
// Mock request
const request = new Request('http://localhost/my-route')
// Execute loader
const result = await loader({ request, params: {}, context: {} })
// Verify result
expect(result.data).toBeDefined()
})
})
```
### Testing Actions
**Test action:**
```typescript
// tests/e2e/notes.test.ts
test('User can create note', async ({ page, navigate, login }) => {
const user = await login()
await navigate('/users/:username/notes', { username: user.username })
await page.getByRole('link', { name: /new note/i }).click()
const formData = new FormData()
formData.set('title', 'Test Note')
formData.set('content', 'Test Content')
await page.getByRole('textbox', { name: /title/i }).fill('Test Note')
await page.getByRole('textbox', { name: /content/i }).fill('Test Content')
await page.getByRole('button', { name: /submit/i }).click()
// Verify that note was created
await expect(page.getByText('Test Note')).toBeVisible()
})
```
### Testing Permissions
**Test permissions:**
```typescript
test('Only owner can delete note', async ({
page,
navigate,
login,
insertNewUser,
}) => {
const owner = await login()
const otherUser = await insertNewUser()
const note = await prisma.note.create({
data: {
title: 'Test Note',
content: 'Test',
ownerId: owner.id,
},
})
// Login as other user
const session = await createSession(otherUser.id)
await page.context().addCookies([getCookie(session)])
await navigate('/users/:username/notes/:noteId', {
username: owner.username,
noteId: note.id,
})
// Verify that can't delete
View on GitHub