前端测试

 

🚀 第一步:掌握核心概念

  1. 单元测试: 验证纯逻辑、工具函数、Hooks/Composables。不依赖 DOM。
  2. 组件测试: 验证组件的渲染输出、交互行为、Props/Events。模拟部分依赖。
  3. E2E 测试: 在真实浏览器中验证完整用户流程。最重要但最慢。
  4. 视觉回归测试: 自动对比 UI 截图,防止样式意外变更。

💡 2026 年黄金法则
优先编写 E2E 和组件测试,它们比单纯的单元测试更能发现真实问题。遵循 "Testing Trophy" 而非传统的测试金字塔。

🛠️ 第二步:选择主流框架(按优先级排序)

1. 单元测试 & 组件测试

框架适用场景2026 年状态推荐理由
Vitest Vite 项目 (Vue/React/Svelte)  行业标准 原生兼容 Vite,极速 HMR,API 兼容 Jest,配置几乎为零
Jest Webpack/Legacy 项目  维护模式 生态最大,但配置复杂,新项目不再推荐
React Testing Library React 组件测试  必学 强制你像用户一样测试(查文本/角色),而非测实现细节
Vue Test Utils Vue 组件测试 Vue 官方 配合 Vitest 使用,支持挂载、插槽、全局插件模拟

 

 

 

 

 

  

2. E2E 测试

框架特点推荐理由
Playwright 多浏览器、自动等待、Trace Viewer 2026 年首选。调试体验碾压级,支持移动端模拟、网络拦截、视觉对比
Cypress 时间旅行调试、丰富插件 社区庞大,上手简单,但在性能和跨标签页场景弱于 Playwright

 

 

 

3. 视觉回归 & 辅助测试

  • Chromatic / Percy: CI 中自动截图对比,集成 Storybook
  • axe-core / @axe-core/playwright: 自动化无障碍检测,确保符合 WCAG 标准

📚 第三步:实践

⚙️ 1. 快速搭建环境

# 核心依赖
npm install -D vitest @vue/test-utils happy-dom

# 可选但强烈推荐
npm install -D @testing-library/vue msw # 用户中心测试 + API Mock

vitest.config.ts 基础配置

import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'happy-dom', // 或 'jsdom'
    globals: true,            // 启用 describe/it/expect 全局API
    setupFiles: ['./src/test/setup.ts'], // 全局setup(如注册全局组件)
    coverage: {
      provider: 'v8',         // 推荐使用 v8 而非 istanbul
      reporter: ['text', 'html'],
    },
  },
})

package.json 基础配置

{
  "name": "my-vue3-project",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite","test": "vitest",
    "test:run": "vitest run",   //单元测试
    "test:coverage": "vitest run --coverage",
    "e2e": "playwright test",   //e2e测试
    "e2e:ui": "playwright test --ui",
    "e2e:view": "playwright test --headed"
  },"devDependencies": {"@vitejs/plugin-vue": "^5.2.1",
    "@vitest/coverage-v8": "^3.2.7",
    "@vue/test-utils": "^2.5.0",
    "fast-glob": "^3.3.3",
    "jsdom": "^29.1.1",
    "happy-dom": "^20.12.2",
    "less": "^4.3.0",
    "less-loader": "^12.3.0",
    "postcss-pxtorem": "^6.1.0",
    "typescript": "^5.3.3",
    "vite": "^6.2.0",
    "vite-plugin-svg-icons": "^2.0.1",
    "vitest": "^3.2.7"
  },
}

💻 2. 实战示例

单元测试 

使用框架:Vitest
说明:Vue + Vitest 是目前 Vue 生态测试的官方推荐组合。Vitest 由 Vite 团队打造,与 Vue 项目无缝集成,无需额外转换配置,且原生支持 ESM、TypeScript 和 JSX/TSX。

待测单元函数件说明:auth.ts文件

1、登录/鉴权相关接口白名单(无需触发刷新或注入鉴权)
2、使用精确匹配与简单模式匹配结合,保证灵活性
const exactPathnames: string[] = [
  '/sysuser/login/pwdAuth',
  '/sysuser/login/refresh/token',
  '/sysuser/login/getPubKey',
  '/sysuser/login/pageConfInfo',
  '/sysuser/login/dmpAuth',
  '/sysuser/user/getUserProfile',
  '/sysuser/menu/getRouters'
]

// 简易通配:以星号结尾表示前缀匹配
const prefixPatterns: string[] = [
  '/public/',
  '/sysuser/login/'
]

export const isAuthFree = (url: string | undefined): boolean => {
  if (!url) return false
  try {
    // 只基于路径匹配,忽略查询
    const pathname = url.split('?')[0]
    if (exactPathnames.includes(pathname)) return true
    for (const prefix of prefixPatterns) {
      if (pathname.startsWith(prefix)) return true
    }
    return false
  } catch {
    return false
  }
}

export default {
  isAuthFree
}

单元测试文件:authEndpoints.spec.ts(以spec.ts结尾,执行测试命令,会自动执行)

记住一句话:单元测试是验证函数的输入输出,你就会写测试用例了!

import { isAuthFree } from './authEndpoints';

describe('isAuthFree', () => {
  it('为空时,返回false', () => {
    expect(isAuthFree('')).toBe(false);
  });
  it('未定义时,返回false', () => {
    expect(isAuthFree(undefined)).toBe(false);
  });
  it('精确白名单中的用户资料接口视为免鉴权', () => {
    expect(isAuthFree('/sysuser/user/getUserProfile')).toBe(true);
  });
  it('登录路径前缀下的其它接口视为免鉴权', () => {
    expect(isAuthFree('/sysuser/login/validCodeAuth')).toBe(true);
  });
  it('/public/ 前缀视为免鉴权', () => {
    expect(isAuthFree('/public/captcha.png')).toBe(true);
  });
  it('忽略查询串,只按路径判断', () => {
    expect(isAuthFree('/sysuser/user/getUserProfile?redirect=/home')).toBe(
      true,
    );
  });
  it('普通业务接口需要鉴权', () => {
    expect(isAuthFree('/sysuser/device/list')).toBe(false);
  });
});

组件测试 :

 使用框架:Vitest+@vue/test-utils+happy-dom

说明:验证组件的渲染输出、交互行为、Props/Events。模拟部分依赖。
待测单元组件件说明:
SdtDeviceKeywordInput(批量查询抽屉组件,点击输入框,抽屉弹出,输入确定,带回输入框)

SdtDeviceKeywordInput/index.vue

<template>
  <div class="sdt-device-keyword-input" :style="wrapStyle">
    <el-input
      ref="singleInputRef"
      v-model="innerValue"
      :placeholder="triggerPlaceholder"
      :clearable="true"
      :disabled="disabled"
      class="sdt-device-keyword-input__trigger"
      @click="openDrawer"
      @clear="onClear"
    >
      <template #suffix>
        <el-icon class="sdt-device-keyword-input__search-icon">
          <Search />
        </el-icon>
      </template>
    </el-input>

    <el-drawer
      v-model="drawerVisible"
      :title="t('common.sdtDeviceKeywordDialogTitle')"
      :size="drawerSize"
      direction="rtl"
      append-to-body
      destroy-on-close
      class="sdt-device-keyword-drawer"
      @closed="onDrawerClosed"
    >
      <div
        class="drawer-resize-handle"
        :class="{ 'is-dragging': dragging }"
        @mousedown.prevent="onResizeStart"
      />
      <div class="sdt-device-keyword-drawer__body">
        <div class="sdt-device-keyword-drawer__mode">
          <span class="sdt-device-keyword-drawer__mode-label">
            {{ t('common.sdtDeviceKeywordModeLabel') }}
          </span>
          <el-radio-group v-model="draftMode" class="sdt-device-keyword-drawer__mode-radio">
            <el-radio
              v-for="item in modeOptions"
              :key="item.value"
              :value="item.value"
            >
              {{ t(item.labelKey) }}
            </el-radio>
          </el-radio-group>
        </div>
        <div class="sdt-device-keyword-drawer__textarea-wrap">
          <el-input
            ref="drawerTextareaRef"
            v-model="draftValue"
            type="textarea"
            :placeholder="drawerPlaceholder"
            :rows="drawerRows"
            :maxlength="maxlength"
            :clearable="clearable"
            resize="none"
            @keydown.enter.ctrl="onDrawerConfirm"
          />
        </div>
        <p class="sdt-device-keyword-drawer__hint">{{ tipText }}</p>
        <p class="sdt-device-keyword-drawer__count">{{ countText }}</p>
      </div>
      <template #footer>
        <el-button @click="onDrawerCancel">{{ t('common.cancel') }}</el-button>
        <el-button type="primary" @click="onDrawerConfirm">{{ t('common.confirm') }}</el-button>
      </template>
    </el-drawer>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import {
  validateSdtDeviceKeyword,
  parseSdtDeviceKeywords,
  getSdtDeviceKeywordMax,
  SDT_DEVICE_KEYWORD_MODE_DEFAULT,
  type SdtDeviceKeywordMode
} from '@/utils/sdtDeviceKeyword'
import { pxToRem } from '@/utils/remAdapter'
import { useResizableDrawer } from '@/composables/useResizableDrawer'

const props = withDefaults(
  defineProps<{
    modelValue?: string | null
    mode?: SdtDeviceKeywordMode
    width?: number | string
    placeholder?: string
    clearable?: boolean
    disabled?: boolean
    drawerRows?: number
    maxlength?: number
  }>(),
  {
    modelValue: '',
    mode: SDT_DEVICE_KEYWORD_MODE_DEFAULT,
    width: 240,
    clearable: true,
    disabled: false,
    drawerRows: 14,
    maxlength: 20000
  }
)

const emit = defineEmits<{
  'update:modelValue': [value: string]
  'update:mode': [mode: SdtDeviceKeywordMode]
  enter: []
  clear: []
}>()

const { t } = useI18n()
const { drawerSize, dragging, onResizeStart } = useResizableDrawer({
  defaultSize: '480px',
  minWidth: 400,
  direction: 'rtl'
})

const innerValue = ref(props.modelValue ?? '')
const innerMode = ref<SdtDeviceKeywordMode>(props.mode)
const draftValue = ref('')
const draftMode = ref<SdtDeviceKeywordMode>(SDT_DEVICE_KEYWORD_MODE_DEFAULT)
const modeOptions: { value: SdtDeviceKeywordMode; labelKey: string }[] = [
  { value: 'fuzzy', labelKey: 'common.sdtDeviceKeywordModeFuzzy' },
  { value: 'exact', labelKey: 'common.sdtDeviceKeywordModeExact' }
]
const drawerVisible = ref(false)
const singleInputRef = ref()
const drawerTextareaRef = ref()

watch(
  () => props.modelValue,
  (v) => {
    const next = v == null ? '' : String(v)
    if (next !== innerValue.value) {
      innerValue.value = next
    }
  },
  { immediate: true }
)

watch(
  () => props.mode,
  (v) => {
    const next = v ?? SDT_DEVICE_KEYWORD_MODE_DEFAULT
    if (next !== innerMode.value) {
      innerMode.value = next
    }
  },
  { immediate: true }
)

const triggerPlaceholder = computed(
  () => props.placeholder ?? t('common.sdtDeviceKeywordPh')
)

const drawerPlaceholder = computed(() => t('common.sdtDeviceKeywordDrawerPh'))

const currentMax = computed(() => getSdtDeviceKeywordMax(draftMode.value))

const keywordCount = computed(() => parseSdtDeviceKeywords(draftValue.value).length)

const tipText = computed(() => {
  const base = t('common.sdtDeviceKeywordTip')
  const maxTip = t('common.sdtDeviceKeywordModeMaxTip', { max: currentMax.value })
  return `${base}\n${maxTip}`
})

const countText = computed(() =>
  t('common.sdtDeviceKeywordCount', { count: keywordCount.value, max: currentMax.value })
)

const wrapStyle = computed(() => {
  const w = props.width
  if (w === undefined || w === null || w === '') {
    return { boxSizing: 'border-box' as const }
  }
  const widthVal = typeof w === 'number' ? pxToRem(w) : String(w)
  return { width: widthVal, maxWidth: widthVal, boxSizing: 'border-box' as const }
})

function openDrawer (): void {
  if (props.disabled) return
  draftValue.value = innerValue.value
  draftMode.value = innerMode.value
  drawerVisible.value = true
  nextTick(() => {
    const el = drawerTextareaRef.value as { focus?: () => void } | undefined
    el?.focus?.()
  })
}

function onDrawerClosed (): void {
  draftValue.value = innerValue.value
  draftMode.value = innerMode.value
}

function onDrawerCancel (): void {
  drawerVisible.value = false
}

function onDrawerConfirm (): void {
  const result = validateSdtDeviceKeyword(draftValue.value, draftMode.value)
  if (!result.valid) {
    ElMessage.warning(
      t('common.sdtDeviceKeywordMax', { max: result.max, count: result.count })
    )
    return
  }
  const next = draftValue.value.trim()
  innerValue.value = next
  innerMode.value = draftMode.value
  emit('update:modelValue', next)
  emit('update:mode', draftMode.value)
  drawerVisible.value = false
  emit('enter')
}

function onClear (): void {
  innerValue.value = ''
  draftValue.value = ''
  emit('update:modelValue', '')
  emit('clear')
}

defineExpose({
  focus: () => openDrawer()
})
</script>

image

测试用例文件:SdtDeviceKeywordInput/index.spec.ts

测试用例编写思路:
1、验证页面是否渲染正确:通过组件行为定义必须组件是否存在,比如输入框、确定按钮
2、组件配置属性有没有生效:props对组件行为的影响,比如disable模式,input输入框class里面有没有el-input--disable
3、交互行文是否正确:比如点击是否有没有出现弹窗

import { mount, type VueWrapper } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import ElementPlus, { ElMessage } from 'element-plus'
import SdtDeviceKeywordInput from './index.vue'

let wrapper: VueWrapper | undefined

function mountInput (props?: {
  modelValue?: string | null
  disabled?: boolean
}): VueWrapper {
  const i18n = createI18n({
    legacy: false,
    locale: 'zh-CN',
    messages: {
      'zh-CN': {
        common: {
          cancel: '取消',
          confirm: '确定',
          sdtDeviceKeywordPh: '多个BT/SN/DK,逗号分隔',
          sdtDeviceKeywordDrawerPh: '每行一个关键字',
          sdtDeviceKeywordTip: '可输入设备 BT、SN 或 DK',
          sdtDeviceKeywordCount: '{count}/{max}',
          sdtDeviceKeywordDialogTitle: '批量查询',
          sdtDeviceKeywordMax: '批量查询最多支持 {max} 个关键字,当前 {count} 个',
          sdtDeviceKeywordModeLabel: '匹配模式',
          sdtDeviceKeywordModeFuzzy: '模糊查询',
          sdtDeviceKeywordModeExact: '精准查询',
          sdtDeviceKeywordModeMaxTip: '当前模式建议不超过 {max} 个关键字',
        },
      },
    },
  })

  wrapper = mount(SdtDeviceKeywordInput, {
    props: {
      modelValue: '',
      ...props,
    },
    global: {
      plugins: [ElementPlus, i18n],
      stubs: {
        ElDrawer: {
          props: ['modelValue'],
          template: `
            <div v-if="modelValue">
              <slot />
              <slot name="footer" />
            </div>
          `,
        },
        ElIcon: true,
        Search: true,
      },
    },
  })
  return wrapper
}

function buttonByText (root: VueWrapper, text: string) {
  const found = root.findAll('button').find((btn) => btn.text().trim() === text)
  if (!found) {
    throw new Error(`未找到按钮「${text}」`)
  }
  return found
}

function isDrawerOpen (root: VueWrapper): boolean {
  return root.findAll('button').some((btn) => btn.text().trim() === '确定')
}

async function openDrawer (root: VueWrapper): Promise<void> {
  await root.get('.sdt-device-keyword-input__trigger .el-input__inner').trigger('click')
}

describe('SdtDeviceKeywordInput', () => {
  afterEach(() => {
    wrapper?.unmount()
    wrapper = undefined
    vi.restoreAllMocks()
    document.body.innerHTML = ''
  })

  it('禁用时点击输入框不会打开批量查询抽屉', async () => {
    const root = mountInput({ disabled: true })
    await openDrawer(root)
    expect(isDrawerOpen(root)).toBe(false)
  })

  it('确认合法关键字后会回写并通知父组件', async () => {
    const root = mountInput()
    await openDrawer(root)
    expect(isDrawerOpen(root)).toBe(true)

    await root.get('textarea').setValue('SN001, SN002')
    await buttonByText(root, '确定').trigger('click')

    expect(root.emitted('update:modelValue')?.[0]).toEqual(['SN001, SN002'])
    expect(root.emitted('update:mode')?.[0]).toEqual(['fuzzy'])
    expect(root.emitted('enter')).toHaveLength(1)
    expect(isDrawerOpen(root)).toBe(false)
  })

  it('切换精准查询后确认会回写 mode', async () => {
    const root = mountInput()
    await openDrawer(root)
    const exactRadio = root.findAll('.el-radio').find((el) => el.text().includes('精准查询'))
    if (!exactRadio) {
      throw new Error('未找到精准查询选项')
    }
    await exactRadio.get('input[type="radio"]').setValue(true)
    await root.get('textarea').setValue('SN001')
    await buttonByText(root, '确定').trigger('click')
    expect(root.emitted('update:mode')?.[0]).toEqual(['exact'])
  })

  it('取消编辑不会改掉已确认的关键字', async () => {
    const root = mountInput({ modelValue: 'SN001' })
    await openDrawer(root)
    await root.get('textarea').setValue('SHOULD-NOT-SAVE')
    await buttonByText(root, '取消').trigger('click')

    expect(root.emitted('update:modelValue')).toBeUndefined()
    expect(isDrawerOpen(root)).toBe(false)
  })

  it('清空输入会通知父组件', async () => {
    const root = mountInput({ modelValue: 'SN001' })
    await root.get('.sdt-device-keyword-input__trigger .el-input__inner').setValue('SN001')
    await root.get('.el-input__clear').trigger('click')
    expect(root.emitted('update:modelValue')?.[0]).toEqual([''])
    expect(root.emitted('clear')).toHaveLength(1)
  })

  it('超过模糊查询上限时提示且不提交', async () => {
    const warnSpy = vi.spyOn(ElMessage, 'warning').mockImplementation(() => ({
      close: () => {},
    }) as ReturnType<typeof ElMessage.warning>)
    const root = mountInput()
    await openDrawer(root)
    const overLimit = Array.from({ length: 501 }, (_, i) => `k${i}`).join(',')
    await root.get('textarea').setValue(overLimit)
    await buttonByText(root, '确定').trigger('click')

    expect(root.emitted('update:modelValue')).toBeUndefined()
    expect(isDrawerOpen(root)).toBe(true)
    expect(warnSpy).toHaveBeenCalledWith('批量查询最多支持 500 个关键字,当前 501 个')
  })
})

 

🎯  E2E 测试

 定义:E2E(End-to-End)测试,即“端到端测试”,是模拟真实用户在真实浏览器中操作你的应用,验证完整业务流程是否正确的测试方式。

 核心区别:E2E vs 组件测试:

维度组件测试 (Vitest)E2E 测试 (Playwright)
运行环境 Node.js + 模拟 DOM (happy-dom) 真实浏览器 (Chrome/Firefox/Safari)
测试范围 单个组件的渲染和交互 跨页面、跨组件的完整用户流程
后端依赖 全部 Mock 连接真实/测试后端 API
速度 ⚡ 毫秒级 🐢 秒级(启动浏览器+网络请求)
发现什么问题 逻辑错误、渲染异常 路由跳转失败、API 集成错误、登录态丢失、多标签页问题
核心价值 开发时快速反馈 上线前最后一道防线

 

 

 

 

 

 

💡 一句话理解
组件测试验证“零件好不好用”,E2E 测试验证“整辆车能不能从 A 开到 B”。

 

E2E 测试用例怎么写(以 Playwright + Vue 为例)

安装与初始化

npm init playwright@latest
# 选择 TypeScript,安装示例测试,生成 GitHub Actions 配置

一个完整的 E2E 测试用例

登录成功跳转到首页,登录失败弹窗提示错误

import { test, expect } from '@playwright/test'
import { openLoginPage, submitAccountLogin } from './helpers/login'

const LOGIN_USER = 'xxx'
const LOGIN_PASSWORD = 'xxxx'
const WRONG_PASSWORD = 'xxxx'

test.describe('登录页', () => {
  test('打开后能看到账号登录表单', async ({ page }) => {
    await page.goto('/login')

    await expect(page.getByPlaceholder('请输入用户名')).toBeVisible({ timeout: 30_000 })
    await expect(page.getByPlaceholder('请输入密码')).toBeVisible()
    await expect(page.getByRole('button', { name: '登录' })).toBeVisible()
  })

  test('账号密码正确时登录成功并进入首页', async ({ page }) => {
    test.setTimeout(60_000)
    await openLoginPage(page)
    await submitAccountLogin(page, LOGIN_USER, LOGIN_PASSWORD)

    await expect(page.locator('.el-message--success')).toContainText('登录成功', { timeout: 30_000 })
    await expect(page).not.toHaveURL(/\/login/, { timeout: 30_000 })
    await expect(page.locator('.layout-container')).toBeVisible()
  })

  test('密码错误时登录失败并留在登录页', async ({ page }) => {
    test.setTimeout(60_000)
    await openLoginPage(page)
    await submitAccountLogin(page, LOGIN_USER, WRONG_PASSWORD)

    await expect(page.locator('.el-message--error')).toBeVisible({ timeout: 30_000 })
    await expect(page).toHaveURL(/\/login/)
    await expect(page.getByPlaceholder('请输入用户名')).toBeVisible()
  })
})

E2E 测试用例设计清单:

写之前先回答这些问题:
  1. 这条路径是用户高频使用的吗? (登录、下单、发布内容 → 必测;修改头像 → 低优先级)
  2. 这条路径涉及多个服务/API 协作吗? (纯前端逻辑 → 组件测试够了)
  3. 这条路径出过线上 Bug 吗? (回归测试必须有)
  4. 这条路径有明确的“成功标志”吗? (能看到某个元素 / URL 变化 / Toast 提示)

 

📚 第四步:把它加入到ci流程里面

加入 CI 流程是 E2E 测试发挥真正价值的“分水岭”。如果只停留在本地跑,E2E 只能发挥 20% 的功力;接入 CI,才能发挥 100% 的价值

我们可以把 E2E 测试比作“一辆高性能汽车”,本地跑就像在自家院子里开,而加入 CI 则是把它开上了高速公路。

将 E2E 接入 CI(如 GitHub Actions, GitLab CI, Jenkins 等),本质上是把“人的自觉”变成了“机器的强制”。

维度只在本地跑加入 CI 流程(核心质变)
执行触发 靠开发者自觉、手动执行 强制触发:每次提交 PR/MR 自动运行
质量管控 自己保证自己,容易放水 硬性门禁:E2E 不飘绿,代码根本无法合并 (Merge)
运行环境 开发者各自的电脑(不可控) 标准化容器(如 Docker),环境 100% 纯净一致
问题发现时机 开发完、甚至发版前 代码审查阶段 (PR),把 Bug 扼杀在摇篮里
结果沉淀 跑完就没了,失败了只能自己看 自动归档:每次运行生成 HTML 报告、Trace 录像、截图,全员可见

 

 

 

 

 

 

1、核心价值:质量左移

在 CI 中跑 E2E 最大的意义是在代码合并到主干之前(PR 阶段)就拦截住破坏核心流程的代码。在 PR 阶段修复一个 Bug 的成本,只有上线后修复成本的 1/10 甚至 1/100。

2、具体实现:

核心做法是:分层并行、独立门禁、按需触发。如果把单元测试、组件测试和 E2E 测试揉成一个任务串行执行,你会亲手毁掉 E2E 的价值——因为反馈太慢,开发者会开始绕过它

分层并行 + 质量门禁:

PR 提交 / 代码合并
       │
       ├──► Job A: 单元测试 (Unit)      ──┐
       │     ⚡ 1-3 分钟                   │ 并行执行
       ├──► Job B: 组件测试 (Component)  ──┤ 互不阻塞
       │     ⚡ 2-5 分钟                   │
       │                                  ▼
       │                            ✅ 快速反馈门禁
       │                            (A/B 任一失败则阻断)
       │
       └──► Job C: E2E 测试 (Playwright) ──► 🔴 核心流程门禁
             🐢 5-15 分钟                    (C 失败则阻断合并)
  • 单元/组件测试是“秒级/分钟级”反馈,开发者可以高频运行,用于验证逻辑正确性。
  • E2E 测试是“分钟级/十分钟级”反馈,成本高、速度慢。
  • 如果串行:单元(3min) + 组件(5min) + E2E(10min) = 18 分钟。开发者提个 PR 要等 18 分钟才知道结果,体验极差,最终必然导致大家想办法跳过测试。
  • 如果并行:最长耗时 = max(3, 5, 10) = 10 分钟。且单元/组件 3-5 分钟就先出结果了,开发者能立刻修复低级错误,不用干等 E2E。

 3、智能触发:不是每次都要全量跑

根据变更范围动态调整,避免无效等待:
  • 只改了工具函数/纯逻辑 → 只跑单元测试
  • 只改了某个 UI 组件 → 跑单元测试 + 该组件的组件测试
  • 改了 API / 数据库 / 路由 / 权限 → 跑全部三层
  • 合并到主干 / 发版前 → 无条件跑全部三层
注:这可以通过 CI 的 paths-filter 或 Monorepo 工具(如 Nx, Turborepo)实现。

🎯 一句话总结

单元测试保“逻辑对”,组件测试保“界面好”,E2E 保“流程通”。三者在 CI 中并行跑、独立报、按需用,才是兼顾速度与质量的正确姿势。

 

📚 第五步:学习计划和达到的标准

  1. 第 1-2 周:Vitest + Testing Library
    • 学会 describe/it/expect 基本语法
    • 用 screen.getByRole/getByText 查询元素(永远不要用 CSS 选择器)
    • 练习测试自定义 Hook / Composable
    • 掌握 vi.mock() 和异步 waitFor
  2. 第 3-4 周:Playwright E2E
    • 安装并录制第一个测试脚本
    • 学习 Page Object Model 组织测试代码
    • 练习网络请求拦截 (page.route)
    • 使用 Trace Viewer 调试失败用例
  3. 第 5 周+:进阶与工程化
    • 配置 CI 中的测试流水线(GitHub Actions / GitLab CI)
    • 接入覆盖率报告并设置阈值
    • 学习 MSW 在服务端层面 Mock API(单元/E2E 通用)
    • 阅读 Testing JavaScript 或 Kent C. Dodds 的博客
 验证组件的渲染输出、交互行为、Props/Events。模拟部分依赖。
posted @ 2026-09-20 14:27  鸡腿太小  阅读(28)  评论(0)    收藏  举报