测试策略 #

概述 #

测试是保证应用质量的重要环节。本章将介绍 Alpine.js 应用的测试方法和最佳实践。

测试类型 #

类型 范围 速度 工具
单元测试 函数/组件 Vitest/Jest
集成测试 组件交互 Vitest + Testing Library
E2E 测试 完整流程 Playwright/Cypress

单元测试 #

配置 Vitest #

javascript
import { defineConfig } from 'vitest/config'

export default defineConfig({
    test: {
        environment: 'jsdom',
        setupFiles: ['./tests/setup.ts']
    }
})

测试设置 #

typescript
import { JSDOM } from 'jsdom'
import Alpine from 'alpinejs'

const dom = new JSDOM('<!DOCTYPE html>')
global.document = dom.window.document
global.window = dom.window as any
global.Alpine = Alpine

window.Alpine = Alpine

测试组件逻辑 #

typescript
import { describe, it, expect, beforeEach } from 'vitest'
import Alpine from 'alpinejs'

describe('Counter Component', () => {
    let component: ReturnType<typeof counter>
    
    function counter(initial = 0) {
        return {
            count: initial,
            increment() { this.count++ },
            decrement() { this.count-- },
            reset() { this.count = initial }
        }
    }
    
    beforeEach(() => {
        component = counter(0)
    })
    
    it('should initialize with correct value', () => {
        expect(component.count).toBe(0)
    })
    
    it('should increment count', () => {
        component.increment()
        expect(component.count).toBe(1)
    })
    
    it('should decrement count', () => {
        component.increment()
        component.decrement()
        expect(component.count).toBe(0)
    })
    
    it('should reset to initial value', () => {
        component.increment()
        component.increment()
        component.reset()
        expect(component.count).toBe(0)
    })
})

测试计算属性 #

typescript
describe('Todo List', () => {
    function todoList() {
        return {
            todos: [
                { id: 1, text: 'Task 1', completed: false },
                { id: 2, text: 'Task 2', completed: true }
            ],
            
            get activeTodos() {
                return this.todos.filter(t => !t.completed)
            },
            
            get completedTodos() {
                return this.todos.filter(t => t.completed)
            },
            
            get remainingCount() {
                return this.activeTodos.length
            }
        }
    }
    
    it('should filter active todos', () => {
        const component = todoList()
        expect(component.activeTodos.length).toBe(1)
    })
    
    it('should filter completed todos', () => {
        const component = todoList()
        expect(component.completedTodos.length).toBe(1)
    })
    
    it('should count remaining todos', () => {
        const component = todoList()
        expect(component.remainingCount).toBe(1)
    })
})

测试异步操作 #

typescript
describe('Async Component', () => {
    function asyncComponent() {
        return {
            data: null as any,
            loading: false,
            error: null as string | null,
            
            async fetchData() {
                this.loading = true
                try {
                    const res = await fetch('/api/data')
                    this.data = await res.json()
                } catch (e) {
                    this.error = (e as Error).message
                } finally {
                    this.loading = false
                }
            }
        }
    }
    
    it('should handle successful fetch', async () => {
        global.fetch = vi.fn().mockResolvedValue({
            json: () => Promise.resolve({ name: 'Test' })
        })
        
        const component = asyncComponent()
        await component.fetchData()
        
        expect(component.loading).toBe(false)
        expect(component.data).toEqual({ name: 'Test' })
        expect(component.error).toBeNull()
    })
    
    it('should handle fetch error', async () => {
        global.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
        
        const component = asyncComponent()
        await component.fetchData()
        
        expect(component.loading).toBe(false)
        expect(component.error).toBe('Network error')
    })
})

集成测试 #

使用 Testing Library #

typescript
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, fireEvent, screen } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
import Alpine from 'alpinejs'

describe('Dropdown Integration', () => {
    beforeEach(() => {
        document.body.innerHTML = ''
    })
    
    function renderDropdown() {
        const html = `
            <div x-data="{ open: false }">
                <button @click="open = !open" data-testid="toggle">
                    Toggle
                </button>
                <div x-show="open" data-testid="menu">
                    Menu Content
                </div>
            </div>
        `
        
        document.body.innerHTML = html
        Alpine.initTree(document.body)
        
        return {
            toggle: screen.getByTestId('toggle'),
            menu: screen.getByTestId('menu')
        }
    }
    
    it('should toggle menu visibility', async () => {
        const { toggle, menu } = renderDropdown()
        
        expect(menu).not.toBeVisible()
        
        await fireEvent.click(toggle)
        expect(menu).toBeVisible()
        
        await fireEvent.click(toggle)
        expect(menu).not.toBeVisible()
    })
})

测试表单 #

typescript
describe('Form Integration', () => {
    function renderForm() {
        const html = `
            <form x-data="{
                email: '',
                password: '',
                errors: {},
                submit() {
                    this.errors = {}
                    if (!this.email) this.errors.email = 'Required'
                    if (!this.password) this.errors.password = 'Required'
                }
            }" @submit.prevent="submit()">
                <input x-model="email" data-testid="email">
                <span x-show="errors.email" data-testid="email-error" x-text="errors.email"></span>
                
                <input x-model="password" data-testid="password">
                <span x-show="errors.password" data-testid="password-error" x-text="errors.password"></span>
                
                <button type="submit" data-testid="submit">Submit</button>
            </form>
        `
        
        document.body.innerHTML = html
        Alpine.initTree(document.body)
        
        return {
            email: screen.getByTestId('email') as HTMLInputElement,
            password: screen.getByTestId('password') as HTMLInputElement,
            submit: screen.getByTestId('submit'),
            emailError: screen.getByTestId('email-error'),
            passwordError: screen.getByTestId('password-error')
        }
    }
    
    it('should show validation errors', async () => {
        const { submit, emailError, passwordError } = renderForm()
        
        await fireEvent.click(submit)
        
        expect(emailError).toBeVisible()
        expect(passwordError).toBeVisible()
    })
    
    it('should not show errors when filled', async () => {
        const { email, password, submit, emailError, passwordError } = renderForm()
        
        await userEvent.type(email, 'test@example.com')
        await userEvent.type(password, 'password123')
        await fireEvent.click(submit)
        
        expect(emailError).not.toBeVisible()
        expect(passwordError).not.toBeVisible()
    })
})

E2E 测试 #

Playwright 配置 #

typescript
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
    testDir: './e2e',
    fullyParallel: true,
    use: {
        baseURL: 'http://localhost:3000',
        trace: 'on-first-retry'
    },
    projects: [
        {
            name: 'chromium',
            use: { ...devices['Desktop Chrome'] }
        }
    ],
    webServer: {
        command: 'npm run dev',
        url: 'http://localhost:3000',
        reuseExistingServer: true
    }
})

E2E 测试示例 #

typescript
import { test, expect } from '@playwright/test'

test.describe('Todo App', () => {
    test.beforeEach(async ({ page }) => {
        await page.goto('/')
    })
    
    test('should add a new todo', async ({ page }) => {
        await page.fill('[data-testid="new-todo"]', 'Buy groceries')
        await page.click('[data-testid="add-todo"]')
        
        await expect(page.locator('[data-testid="todo-item"]')).toContainText('Buy groceries')
    })
    
    test('should complete a todo', async ({ page }) => {
        await page.fill('[data-testid="new-todo"]', 'Task to complete')
        await page.click('[data-testid="add-todo"]')
        
        await page.click('[data-testid="todo-checkbox"]')
        
        await expect(page.locator('[data-testid="todo-item"]')).toHaveClass(/completed/)
    })
    
    test('should delete a todo', async ({ page }) => {
        await page.fill('[data-testid="new-todo"]', 'Task to delete')
        await page.click('[data-testid="add-todo"]')
        
        await page.click('[data-testid="delete-todo"]')
        
        await expect(page.locator('[data-testid="todo-item"]')).not.toBeVisible()
    })
})

测试用户认证 #

typescript
test.describe('Authentication', () => {
    test('should login successfully', async ({ page }) => {
        await page.goto('/login')
        
        await page.fill('[data-testid="email"]', 'user@example.com')
        await page.fill('[data-testid="password"]', 'password123')
        await page.click('[data-testid="login-btn"]')
        
        await expect(page).toHaveURL('/dashboard')
        await expect(page.locator('[data-testid="user-name"]')).toBeVisible()
    })
    
    test('should show error for invalid credentials', async ({ page }) => {
        await page.goto('/login')
        
        await page.fill('[data-testid="email"]', 'wrong@example.com')
        await page.fill('[data-testid="password"]', 'wrongpassword')
        await page.click('[data-testid="login-btn"]')
        
        await expect(page.locator('[data-testid="error-message"]')).toBeVisible()
    })
})

测试 Store #

typescript
describe('Cart Store', () => {
    let cartStore: CartStore
    
    beforeEach(() => {
        cartStore = {
            items: [],
            
            add(product) {
                const existing = this.items.find(i => i.id === product.id)
                if (existing) {
                    existing.quantity++
                } else {
                    this.items.push({ ...product, quantity: 1 })
                }
            },
            
            remove(productId) {
                this.items = this.items.filter(i => i.id !== productId)
            },
            
            get total() {
                return this.items.reduce((sum, i) => sum + i.price * i.quantity, 0)
            },
            
            get count() {
                return this.items.reduce((sum, i) => sum + i.quantity, 0)
            }
        }
    })
    
    it('should add item to cart', () => {
        cartStore.add({ id: 1, name: 'Product', price: 100 })
        
        expect(cartStore.items.length).toBe(1)
        expect(cartStore.count).toBe(1)
    })
    
    it('should increment quantity for existing item', () => {
        cartStore.add({ id: 1, name: 'Product', price: 100 })
        cartStore.add({ id: 1, name: 'Product', price: 100 })
        
        expect(cartStore.items.length).toBe(1)
        expect(cartStore.count).toBe(2)
    })
    
    it('should calculate total correctly', () => {
        cartStore.add({ id: 1, name: 'Product A', price: 100 })
        cartStore.add({ id: 2, name: 'Product B', price: 50 })
        
        expect(cartStore.total).toBe(150)
    })
    
    it('should remove item from cart', () => {
        cartStore.add({ id: 1, name: 'Product', price: 100 })
        cartStore.remove(1)
        
        expect(cartStore.items.length).toBe(0)
    })
})

Mock 策略 #

Mock Fetch #

typescript
const mockFetch = vi.fn()
global.fetch = mockFetch

beforeEach(() => {
    mockFetch.mockReset()
})

it('should fetch data', async () => {
    mockFetch.mockResolvedValue({
        json: () => Promise.resolve({ data: 'test' })
    })
    
    const result = await fetchData()
    expect(result).toEqual({ data: 'test' })
})

Mock LocalStorage #

typescript
const localStorageMock = {
    store: {} as Record<string, string>,
    getItem(key: string) {
        return this.store[key] || null
    },
    setItem(key: string, value: string) {
        this.store[key] = value
    },
    removeItem(key: string) {
        delete this.store[key]
    },
    clear() {
        this.store = {}
    }
}

Object.defineProperty(window, 'localStorage', {
    value: localStorageMock
})

最佳实践 #

1. 测试用户行为 #

typescript
it('should allow user to submit form', async () => {
    await userEvent.type(emailInput, 'test@example.com')
    await userEvent.click(submitButton)
    
    expect(successMessage).toBeVisible()
})

2. 使用 data-testid #

html
<button data-testid="submit-btn">Submit</button>
<input data-testid="email-input">

3. 隔离测试 #

typescript
beforeEach(() => {
    document.body.innerHTML = ''
    vi.clearAllMocks()
})

4. 测试边界情况 #

typescript
it('should handle empty input', () => { })
it('should handle very long input', () => { })
it('should handle special characters', () => { })

小结 #

测试策略要点:

  • 单元测试测试组件逻辑
  • 集成测试测试组件交互
  • E2E 测试测试完整流程
  • 使用 Mock 隔离外部依赖
  • 测试用户行为而非实现细节

至此,Alpine.js 完全指南全部完成!

最后更新:2026-03-28