Testing Vitest + Playwright
MegaShop has gone live, but every release has had bugs—shopping cart calculation errors, occasional login failures, and 404 errors on product pages. Charlie decided to set up an automated testing system, using Vitest for unit tests and Playwright for end-to-end (E2E) tests, to ensure reliable quality with every release.
1. What You'll Learn
- @nuxt/test-utils: The official Nuxt 3 testing toolchain
- Vitest Unit Testing: Testing Components, Composables, and Stores
- Playwright E2E Testing: Page Interaction + SSR Validation
- API Testing: $fetch tests the server API
- MegaShop Shopping Cart Workflow E2E + usePriceFormat Unit Test
2. A True Story of an Architect
(1) Pain Point: Manual testing cannot cover all scenarios
Before every release, Bob manually tests MegaShop—the homepage, product pages, shopping cart, checkout, and admin panel. 5 browsers x 3 languages x 20 pages = 300 manual tests. With a 30% test coverage gap, at least 2 live bugs are found with every release.
(2) Solutions for Automated Testing
Vitest + Playwright: Automating Test Runs:
// Vitest: unit test for usePriceFormat
test('formats USD price correctly', () => {
const price = ref(2999.99)
const { formatted } = usePriceFormat(price)
expect(formatted.value).toBe('$2,999.99')
})
(3) Results: 0 manual tests + 0 production bugs
Automated testing covers core processes; CI runs automatically with every commit, reducing the test omission rate to 2% and bringing the number of production bugs down to 0.
3. The Testing Pyramid
(1) Test Hierarchy
graph TB
A[E2E Tests - Playwright<br/>Critical user flows] --> B[Integration Tests<br/>API + Component interactions]
B --> C[Unit Tests - Vitest<br/>Composables / Stores / Utils]
style A fill:#f96,stroke:#333
style B fill:#ff9,stroke:#333
style C fill:#9f9,stroke:#333
(2) Comparison of Test Types
| Dimension | Unit Testing | Integration Testing | E2E Testing |
|---|---|---|---|
| Tools | Vitest | Vitest | Playwright |
| Scope | Single Function/Component | Interaction Among Multiple Modules | Complete User Flow |
| Speed | ⚡⚡⚡ Fast | ⚡⚡ Medium | 🐢 Slow |
| Quantity | High | Medium | Low |
| Value | Logical Correctness | Interface Consistency | User Flow Assurance |
| MegaShop Example | usePriceFormat | API Routes | Cart → Checkout |
4. Vitest Configuration and Unit Testing
(1) ▶ Example: Installation and Configuration
npm install -D vitest @nuxt/test-utils @vue/test-utils happy-dom
Output:
# Command executed successfully
// vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
environment: 'nuxt',
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['node_modules', '.nuxt']
}
}
})
(2) ▶ Example: Composable Unit Tests
// tests/composables/usePriceFormat.test.ts
import { ref } from 'vue'
describe('usePriceFormat', () => {
test('formats USD price with comma separator', () => {
const price = ref(2999.99)
const { formatted } = usePriceFormat(price)
expect(formatted.value).toBe('$2,999.99')
})
test('formats JPY price with no decimals', () => {
const price = ref(29999)
const { formatted } = usePriceFormat(price, { currency: 'JPY', locale: 'ja-JP' })
expect(formatted.value).toBe('¥29,999')
})
test('reactively updates when price changes', () => {
const price = ref(100)
const { formatted } = usePriceFormat(price)
expect(formatted.value).toBe('$100.00')
price.value = 200
expect(formatted.value).toBe('$200.00')
})
test('handles zero price', () => {
const price = ref(0)
const { formatted } = usePriceFormat(price)
expect(formatted.value).toBe('$0.00')
})
test('handles raw number input (not ref)', () => {
const { formatted } = usePriceFormat(50.5)
expect(formatted.value).toBe('$50.50')
})
})
Output:
// Execution Successful
(3) ▶ Example: Pinia Store Test
// tests/stores/cart.test.ts
import { setActivePinia, createPinia } from 'pinia'
describe('useCartStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
test('adds item to cart', () => {
const store = useCartStore()
store.addItem({ id: 1, name: 'Headphones', price: 299.99, image: '/img.jpg' })
expect(store.items.length).toBe(1)
expect(store.totalItems).toBe(1)
})
test('increments quantity for existing item', () => {
const store = useCartStore()
const product = { id: 1, name: 'Headphones', price: 299.99, image: '/img.jpg' }
store.addItem(product)
store.addItem(product)
expect(store.items.length).toBe(1)
expect(store.items[0].quantity).toBe(2)
})
test('removes item from cart', () => {
const store = useCartStore()
store.addItem({ id: 1, name: 'Headphones', price: 299.99, image: '/img.jpg' })
store.removeItem(1)
expect(store.items.length).toBe(0)
})
test('calculates total price correctly', () => {
const store = useCartStore()
store.addItem({ id: 1, name: 'A', price: 100, image: '/a.jpg' })
store.addItem({ id: 2, name: 'B', price: 200, image: '/b.jpg' })
store.addItem({ id: 1, name: 'A', price: 100, image: '/a.jpg' }) // quantity = 2
expect(store.totalPrice).toBe(400) // 100*2 + 200
})
test('clears cart', () => {
const store = useCartStore()
store.addItem({ id: 1, name: 'A', price: 100, image: '/a.jpg' })
store.clearCart()
expect(store.items.length).toBe(0)
expect(store.totalPrice).toBe(0)
})
})
Output:
// Execution Successful
5. Component Testing
(1) ▶ Example: Testing the mountSuspended component
// tests/components/ProductCard.test.ts
import { mountSuspended } from '@nuxt/test-utils/runtime'
describe('ProductCard', () => {
const mockProduct = {
id: 1, name: 'Premium Headphones', price: 299.99,
image: '/headphones.jpg', inStock: true
}
test('renders product name and price', async () => {
const wrapper = await mountSuspended(ProductCard, {
props: { product: mockProduct }
})
expect(wrapper.text()).toContain('Premium Headphones')
expect(wrapper.text()).toContain('299.99')
})
test('emits add-to-cart event on button click', async () => {
const wrapper = await mountSuspended(ProductCard, {
props: { product: mockProduct }
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('add-to-cart')).toBeTruthy()
expect(wrapper.emitted('add-to-cart')[0][0]).toEqual(mockProduct)
})
test('shows out of stock when not available', async () => {
const wrapper = await mountSuspended(ProductCard, {
props: { product: { ...mockProduct, inStock: false } }
})
expect(wrapper.text()).toContain('Out of Stock')
})
})
Output:
// Execution Successful
6. Playwright E2E Testing
(1) ▶ Example: Playwright Configuration
// playwright.config.ts
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
baseURL: 'http://localhost:3000',
webServer: {
command: 'npm run dev',
port: 3000,
reuseExistingServer: !process.env.CI
},
use: {
headless: true,
screenshot: 'only-on-failure'
}
})
Output:
// Execution Successful
(2) ▶ Example: End-to-End Shopping Cart Workflow
// e2e/cart-flow.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Shopping Cart Flow', () => {
test('add product to cart and verify', async ({ page }) => {
// Navigate to product list
await page.goto('/products')
// Wait for products to load
await page.waitForSelector('.product-card')
// Click first product's "Add to Cart" button
await page.locator('.product-card:first-child button').click()
// Verify cart count updated
const cartCount = page.locator('.cart-count')
await expect(cartCount).toHaveText('1')
// Navigate to cart page
await page.goto('/cart')
// Verify item in cart
await expect(page.locator('.cart-item')).toHaveCount(1)
})
test('complete checkout flow', async ({ page }) => {
// Login first
await page.goto('/login')
await page.fill('input[type="email"]', 'alice@example.com')
await page.fill('input[type="password"]', 'password123')
await page.click('button[type="submit"]')
// Add item and go to checkout
await page.goto('/products/1')
await page.click('button:has-text("Add to Cart")')
await page.click('a:has-text("Cart")')
await page.click('a:has-text("Proceed to Checkout")')
// Verify checkout page
await expect(page.locator('h1')).toHaveText(/checkout/i)
})
})
Output:
// Execution Successful
7. API Testing
(1) ▶ Example: Server API Testing
// tests/api/products.test.ts
import { setupTest } from '@nuxt/test-utils'
describe('Products API', () => {
setupTest()
test('GET /api/products returns product list', async () => {
const response = await $fetch('/api/products')
expect(response.items).toBeDefined()
expect(response.total).toBeGreaterThan(0)
expect(response.page).toBe(1)
})
test('GET /api/products/:id returns product detail', async () => {
const product = await $fetch('/api/products/1')
expect(product.id).toBe(1)
expect(product.name).toBeDefined()
expect(product.price).toBeDefined()
})
test('GET /api/products/:id returns 404 for invalid id', async () => {
await expect($fetch('/api/products/99999')).rejects.toThrow('404')
})
test('GET /api/products supports pagination', async () => {
const page1 = await $fetch('/api/products?page=1&limit=5')
const page2 = await $fetch('/api/products?page=2&limit=5')
expect(page1.items.length).toBeLessThanOrEqual(5)
expect(page2.items[0].id).not.toBe(page1.items[0].id)
})
test('GET /api/products supports category filter', async () => {
const result = await $fetch('/api/products?category=electronics')
result.items.forEach((item: any) => {
expect(item.category).toBe('electronics')
})
})
})
Output:
// Execution Successful
8. Comprehensive Example: The MegaShop Testing Framework
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:all": "npm run test && npm run test:e2e"
}
}
| Test Type | Command | Coverage | When to Run |
|---|---|---|---|
| Unit Tests | vitest | Composable/Store/Utils | Every commit |
| Component Testing | vitest | Vue Components | On Every Commit |
| API Testing | vitest | Server API | Each Submission |
| E2E Testing | Playwright | User Flow | Before PR Merge |
| Coverage | vitest --coverage | All | CI Pipeline |
❓ FAQ
mountSuspended and mount?mount is the basic mounting function provided by Vue Test Utils, while mountSuspended is provided by @nuxt/test-utils and supports the full context, including Nuxt plugins, auto-imports, and Composables.$fetch method from @nuxt/test-utils to directly request the server-rendered HTML and verify that the HTML contains the data. You can also use Playwright to disable JavaScript and inspect the page.$fetch action called within the Store needs to be mocked. Use vitest.mock to intercept $fetch, or extract the API call into a Composable to facilitate mocking.📖 Summary
- Testing Pyramid: Extensive unit tests + moderate integration tests + minimal end-to-end (E2E) tests
- Vitest + @nuxt/test-utils for testing Composables, Stores, and components; supports the Nuxt context
- mountSuspended mounts components in a Nuxt environment; auto-import and plugins are automatically available
- Playwright E2E tests key user workflows; SSR verification can be disabled in JavaScript
- Use
$fetchto directly validate the server API response during API testing
📝 Exercises
- Basic Exercise (Difficulty ⭐): Install Vitest and @nuxt/test-utils, and write 5 unit tests for
usePriceFormat. - Advanced Exercise (Difficulty: ⭐⭐): Write a complete CRUD test for
cartStoreand a component test forProductCard. - Challenge (Difficulty: ⭐⭐⭐): Write a complete end-to-end (E2E) test for a shopping cart using Playwright: Browse products → Add to cart → View cart → Checkout
---|



