End-to-End Testing
End-to-end (E2E) testing with Pinia involves testing complete user workflows in a real browser environment. This guide covers strategies for testing applications that use Pinia stores from a user's perspective.
Overview
E2E testing with Pinia focuses on:
- Testing complete user workflows
- Verifying state persistence across page reloads
- Testing real API interactions
- Validating cross-store communication
- Ensuring proper error handling in real scenarios
Setup
Cypress Setup
npm install -D cypress
// cypress.config.js
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
setupNodeEvents(on, config) {
// implement node event listeners here
},
},
component: {
devServer: {
framework: 'vue',
bundler: 'vite',
},
},
})
Playwright Setup
npm install -D @playwright/test
// playwright.config.js
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
Testing Strategies
1. User Authentication Flow
// stores/auth.js
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
token: null,
isLoading: false,
error: null
}),
getters: {
isAuthenticated: (state) => !!state.token
},
actions: {
async login(credentials) {
this.isLoading = true
this.error = null
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
})
if (!response.ok) {
throw new Error('Login failed')
}
const data = await response.json()
this.token = data.token
this.user = data.user
// Store in localStorage for persistence
localStorage.setItem('auth_token', data.token)
} catch (error) {
this.error = error.message
throw error
} finally {
this.isLoading = false
}
},
logout() {
this.user = null
this.token = null
localStorage.removeItem('auth_token')
}
}
})
Cypress Test
// cypress/e2e/auth.cy.js
describe('Authentication Flow', () => {
beforeEach(() => {
// Clear localStorage before each test
cy.clearLocalStorage()
cy.visit('/')
})
it('should login successfully', () => {
// Mock the API response
cy.intercept('POST', '/api/login', {
statusCode: 200,
body: {
token: 'mock-jwt-token',
user: { id: 1, name: 'John Doe', email: 'john@example.com' }
}
}).as('loginRequest')
// Navigate to login page
cy.get('[data-testid="login-button"]').click()
// Fill login form
cy.get('[data-testid="email-input"]').type('john@example.com')
cy.get('[data-testid="password-input"]').type('password123')
cy.get('[data-testid="submit-button"]').click()
// Wait for API call
cy.wait('@loginRequest')
// Verify successful login
cy.get('[data-testid="user-name"]').should('contain', 'John Doe')
cy.get('[data-testid="logout-button"]').should('be.visible')
// Verify token is stored
cy.window().its('localStorage').invoke('getItem', 'auth_token')
.should('equal', 'mock-jwt-token')
})
it('should handle login errors', () => {
cy.intercept('POST', '/api/login', {
statusCode: 401,
body: { message: 'Invalid credentials' }
}).as('loginError')
cy.get('[data-testid="login-button"]').click()
cy.get('[data-testid="email-input"]').type('wrong@example.com')
cy.get('[data-testid="password-input"]').type('wrongpassword')
cy.get('[data-testid="submit-button"]').click()
cy.wait('@loginError')
cy.get('[data-testid="error-message"]')
.should('be.visible')
.and('contain', 'Login failed')
})
it('should persist authentication across page reloads', () => {
// Set up authenticated state
cy.window().then((win) => {
win.localStorage.setItem('auth_token', 'mock-jwt-token')
})
// Reload page
cy.reload()
// Verify user is still authenticated
cy.get('[data-testid="user-name"]').should('be.visible')
cy.get('[data-testid="logout-button"]').should('be.visible')
})
it('should logout successfully', () => {
// Set up authenticated state
cy.window().then((win) => {
win.localStorage.setItem('auth_token', 'mock-jwt-token')
})
cy.reload()
// Logout
cy.get('[data-testid="logout-button"]').click()
// Verify logout
cy.get('[data-testid="login-button"]').should('be.visible')
cy.window().its('localStorage').invoke('getItem', 'auth_token')
.should('be.null')
})
})
Playwright Test
// tests/e2e/auth.spec.js
import { test, expect } from '@playwright/test'
test.describe('Authentication Flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('should login successfully', async ({ page }) => {
// Mock the API response
await page.route('/api/login', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
token: 'mock-jwt-token',
user: { id: 1, name: 'John Doe', email: 'john@example.com' }
})
})
})
// Navigate to login page
await page.getByTestId('login-button').click()
// Fill login form
await page.getByTestId('email-input').fill('john@example.com')
await page.getByTestId('password-input').fill('password123')
await page.getByTestId('submit-button').click()
// Verify successful login
await expect(page.getByTestId('user-name')).toContainText('John Doe')
await expect(page.getByTestId('logout-button')).toBeVisible()
// Verify token is stored
const token = await page.evaluate(() => localStorage.getItem('auth_token'))
expect(token).toBe('mock-jwt-token')
})
test('should handle login errors', async ({ page }) => {
await page.route('/api/login', async (route) => {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ message: 'Invalid credentials' })
})
})
await page.getByTestId('login-button').click()
await page.getByTestId('email-input').fill('wrong@example.com')
await page.getByTestId('password-input').fill('wrongpassword')
await page.getByTestId('submit-button').click()
await expect(page.getByTestId('error-message')).toBeVisible()
await expect(page.getByTestId('error-message')).toContainText('Login failed')
})
test('should persist authentication across page reloads', async ({ page }) => {
// Set up authenticated state
await page.evaluate(() => {
localStorage.setItem('auth_token', 'mock-jwt-token')
})
// Reload page
await page.reload()
// Verify user is still authenticated
await expect(page.getByTestId('user-name')).toBeVisible()
await expect(page.getByTestId('logout-button')).toBeVisible()
})
})
2. Shopping Cart Workflow
// stores/cart.js
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'
export const useCartStore = defineStore('cart', {
state: () => ({
items: [],
isLoading: false,
error: null
}),
getters: {
itemCount: (state) => state.items.reduce((total, item) => total + item.quantity, 0),
total: (state) => state.items.reduce((total, item) => total + (item.price * item.quantity), 0)
},
actions: {
async addItem(product, quantity = 1) {
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
throw new Error('Must be logged in to add items')
}
this.isLoading = true
try {
const response = await fetch('/api/cart/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authStore.token}`
},
body: JSON.stringify({ productId: product.id, quantity })
})
if (!response.ok) {
throw new Error('Failed to add item to cart')
}
const existingItem = this.items.find(item => item.id === product.id)
if (existingItem) {
existingItem.quantity += quantity
} else {
this.items.push({ ...product, quantity })
}
} catch (error) {
this.error = error.message
throw error
} finally {
this.isLoading = false
}
},
async checkout() {
const authStore = useAuthStore()
this.isLoading = true
try {
const response = await fetch('/api/cart/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authStore.token}`
},
body: JSON.stringify({ items: this.items })
})
if (!response.ok) {
throw new Error('Checkout failed')
}
const result = await response.json()
this.items = [] // Clear cart after successful checkout
return result
} catch (error) {
this.error = error.message
throw error
} finally {
this.isLoading = false
}
}
}
})
Cypress Test
// cypress/e2e/shopping-cart.cy.js
describe('Shopping Cart Workflow', () => {
beforeEach(() => {
cy.clearLocalStorage()
// Set up authenticated user
cy.window().then((win) => {
win.localStorage.setItem('auth_token', 'mock-jwt-token')
})
// Mock product API
cy.intercept('GET', '/api/products', {
statusCode: 200,
body: [
{ id: 1, name: 'Product 1', price: 10.99 },
{ id: 2, name: 'Product 2', price: 15.99 }
]
}).as('getProducts')
cy.visit('/')
})
it('should add items to cart and checkout', () => {
// Mock cart API calls
cy.intercept('POST', '/api/cart/add', {
statusCode: 200,
body: { success: true }
}).as('addToCart')
cy.intercept('POST', '/api/cart/checkout', {
statusCode: 200,
body: { orderId: '12345', total: 26.98 }
}).as('checkout')
// Navigate to products page
cy.get('[data-testid="products-link"]').click()
cy.wait('@getProducts')
// Add first product to cart
cy.get('[data-testid="product-1"] [data-testid="add-to-cart"]').click()
cy.wait('@addToCart')
// Verify cart count updated
cy.get('[data-testid="cart-count"]').should('contain', '1')
// Add second product to cart
cy.get('[data-testid="product-2"] [data-testid="add-to-cart"]').click()
cy.wait('@addToCart')
// Verify cart count updated
cy.get('[data-testid="cart-count"]').should('contain', '2')
// Go to cart
cy.get('[data-testid="cart-link"]').click()
// Verify cart contents
cy.get('[data-testid="cart-item-1"]').should('contain', 'Product 1')
cy.get('[data-testid="cart-item-2"]').should('contain', 'Product 2')
cy.get('[data-testid="cart-total"]').should('contain', '$26.98')
// Proceed to checkout
cy.get('[data-testid="checkout-button"]').click()
cy.wait('@checkout')
// Verify successful checkout
cy.get('[data-testid="order-confirmation"]')
.should('be.visible')
.and('contain', 'Order #12345')
// Verify cart is empty
cy.get('[data-testid="cart-count"]').should('contain', '0')
})
it('should handle add to cart errors', () => {
cy.intercept('POST', '/api/cart/add', {
statusCode: 500,
body: { message: 'Server error' }
}).as('addToCartError')
cy.get('[data-testid="products-link"]').click()
cy.get('[data-testid="product-1"] [data-testid="add-to-cart"]').click()
cy.wait('@addToCartError')
cy.get('[data-testid="error-message"]')
.should('be.visible')
.and('contain', 'Failed to add item to cart')
})
it('should require authentication for cart operations', () => {
// Clear authentication
cy.clearLocalStorage()
cy.reload()
cy.get('[data-testid="products-link"]').click()
cy.get('[data-testid="product-1"] [data-testid="add-to-cart"]').click()
// Should redirect to login or show error
cy.get('[data-testid="login-required-message"]')
.should('be.visible')
.and('contain', 'Must be logged in')
})
})
3. Cross-Store Communication
// cypress/e2e/cross-store-communication.cy.js
describe('Cross-Store Communication', () => {
beforeEach(() => {
cy.clearLocalStorage()
cy.visit('/')
})
it('should update user preferences and reflect in cart', () => {
// Mock APIs
cy.intercept('POST', '/api/login', {
statusCode: 200,
body: {
token: 'mock-jwt-token',
user: { id: 1, name: 'John Doe', currency: 'USD' }
}
}).as('login')
cy.intercept('PUT', '/api/user/preferences', {
statusCode: 200,
body: { success: true }
}).as('updatePreferences')
// Login
cy.get('[data-testid="login-button"]').click()
cy.get('[data-testid="email-input"]').type('john@example.com')
cy.get('[data-testid="password-input"]').type('password123')
cy.get('[data-testid="submit-button"]').click()
cy.wait('@login')
// Add item to cart (should show USD prices)
cy.get('[data-testid="products-link"]').click()
cy.get('[data-testid="product-1"] [data-testid="price"]')
.should('contain', '$10.99')
// Change currency preference
cy.get('[data-testid="user-menu"]').click()
cy.get('[data-testid="preferences-link"]').click()
cy.get('[data-testid="currency-select"]').select('EUR')
cy.get('[data-testid="save-preferences"]').click()
cy.wait('@updatePreferences')
// Verify prices updated in cart
cy.get('[data-testid="products-link"]').click()
cy.get('[data-testid="product-1"] [data-testid="price"]')
.should('contain', '€9.99')
})
})
Advanced Testing Patterns
Testing State Persistence
// cypress/e2e/state-persistence.cy.js
describe('State Persistence', () => {
it('should persist cart across browser sessions', () => {
// Set up authenticated user
cy.window().then((win) => {
win.localStorage.setItem('auth_token', 'mock-jwt-token')
})
cy.visit('/')
// Add items to cart
cy.get('[data-testid="products-link"]').click()
cy.get('[data-testid="product-1"] [data-testid="add-to-cart"]').click()
// Verify cart has items
cy.get('[data-testid="cart-count"]').should('contain', '1')
// Simulate browser restart by clearing everything except localStorage
cy.reload()
// Verify cart state is restored
cy.get('[data-testid="cart-count"]').should('contain', '1')
cy.get('[data-testid="cart-link"]').click()
cy.get('[data-testid="cart-item-1"]').should('be.visible')
})
})
Testing Real-time Updates
// cypress/e2e/realtime-updates.cy.js
describe('Real-time Updates', () => {
it('should update inventory in real-time', () => {
cy.visit('/')
// Mock WebSocket connection
cy.window().then((win) => {
// Simulate WebSocket message
win.dispatchEvent(new CustomEvent('inventory-update', {
detail: { productId: 1, stock: 5 }
}))
})
// Verify inventory updated
cy.get('[data-testid="product-1"] [data-testid="stock"]')
.should('contain', '5 in stock')
})
})
Performance Testing
// cypress/e2e/performance.cy.js
describe('Performance', () => {
it('should handle large datasets efficiently', () => {
// Mock large dataset
const largeProductList = Array.from({ length: 1000 }, (_, i) => ({
id: i + 1,
name: `Product ${i + 1}`,
price: Math.random() * 100
}))
cy.intercept('GET', '/api/products', {
statusCode: 200,
body: largeProductList
}).as('getLargeProductList')
cy.visit('/')
cy.get('[data-testid="products-link"]').click()
cy.wait('@getLargeProductList')
// Verify page loads within reasonable time
cy.get('[data-testid="product-list"]', { timeout: 5000 })
.should('be.visible')
// Test search performance
cy.get('[data-testid="search-input"]').type('Product 500')
cy.get('[data-testid="product-500"]', { timeout: 2000 })
.should('be.visible')
})
})
Best Practices
1. Use Data Test IDs
Always use data-testid
attributes for reliable element selection:
<template>
<button data-testid="add-to-cart" @click="addToCart">
Add to Cart
</button>
</template>
2. Mock External Dependencies
Mock API calls to ensure consistent test results:
// Cypress
cy.intercept('GET', '/api/products', { fixture: 'products.json' })
// Playwright
await page.route('/api/products', route => {
route.fulfill({ path: 'fixtures/products.json' })
})
3. Test User Journeys
Focus on complete user workflows rather than isolated features:
it('should complete purchase journey', () => {
// 1. Browse products
// 2. Add to cart
// 3. Login
// 4. Checkout
// 5. Verify order confirmation
})
4. Handle Async Operations
Properly wait for async operations to complete:
// Cypress
cy.get('[data-testid="loading"]').should('not.exist')
cy.get('[data-testid="content"]').should('be.visible')
// Playwright
await expect(page.getByTestId('loading')).toBeHidden()
await expect(page.getByTestId('content')).toBeVisible()
5. Clean Up Between Tests
Ensure tests are isolated:
beforeEach(() => {
cy.clearLocalStorage()
cy.clearCookies()
// Reset any global state
})
Testing Utilities
Custom Commands (Cypress)
// cypress/support/commands.js
Cypress.Commands.add('login', (email = 'test@example.com', password = 'password') => {
cy.intercept('POST', '/api/login', {
statusCode: 200,
body: {
token: 'mock-jwt-token',
user: { id: 1, name: 'Test User', email }
}
}).as('loginRequest')
cy.get('[data-testid="login-button"]').click()
cy.get('[data-testid="email-input"]').type(email)
cy.get('[data-testid="password-input"]').type(password)
cy.get('[data-testid="submit-button"]').click()
cy.wait('@loginRequest')
})
Cypress.Commands.add('addToCart', (productId) => {
cy.intercept('POST', '/api/cart/add', {
statusCode: 200,
body: { success: true }
}).as('addToCart')
cy.get(`[data-testid="product-${productId}"] [data-testid="add-to-cart"]`).click()
cy.wait('@addToCart')
})
Page Object Model (Playwright)
// tests/pages/LoginPage.js
export class LoginPage {
constructor(page) {
this.page = page
this.emailInput = page.getByTestId('email-input')
this.passwordInput = page.getByTestId('password-input')
this.submitButton = page.getByTestId('submit-button')
this.errorMessage = page.getByTestId('error-message')
}
async login(email, password) {
await this.emailInput.fill(email)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
async expectError(message) {
await expect(this.errorMessage).toContainText(message)
}
}
Debugging E2E Tests
Cypress Debugging
it('should debug test', () => {
cy.visit('/')
cy.debug() // Pause execution
cy.get('[data-testid="button"]').click()
cy.pause() // Interactive pause
})
Playwright Debugging
test('should debug test', async ({ page }) => {
await page.goto('/')
await page.pause() // Interactive debugging
await page.getByTestId('button').click()
})
Conclusion
E2E testing with Pinia requires a comprehensive approach that covers:
- Complete user workflows
- State persistence and restoration
- Cross-store communication
- Error handling scenarios
- Performance considerations
By following these patterns and best practices, you can create robust E2E tests that give you confidence in your application's behavior from a user's perspective.
For more testing strategies, see:
- Component Testing - Testing individual components with Pinia
- Store Testing - Testing stores in isolation