前端页面总是崩功能上线后一堆bug从零开始学web应用测试全流程实战指南
先说说那些崩溃的下午
你是不是也经历过这样的场景:周五下午五点,代码提交上线,然后微信群里消息炸了——”页面打不开了”、”点了按钮没反应”、”数据怎么全没了”。你慌慌张张打开电脑,发现明明本地跑得好好的,怎么一到生产环境就各种报错。
别慌,我不是来教你背测试理论的,我是来帮你把这套东西真正搞懂的。咱们先从最简单的问题开始:为什么代码在本地跑得好好的,一上线就崩?
理解前端崩溃的常见原因
先别急着学测试,你得知道自己在测试什么。前端页面崩溃最常见的几种情况,我先给你列出来:
1. 空指针或undefined导致的报错
// 这段代码看着没问题,对吧?
const userName = response.data.user.name;
// 但如果后端返回的是:
// { "data": {} } 或者 { "data": { "user": null } }
// 这行代码就直接报错:Cannot read properties of undefined (reading 'name')
这种问题在开发环境几乎不会出现,因为开发时你手动构造的测试数据都是完整的。但生产环境的数据千变万化,任何一个接口返回结构稍微变化,你的代码就直接GG。
2. 浏览器兼容性问题
// 用了Array.find()方法
const user = users.find(u => u.id === targetId);
// 这个方法在IE浏览器里是不支持的。
// 你的用户如果是国内某些企业内网,用的可能是IE,直接白屏。
3. 异步操作竞态条件
// 用户快速点击了三次"提交"按钮
async function handleSubmit() {
const response = await api.submit(formData);
showSuccess(response.data);
}
// 第一次请求还没返回,用户已经点了第二次、第三次
// 结果可能是:页面显示了三次"提交成功",或者数据被提交了三次
4. 内存泄漏导致页面卡死
// 监听事件忘了清除
useEffect(() => {
window.addEventListener('resize', handleResize);
// 忘记写清理函数!
}, []);
// 每次组件重新渲染都会新增一个监听器
// 用久了页面内存占用越来越大,最后浏览器直接崩溃
现在你知道前端为什么会崩了。接下来,我们聊聊怎么系统地避免这些问题。
测试金字塔:你应该测试什么
很多新手学测试,上来就写各种奇怪的测试框架,其实根本不用那么复杂。我们先理解一个核心概念:测试金字塔。
/\
/ \ E2E测试(端到端测试)
/----\ 数量:少,10-20个
/ \
/--------\ 集成测试
/ \ 数量:中等,50-100个
/------------\
/ \
/----------------\ 单元测试
数量:多,200+个
这个金字塔告诉我们要分层测试。你不需要在每个层级都投入同样的精力,底层(单元测试)应该投入最多,顶层(E2E测试)投入最少。
单元测试:基础中的基础
单元测试是测试金字塔的底部,也是最容易上手的一层。它测试的是单个函数或组件的逻辑,不依赖任何外部环境。
咱们用目前最流行的Vue 3 + TypeScript项目来举例,测试工具用Vitest(比Jest更快更现代)。
安装测试工具
# 创建项目时直接选Vitest
npm create vite@latest my-app -- --template vue-ts
cd my-app
npm install -D vitest @vue/test-utils vitest-fetch-mock
# 或者手动安装
npm install -D vitest
写第一个单元测试
假设我们有一个计算购物车总价的工具函数:
// src/utils/cart.ts
export interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
export function calculateTotal(items: CartItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
export function formatPrice(price: number): string {
return `¥${price.toFixed(2)}`;
}
然后我们写测试:
// src/utils/cart.test.ts
import { describe, it, expect } from 'vitest';
import { calculateTotal, formatPrice } from './cart';
describe('购物车工具函数', () => {
// 测试计算总价
it('应该正确计算单个商品的价格', () => {
const items = [{
id: '1',
name: '苹果',
price: 5.5,
quantity: 3
}];
expect(calculateTotal(items)).toBe(16.5);
});
it('应该正确计算多个商品的总价', () => {
const items = [
{ id: '1', name: '苹果', price: 5.5, quantity: 3 },
{ id: '2', name: '香蕉', price: 3.0, quantity: 5 },
{ id: '3', name: '橙子', price: 8.0, quantity: 2 }
];
// 苹果: 5.5 * 3 = 16.5
// 香蕉: 3.0 * 5 = 15.0
// 橙子: 8.0 * 2 = 16.0
// 总计: 47.5
expect(calculateTotal(items)).toBe(47.5);
});
it('空购物车应该返回0', () => {
expect(calculateTotal([])).toBe(0);
});
// 测试价格格式化
it('应该正确格式化价格', () => {
expect(formatPrice(10)).toBe('¥10.00');
expect(formatPrice(10.5)).toBe('¥10.50');
expect(formatPrice(0)).toBe('¥0.00');
});
});
运行测试:
npx vitest run
看到类似这样的输出就说明测试通过了:
✓ src/utils/cart.test.ts (4)
✓ 购物车工具函数 (4)
✓ 应该正确计算单个商品的价格
✓ 应该正确计算多个商品的总价
✓ 空购物车应该返回0
✓ 应该正确格式化价格
Test Files 1 passed (1)
Tests 4 passed (4)
是不是很简单?这就是单元测试——你写一个函数,然后写几个测试用例验证它的各种情况。
单元测试的最佳实践
一个测试只测一件事:别把一个测试写成大杂烩,这样出错时你才知道哪里出了问题。
测试边界情况:除了正常情况,还要测边界——空数组、极大值、极小值、null值等等。
测试命名要有意义:
it('应该正确计算总价')比it('test1')好一万倍。将来你看到测试失败,名字就能告诉你问题在哪。使用AAA模式:Arrange(准备)、Act(执行)、Assert(断言)。先把数据准备好,然后执行函数,最后检查结果。
组件测试:测试你的Vue/React组件
现在我们知道怎么测纯函数了,接下来测组件。组件测试比单元测试稍微复杂一点,因为它要模拟DOM环境。
Vue 3组件测试示例
假设我们有一个商品卡片组件:
<!-- src/components/ProductCard.vue -->
<template>
<div class="product-card" @click="handleClick">
<img :src="product.image" :alt="product.name" />
<h3>{{ product.name }}</h3>
<p class="price">{{ formatPrice(product.price) }}</p>
<button
v-if="product.inStock"
@click.stop="addToCart(product)"
>
加入购物车
</button>
<span v-else class="out-of-stock">缺货</span>
</div>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
interface Product {
id: string;
name: string;
price: number;
image: string;
inStock: boolean;
}
const props = defineProps<{
product: Product;
}>();
const emit = defineEmits<{
(e: 'add-to-cart', product: Product): void;
(e: 'click', product: Product): void;
}>();
function formatPrice(price: number): string {
return `¥${price.toFixed(2)}`;
}
function handleClick() {
emit('click', props.product);
}
function addToCart(product: Product) {
emit('add-to-cart', product);
}
</script>
然后写它的测试:
// src/components/ProductCard.test.ts
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import ProductCard from './ProductCard.vue';
describe('ProductCard组件', () => {
const mockProduct = {
id: '1',
name: 'iPhone 15',
price: 7999,
image: '/images/iphone15.jpg',
inStock: true
};
// 测试渲染
it('应该正确渲染商品信息', () => {
const wrapper = mount(ProductCard, {
props: { product: mockProduct }
});
expect(wrapper.find('h3').text()).toBe('iPhone 15');
expect(wrapper.find('.price').text()).toBe('¥7999.00');
expect(wrapper.find('img').attributes('src')).toBe('/images/iphone15.jpg');
});
// 测试有货时显示"加入购物车"按钮
it('有货时应该显示加入购物车按钮', () => {
const wrapper = mount(ProductCard, {
props: { product: mockProduct }
});
expect(wrapper.find('button').exists()).toBe(true);
expect(wrapper.find('button').text()).toBe('加入购物车');
expect(wrapper.find('.out-of-stock').exists()).toBe(false);
});
// 测试缺货时显示缺货标签
it('缺货时应该显示缺货标签', () => {
const outOfStockProduct = { ...mockProduct, inStock: false };
const wrapper = mount(ProductCard, {
props: { product: outOfStockProduct }
});
expect(wrapper.find('button').exists()).toBe(false);
expect(wrapper.find('.out-of-stock').text()).toBe('缺货');
});
// 测试点击商品触发click事件
it('点击商品应该触发click事件', async () => {
const wrapper = mount(ProductCard, {
props: { product: mockProduct }
});
await wrapper.find('.product-card').trigger('click');
expect(wrapper.emitted('click')).toHaveLength(1);
expect(wrapper.emitted('click')[0]).toEqual([mockProduct]);
});
// 测试点击加入购物车触发add-to-cart事件
it('点击加入购物车应该触发add-to-cart事件', async () => {
const wrapper = mount(ProductCard, {
props: { product: mockProduct }
});
await wrapper.find('button').trigger('click');
expect(wrapper.emitted('add-to-cart')).toHaveLength(1);
expect(wrapper.emitted('add-to-cart')[0]).toEqual([mockProduct]);
});
});
React组件测试示例
如果你用的是React,流程类似:
// src/components/ProductCard.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ProductCard from './ProductCard';
describe('ProductCard组件', () => {
const mockProduct = {
id: '1',
name: 'iPhone 15',
price: 7999,
image: '/images/iphone15.jpg',
inStock: true
};
it('应该正确渲染商品信息', () => {
render(<ProductCard product={mockProduct} />);
expect(screen.getByText('iPhone 15')).toBeInTheDocument();
expect(screen.getByText('¥7999.00')).toBeInTheDocument();
expect(screen.getByAltText('iPhone 15')).toHaveAttribute('src', '/images/iphone15.jpg');
});
it('有货时应该显示加入购物车按钮', () => {
render(<ProductCard product={mockProduct} />);
expect(screen.getByText('加入购物车')).toBeInTheDocument();
expect(screen.queryByText('缺货')).not.toBeInTheDocument();
});
it('点击加入购物车应该触发事件', () => {
const onAddToCart = vi.fn();
render(<ProductCard product={mockProduct} onAddToCart={onAddToCart} />);
fireEvent.click(screen.getByText('加入购物车'));
expect(onAddToCart).toHaveBeenCalledTimes(1);
expect(onAddToCart).toHaveBeenCalledWith(mockProduct);
});
});
网络请求测试:Mock API接口
前端开发中最头疼的问题之一就是依赖后端接口。有时候后端还没好,有时候测试环境不稳定。这时候就需要Mock——模拟API返回。
用Vitest Mock API
// src/services/productService.ts
export interface Product {
id: string;
name: string;
price: number;
inStock: boolean;
}
export async function fetchProducts(): Promise<Product[]> {
const response = await fetch('/api/products');
if (!response.ok) {
throw new Error('Failed to fetch products');
}
return response.json();
}
export async function addToCart(productId: string, quantity: number): Promise<{ success: boolean }> {
const response = await fetch('/api/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, quantity })
});
return response.json();
}
测试这个服务:
// src/services/productService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fetchProducts, addToCart } from './productService';
// Mock fetch
global.fetch = vi.fn();
describe('产品服务', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('应该成功获取产品列表', async () => {
const mockProducts = [
{ id: '1', name: 'iPhone 15', price: 7999, inStock: true },
{ id: '2', name: 'MacBook Pro', price: 14999, inStock: true }
];
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => mockProducts
} as Response);
const products = await fetchProducts();
expect(fetch).toHaveBeenCalledWith('/api/products');
expect(products).toEqual(mockProducts);
expect(products).toHaveLength(2);
});
it('接口失败时应该抛出错误', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 500
} as Response);
await expect(fetchProducts()).rejects.toThrow('Failed to fetch products');
});
it('应该成功添加商品到购物车', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ success: true })
} as Response);
const result = await addToCart('1', 2);
expect(fetch).toHaveBeenCalledWith('/api/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: '1', quantity: 2 })
});
expect(result).toEqual({ success: true });
});
});
MSW - 更优雅的Mock方式
对于更复杂的场景,推荐使用 MSW(Mock Service Worker)。它拦截网络请求并在浏览器层面模拟响应,更贴近真实环境。
npm install msw --save-dev
npx msw init public --save
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
export const handlers = [
http.get('/api/products', () => {
return HttpResponse.json([
{ id: '1', name: 'iPhone 15', price: 7999, inStock: true },
{ id: '2', name: 'MacBook Pro', price: 14999, inStock: false }
]);
}),
http.post('/api/cart', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ success: true, cartId: 'abc123' });
}),
http.get('/api/products/:id', ({ params }) => {
if (params.id === '999') {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json({
id: params.id,
name: 'Test Product',
price: 999,
inStock: true
});
})
];
export const server = setupServer(...handlers);
// src/products.test.ts
import { beforeAll, afterAll, afterEach, describe, it, expect } from 'vitest';
import { server } from './mocks/handlers';
beforeAll(() => server.listen());
afterEach(() => server.restoreHandlers());
afterAll(() => server.close());
describe('产品页面', () => {
it('应该渲染产品列表', async () => {
const response = await fetch('/api/products');
const products = await response.json();
expect(products).toHaveLength(2);
expect(products[0].name).toBe('iPhone 15');
});
});
集成测试:组件之间的协作
集成测试测试的是多个组件或服务一起工作时的表现。比如一个订单页面,它可能需要:
- 调用API获取订单详情
- 渲染多个子组件
- 处理用户交互
- 更新状态
Vue 3集成测试示例
<!-- src/views/OrderDetail.vue -->
<template>
<div class="order-detail">
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<template v-else>
<h2>订单号:{{ order.orderNo }}</h2>
<p>状态:{{ order.status }}</p>
<p>金额:{{ formatPrice(order.totalAmount) }}</p>
<ul>
<li v-for="item in order.items" :key="item.id">
{{ item.name }} × {{ item.quantity }} - {{ formatPrice(item.price) }}
</li>
</ul>
<button @click="handleCancel" :disabled="!canCancel">取消订单</button>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router';
interface OrderItem {
id: string;
name: string;
quantity: number;
price: number;
}
interface Order {
orderNo: string;
status: string;
totalAmount: number;
items: OrderItem[];
}
const route = useRoute();
const order = ref<Order | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
const canCancel = computed(() => {
return order.value?.status === 'pending';
});
async function fetchOrder() {
try {
loading.value = true;
const response = await fetch(`/api/orders/${route.params.id}`);
if (!response.ok) throw new Error('订单不存在');
order.value = await response.json();
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败';
} finally {
loading.value = false;
}
}
function formatPrice(amount: number): string {
return `¥${amount.toFixed(2)}`;
}
function handleCancel() {
// 调用取消订单API
fetch(`/api/orders/${route.params.id}/cancel`, { method: 'POST' })
.then(() => {
order.value!.status = 'cancelled';
});
}
onMounted(fetchOrder);
</script>
集成测试:
// src/views/OrderDetail.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import OrderDetail from './OrderDetail.vue';
// Mock fetch
global.fetch = vi.fn();
describe('订单详情页面', () => {
const mockOrder = {
orderNo: 'ORD2024001',
status: 'pending',
totalAmount: 15998,
items: [
{ id: '1', name: 'iPhone 15', quantity: 1, price: 7999 },
{ id: '2', name: 'AirPods Pro', quantity: 1, price: 1999 }
]
};
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/order/:id', component: OrderDetail }]
});
beforeEach(() => {
vi.clearAllMocks();
router.push('/order/123');
router.isReady();
});
it('应该显示加载状态', async () => {
// 让fetch永远不返回,模拟加载中
vi.mocked(fetch).mockImplementation(() => new Promise(() => {}));
const wrapper = mount(OrderDetail, {
global: { plugins: [router] }
});
await vi.waitFor(() => {
expect(wrapper.text()).toContain('加载中');
});
});
it('应该正确渲染订单信息', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => mockOrder
} as Response);
const wrapper = mount(OrderDetail, {
global: { plugins: [router] }
});
await vi.waitFor(() => {
expect(wrapper.text()).toContain('订单号:ORD2024001');
expect(wrapper.text()).toContain('¥15998.00');
expect(wrapper.text()).toContain('iPhone 15');
});
});
it('应该显示取消按钮当订单待处理时', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => mockOrder
} as Response);
const wrapper = mount(OrderDetail, {
global: { plugins: [router] }
});
await vi.waitFor(() => {
expect(wrapper.find('button').exists()).toBe(true);
expect(wrapper.find('button').text()).toBe('取消订单');
expect(wrapper.find('button').attributes('disabled')).toBeUndefined();
});
});
it('API请求失败时应该显示错误信息', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 404
} as Response);
const wrapper = mount(OrderDetail, {
global: { plugins: [router] }
});
await vi.waitFor(() => {
expect(wrapper.text()).toContain('订单不存在');
});
});
});
E2E测试:模拟真实用户行为
E2E测试是测试金字塔的顶端,它模拟真实用户在浏览器中操作整个应用。虽然写起来成本高,但能发现前面几层测试发现不了的问题。
Playwright:新一代E2E测试工具
Playwright是微软开发的,支持Chromium、Firefox、WebKit三个引擎,API设计很优雅。
npm install -D @playwright/test
npx playwright install
// tests/e2e/order.spec.ts
import { test, expect } from '@playwright/test';
test.describe('订单流程', () => {
test('应该能完成下单流程', async ({ page }) => {
// 1. 访问首页
await page.goto('http://localhost:5173');
// 2. 搜索商品
await page.getByPlaceholder('搜索商品').fill('iPhone');
await page.getByRole('button', { name: '搜索' }).click();
// 3. 选择第一个商品
await page.getByText('iPhone 15').first().click();
// 4. 添加到购物车
await page.getByRole('button', { name: '加入购物车' }).click();
await expect(page.getByText('已加入购物车')).toBeVisible();
// 5. 进入购物车
await page.getByRole('link', { name: '购物车' }).click();
// 6. 确认购物车商品
await expect(page.getByText('iPhone 15')).toBeVisible();
await expect(page.getByText('¥7999.00')).toBeVisible();
// 7. 点击结算
await page.getByRole('button', { name: '结算' }).click();
// 8. 填写收货地址
await page.getByLabel('收货人').fill('张三');
await page.getByLabel('手机号').fill('13800138000');
await page.getByLabel('地址').fill('北京市朝阳区xxx街道xxx号');
// 9. 提交订单
await page.getByRole('button', { name: '提交订单' }).click();
// 10. 确认支付成功
await expect(page.getByText('支付成功')).toBeVisible();
await expect(page.getByText(/订单号:/)).toBeVisible();
});
test('购物车为空时不能结算', async ({ page }) => {
await page.goto('http://localhost:5173/cart');
await expect(page.getByText('购物车是空的')).toBeVisible();
await expect(page.getByRole('button', { name: '结算' })).toBeDisabled();
});
});
Cypress:更直观的E2E工具
Cypress是另一个流行的E2E测试工具,它的特点是可以在测试中看到每一步的操作,就像在真实浏览器里操作一样。
npm install -D cypress
npx cypress open
// cypress/e2e/order.cy.ts
describe('订单流程测试', () => {
it('应该能完成完整下单流程', () => {
cy.visit('http://localhost:5173');
// 搜索
cy.get('[data-testid="search-input"]').type('iPhone');
cy.get('[data-testid="search-button"]').click();
// 选择商品
cy.contains('iPhone 15').first().click();
// 加入购物车
cy.get('[data-testid="add-to-cart"]').click();
cy.contains('已加入购物车').should('be.visible');
// 进入购物车
cy.visit('http://localhost:5173/cart');
// 结算
cy.get('[data-testid="checkout-button"]').click();
// 填写地址
cy.get('[data-testid="name-input"]').type('张三');
cy.get('[data-testid="phone-input"]').type('13800138000');
cy.get('[data-testid="address-input"]').type('北京市朝阳区xxx');
// 提交订单
cy.get('[data-testid="submit-order"]').click();
// 验证结果
cy.url().should('include', '/order-success');
cy.contains('支付成功').should('be.visible');
});
it('空购物车不能结算', () => {
cy.visit('http://localhost:5173/cart');
cy.contains('购物车是空的').should('be.visible');
cy.get('[data-testid="checkout-button"]').should('be.disabled');
});
});
性能测试:别让页面慢到崩溃
测试不仅仅是功能对不对,还有页面快不快。性能问题往往在开发环境发现不了,因为开发环境的测试数据量小、网络快。
Lighthouse:内置性能检测
Chrome浏览器自带Lighthouse,可以直接在DevTools里运行。
# 命令行方式运行
npx lighthouse http://localhost:5173 --output html --output-path ./lighthouse-report.html
重点关注的指标:
- FCP(First Contentful Paint):首次内容绘制,应该小于1.8秒
- LCP(Largest Contentful Paint):最大内容绘制,应该小于2.5秒
- FID(First Input Delay):首次输入延迟,应该小于100ms
- CLS(Cumulative Layout Shift):累计布局偏移,应该小于0.1
Web Vitals:运行时性能监控
在代码中加入性能监控,可以实时发现问题:
// src/performance.ts
import { onLCP, onFID, onCLS } from 'web-vitals';
export function reportWebVitals() {
onLCP(({ value }) => {
console.log('LCP:', value);
if (value > 2500) {
// 性能不达标,上报监控
sendPerformanceMetric('lcp', value);
}
});
onFID(({ value }) => {
console.log('FID:', value);
if (value > 100) {
sendPerformanceMetric('fid', value);
}
});
onCLS(({ value }) => {
console.log('CLS:', value);
if (value > 0.1) {
sendPerformanceMetric('cls', value);
}
});
}
function sendPerformanceMetric(type: string, value: number) {
// 上报到你的性能监控平台
navigator.sendBeacon('/api/performance', JSON.stringify({ type, value }));
}
自动化性能测试
// tests/performance.spec.ts
import { test, expect } from '@playwright/test';
test('首页加载性能', async ({ page }) => {
const startTime = Date.now();
await page.goto('http://localhost:5173');
await page.waitForLoadState('networkidle');
const loadTime = Date.now() - startTime;
// 页面加载时间应该小于3秒
expect(loadTime).toBeLessThan(3000);
// 截图验证布局
await expect(page).toHaveScreenshot('home-page.png');
});
test('关键指标检查', async ({ page }) => {
await page.goto('http://localhost:5173');
// 等待LCP元素出现
const lcpElement = await page.locator('main > *:first-child');
await lcpElement.waitFor();
// 获取性能指标
const performance = await page.evaluate(() => {
const entries = performance.getEntriesByType('paint');
const lcp = performance.getEntriesByType('largest-contentful-paint');
return {
fcp: entries.find(e => e.name === 'first-contentful-paint')?.startTime,
lcp: lcp.length > 0 ? lcp[lcp.length - 1].startTime : null
};
});
expect(performance.fcp).toBeLessThan(1800);
expect(performance.lcp).toBeLessThan(2500);
});
兼容性测试:别让浏览器成为问题
不同浏览器、不同设备之间的差异是前端崩溃的常见原因。
BrowserStack / Sauce Labs:云端跨浏览器测试
这些服务让你可以在各种真实设备和浏览器上运行测试,不用自己买那么多设备。
手动兼容性检查清单
如果你没有预算用专业工具,至少要保证这些基础兼容性:
// 在CSS中加入浏览器前缀
// autoprefixer会自动处理,但你要确保配置正确
// postcss.config.js
module.exports = {
plugins: {
autoprefixer: {
overrideBrowserslist: [
'last 2 versions',
'> 1%',
'not ie <= 11', // 国内还要考虑IE用户
'safari >= 12',
'Android >= 5'
]
}
}
};
响应式测试
// tests/responsive.spec.ts
import { test, expect } from '@playwright/test';
const viewports = [
{ name: 'iPhone SE', width: 375, height: 667 },
{ name: 'iPhone 14 Pro Max', width: 430, height: 932 },
{ name: 'iPad', width: 768, height: 1024 },
{ name: 'Desktop', width: 1920, height: 1080 }
];
viewports.forEach(({ name, width, height }) => {
test(`${name} 响应式布局`, async ({ page }) => {
await page.setViewportSize({ width, height });
await page.goto('http://localhost:5173');
// 截图对比
await expect(page).toHaveScreenshot(`${name}.png`);
// 基本功能检查
await expect(page.locator('header')).toBeVisible();
await expect(page.locator('main')).toBeVisible();
});
});
错误监控:线上出了问题怎么办
即使测试做得再好,也总会有一些漏网之鱼。这时候你需要错误监控系统来第一时间发现问题。
Sentry:最流行的错误监控
npm install @sentry/browser
// src/main.ts
import * as Sentry from '@sentry/browser';
Sentry.init({
dsn: 'https://your-dsn@sentry.io/your-project',
environment: import.meta.env.MODE,
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration() // 用户操作录屏,方便复现问题
],
tracesSampleRate: 1.0,
replaysSessionSampleRate: 0.1, // 10%的会话会录制
});
createApp(App).use(router).mount('#app');
手动捕获错误:
import * as Sentry from '@sentry/browser';
try {
await api.submitOrder(orderData);
} catch (error) {
Sentry.captureException(error, {
tags: { feature: 'checkout' },
user: { id: currentUser.id },
extra: { orderId: orderData.id }
});
throw error;
}
日志追踪
// 统一的日志工具
class Logger {
static info(message: string, context?: Record<string, any>) {
console.log(`[INFO] ${message}`, context);
// 上报到日志平台
sendToLoggingService('info', message, context);
}
static error(message: string, error?: Error, context?: Record<string, any>) {
console.error(`[ERROR] ${message}`, error, context);
// 上报到错误监控
Sentry.captureException(error, {
tags: { level: 'error' },
extra: context
});
sendToLoggingService('error', message, { ...context, error: error?.message });
}
}
// 使用
async function checkout() {
Logger.info('开始结算流程', { userId: currentUser.id });
try {
const result = await api.createOrder(orderData);
Logger.info('订单创建成功', { orderId: result.id });
return result;
} catch (error) {
Logger.error('订单创建失败', error as Error, {
orderId: orderData.id,
amount: orderData.totalAmount
});
throw error;
}
}
测试驱动开发:先写测试再写代码
TDD(Test-Driven Development)是一种开发方法论:先写测试,然后写代码让测试通过。虽然刚开始觉得慢,但长期来看能大幅减少bug。
TDD循环
1. 写一个失败的测试(红)
2. 写最少的代码让测试通过(绿)
3. 重构代码(重构)
4. 重复
实际例子
假设我们要写一个价格计算工具:
第一步:写失败的测试
// src/utils/price.test.ts
import { describe, it, expect } from 'vitest';
import { calculateFinalPrice } from './price';
describe('价格计算', () => {
it('应该计算折后价格', () => {
expect(calculateFinalPrice(100, 0.8)).toBe(80);
});
});
这时候运行测试会失败,因为calculateFinalPrice还不存在。
第二步:写代码让测试通过
// src/utils/price.ts
export function calculateFinalPrice(originalPrice: number, discount: number): number {
return originalPrice * discount;
}
测试通过了。
第三步:添加更多测试用例
it('折扣为1时价格不变', () => {
expect(calculateFinalPrice(100, 1)).toBe(100);
});
it('折扣为0时价格为0', () => {
expect(calculateFinalPrice(100, 0)).toBe(0);
});
it('应该处理边界情况', () => {
expect(calculateFinalPrice(0, 0.5)).toBe(0);
expect(calculateFinalPrice(100, 1.5)).toBe(150); // 可能是溢价
});
持续集成:让测试自动运行
写好的测试不能只靠手动运行,要集成到CI/CD流程中,每次提交代码都自动运行。
GitHub Actions配置
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run test:unit
- run: npm run test:e2e
env:
CI: true
测试覆盖率
让团队知道哪些代码有测试覆盖,哪些没有:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
}
}
});
运行后会在coverage/目录生成详细的覆盖率报告:
-------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-------------------|---------|----------|---------|---------|
src/utils/ | 100 | 100 | 100 | 100 |
cart.ts | 100 | 100 | 100 | 100 |
price.ts | 100 | 100 | 100 | 100 |
src/components/ | 95 | 85 | 90 | 95 |
ProductCard.vue | 100 | 100 | 100 | 100 |
OrderDetail.vue | 85 | 70 | 80 | 85 |
-------------------|---------|----------|---------|---------|
常见bug场景与测试策略
我把最常见的几类bug场景和对应的测试策略给你整理出来:
1. 接口数据为空导致的崩溃
场景:后端返回的数据结构和前端预期不一致。
测试策略:
// 测试数据缺失的情况
it('接口返回空数据时不应该崩溃', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ data: null })
} as Response);
const wrapper = mount(OrderDetail, {
global: { plugins: [router] }
});
// 应该显示错误信息,而不是白屏
await vi.waitFor(() => {
expect(wrapper.text()).toContain('暂无数据');
});
});
2. 用户快速操作导致的竞态
场景:用户快速点击多次提交按钮。
测试策略:
it('快速点击应该只提交一次', async () => {
const submitFn = vi.fn().mockResolvedValue({ success: true });
vi.mocked(fetch).mockImplementation(submitFn);
const wrapper = mount(OrderDetail, {
global: { plugins: [router] }
});
// 快速点击三次
await wrapper.find('button').trigger('click');
await wrapper.find('button').trigger('click');
await wrapper.find('button').trigger('click');
// 应该只调用一次
expect(submitFn).toHaveBeenCalledTimes(1);
});
3. 内存泄漏
测试策略:
it('组件卸载后应该清理所有监听', async () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const wrapper = mount(SomeComponent);
// 触发某些操作
await wrapper.find('input').trigger('input');
// 卸载组件
wrapper.unmount();
// 应该调用removeEventListener
expect(removeEventListenerSpy).toHaveBeenCalled();
addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
});
4. 样式兼容性
测试策略:
it('不同浏览器下布局应该正常', async ({ page }) => {
await page.goto('http://localhost:5173');
// 检查关键元素是否在正确位置
const header = page.locator('header');
const main = page.locator('main');
await expect(header).toBeAttached();
await expect(main).toBeAttached();
// 截图对比
await expect(page).toHaveScreenshot();
});
测试维护:让测试长期有效
写好测试只是第一步,更重要的是让测试长期有效、易于维护。
给测试取好名字
// ❌ 差的命名
it('test1')
it('should work')
it('check')
// ✅ 好的命名
it('应该显示加载状态当数据正在获取时')
it('应该禁用提交按钮当表单无效时')
it('应该显示错误信息当API请求失败时')
分离测试数据和测试逻辑
// 把测试数据抽出来
const TEST_ORDERS = [
{
id: '1',
status: 'pending',
totalAmount: 100,
items: [{ name: '商品A', quantity: 1, price: 100 }]
},
{
id: '2',
status: 'shipped',
totalAmount: 200,
items: [{ name: '商品B', quantity: 2, price: 100 }]
}
];
describe('订单状态展示', () => {
it('待处理订单应该显示取消按钮', () => {
render(<OrderCard order={TEST_ORDERS[0]} />);
expect(screen.getByText('取消订单')).toBeInTheDocument();
});
});
避免脆弱的测试
// ❌ 脆弱的测试 - 依赖具体实现细节
it('应该有一个div包含类名product-card', () => {
const wrapper = mount(ProductCard);
expect(wrapper.find('.product-card').exists()).toBe(true);
});
// ✅ 稳定的测试 - 测试用户可见的行为
it('应该渲染商品名称', () => {
render(<ProductCard product={mockProduct} />);
expect(screen.getByText('iPhone 15')).toBeInTheDocument();
});
从零开始的实战路径
如果你现在完全是零基础,我建议你按照这个顺序来:
第一阶段:理解概念(1-2天)
- 了解测试金字塔
- 明白为什么要写测试
- 学会写最简单的单元测试
第二阶段:动手实践(1-2周)
- 在你的项目里安装Vitest
- 给现有的工具函数写测试
- 给核心组件写测试
- 遇到报错就解决,边做边学
第三阶段:深入提升(2-4周)
- 学习Mock技术
- 写集成测试
- 配置CI/CD
- 添加错误监控
第四阶段:持续优化(持续)
- 建立测试规范
- 定期Review测试代码
- 监控测试覆盖率
- 根据bug情况补充测试
一些实用的建议
不要追求100%覆盖率:80%左右的覆盖率就很好了,剩下的10-20%可能是难以测试的边缘情况或者第三方库的代码。
测试是投资,不是成本:一开始写测试会慢一点,但长期来看能节省大量调试bug的时间。
测试代码也是代码:要像写业务代码一样写测试代码——要有清晰的命名、良好的结构、适当的注释。
从核心逻辑开始:不要一上来就测UI组件,先测那些最容易出bug的纯函数和业务逻辑。
和开发流程结合:把测试作为代码合并的前置条件,没通过测试的代码不允许上线。
定期重构测试:测试代码也会腐化,定期清理过时的测试,补充新的测试。
总结
前端测试不是一蹴而就的事情,它需要学习和实践。但只要你按照上面的步骤一步一步来,从单元测试开始,逐渐扩展到组件测试、集成测试、E2E测试,你会发现:
- 上线后崩溃的情况会大幅减少
- 改代码时更有底气,不用担心改坏了什么
- 团队协作更顺畅,测试文档就是你的API文档
- 用户满意度提升,因为产品更稳定了
记住,测试的终极目标不是”通过所有测试”,而是”写出更可靠的代码”。测试是你写代码时的思考伙伴,帮你发现那些容易被忽略的边界情况。
现在就开始吧!选一个你最熟悉的功能,写一个测试,感受一下测试带来的安心感。当你看到绿色的”Passed”时,那种满足感是无可替代的。
