微前端进阶(三)
从单元测试到 E2E,构建微前端架构下的完整质量保障体系
目录
- 微前端测试挑战与策略总览
- 单元测试:子应用独立测试
- [组件测试:UI 组件与共享组件](#组件测试:UI 组件与共享组件)
- 集成测试:跨子应用交互测试
- [E2E 测试:全链路用户场景](#E2E 测试:全链路用户场景)
- 契约测试:子应用接口契约
- [测试基础设施与 Mock 策略](#测试基础设施与 Mock 策略)
- [质量门禁与 CI 集成](#质量门禁与 CI 集成)
一、微前端测试挑战与策略总览
1.1 微前端测试的核心挑战
┌─────────────────────────────────────────────────────────────┐
│ 微前端测试的核心挑战 │
├─────────────────────────────────────────────────────────────┤
│ 挑战 1:多技术栈 │
│ 基座是 Vue3,子应用可能是 React/Angular,跨框架测试困难 │
├─────────────────────────────────────────────────────────────┤
│ 挑战 2:运行时集成 │
│ 子应用在运行时动态加载,无法在构建时静态分析全部交互路径 │
├─────────────────────────────────────────────────────────────┤
│ 挑战 3:隔离环境 │
│ JS 沙箱和样式隔离使测试环境与生产环境存在差异 │
├─────────────────────────────────────────────────────────────┤
│ 挑战 4:跨应用通信 │
│ 子应用间通过 EventBus/GlobalState 通信,链路追踪困难 │
├─────────────────────────────────────────────────────────────┤
│ 挑战 5:依赖共享 │
│ 公共依赖通过 CDN/Module Federation 共享,版本一致性需验证 │
├─────────────────────────────────────────────────────────────┤
│ 挑战 6:独立部署 │
│ 各子应用独立发布版本,回归测试范围难以确定 │
└─────────────────────────────────────────────────────────────┘
1.2 测试金字塔(微前端版)
┌──────────────────────┐
│ E2E 测试 (5%) │ 全链路用户场景
│ Cypress / Playwright │ 跨子应用流程
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ 集成测试 (15%) │ 跨应用交互
│ qiankun 沙箱内测试 │ 通信机制
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ 契约测试 (10%) │ 子应用接口契约
│ Pact / MSW │ 公共依赖兼容性
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ 组件测试 (20%) │ 共享组件
│ Vitest / Testing Lib│ UI 组件
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ 单元测试 (50%) │ 工具函数
│ Vitest / Jest │ Store / Hooks
│ │ API Service
└──────────────────────┘
1.3 各层测试责任矩阵
| 测试类型 | 测试对象 | 运行时机 | 执行环境 | 责任人 |
|---|---|---|---|---|
| 单元测试 | 工具函数、Store、API Service | 每次 commit | CI | 子应用团队 |
| 组件测试 | UI 组件、共享组件 | 每次 commit | CI | 子应用团队 |
| 契约测试 | 子应用接口、公共依赖 | PR 时 | CI | 子应用团队 |
| 集成测试 | 基座+子应用交互、通信 | 每日构建 | CI | 平台团队 |
| E2E 测试 | 全链路用户场景 | 预发布 | Staging | QA 团队 |
二、单元测试:子应用独立测试
2.1 测试环境搭建
bash
# 使用 Vitest(推荐,与 Vite 原生集成)
pnpm add -D vitest @vue/test-utils jsdom
typescript
// vite.config.ts(子应用)
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
// 测试环境
environment: 'jsdom',
// 全局 API 模拟
globals: true,
// 测试文件匹配
include: ['src/**/*.{test,spec}.{ts,tsx}'],
// 覆盖率
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{ts,vue}'],
exclude: [
'src/main.ts',
'src/public-path.ts',
'src/**/*.d.ts',
'src/**/*.{test,spec}.ts'
],
thresholds: {
statements: 80,
branches: 70,
functions: 80,
lines: 80
}
},
// 全局 setup
setupFiles: ['./src/__tests__/setup.ts'],
// 模块别名
resolve: {
alias: {
'@': '/src'
}
}
}
});
typescript
// src/__tests__/setup.ts
// 测试全局 setup:模拟 qiankun 环境变量
import { vi } from 'vitest';
// 模拟 qiankun 全局变量
Object.defineProperty(window, '__POWERED_BY_QIANKUN__', {
value: false,
writable: true
});
Object.defineProperty(window, '__INJECTED_PUBLIC_PATH_BY_QIANKUN__', {
value: '',
writable: true
});
// 模拟 matchMedia
Object.defineProperty(window, 'matchMedia', {
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
});
// 模拟 IntersectionObserver
class MockIntersectionObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
Object.defineProperty(window, 'IntersectionObserver', {
value: MockIntersectionObserver
});
2.2 工具函数测试
typescript
// src/utils/__tests__/helpers.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate, debounce, deepClone, parseQueryString } from '../helpers';
describe('formatDate', () => {
it('应正确格式化日期', () => {
const date = new Date('2024-01-15 10:30:00');
expect(formatDate(date, 'YYYY-MM-DD')).toBe('2024-01-15');
expect(formatDate(date, 'YYYY/MM/DD HH:mm')).toBe('2024/01/15 10:30');
expect(formatDate(date, 'MM-DD')).toBe('01-15');
});
it('应处理边界情况', () => {
expect(formatDate(null, 'YYYY-MM-DD')).toBe('');
expect(formatDate(undefined, 'YYYY-MM-DD')).toBe('');
expect(formatDate(new Date('invalid'), 'YYYY-MM-DD')).toBe('');
});
});
describe('debounce', () => {
it('应在延迟后执行函数', async () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced();
debounced();
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it('应传递正确的参数', async () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced('arg1', 'arg2');
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledWith('arg1', 'arg2');
vi.useRealTimers();
});
});
describe('deepClone', () => {
it('应深度克隆对象', () => {
const original = { a: 1, b: { c: 2, d: [3, 4] } };
const cloned = deepClone(original);
expect(cloned).toEqual(original);
expect(cloned).not.toBe(original);
expect(cloned.b).not.toBe(original.b);
expect(cloned.b.d).not.toBe(original.b.d);
});
it('应处理循环引用', () => {
const obj: any = { a: 1 };
obj.self = obj;
expect(() => deepClone(obj)).not.toThrow();
});
});
describe('parseQueryString', () => {
it('应正确解析查询字符串', () => {
const result = parseQueryString('?name=test&age=25&page=1');
expect(result).toEqual({ name: 'test', age: '25', page: '1' });
});
it('应处理空值', () => {
expect(parseQueryString('')).toEqual({});
expect(parseQueryString('?')).toEqual({});
});
it('应解码 URL 编码', () => {
const result = parseQueryString('?q=%E6%B5%8B%E8%AF%95&redirect=http%3A%2F%2Fexample.com');
expect(result).toEqual({ q: '测试', redirect: 'http://example.com' });
});
});
2.3 Store 测试
typescript
// src/stores/__tests__/user.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useUserStore } from '../user';
import { request } from '@/utils/request';
// Mock axios 请求
vi.mock('@/utils/request', () => ({
request: {
get: vi.fn(),
post: vi.fn()
}
}));
describe('useUserStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('应初始化用户信息为 null', () => {
const store = useUserStore();
expect(store.userInfo).toBeNull();
expect(store.token).toBe('');
expect(store.isLoggedIn).toBe(false);
});
it('login 应更新 token 和用户信息', async () => {
const mockLoginResult = {
accessToken: 'mock-token-123',
refreshToken: 'mock-refresh-token',
expiresIn: 7200,
tokenType: 'Bearer'
};
const mockUserInfo = {
id: '1',
name: '张三',
email: 'zhangsan@company.com',
roles: ['admin'],
permissions: ['dashboard:view', 'report:export']
};
(request.post as any).mockResolvedValueOnce(mockLoginResult);
(request.get as any).mockResolvedValueOnce(mockUserInfo);
const store = useUserStore();
await store.login({ username: 'admin', password: '123456' });
expect(store.token).toBe('mock-token-123');
expect(store.userInfo).toEqual(mockUserInfo);
expect(store.isLoggedIn).toBe(true);
});
it('login 失败应抛出错误且不更新状态', async () => {
(request.post as any).mockRejectedValueOnce(new Error('密码错误'));
const store = useUserStore();
await expect(
store.login({ username: 'admin', password: 'wrong' })
).rejects.toThrow('密码错误');
expect(store.token).toBe('');
expect(store.userInfo).toBeNull();
expect(store.isLoggedIn).toBe(false);
});
it('logout 应重置状态', () => {
const store = useUserStore();
store.$patch({
token: 'mock-token',
userInfo: { id: '1', name: '张三', roles: [] }
});
store.logout();
expect(store.token).toBe('');
expect(store.userInfo).toBeNull();
expect(store.isLoggedIn).toBe(false);
});
});
2.4 组合式函数(Composables)测试
typescript
// src/composables/__tests__/usePermission.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { usePermissionStore } from '@/stores/permission';
import { usePermission } from '../usePermission';
describe('usePermission', () => {
beforeEach(() => {
setActivePinia(createPinia());
const store = usePermissionStore();
store.permissions = ['user:view', 'report:export', 'system:config'];
store.roles = ['editor'];
});
describe('hasPermission', () => {
it('应返回 true 当用户有所需权限', () => {
const { hasPermission } = usePermission();
expect(hasPermission('report:export')).toBe(true);
});
it('应返回 false 当用户没有所需权限', () => {
const { hasPermission } = usePermission();
expect(hasPermission('user:manage')).toBe(false);
});
it('超级管理员应有所有权限', () => {
const store = usePermissionStore();
store.roles = ['super_admin'];
const { hasPermission } = usePermission();
expect(hasPermission('any:permission')).toBe(true);
});
});
describe('hasAnyPermission', () => {
it('有任一权限应返回 true', () => {
const { hasAnyPermission } = usePermission();
expect(hasAnyPermission(['user:manage', 'report:export'])).toBe(true);
});
it('没有任何权限应返回 false', () => {
const { hasAnyPermission } = usePermission();
expect(hasAnyPermission(['user:manage', 'admin:access'])).toBe(false);
});
});
describe('filterMenusByPermission', () => {
it('应过滤掉无权限的菜单', () => {
const { filterMenusByPermission } = usePermission();
const menus = [
{ path: '/users', title: '用户管理', permissions: ['user:view'] },
{ path: '/admin', title: '管理员', permissions: ['admin:access'] },
{ path: '/dashboard', title: '工作台' }
];
const result = filterMenusByPermission(menus);
expect(result).toHaveLength(2);
expect(result.find(m => m.path === '/admin')).toBeUndefined();
});
});
});
三、组件测试:UI 组件与共享组件
3.1 Vue 组件测试
typescript
// src/components/__tests__/ThemeToggle.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import ThemeToggle from '@/components/ThemeToggle.vue';
describe('ThemeToggle', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('应渲染主题切换按钮', () => {
const wrapper = mount(ThemeToggle);
expect(wrapper.find('.el-button').exists()).toBe(true);
});
it('点击应弹出主题选项', async () => {
const wrapper = mount(ThemeToggle);
await wrapper.find('.el-button').trigger('click');
// 检查下拉菜单出现
expect(document.body.querySelector('.el-dropdown-menu')).toBeTruthy();
});
it('选择主题色应触发 store 更新', async () => {
const wrapper = mount(ThemeToggle);
await wrapper.find('.el-button').trigger('click');
// 模拟点击颜色选择
const colorItems = document.body.querySelectorAll('.el-dropdown-menu .el-dropdown-menu__item');
if (colorItems.length > 0) {
(colorItems[1] as HTMLElement).click();
}
const themeStore = useThemeStore();
expect(themeStore.color).toBe('green');
});
});
3.2 React 组件测试
typescript
// sub-app-react/src/components/__tests__/Button.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '../Button';
describe('Button 组件', () => {
it('应正确渲染文本', () => {
render(<Button label="提交" />);
expect(screen.getByText('提交')).toBeInTheDocument();
});
it('点击应触发 onClick', async () => {
const onClick = vi.fn();
render(<Button label="提交" onClick={onClick} />);
await userEvent.click(screen.getByText('提交'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('不同 variant 应应用不同样式', () => {
const { rerender } = render(<Button label="测试" variant="primary" />);
const button = screen.getByText('测试');
expect(button.className).toContain('btn-primary');
rerender(<Button label="测试" variant="danger" />);
expect(button.className).toContain('btn-danger');
});
it('disabled 时不应触发 onClick', async () => {
const onClick = vi.fn();
render(<Button label="提交" disabled onClick={onClick} />);
await userEvent.click(screen.getByText('提交'));
expect(onClick).not.toHaveBeenCalled();
});
});
3.3 共享组件测试
typescript
// @company/ui/src/__tests__/DataTable.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DataTable } from '../DataTable';
const mockColumns = [
{ key: 'name', title: '姓名' },
{ key: 'age', title: '年龄' },
{ key: 'email', title: '邮箱' }
];
const mockData = [
{ id: 1, name: '张三', age: 28, email: 'zhang@example.com' },
{ id: 2, name: '李四', age: 32, email: 'li@example.com' },
{ id: 3, name: '王五', age: 25, email: 'wang@example.com' }
];
describe('DataTable', () => {
it('应正确渲染表头和数据行', () => {
render(<DataTable columns={mockColumns} dataSource={mockData} />);
// 检查表头
expect(screen.getByText('姓名')).toBeInTheDocument();
expect(screen.getByText('年龄')).toBeInTheDocument();
expect(screen.getByText('邮箱')).toBeInTheDocument();
// 检查数据行数
const rows = screen.getAllByRole('row');
expect(rows).toHaveLength(mockData.length + 1); // +1 for header
});
it('点击排序应触发 onSort 回调', async () => {
const onSort = vi.fn();
render(<DataTable columns={mockColumns} dataSource={mockData} onSort={onSort} />);
await userEvent.click(screen.getByText('年龄'));
expect(onSort).toHaveBeenCalledWith('age', 'ascend');
await userEvent.click(screen.getByText('年龄'));
expect(onSort).toHaveBeenCalledWith('age', 'descend');
});
it('空数据应显示空状态', () => {
render(<DataTable columns={mockColumns} dataSource={[]} />);
expect(screen.getByText('暂无数据')).toBeInTheDocument();
});
it('加载状态应显示骨架屏', () => {
render(<DataTable columns={mockColumns} dataSource={[]} loading />);
expect(document.querySelector('.skeleton-loader')).toBeTruthy();
});
});
四、集成测试:跨子应用交互测试
4.1 qiankun 沙箱内测试
typescript
// portal/src/__tests__/micro-integration.test.ts
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import {
registerMicroApps,
start,
loadMicroApp
} from 'qiankun';
// 模拟子应用入口
const mockReactApp = {
bootstrap: vi.fn().mockResolvedValue(undefined),
mount: vi.fn().mockResolvedValue(undefined),
unmount: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined)
};
describe('qiankun 集成测试', () => {
beforeAll(() => {
// 模拟子应用 UMD 入口
(window as any).appReact = mockReactApp;
});
afterAll(() => {
vi.restoreAllMocks();
});
it('应正确注册子应用', () => {
const apps = [
{
name: 'app-react',
entry: '//localhost:3001',
container: '#micro-container',
activeRule: '/react',
props: { testMode: true }
}
];
registerMicroApps(apps);
// 验证注册是否成功(通过 qiankun 内部 API)
const registeredApps = (window as any).__QIANKUN_APPS__;
expect(registeredApps).toBeDefined();
expect(registeredApps).toHaveLength(1);
expect(registeredApps[0].name).toBe('app-react');
});
it('加载子应用应调用生命周期', async () => {
const app = loadMicroApp({
name: 'app-react',
entry: '//localhost:3001',
container: '#micro-container',
props: { testMode: true }
});
await app.mount();
expect(mockReactApp.bootstrap).toHaveBeenCalled();
expect(mockReactApp.mount).toHaveBeenCalled();
await app.unmount();
expect(mockReactApp.unmount).toHaveBeenCalled();
});
});
4.2 全局状态通信测试
typescript
// portal/src/__tests__/global-state.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { initGlobalState } from 'qiankun';
describe('GlobalState 通信', () => {
let actions: ReturnType<typeof initGlobalState>;
beforeEach(() => {
actions = initGlobalState({
user: null,
theme: 'light',
token: null
});
});
it('应初始化默认状态', () => {
let capturedState: any;
actions.onGlobalStateChange((state) => {
capturedState = state;
}, true);
expect(capturedState).toEqual({
user: null,
theme: 'light',
token: null
});
});
it('更新状态应触发监听回调', () => {
const callback = vi.fn();
actions.onGlobalStateChange(callback, true);
actions.setGlobalState({ theme: 'dark' });
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({ theme: 'dark' }),
expect.objectContaining({ theme: 'light' })
);
});
it('子应用发送的消息应能通过全局状态广播', () => {
const subApp1Callback = vi.fn();
const subApp2Callback = vi.fn();
actions.onGlobalStateChange(subApp1Callback);
actions.onGlobalStateChange(subApp2Callback);
actions.setGlobalState({
messages: [{
from: 'app-react',
to: 'app-vue',
type: 'data-request',
payload: { id: 123 },
timestamp: Date.now()
}]
});
expect(subApp1Callback).toHaveBeenCalled();
expect(subApp2Callback).toHaveBeenCalled();
});
});
4.3 事件总线集成测试
typescript
// portal/src/__tests__/event-bus.test.ts
import { describe, it, expect, vi } from 'vitest';
import { MicroEventBus } from '@/shared/eventBus';
describe('MicroEventBus 集成测试', () => {
const bus = new MicroEventBus();
it('应在不同应用间传递事件', () => {
const fromReact = vi.fn();
const fromVue = vi.fn();
bus.on('micro:user:login', fromReact);
bus.on('micro:user:login', fromVue);
bus.emit('micro:user:login', { userId: '001', name: '张三' });
expect(fromReact).toHaveBeenCalledWith({ userId: '001', name: '张三' });
expect(fromVue).toHaveBeenCalledWith({ userId: '001', name: '张三' });
});
it('取消订阅后不应再收到事件', () => {
const callback = vi.fn();
const unsubscribe = bus.on('micro:test', callback);
unsubscribe();
bus.emit('micro:test', { data: 'test' });
expect(callback).not.toHaveBeenCalled();
});
it('事件源信息应正确传递', () => {
const callback = vi.fn();
bus.on('micro:navigate', callback);
window.dispatchEvent(new CustomEvent('micro:navigate', {
detail: {
source: 'app-react',
target: 'app-vue',
path: '/detail/123',
timestamp: Date.now()
}
}));
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
source: 'app-react',
path: '/detail/123'
})
);
});
});
五、E2E 测试:全链路用户场景
5.1 测试环境搭建
bash
# 安装 Playwright
pnpm add -D @playwright/test
npx playwright install chromium
typescript
// playwright.config.ts
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'],
['json', { outputFile: 'test-results/e2e-results.json' }]
],
use: {
baseURL: 'http://localhost:8080',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
// 启动所有微前端应用
webServer: [
{
command: 'pnpm run dev:portal',
port: 8080,
reuseExistingServer: !process.env.CI
},
{
command: 'pnpm run dev:react-app',
port: 3001,
reuseExistingServer: !process.env.CI
},
{
command: 'pnpm run dev:vue-app',
port: 3002,
reuseExistingServer: !process.env.CI
}
],
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
]
});
5.2 全链路用户场景测试
typescript
// e2e/user-journey.spec.ts
import { test, expect } from '@playwright/test';
test.describe('用户核心流程', () => {
test.beforeEach(async ({ page }) => {
// 每次测试前登录
await page.goto('/login');
await page.fill('[data-testid="username"]', 'admin');
await page.fill('[data-testid="password"]', 'password123');
await page.click('[data-testid="login-btn"]');
await page.waitForURL('/dashboard');
});
test('完整用户流程:仪表盘 → React 子应用 → Vue 子应用', async ({ page }) => {
// 1. 验证基座首页
await expect(page.locator('.header-bar')).toBeVisible();
await expect(page.locator('.sidebar')).toBeVisible();
await expect(page.locator('[data-testid="welcome-message"]')).toContainText('欢迎回来');
// 2. 导航到 React 子应用
await page.click('[data-micro-app="app-react"]');
await page.waitForSelector('#micro-app-container', { state: 'visible' });
// 等待子应用加载
await page.waitForSelector('[data-testid="react-app-loaded"]', { timeout: 10000 });
await expect(page.locator('[data-testid="react-title"]')).toContainText('React 子应用');
// 3. React 子应用内操作:按钮点击
await page.click('[data-testid="react-notify-btn"]');
await expect(page.locator('.el-message')).toContainText('通知已发送');
// 4. 切换到 Vue 子应用
await page.click('[data-micro-app="app-vue"]');
await page.waitForSelector('[data-testid="vue-app-loaded"]', { timeout: 10000 });
await expect(page.locator('[data-testid="vue-title"]')).toContainText('Vue 子应用');
// 5. 验证跨应用通信:Vue 发送消息给基座
await page.click('[data-testid="vue-send-to-main"]');
await expect(page.locator('[data-testid="global-notification"]')).toContainText('来自 Vue 的消息');
// 6. 验证主题切换
await page.click('[data-testid="theme-toggle"]');
await page.click('text=暗黑模式');
await expect(page.locator('html')).toHaveClass(/dark/);
// 7. 验证国际化
await page.click('[data-testid="lang-toggle"]');
await page.click('text=English');
await expect(page.locator('[data-testid="dashboard-title"]')).toContainText('Dashboard');
});
test('子应用加载失败应显示降级 UI', async ({ page }) => {
// 模拟子应用不可用
await page.route('**/localhost:3001/**', route => {
route.abort('connectionrefused');
});
await page.click('[data-micro-app="app-react"]');
await page.waitForSelector('[data-testid="micro-fallback"]', { timeout: 10000 });
await expect(page.locator('[data-testid="micro-fallback"]')).toContainText('子应用加载失败');
await expect(page.locator('[data-testid="retry-btn"]')).toBeVisible();
});
test('无权限时访问子应用应被阻止', async ({ page }) => {
// 使用只有 viewer 角色的用户
await page.goto('/login');
await page.fill('[data-testid="username"]', 'viewer');
await page.fill('[data-testid="password"]', 'viewer123');
await page.click('[data-testid="login-btn"]');
// viewer 无权限访问 React 子应用
await page.goto('/react/dashboard');
await expect(page.locator('.error-boundary')).toContainText('无权限访问此应用');
});
});
test.describe('性能与稳定性测试', () => {
test('子应用切换应在 2 秒内完成', async ({ page }) => {
await page.goto('/dashboard');
const startTime = Date.now();
await page.click('[data-micro-app="app-react"]');
await page.waitForSelector('[data-testid="react-app-loaded"]', { timeout: 10000 });
const elapsed = Date.now() - startTime;
expect(elapsed).toBeLessThan(2000);
});
test('快速切换子应用不应导致页面崩溃', async ({ page }) => {
await page.goto('/dashboard');
// 快速多次切换
for (let i = 0; i < 5; i++) {
await page.click('[data-micro-app="app-react"]');
await page.waitForTimeout(500);
await page.click('[data-micro-app="app-vue"]');
await page.waitForTimeout(500);
}
// 验证页面没有崩溃
await expect(page.locator('.main-layout')).toBeVisible();
const errors = await page.evaluate(() => {
return (window as any).__testErrors__ || [];
});
expect(errors).toHaveLength(0);
});
});
5.3 自定义测试工具
typescript
// e2e/helpers/micro-app.ts
import { Page } from '@playwright/test';
export class MicroAppHelper {
constructor(private page: Page) {}
// 等待子应用加载完成
async waitForMicroApp(appName: string, timeout = 10000) {
await this.page.waitForFunction(
(name: string) => {
const el = document.querySelector(`[data-micro-app-loaded="${name}"]`);
return !!el;
},
appName,
{ timeout }
);
}
// 获取子应用内的文本
async getMicroAppText(selector: string): Promise<string> {
return this.page.evaluate((sel: string) => {
const container = document.getElementById('micro-app-container');
if (!container) return '';
const el = container.querySelector(sel);
return el?.textContent || '';
}, selector);
}
// 在子应用内执行操作
async actInMicroApp(selector: string, action: 'click' | 'hover') {
await this.page.evaluate(
({ sel, act }: { sel: string; act: string }) => {
const container = document.getElementById('micro-app-container');
if (!container) return;
const el = container.querySelector(sel) as HTMLElement;
if (act === 'click') el?.click();
else if (act === 'hover') el?.dispatchEvent(new MouseEvent('mouseenter'));
},
{ sel: selector, act: action }
);
}
// 获取子应用控制台日志
async getMicroAppLogs(): Promise<string[]> {
return this.page.evaluate(() => {
return (window as any).__MICRO_APP_LOGS__ || [];
});
}
// 检查子应用内存泄漏
async checkMemoryLeaks(appName: string): Promise<boolean> {
return this.page.evaluate((name: string) => {
const detector = (window as any).__MEMORY_DETECTOR__;
return detector?.checkUnmount(name);
}, appName);
}
}
六、契约测试:子应用接口契约
6.1 为什么需要契约测试
┌─────────────────────────────────────────────────────────────┐
│ 无契约测试的问题 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 后端 /api/users 接口返回 { id, name, email } │
│ │
│ 子应用 A 期望: { id, name, email } 兼容 │
│ 子应用 B 期望: { id, name, avatar } ❌ 字段缺失 │
│ 子应用 C 期望: { userId, userName, email } ❌ 字段名不同 │
│ │
│ → 后端修改接口后,子应用 B/C 会出问题 │
│ → 契约测试可以在变更时提前发现不兼容 │
│ │
└─────────────────────────────────────────────────────────────┘
6.2 使用 MSW 进行接口契约测试
typescript
// src/__tests__/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
// 用户相关
http.get('/api/user/info', () => {
return HttpResponse.json({
id: '001',
name: '张三',
email: 'zhangsan@company.com',
avatar: '/avatars/001.png',
roles: ['admin'],
permissions: ['dashboard:view', 'report:export']
});
}),
// 菜单接口
http.get('/api/menus', () => {
return HttpResponse.json([
{
path: '/dashboard',
title: '工作台',
icon: 'Odometer'
},
{
path: '/react',
title: 'React 应用',
icon: 'Coin',
microApp: 'app-react',
permissions: ['micro-app:app-react']
},
{
path: '/vue',
title: 'Vue 应用',
icon: 'Coin',
microApp: 'app-vue',
permissions: ['micro-app:app-vue']
}
]);
}),
// 子应用配置接口
http.get('/api/micro-apps/configs', () => {
return HttpResponse.json([
{
name: 'app-react',
entry: 'http://localhost:3001',
activeRule: '/react',
platform: 'react'
},
{
name: 'app-vue',
entry: 'http://localhost:3002',
activeRule: '/vue',
platform: 'vue'
}
]);
})
];
typescript
// src/__tests__/contract/api-contract.test.ts
import { describe, it, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { request } from '@/utils/request';
// 契约定义:接口返回的数据结构必须符合此形状
interface UserInfoContract {
id: string;
name: string;
email: string;
avatar?: string;
roles: string[];
permissions: string[];
}
interface MenuContract {
path: string;
title: string;
icon?: string;
children?: MenuContract[];
microApp?: string;
permissions?: string[];
}
interface MicroAppConfigContract {
name: string;
entry: string;
activeRule: string;
platform: 'react' | 'vue' | 'angular';
}
const server = setupServer();
describe('API 契约测试', () => {
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('用户信息接口必须满足契约', async () => {
server.use(
http.get('/api/user/info', () => {
return HttpResponse.json({
id: '001',
name: '张三',
email: 'zhangsan@company.com',
roles: ['admin'],
permissions: ['dashboard:view']
});
})
);
const data = await request.get<UserInfoContract>('/api/user/info');
// 契约校验
expect(data).toHaveProperty('id');
expect(typeof data.id).toBe('string');
expect(data).toHaveProperty('name');
expect(typeof data.name).toBe('string');
expect(data).toHaveProperty('email');
expect(typeof data.email).toBe('string');
expect(data).toHaveProperty('roles');
expect(Array.isArray(data.roles)).toBe(true);
expect(data).toHaveProperty('permissions');
expect(Array.isArray(data.permissions)).toBe(true);
});
it('菜单接口必须满足契约', async () => {
server.use(
http.get('/api/menus', () => {
return HttpResponse.json([
{ path: '/dashboard', title: '工作台' }
]);
})
);
const data = await request.get<MenuContract[]>('/api/menus');
data.forEach(menu => {
expect(menu).toHaveProperty('path');
expect(typeof menu.path).toBe('string');
expect(menu).toHaveProperty('title');
expect(typeof menu.title).toBe('string');
});
});
it('子应用配置接口必须满足契约', async () => {
// 模拟后端返回不符合契约的数据(缺失 platform 字段)
server.use(
http.get('/api/micro-apps/configs', () => {
return HttpResponse.json([
{
name: 'app-react',
entry: 'http://localhost:3001',
activeRule: '/react'
// 缺少 platform 字段
}
]);
})
);
const data = await request.get<MicroAppConfigContract[]>('/api/micro-apps/configs');
// 契约校验应失败
data.forEach(config => {
const isValid = (
typeof config.name === 'string' &&
typeof config.entry === 'string' &&
typeof config.activeRule === 'string' &&
['react', 'vue', 'angular'].includes(config.platform)
);
// platform 缺失,校验失败
if (!config.platform) {
console.warn('[Contract] 子应用配置缺少 platform 字段');
}
});
});
});
6.3 使用 Pact 进行消费者驱动契约测试
typescript
// pact/consumer/user-service.pact.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { describe, it, expect } from 'vitest';
const provider = new PactV3({
consumer: 'portal-platform',
provider: 'user-service',
port: 9000
});
describe('User Service 契约', () => {
it('获取用户信息接口应满足消费者期望', async () => {
// 定义消费者期望的交互
provider
.given('用户已登录')
.uponReceiving('获取用户信息请求')
.withRequest({
method: 'GET',
path: '/api/user/info',
headers: { Authorization: 'Bearer valid-token' }
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: MatchersV3.string('001'),
name: MatchersV3.string('张三'),
email: MatchersV3.string('zhangsan@company.com'),
roles: MatchersV3.eachLike('admin'),
permissions: MatchersV3.eachLike('dashboard:view')
}
});
await provider.executeTest(async (mockProvider) => {
// 发送真实请求到 Mock 服务
const response = await fetch(
`${mockProvider.url}/api/user/info`,
{ headers: { Authorization: 'Bearer valid-token' } }
);
const data = await response.json();
// 验证响应符合消费者期望
expect(response.status).toBe(200);
expect(data).toHaveProperty('id');
expect(data).toHaveProperty('name');
expect(data).toHaveProperty('email');
expect(Array.isArray(data.roles)).toBe(true);
expect(Array.isArray(data.permissions)).toBe(true);
});
});
});
七、测试基础设施与 Mock 策略
7.1 Mock 策略总览
┌─────────────────────────────────────────────────────────────┐
│ Mock 策略 │
├────────────┬──────────────────┬──────────────────────────────┤
│ 层级 │ Mock 方式 │ 适用场景 │
├────────────┼──────────────────┼──────────────────────────────┤
│ 网络请求 │ MSW / Mock Axios │ API 调用、接口契约测试 │
│ 模块 │ vi.mock │ 第三方库、工具函数 │
│ 组件 │ Stub 组件 │ 子组件、远程组件 │
│ 路由 │ createRouter │ 路由跳转、导航守卫 │
│ Store │ setActivePinia │ Pinia Store 测试 │
│ qiankun │ 模拟生命周期 │ 微前端集成测试 │
│ 浏览器 API │ jsdom / happy-dom │ DOM 操作、Storage 等 │
│ 时间 │ vi.useFakeTimers │ 定时器、debounce 等 │
└────────────┴──────────────────┴──────────────────────────────┘
7.2 Module Federation 模块 Mock
typescript
// src/__tests__/mocks/remote-modules.ts
import { vi } from 'vitest';
// Mock Module Federation 远程模块
vi.mock('remoteApp/Button', () => ({
default: ({ label, onClick }: { label: string; onClick?: () => void }) => {
return {
type: 'button',
props: { children: label, onClick },
$$typeof: Symbol.for('react.element')
};
}
}));
vi.mock('remoteApp/Header', () => ({
default: ({ title }: { title: string }) => {
return {
type: 'header',
props: { children: title },
$$typeof: Symbol.for('react.element')
};
}
}));
// 动态获取远程模块的工厂函数 Mock
export function createRemoteModuleMock<T>(defaultValue: T) {
let currentValue = defaultValue;
return {
get: () => currentValue,
set: (value: T) => { currentValue = value; },
reset: () => { currentValue = defaultValue; }
};
}
7.3 qiankun 沙箱 Mock
typescript
// src/__tests__/mocks/qiankun-sandbox.ts
import { vi } from 'vitest';
// 模拟 qiankun 沙箱环境
export function setupQiankunSandboxMock() {
const originalWindow = { ...window };
// 保存原始方法
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
// 沙箱模拟
const sandboxMock = {
proxyWindow: new Proxy(window, {
get(target: any, prop: string) {
if (prop === '__POWERED_BY_QIANKUN__') return true;
if (prop === '__INJECTED_PUBLIC_PATH_BY_QIANKUN__') return 'http://localhost:3001/';
return target[prop];
},
set(target: any, prop: string, value: any) {
// 模拟沙箱内的全局变量隔离
if (prop.startsWith('__')) return true;
target[prop] = value;
return true;
}
}),
// 模拟沙箱内的事件监听隔离
eventListeners: new Map<string, Set<EventListener>>(),
addEventListener(type: string, listener: EventListener) {
if (!this.eventListeners.has(type)) {
this.eventListeners.set(type, new Set());
}
this.eventListeners.get(type)!.add(listener);
},
removeEventListener(type: string, listener: EventListener) {
this.eventListeners.get(type)?.delete(listener);
},
// 清理沙箱
destroy() {
this.eventListeners.clear();
// 恢复原始 window
Object.assign(window, originalWindow);
}
};
return sandboxMock;
}
7.4 测试夹具(Fixtures)
typescript
// src/__tests__/fixtures/micro-app.fixtures.ts
// 子应用配置夹具
export const microAppConfigFixture = {
name: 'app-react',
entry: 'http://localhost:3001',
container: '#micro-app-container',
activeRule: '/react',
props: {
appName: 'app-react',
platform: 'react',
token: 'mock-token',
user: {
id: '001',
name: '张三',
roles: ['admin'],
permissions: ['*']
}
}
};
// 全局状态夹具
export const globalStateFixture = {
user: {
id: '001',
name: '张三',
avatar: '/avatars/001.png',
permissions: ['dashboard:view', 'report:export']
},
theme: 'light',
token: 'mock-token',
messages: []
};
// 用户信息夹具
export const userInfoFixture = {
id: '001',
name: '张三',
email: 'zhangsan@company.com',
avatar: '/avatars/001.png',
roles: ['admin'],
permissions: [
'dashboard:view',
'report:export',
'user:manage',
'system:config',
'micro-app:app-react',
'micro-app:app-vue'
]
};
// 菜单夹具
export const menuFixture = [
{
path: '/dashboard',
title: '工作台',
icon: 'Odometer'
},
{
path: '/react',
title: 'React 应用',
icon: 'Coin',
microApp: 'app-react',
permissions: ['micro-app:app-react']
},
{
path: '/vue',
title: 'Vue 应用',
icon: 'Coin',
microApp: 'app-vue',
permissions: ['micro-app:app-vue']
},
{
path: '/system',
title: '系统管理',
icon: 'Setting',
children: [
{
path: '/system/users',
title: '用户管理',
permissions: ['user:manage']
},
{
path: '/system/config',
title: '系统配置',
permissions: ['system:config']
}
]
}
];
八、质量门禁与 CI 集成
8.1 质量门禁流程
┌──────────────────────┐
│ 开发者提交代码 │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ Pre-commit Hook │
│ ┌────────────────┐ │
│ │ ESLint │ │
│ │ Prettier │ │
│ │ TypeScript │ │
│ └────────────────┘ │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ 单元测试 + 组件测试 │
│ ┌───────────────┐ │
│ │ 覆盖率 ≥ 80% │ │
│ └───────────────┘ │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ CI Pipeline │
│ ┌─────────────────┐ │
│ │ Lint & Type │ │
│ │ 单元测试 │ │
│ │ 契约测试 │ │
│ │ 构建验证 │ │
│ └─────────────────┘ │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ 集成测试(每日) │
│ ┌───────────────┐ │
│ │ 跨应用交互 │ │
│ │ 通信机制 │ │
│ └───────────────┘ │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ E2E 测试(预发布) │
│ ┌───────────────┐ │
│ │ 全链路场景 │ │
│ │ 性能基线 │ │
│ └───────────────┘ │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ 生产发布 │
│ 灰度 10% → 50% →100%│
└──────────────────────┘
8.2 CI 配置
yaml
# .github/workflows/test.yml
name: Micro Frontend CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
quality-gate:
runs-on: ubuntu-latest
strategy:
matrix:
app: [portal, app-react, app-vue]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: |
cd apps/${{ matrix.app }}
pnpm install --frozen-lockfile
- name: Lint & Type Check
run: |
cd apps/${{ matrix.app }}
pnpm lint
pnpm typecheck
- name: Unit Tests with Coverage
run: |
cd apps/${{ matrix.app }}
pnpm test:unit --coverage
- name: Check Coverage Threshold
run: |
cd apps/${{ matrix.app }}
pnpm test:coverage-check
- name: Build
run: |
cd apps/${{ matrix.app }}
pnpm build
contract-test:
runs-on: ubuntu-latest
needs: quality-gate
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: Install dependencies
run: pnpm install
- name: Contract Tests
run: pnpm test:contract
- name: Publish Pact
if: github.ref == 'refs/heads/main'
run: pnpm pact:publish
env:
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
integration-test:
runs-on: ubuntu-latest
needs: [quality-gate, contract-test]
if: github.ref == 'refs/heads/main' || github.event_name == 'schedule'
services:
portal:
image: portal-platform:latest
ports:
- 8080:80
react-app:
image: react-app:latest
ports:
- 3001:80
vue-app:
image: vue-app:latest
ports:
- 3002:80
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: Integration Tests
run: pnpm test:integration
e2e-test:
runs-on: ubuntu-latest
needs: integration-test
if: github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: Install dependencies
run: pnpm install
- name: Install Playwright
run: npx playwright install chromium
- name: Start all apps
run: |
pnpm dev:portal &
pnpm dev:react-app &
pnpm dev:vue-app &
npx wait-on http://localhost:8080 http://localhost:3001 http://localhost:3002
- name: Run E2E Tests
run: pnpm test:e2e
- name: Upload Test Results
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-results
path: test-results/
8.3 覆盖率配置
typescript
// vitest.config.ts(平台级)
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
// 各应用覆盖率阈值(硬性门禁)
thresholds: {
// 基座应用
'apps/portal/src': {
statements: 85,
branches: 75,
functions: 85,
lines: 85
},
// 共享组件库
'packages/ui/src': {
statements: 90,
branches: 80,
functions: 90,
lines: 90
},
// 子应用
'apps/app-react/src': {
statements: 80,
branches: 70,
functions: 80,
lines: 80
},
'apps/app-vue/src': {
statements: 80,
branches: 70,
functions: 80,
lines: 80
}
},
// 排除无需覆盖的文件
exclude: [
'**/*.d.ts',
'**/*.config.*',
'**/main.ts',
'**/public-path.ts',
'**/env.d.ts',
'**/__tests__/**',
'**/node_modules/**'
]
}
}
});
8.4 测试报告与可视化
typescript
// scripts/generate-test-report.ts
// 合并所有子应用的测试报告,生成统一看板
interface TestReport {
appName: string;
totalTests: number;
passed: number;
failed: number;
skipped: number;
coverage: {
statements: number;
branches: number;
functions: number;
lines: number;
};
duration: number;
}
async function generateUnifiedReport() {
const apps = ['portal', 'app-react', 'app-vue', 'app-angular'];
const reports: TestReport[] = [];
for (const app of apps) {
const reportPath = `apps/${app}/test-results/report.json`;
try {
const report = await import(reportPath);
reports.push(report);
} catch {
console.warn(`未找到 ${app} 的测试报告`);
}
}
// 生成总览
const total = {
totalTests: reports.reduce((s, r) => s + r.totalTests, 0),
passed: reports.reduce((s, r) => s + r.passed, 0),
failed: reports.reduce((s, r) => s + r.failed, 0),
avgCoverage: reports.reduce((s, r) => s + r.coverage.lines, 0) / reports.length
};
console.table(total);
// 输出 Markdown 格式报告
const markdown = [
'# 微前端测试报告',
'',
'| 应用 | 通过率 | 行覆盖率 | 耗时 |',
'|------|--------|---------|------|',
...reports.map(r =>
`| ${r.appName} | ${(r.passed / r.totalTests * 100).toFixed(1)}% | ${r.coverage.lines.toFixed(1)}% | ${r.duration}ms |`
),
'',
`**总测试数**: ${total.totalTests}`,
`**通过率**: ${(total.passed / total.totalTests * 100).toFixed(1)}%`,
`**平均覆盖率**: ${total.avgCoverage.toFixed(1)}%`
].join('\n');
return markdown;
}
通过本文,你已经构建了微前端架构下的完整质量保障体系:
- 单元测试:Store、工具函数、Composables
- 组件测试:Vue/React 组件、共享组件
- 集成测试:qiankun 沙箱、全局状态、事件总线
- E2E 测试:全链路用户场景、性能基线
- 契约测试:API 接口契约、消费者驱动测试
- Mock 策略:远程模块、沙箱环境
- 质量门禁与 CI 集成