工程化与框架系列(32)--前端测试实践指南

发布于:2025-03-15 ⋅ 阅读:(13) ⋅ 点赞:(0)

前端测试实践指南 🧪

引言

前端测试是保证应用质量的重要环节。本文将深入探讨前端测试的各个方面,包括单元测试、集成测试、端到端测试等,并提供实用的测试工具和最佳实践。

测试概述

前端测试主要包括以下类型:

  • 单元测试:测试独立组件和函数
  • 集成测试:测试多个组件的交互
  • 端到端测试:模拟用户行为的完整测试
  • 性能测试:测试应用性能指标
  • 快照测试:UI组件的视觉回归测试

测试工具实现

测试运行器

// 测试运行器类
class TestRunner {
    private tests: TestCase[] = [];
    private beforeEachHooks: Hook[] = [];
    private afterEachHooks: Hook[] = [];
    private beforeAllHooks: Hook[] = [];
    private afterAllHooks: Hook[] = [];
    
    constructor(private config: TestConfig = {}) {
        this.initialize();
    }
    
    // 初始化运行器
    private initialize(): void {
        // 设置默认配置
        this.config = {
            timeout: 5000,
            bail: false,
            verbose: true,
            ...this.config
        };
    }
    
    // 添加测试用例
    addTest(test: TestCase): void {
        this.tests.push(test);
    }
    
    // 添加beforeEach钩子
    beforeEach(hook: Hook): void {
        this.beforeEachHooks.push(hook);
    }
    
    // 添加afterEach钩子
    afterEach(hook: Hook): void {
        this.afterEachHooks.push(hook);
    }
    
    // 添加beforeAll钩子
    beforeAll(hook: Hook): void {
        this.beforeAllHooks.push(hook);
    }
    
    // 添加afterAll钩子
    afterAll(hook: Hook): void {
        this.afterAllHooks.push(hook);
    }
    
    // 运行所有测试
    async runTests(): Promise<TestResult[]> {
        const results: TestResult[] = [];
        let failedTests = 0;
        
        console.log('\nStarting test run...\n');
        
        // 运行beforeAll钩子
        for (const hook of this.beforeAllHooks) {
            await this.runHook(hook);
        }
        
        // 运行测试用例
        for (const test of this.tests) {
            const result = await this.runTest(test);
            results.push(result);
            
            if (!result.passed) {
                failedTests++;
                if (this.config.bail) {
                    break;
                }
            }
        }
        
        // 运行afterAll钩子
        for (const hook of this.afterAllHooks) {
            await this.runHook(hook);
        }
        
        // 输出测试报告
        this.printReport(results);
        
        return results;
    }
    
    // 运行单个测试
    private async runTest(test: TestCase): Promise<TestResult> {
        const startTime = Date.now();
        let error: Error | null = null;
        
        try {
            // 运行beforeEach钩子
            for (const hook of this.beforeEachHooks) {
                await this.runHook(hook);
            }
            
            // 运行测试
            await Promise.race([
                test.fn(),
                new Promise((_, reject) => {
                    setTimeout(() => {
                        reject(new Error('Test timed out'));
                    }, this.config.timeout);
                })
            ]);
            
            // 运行afterEach钩子
            for (const hook of this.afterEachHooks) {
                await this.runHook(hook);
            }
        } catch (e) {
            error = e as Error;
        }
        
        const endTime = Date.now();
        const duration = endTime - startTime;
        
        return {
            name: test.name,
            passed: !error,
            duration,
            error: error?.message
        };
    }
    
    // 运行钩子函数
    private async runHook(hook: Hook): Promise<void> {
        try {
            await hook();
        } catch (error) {
            console.error('Hook failed:', error);
        }
    }
    
    // 打印测试报告
    private printReport(results: TestResult[]): void {
        console.log('\nTest Results:\n');
        
        results.forEach(result => {
            const status = result.passed ? '✅ PASS' : '❌ FAIL';
            console.log(`${status} ${result.name} (${result.duration}ms)`);
            
            if (!result.passed && result.error) {
                console.log(`  Error: ${result.error}\n`);
            }
        });
        
        const totalTests = results.length;
        const passedTests = results.filter(r => r.passed).length;
        const failedTests = totalTests - passedTests;
        
        console.log('\nSummary:');
        console.log(`Total: ${totalTests}`);
        console.log(`Passed: ${passedTests}`);
        console.log(`Failed: ${failedTests}`);
        
        const duration = results.reduce((sum, r) => sum + r.duration, 0);
        console.log(`Duration: ${duration}ms\n`);
    }
}

// 断言工具类
class Assertions {
    static assertEquals<T>(actual: T, expected: T, message?: string): void {
        if (actual !== expected) {
            throw new Error(
                message || 
                `Expected ${expected} but got ${actual}`
            );
        }
    }
    
    static assertNotEquals<T>(actual: T, expected: T, message?: string): void {
        if (actual === expected) {
            throw new Error(
                message || 
                `Expected ${actual} to be different from ${expected}`
            );
        }
    }
    
    static assertTrue(value: boolean, message?: string): void {
        if (!value) {
            throw new Error(
                message || 
                'Expected value to be true'
            );
        }
    }
    
    static assertFalse(value: boolean, message?: string): void {
        if (value) {
            throw new Error(
                message || 
                'Expected value to be false'
            );
        }
    }
    
    static assertDefined<T>(value: T, message?: string): void {
        if (value === undefined) {
            throw new Error(
                message || 
                'Expected value to be defined'
            );
        }
    }
    
    static assertUndefined<T>(value: T, message?: string): void {
        if (value !== undefined) {
            throw new Error(
                message || 
                'Expected value to be undefined'
            );
        }
    }
    
    static assertNull<T>(value: T, message?: string): void {
        if (value !== null) {
            throw new Error(
                message || 
                'Expected value to be null'
            );
        }
    }
    
    static assertNotNull<T>(value: T, message?: string): void {
        if (value === null) {
            throw new Error(
                message || 
                'Expected value to be not null'
            );
        }
    }
    
    static assertThrows(fn: () => void, message?: string): void {
        try {
            fn();
            throw new Error(
                message || 
                'Expected function to throw'
            );
        } catch (error) {
            // 期望抛出错误
        }
    }
    
    static async assertRejects(
        fn: () => Promise<any>,
        message?: string
    ): Promise<void> {
        try {
            await fn();
            throw new Error(
                message || 
                'Expected promise to reject'
            );
        } catch (error) {
            // 期望抛出错误
        }
    }
    
    static assertMatch(
        actual: string,
        pattern: RegExp,
        message?: string
    ): void {
        if (!pattern.test(actual)) {
            throw new Error(
                message || 
                `Expected ${actual} to match ${pattern}`
            );
        }
    }
    
    static assertNotMatch(
        actual: string,
        pattern: RegExp,
        message?: string
    ): void {
        if (pattern.test(actual)) {
            throw new Error(
                message || 
                `Expected ${actual} not to match ${pattern}`
            );
        }
    }
}

// 模拟工具类
class Mock {
    private calls: any[][] = [];
    private implementation?: (...args: any[]) => any;
    
    constructor(implementation?: (...args: any[]) => any) {
        this.implementation = implementation;
    }
    
    // 创建模拟函数
    fn = (...args: any[]): any => {
        this.calls.push(args);
        return this.implementation?.(...args);
    }
    
    // 获取调用次数
    callCount(): number {
        return this.calls.length;
    }
    
    // 获取调用参数
    getCall(index: number): any[] {
        return this.calls[index];
    }
    
    // 获取所有调用
    getCalls(): any[][] {
        return this.calls;
    }
    
    // 清除调用记录
    clear(): void {
        this.calls = [];
    }
    
    // 设置实现
    setImplementation(implementation: (...args: any[]) => any): void {
        this.implementation = implementation;
    }
}

// 接口定义
interface TestCase {
    name: string;
    fn: () => Promise<void> | void;
}

interface TestResult {
    name: string;
    passed: boolean;
    duration: number;
    error?: string;
}

interface TestConfig {
    timeout?: number;
    bail?: boolean;
    verbose?: boolean;
}

type Hook = () => Promise<void> | void;

// 使用示例
const runner = new TestRunner({
    timeout: 2000,
    bail: true
});

// 添加钩子
runner.beforeAll(async () => {
    console.log('Setting up test environment...');
});

runner.afterAll(async () => {
    console.log('Cleaning up test environment...');
});

runner.beforeEach(async () => {
    console.log('Setting up test case...');
});

runner.afterEach(async () => {
    console.log('Cleaning up test case...');
});

// 添加测试用例
runner.addTest({
    name: 'should add numbers correctly',
    fn: () => {
        const result = 1 + 1;
        Assertions.assertEquals(result, 2);
    }
});

runner.addTest({
    name: 'should handle async operations',
    fn: async () => {
        const result = await Promise.resolve(42);
        Assertions.assertEquals(result, 42);
    }
});

// 运行测试
runner.runTests().then(results => {
    process.exit(results.every(r => r.passed) ? 0 : 1);
});

组件测试工具

// 组件测试工具类
class ComponentTester {
    private element: HTMLElement;
    private eventListeners: Map<string, Function[]> = new Map();
    
    constructor(private component: any) {
        this.element = this.mount();
    }
    
    // 挂载组件
    private mount(): HTMLElement {
        const container = document.createElement('div');
        document.body.appendChild(container);
        
        if (typeof this.component === 'string') {
            container.innerHTML = this.component;
        } else {
            // 假设组件是一个类
            const instance = new this.component();
            container.appendChild(instance.render());
        }
        
        return container;
    }
    
    // 查找元素
    find(selector: string): HTMLElement | null {
        return this.element.querySelector(selector);
    }
    
    // 查找所有元素
    findAll(selector: string): NodeListOf<HTMLElement> {
        return this.element.querySelectorAll(selector);
    }
    
    // 触发事件
    trigger(
        selector: string,
        eventName: string,
        eventData: any = {}
    ): void {
        const element = this.find(selector);
        if (!element) {
            throw new Error(`Element not found: ${selector}`);
        }
        
        const event = new CustomEvent(eventName, {
            detail: eventData,
            bubbles: true,
            cancelable: true
        });
        
        element.dispatchEvent(event);
    }
    
    // 等待元素出现
    async waitForElement(
        selector: string,
        timeout: number = 1000
    ): Promise<HTMLElement> {
        const startTime = Date.now();
        
        while (Date.now() - startTime < timeout) {
            const element = this.find(selector);
            if (element) {
                return element;
            }
            await new Promise(resolve => setTimeout(resolve, 100));
        }
        
        throw new Error(`Timeout waiting for element: ${selector}`);
    }
    
    // 等待元素消失
    async waitForElementToDisappear(
        selector: string,
        timeout: number = 1000
    ): Promise<void> {
        const startTime = Date.now();
        
        while (Date.now() - startTime < timeout) {
            const element = this.find(selector);
            if (!element) {
                return;
            }
            await new Promise(resolve => setTimeout(resolve, 100));
        }
        
        throw new Error(
            `Timeout waiting for element to disappear: ${selector}`
        );
    }
    
    // 获取元素文本
    getText(selector: string): string {
        const element = this.find(selector);
        if (!element) {
            throw new Error(`Element not found: ${selector}`);
        }
        return element.textContent || '';
    }
    
    // 获取元素属性
    getAttribute(
        selector: string,
        attributeName: string
    ): string | null {
        const element = this.find(selector);
        if (!element) {
            throw new Error(`Element not found: ${selector}`);
        }
        return element.getAttribute(attributeName);
    }
    
    // 设置输入值
    setInputValue(selector: string, value: string): void {
        const element = this.find(selector) as HTMLInputElement;
        if (!element) {
            throw new Error(`Input element not found: ${selector}`);
        }
        
        element.value = value;
        this.trigger(selector, 'input');
        this.trigger(selector, 'change');
    }
    
    // 检查元素是否可见
    isVisible(selector: string): boolean {
        const element = this.find(selector);
        if (!element) {
            return false;
        }
        
        const style = window.getComputedStyle(element);
        return style.display !== 'none' && 
               style.visibility !== 'hidden' && 
               style.opacity !== '0';
    }
    
    // 检查元素是否存在
    exists(selector: string): boolean {
        return !!this.find(selector);
    }
    
    // 检查元素是否包含类名
    hasClass(selector: string, className: string): boolean {
        const element = this.find(selector);
        return element ? element.classList.contains(className) : false;
    }
    
    // 检查元素是否禁用
    isDisabled(selector: string): boolean {
        const element = this.find(selector) as HTMLInputElement;
        return element ? element.disabled : false;
    }
    
    // 清理
    cleanup(): void {
        document.body.removeChild(this.element);
        this.eventListeners.clear();
    }
}

// 使用示例
class Counter {
    private count = 0;
    private element: HTMLElement;
    
    constructor() {
        this.element = document.createElement('div');
        this.render();
    }
    
    increment(): void {
        this.count++;
        this.render();
    }
    
    render(): HTMLElement {
        this.element.innerHTML = `
            <div class="counter">
                <span class="count">${this.count}</span>
                <button class="increment">+</button>
            </div>
        `;
        
        const button = this.element.querySelector('.increment');
        button?.addEventListener('click', () => this.increment());
        
        return this.element;
    }
}

// 测试计数器组件
const runner = new TestRunner();

runner.addTest({
    name: 'Counter component should render correctly',
    fn: () => {
        const tester = new ComponentTester(Counter);
        
        // 检查初始状态
        Assertions.assertEquals(
            tester.getText('.count'),
            '0'
        );
        
        // 触发点击事件
        tester.trigger('.increment', 'click');
        
        // 检查更新后的状态
        Assertions.assertEquals(
            tester.getText('.count'),
            '1'
        );
        
        tester.cleanup();
    }
});

runner.runTests();

最佳实践与建议

  1. 测试策略

    • 遵循测试金字塔
    • 合理分配测试类型
    • 关注核心功能
    • 维护测试质量
  2. 测试设计

    • 单一职责
    • 独立性
    • 可重复性
    • 可维护性
  3. 测试覆盖率

    • 设置合理目标
    • 关注重要代码
    • 避免过度测试
    • 持续监控
  4. 测试效率

    • 并行执行
    • 优化速度
    • 自动化集成
    • 持续集成

总结

前端测试需要考虑以下方面:

  1. 测试类型选择
  2. 测试工具使用
  3. 测试策略制定
  4. 测试效率优化
  5. 测试维护管理

通过合理的测试实践,可以提高代码质量和项目可维护性。

学习资源

  1. Jest官方文档
  2. Testing Library指南
  3. Cypress文档
  4. 测试最佳实践
  5. 自动化测试教程

如果你觉得这篇文章有帮助,欢迎点赞收藏,也期待在评论区看到你的想法和建议!👇

终身学习,共同成长。

咱们下一期见

💻