Nuxt.js测试策略 #

一、测试概述 #

1.1 测试类型 #

类型 说明 工具
单元测试 测试独立函数和组件 Vitest
组件测试 测试组件行为 Vitest + Vue Test Utils
端到端测试 测试完整用户流程 Playwright

1.2 测试金字塔 #

text
        /\
       /  \
      / E2E\
     /------\
    /  集成  \
   /----------\
  /   单元测试  \
 /--------------\

二、Vitest配置 #

2.1 安装依赖 #

bash
pnpm add -D vitest @vue/test-utils @nuxt/test-utils happy-dom

2.2 配置Vitest #

vitest.config.ts

typescript
import { defineVitestConfig } from '@nuxt/test-utils/config'

export default defineVitestConfig({
  test: {
    environment: 'nuxt',
    environmentOptions: {
      nuxt: {
        mock: {
          intersectionObserver: true
        }
      }
    },
    globals: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html']
    }
  }
})

2.3 添加脚本 #

package.json

json
{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage"
  }
}

三、单元测试 #

3.1 测试工具函数 #

utils/formatDate.ts

typescript
export const formatDate = (date: string | Date): string => {
  return new Date(date).toLocaleDateString('zh-CN')
}

utils/formatDate.test.ts

typescript
import { describe, it, expect } from 'vitest'
import { formatDate } from './formatDate'

describe('formatDate', () => {
  it('should format date string', () => {
    const result = formatDate('2024-01-15')
    expect(result).toBe('2024/1/15')
  })
  
  it('should format Date object', () => {
    const result = formatDate(new Date('2024-01-15'))
    expect(result).toBe('2024/1/15')
  })
})

3.2 测试组合式函数 #

composables/useCounter.ts

typescript
export const useCounter = (initialValue = 0) => {
  const count = ref(initialValue)
  
  const increment = () => count.value++
  const decrement = () => count.value--
  const reset = () => count.value = initialValue
  
  return { count, increment, decrement, reset }
}

composables/useCounter.test.ts

typescript
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('should initialize with default value', () => {
    const { count } = useCounter()
    expect(count.value).toBe(0)
  })
  
  it('should initialize with custom value', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })
  
  it('should increment count', () => {
    const { count, increment } = useCounter()
    increment()
    expect(count.value).toBe(1)
  })
  
  it('should decrement count', () => {
    const { count, decrement } = useCounter(5)
    decrement()
    expect(count.value).toBe(4)
  })
  
  it('should reset count', () => {
    const { count, increment, reset } = useCounter(5)
    increment()
    increment()
    reset()
    expect(count.value).toBe(5)
  })
})

四、组件测试 #

4.1 测试基础组件 #

components/BaseButton.vue

vue
<template>
  <button :class="['btn', `btn-${variant}`]" :disabled="disabled">
    <slot />
  </button>
</template>

<script setup lang="ts">
interface Props {
  variant?: 'primary' | 'secondary'
  disabled?: boolean
}

withDefaults(defineProps<Props>(), {
  variant: 'primary',
  disabled: false
})
</script>

components/BaseButton.test.ts

typescript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseButton from './BaseButton.vue'

describe('BaseButton', () => {
  it('renders slot content', () => {
    const wrapper = mount(BaseButton, {
      slots: {
        default: 'Click me'
      }
    })
    
    expect(wrapper.text()).toBe('Click me')
  })
  
  it('applies variant class', () => {
    const wrapper = mount(BaseButton, {
      props: {
        variant: 'secondary'
      }
    })
    
    expect(wrapper.classes()).toContain('btn-secondary')
  })
  
  it('can be disabled', () => {
    const wrapper = mount(BaseButton, {
      props: {
        disabled: true
      }
    })
    
    expect(wrapper.attributes('disabled')).toBeDefined()
  })
  
  it('emits click event', async () => {
    const wrapper = mount(BaseButton)
    
    await wrapper.trigger('click')
    
    expect(wrapper.emitted('click')).toBeTruthy()
  })
})

4.2 测试组件交互 #

components/Counter.vue

vue
<template>
  <div>
    <span data-testid="count">{{ count }}</span>
    <button data-testid="increment" @click="increment">+</button>
    <button data-testid="decrement" @click="decrement">-</button>
  </div>
</template>

<script setup lang="ts">
const count = ref(0)

const increment = () => count.value++
const decrement = () => count.value--
</script>

components/Counter.test.ts

typescript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

describe('Counter', () => {
  it('displays initial count', () => {
    const wrapper = mount(Counter)
    
    expect(wrapper.find('[data-testid="count"]').text()).toBe('0')
  })
  
  it('increments count', async () => {
    const wrapper = mount(Counter)
    
    await wrapper.find('[data-testid="increment"]').trigger('click')
    
    expect(wrapper.find('[data-testid="count"]').text()).toBe('1')
  })
  
  it('decrements count', async () => {
    const wrapper = mount(Counter)
    
    await wrapper.find('[data-testid="decrement"]').trigger('click')
    
    expect(wrapper.find('[data-testid="count"]').text()).toBe('-1')
  })
})

4.3 测试Nuxt组件 #

typescript
import { describe, it, expect } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import MyComponent from './MyComponent.vue'

describe('MyComponent', () => {
  it('renders with Nuxt context', async () => {
    const wrapper = await mountSuspended(MyComponent, {
      route: '/test'
    })
    
    expect(wrapper.html()).toContain('Expected content')
  })
})

五、端到端测试 #

5.1 安装Playwright #

bash
pnpm add -D @playwright/test
npx playwright install

5.2 配置Playwright #

playwright.config.ts

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

export default defineConfig({
  testDir: './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'] }
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] }
    }
  ],
  
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI
  }
})

5.3 编写E2E测试 #

e2e/auth.spec.ts

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

test.describe('Authentication', () => {
  test('should login successfully', async ({ page }) => {
    await page.goto('/login')
    
    await page.fill('input[name="email"]', 'test@example.com')
    await page.fill('input[name="password"]', 'password123')
    await page.click('button[type="submit"]')
    
    await expect(page).toHaveURL('/dashboard')
    await expect(page.locator('h1')).toContainText('Dashboard')
  })
  
  test('should show error for invalid credentials', async ({ page }) => {
    await page.goto('/login')
    
    await page.fill('input[name="email"]', 'wrong@example.com')
    await page.fill('input[name="password"]', 'wrongpassword')
    await page.click('button[type="submit"]')
    
    await expect(page.locator('.error-message')).toBeVisible()
  })
})

5.4 测试页面导航 #

e2e/navigation.spec.ts

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

test.describe('Navigation', () => {
  test('should navigate to about page', async ({ page }) => {
    await page.goto('/')
    
    await page.click('a[href="/about"]')
    
    await expect(page).toHaveURL('/about')
    await expect(page.locator('h1')).toContainText('About')
  })
  
  test('should navigate using browser back button', async ({ page }) => {
    await page.goto('/')
    await page.goto('/about')
    
    await page.goBack()
    
    await expect(page).toHaveURL('/')
  })
})

5.5 测试API #

e2e/api.spec.ts

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

test.describe('API', () => {
  test('should fetch users', async ({ request }) => {
    const response = await request.get('/api/users')
    
    expect(response.ok()).toBeTruthy()
    
    const users = await response.json()
    expect(Array.isArray(users)).toBe(true)
  })
  
  test('should create user', async ({ request }) => {
    const response = await request.post('/api/users', {
      data: {
        name: 'Test User',
        email: 'test@example.com'
      }
    })
    
    expect(response.ok()).toBeTruthy()
    
    const user = await response.json()
    expect(user.name).toBe('Test User')
  })
})

六、测试覆盖率 #

6.1 配置覆盖率 #

vitest.config.ts

typescript
export default defineVitestConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        '.nuxt/',
        '**/*.test.ts',
        '**/*.spec.ts'
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80
      }
    }
  }
})

6.2 运行覆盖率 #

bash
npm run test:coverage

七、Mock和Stub #

7.1 Mock API #

typescript
import { describe, it, expect, vi } from 'vitest'

describe('API Mock', () => {
  it('should mock fetch', async () => {
    const mockData = { id: 1, name: 'Test' }
    
    global.$fetch = vi.fn().mockResolvedValue(mockData)
    
    const result = await $fetch('/api/test')
    
    expect(result).toEqual(mockData)
  })
})

7.2 Mock组合式函数 #

typescript
import { describe, it, expect, vi } from 'vitest'

vi.mock('#imports', () => ({
  useState: vi.fn((key, init) => ref(init()))
}))

describe('with mocked useState', () => {
  it('should work', () => {
    // Test code
  })
})

八、最佳实践 #

8.1 测试命名 #

typescript
describe('ComponentName', () => {
  describe('methodName', () => {
    it('should do something when condition', () => {
      // Test
    })
  })
})

8.2 测试结构 #

typescript
describe('Feature', () => {
  beforeEach(() => {
    // Setup
  })
  
  afterEach(() => {
    // Cleanup
  })
  
  it('should work', () => {
    // Arrange
    const input = 'test'
    
    // Act
    const result = process(input)
    
    // Assert
    expect(result).toBe('expected')
  })
})

8.3 测试数据 #

typescript
const createMockUser = (overrides = {}): User => ({
  id: 1,
  name: 'Test User',
  email: 'test@example.com',
  role: 'user',
  ...overrides
})

it('should work with mock user', () => {
  const user = createMockUser({ role: 'admin' })
  // Test
})

九、CI集成 #

9.1 GitHub Actions #

yaml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: pnpm/action-setup@v2
        with:
          version: 8
      
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      
      - run: pnpm install
      
      - run: pnpm test
      
      - run: pnpm test:e2e

十、总结 #

本章介绍了 Nuxt.js 测试策略:

  • Vitest 配置和单元测试
  • 组件测试
  • Playwright 端到端测试
  • 测试覆盖率
  • Mock 和 Stub
  • 最佳实践
  • CI 集成

测试是保证代码质量的重要手段,合理的测试策略可以显著提升开发效率。

最后更新:2026-03-28