实战案例分析(三)- 测试框架实践

1. 测试基础概念介绍

测试类型对比表

测试类型 测试范围 测试目标 依赖处理
单元测试 单个函数/类 验证最小可测试单元 使用Mock替代依赖
集成测试 多个模块交互 验证模块间协作 使用真实依赖或集成环境
端到端测试 整个应用流程 验证完整业务流程 使用完整的应用环境

让我们通过一个实际的API开发案例来学习如何使用Cursor AI进行测试。

2. 案例:用户认证API测试实现

2.1 API实现代码

// src/services/AuthService.ts
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { UserRepository } from '../repositories/UserRepository';

export interface IUserCredentials {
    email: string;
    password: string;
}

export interface IUser {
    id: string;
    email: string;
    password: string;
    createdAt: Date;
}

export class AuthService {
    private userRepo: UserRepository;
    private readonly JWT_SECRET = process.env.JWT_SECRET || 'default_secret';
    private readonly SALT_ROUNDS = 10;

    constructor(userRepo: UserRepository) {
        this.userRepo = userRepo;
    }

    async register(credentials: IUserCredentials): Promise<Omit<IUser, 'password'>> {
        // Validate input
        if (!this.isValidEmail(credentials.email)) {
            throw new Error('Invalid email format');
        }
        if (!this.isValidPassword(credentials.password)) {
            throw new Error('Password must be at least 8 characters long');
        }

        // Check if user exists
        const existingUser = await this.userRepo.findByEmail(credentials.email);
        if (existingUser) {
            throw new Error('Email already registered');
        }

        // Hash password
        const hashedPassword = await bcrypt.hash(credentials.password, this.SALT_ROUNDS);

        // Create user
        const user = await this.userRepo.create({
            email: credentials.email,
            password: hashedPassword
        });

        // Remove password from response
        const { password, ...userWithoutPassword } = user;
        return userWithoutPassword;
    }

    async login(credentials: IUserCredentials): Promise<string> {
        // Find user
        const user = await this.userRepo.findByEmail(credentials.email);
        if (!user) {
            throw new Error('User not found');
        }

        // Verify password
        const isValid = await bcrypt.compare(credentials.password, user.password);
        if (!isValid) {
            throw new Error('Invalid password');
        }

        // Generate token
        return jwt.sign(
            { userId: user.id, email: user.email },
            this.JWT_SECRET,
            { expiresIn: '24h' }
        );
    }

    private isValidEmail(email: string): boolean {
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        return emailRegex.test(email);
    }

    private isValidPassword(password: string): boolean {
        return password.length >= 8;
    }
}

2.2 单元测试实现

// src/tests/unit/AuthService.test.ts
import { AuthService, IUserCredentials, IUser } from '../../services/AuthService';
import { UserRepository } from '../../repositories/UserRepository';

// Mock UserRepository
jest.mock('../../repositories/UserRepository');

describe('AuthService', () => {
    let authService: AuthService;
    let mockUserRepo: jest.Mocked<UserRepository>;

    beforeEach(() => {
        // Clear all mocks before each test
        jest.clearAllMocks();
        mockUserRepo = new UserRepository() as jest.Mocked<UserRepository>;
        authService = new AuthService(mockUserRepo);
    });

    describe('register', () => {
        const validCredentials: IUserCredentials = {
            email: 'test@example.com',
            password: 'password123'
        };

        it('should successfully register a new user', async () => {
            // Arrange
            mockUserRepo.findByEmail.mockResolvedValue(null);
            mockUserRepo.create.mockResolvedValue({
                id: '1',
                email: validCredentials.email,
                password: 'hashed_password',
                createdAt: new Date()
            });

            // Act
            const result = await authService.register(validCredentials);

            // Assert
            expect(result).toHaveProperty('id');
            expect(result.email).toBe(validCredentials.email);
            expect(result).not.toHaveProperty('password');
            expect(mockUserRepo.create).toHaveBeenCalledTimes(1);
        });

        it('should throw error for invalid email format', async () => {
            // Arrange
            const invalidCredentials = {
                email: 'invalid-email',
                password: 'password123'
            };

            // Act & Assert
            await expect(
                authService.register(invalidCredentials)
            ).rejects.toThrow('Invalid email format');
            expect(mockUserRepo.create).not.toHaveBeenCalled();
        });

        it('should throw error for short password', async () => {
            // Arrange
            const invalidCredentials = {
                email: 'test@example.com',
                password: 'short'
            };

            // Act & Assert
            await expect(
                authService.register(invalidCredentials)
            ).rejects.toThrow('Password must be at least 8 characters long');
            expect(mockUserRepo.create).not.toHaveBeenCalled();
        });

        it('should throw error for existing email', async () => {
            // Arrange
            mockUserRepo.findByEmail.mockResolvedValue({
                id: '1',
                email: validCredentials.email,
                password: 'hashed_password',
                createdAt: new Date()
            });

            // Act & Assert
            await expect(
                authService.register(validCredentials)
            ).rejects.toThrow('Email already registered');
            expect(mockUserRepo.create).not.toHaveBeenCalled();
        });
    });

    describe('login', () => {
        const validCredentials: IUserCredentials = {
            email: 'test@example.com',
            password: 'password123'
        };

        it('should return JWT token for valid credentials', async () => {
            // Arrange
            mockUserRepo.findByEmail.mockResolvedValue({
                id: '1',
                email: validCredentials.email,
                password: await bcrypt.hash(validCredentials.password, 10),
                createdAt: new Date()
            });

            // Act
            const token = await authService.login(validCredentials);

            // Assert
            expect(typeof token).toBe('string');
            expect(token.split('.')).toHaveLength(3); // JWT format validation
        });

        it('should throw error for non-existent user', async () => {
            // Arrange
            mockUserRepo.findByEmail.mockResolvedValue(null);

            // Act & Assert
            await expect(
                authService.login(validCredentials)
            ).rejects.toThrow('User not found');
        });

        it('should throw error for invalid password', async () => {
            // Arrange
            mockUserRepo.findByEmail.mockResolvedValue({
                id: '1',
                email: validCredentials.email,
                password: await bcrypt.hash('different_password', 10),
                createdAt: new Date()
            });

            // Act & Assert
            await expect(
                authService.login(validCredentials)
            ).rejects.toThrow('Invalid password');
        });
    });
});

2.3 集成测试实现

// src/tests/integration/auth.integration.test.ts
import request from 'supertest';
import { app } from '../../app';
import { Database } from '../../database';
import { UserRepository } from '../../repositories/UserRepository';

describe('Authentication API Integration Tests', () => {
    let db: Database;
    let userRepo: UserRepository;

    beforeAll(async () => {
        // Initialize test database
        db = new Database({
            host: process.env.TEST_DB_HOST,
            database: process.env.TEST_DB_NAME
        });
        await db.connect();
        userRepo = new UserRepository(db);
    });

    afterAll(async () => {
        // Cleanup
        await db.disconnect();
    });

    beforeEach(async () => {
        // Clear users table before each test
        await db.query('TRUNCATE TABLE users CASCADE');
    });

    describe('POST /api/auth/register', () => {
        it('should successfully register a new user', async () => {
            const response = await request(app)
                .post('/api/auth/register')
                .send({
                    email: 'test@example.com',
                    password: 'password123'
                });

            expect(response.status).toBe(201);
            expect(response.body).toHaveProperty('id');
            expect(response.body.email).toBe('test@example.com');
            expect(response.body).not.toHaveProperty('password');

            // Verify user was created in database
            const user = await userRepo.findByEmail('test@example.com');
            expect(user).toBeTruthy();
            expect(user?.email).toBe('test@example.com');
        });

        it('should not allow duplicate email registration', async () => {
            // First registration
            await request(app)
                .post('/api/auth/register')
                .send({
                    email: 'test@example.com',
                    password: 'password123'
                });

            // Attempt duplicate registration
            const response = await request(app)
                .post('/api/auth/register')
                .send({
                    email: 'test@example.com',
                    password: 'different_password'
                });

            expect(response.status).toBe(400);
            expect(response.body.error).toBe('Email already registered');
        });
    });

    describe('POST /api/auth/login', () => {
        beforeEach(async () => {
            // Create test user
            await request(app)
                .post('/api/auth/register')
                .send({
                    email: 'test@example.com',
                    password: 'password123'
                });
        });

        it('should return JWT token for valid credentials', async () => {
            const response = await request(app)
                .post('/api/auth/login')
                .send({
                    email: 'test@example.com',
                    password: 'password123'
                });

            expect(response.status).toBe(200);
            expect(response.body).toHaveProperty('token');
            expect(typeof response.body.token).toBe('string');

            // Verify token is valid by using it
            const protectedResponse = await request(app)
                .get('/api/protected-route')
                .set('Authorization', `Bearer ${response.body.token}`);

            expect(protectedResponse.status).toBe(200);
        });

        it('should reject invalid credentials', async () => {
            const response = await request(app)
                .post('/api/auth/login')
                .send({
                    email: 'test@example.com',
                    password: 'wrong_password'
                });

            expect(response.status).toBe(401);
            expect(response.body.error).toBe('Invalid password');
        });
    });
});

2.4 测试流程图

在这里插入图片描述

3. 使用Cursor AI生成测试用例

3.1 测试描述生成器

继续完善测试用例生成器的实现:

interface ITestCase {
    description: string;
    input: any;
    expectedOutput: any;
    type: 'unit' | 'integration';
    setup?: string[];
    cleanup?: string[];
}

class TestCaseGenerator {
    static generateAuthTestCases(): ITestCase[] {
        return [
            // 注册测试用例
            {
                description: "should successfully register new user with valid credentials",
                input: {
                    email: "test@example.com",
                    password: "validPassword123"
                },
                expectedOutput: {
                    status: 201,
                    hasId: true,
                    hasEmail: true,
                    noPassword: true
                },
                type: "unit"
            },
            {
                description: "should reject registration with invalid email",
                input: {
                    email: "invalid-email",
                    password: "validPassword123"
                },
                expectedOutput: {
                    error: "Invalid email format"
                },
                type: "unit"
            },
            // 登录测试用例
            {
                description: "should successfully login with valid credentials",
                input: {
                    email: "test@example.com",
                    password: "validPassword123"
                },
                expectedOutput: {
                    status: 200,
                    hasToken: true
                },
                type: "integration",
                setup: [
                    "create test user",
                    "verify user exists in database"
                ],
                cleanup: [
                    "remove test user"
                ]
            }
        ];
    }
    
    static generateTestCode(testCase: ITestCase): string {
        const { description, input, expectedOutput, type } = testCase;
        let testCode = "";
        
        if (type === "unit") {
            testCode = `
                it('${description}', async () => {
                    const response = await authService.${input.password ? 'register' : 'login'}(${JSON.stringify(input)});
                    ${this.generateAssertions(expectedOutput)}
                });
            `;
        } else {
            testCode = `
                it('${description}', async () => {
                    ${testCase.setup?.map(step => `// ${step}`).join('\n')}
                    
                    const response = await request(app)
                        .post('/api/auth/${input.password ? 'register' : 'login'}')
                        .send(${JSON.stringify(input)});
                        
                    ${this.generateAssertions(expectedOutput)}
                    
                    ${testCase.cleanup?.map(step => `// ${step}`).join('\n')}
                });
            `;
        }
        
        return testCode;
    }
    
    private static generateAssertions(expectedOutput: any): string {
        const assertions = [];
        
        if (expectedOutput.status) {
            assertions.push(`expect(response.status).toBe(${expectedOutput.status});`);
        }
        
        if (expectedOutput.hasId) {
            assertions.push(`expect(response.body).toHaveProperty('id');`);
        }
        
        if (expectedOutput.hasEmail) {
            assertions.push(`expect(response.body).toHaveProperty('email');`);
        }
        
        if (expectedOutput.noPassword) {
            assertions.push(`expect(response.body).not.toHaveProperty('password');`);
        }
        
        if (expectedOutput.hasToken) {
            assertions.push(`expect(response.body).toHaveProperty('token');`);
            assertions.push(`expect(typeof response.body.token).toBe('string');`);
        }
        
        if (expectedOutput.error) {
            assertions.push(`expect(response.body.error).toBe('${expectedOutput.error}');`);
        }
        
        return assertions.join('\n');
    }
}

3.2 测试辅助工具

// src/tests/utils/TestHelper.ts
import { Database } from '../../database';
import jwt from 'jsonwebtoken';

export class TestHelper {
    private db: Database;
    
    constructor(db: Database) {
        this.db = db;
    }
    
    async createTestUser(email: string, hashedPassword: string) {
        return await this.db.query(
            'INSERT INTO users (email, password) VALUES ($1, $2) RETURNING *',
            [email, hashedPassword]
        );
    }
    
    async clearTestData() {
        await this.db.query('TRUNCATE TABLE users CASCADE');
    }
    
    generateTestToken(userId: string): string {
        return jwt.sign(
            { userId },
            process.env.JWT_SECRET || 'test_secret',
            { expiresIn: '1h' }
        );
    }
    
    async verifyTestToken(token: string) {
        return jwt.verify(token, process.env.JWT_SECRET || 'test_secret');
    }
}

// src/tests/utils/MockGenerator.ts
export class MockGenerator {
    static createMockUser(overrides = {}) {
        return {
            id: 'test-user-id',
            email: 'test@example.com',
            password: 'hashed_password',
            createdAt: new Date(),
            ...overrides
        };
    }
    
    static createMockRequest(overrides = {}) {
        return {
            body: {},
            headers: {},
            params: {},
            query: {},
            ...overrides
        };
    }
    
    static createMockResponse() {
        const res: any = {};
        res.status = jest.fn().mockReturnValue(res);
        res.json = jest.fn().mockReturnValue(res);
        res.send = jest.fn().mockReturnValue(res);
        return res;
    }
}

4. 测试覆盖率分析

让我们添加测试覆盖率配置和分析工具:

// jest.config.js
module.exports = {
    preset: 'ts-jest',
    testEnvironment: 'node',
    roots: ['<rootDir>/src'],
    collectCoverage: true,
    coverageDirectory: 'coverage',
    coverageReporters: ['text', 'lcov', 'clover'],
    coverageThreshold: {
        global: {
            branches: 80,
            functions: 80,
            lines: 80,
            statements: 80
        }
    },
    collectCoverageFrom: [
        'src/**/*.{ts,tsx}',
        '!src/**/*.d.ts',
        '!src/**/index.ts',
        '!src/types/**/*'
    ]
};

// package.json scripts
{
    "scripts": {
        "test": "jest",
        "test:watch": "jest --watch",
        "test:coverage": "jest --coverage",
        "test:ci": "jest --ci --coverage --reporters=default --reporters=jest-junit"
    }
}

5. CI/CD集成测试配置

// .github/workflows/test.yml
name: Run Tests

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:13
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
    - uses: actions/checkout@v2

    - name: Use Node.js
      uses: actions/setup-node@v2
      with:
        node-version: '16.x'
        cache: 'npm'

    - name: Install dependencies
      run: npm ci

    - name: Run tests
      run: npm run test:ci
      env:
        TEST_DB_HOST: localhost
        TEST_DB_PORT: 5432
        TEST_DB_USER: test
        TEST_DB_PASSWORD: test
        TEST_DB_NAME: testdb
        JWT_SECRET: test_secret

    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v3
      with:
        token: ${{ secrets.CODECOV_TOKEN }}
        files: ./coverage/lcov.info
        fail_ci_if_error: true

6. 最佳实践总结

  1. 测试原则

    • 遵循 AAA(Arrange-Act-Assert)模式
    • 每个测试只测试一个概念
    • 使用有意义的测试描述
    • 保持测试代码的简洁和可维护性
  2. 测试策略

    - 单元测试:最小可测试单元
    - 集成测试:模块间交互
    - 端到端测试:完整业务流程
    
  3. Mock策略

    • 只Mock必要的依赖
    • 保持Mock的简单性
    • 避免过度Mock
  4. 测试覆盖率目标

    - 分支覆盖率:80%
    - 行覆盖率:80%
    - 函数覆盖率:80%
    

通过本实战案例,学习了如何使用Cursor AI进行全面的测试实践。从基本的单元测试到复杂的集成测试,再到CI/CD环境中的自动化测试,我们掌握了现代软件测试的关键技术和最佳实践。记住,好的测试不仅能够保证代码质量,还能提供清晰的代码文档和使用示例。

在实际项目中,要根据具体需求和资源情况选择合适的测试策略,并持续优化测试流程,以达到高效率和高质量的平衡。同时,要注意保持测试代码的可维护性,避免测试代码本身成为负担。


怎么样今天的内容还满意吗?再次感谢朋友们的观看,关注GZH:凡人的AI工具箱,回复666,送您价值199的AI大礼包。最后,祝您早日实现财务自由,还请给个赞,谢谢!

Logo

技术共进,成长同行——讯飞AI开发者社区

更多推荐