测试是软件开发中最容易引发争议的话题之一。大公司强调测试覆盖率,TDD(测试驱动开发)的拥护者认为不写测试就是不专业,但很多独立开发者觉得写测试是浪费时间。真相在两个极端之间:测试是有价值的,但不是所有代码都值得测试,关键是找到投入产出比最高的平衡点

测试的真实成本和收益

在讨论是否写测试之前,先要算清楚账。测试不是免费的,它有明确的成本:

时间成本

  • 编写测试代码的时间(通常是功能代码的50-100%)
  • 维护测试的时间(需求变更时,测试也要改)
  • 运行测试的时间(CI/CD流程会变长)
  • 学习测试框架和最佳实践的时间

认知成本

  • 需要思考"如何测试"而不只是"如何实现"
  • 需要设计可测试的代码结构
  • 需要处理测试环境的配置和依赖

但测试也有实实在在的收益:

短期收益

  • 更早发现bug(编写时就发现,而不是用户报告后)
  • 重构的信心(改代码后跑一遍测试就知道有没有破坏功能)
  • 强制思考边界情况(写测试时会想到正常流程之外的场景)

长期收益

  • 降低维护成本(修改代码时不用担心引入新bug)
  • 文档作用(测试代码展示了功能的预期行为)
  • 团队协作(新成员通过测试理解代码逻辑)

对于独立开发者,关键问题是:你的项目处于什么阶段?时间预算有多少?代码的关键程度如何?

不同阶段的测试策略

阶段1:MVP验证期(前1-2个月)

这个阶段的核心目标是快速验证想法,代码可能随时推倒重来。建议:几乎不写测试

理由:

  • 需求还不稳定,今天写的测试明天可能就没用了
  • 时间是最宝贵的资源,应该全部投入到功能开发
  • 代码质量不是这个阶段的重点,能跑起来就行

例外情况:

  • 核心算法(如定价计算、推荐算法)可以写单元测试
  • 支付、认证等关键流程可以写集成测试
// MVP阶段:不写测试,快速实现
function calculateDiscount(price: number, couponCode: string) {
  if (couponCode === 'SAVE10') {
    return price * 0.9;
  }
  return price;
}

// 直接用,不测试

阶段2:产品迭代期(3-6个月)

产品已经有了真实用户,核心功能稳定,开始添加新特性。建议:选择性写测试,重点覆盖核心逻辑

应该测试的:

  • 业务逻辑函数(纯函数,输入输出明确)
  • 数据处理和转换
  • 关键的工具函数
  • API端点(集成测试)

不需要测试的:

  • UI组件(除非是复杂的交互组件)
  • 简单的CRUD操作
  • 第三方库的封装(假设第三方库已经测试过)
// 产品迭代期:核心逻辑写测试
function calculateDiscount(price: number, coupon: Coupon | null) {
  if (!coupon) return price;
  
  if (coupon.type === 'percentage') {
    return price * (1 - coupon.value / 100);
  }
  
  if (coupon.type === 'fixed') {
    return Math.max(0, price - coupon.value);
  }
  
  return price;
}

// 测试核心逻辑
describe('calculateDiscount', () => {
  it('无优惠券时返回原价', () => {
    expect(calculateDiscount(100, null)).toBe(100);
  });
  
  it('百分比优惠券正确计算', () => {
    expect(calculateDiscount(100, { type: 'percentage', value: 10 })).toBe(90);
  });
  
  it('固定金额优惠券正确计算', () => {
    expect(calculateDiscount(100, { type: 'fixed', value: 20 })).toBe(80);
  });
  
  it('优惠后价格不低于0', () => {
    expect(calculateDiscount(10, { type: 'fixed', value: 20 })).toBe(0);
  });
});

阶段3:规模化期(6个月后)

用户量增长,团队可能扩大,代码库变得复杂。建议:建立完整的测试体系

测试金字塔:

  • 70%单元测试(快速、独立、覆盖业务逻辑)
  • 20%集成测试(测试模块间交互)
  • 10%端到端测试(模拟用户操作)

这个阶段应该:

  • 设置测试覆盖率目标(如核心模块80%以上)
  • 在CI/CD中强制运行测试
  • 新功能必须包含测试
  • 修复bug时先写失败的测试,再修复

测试金字塔:不同层次的测试策略

1. 单元测试(Unit Tests)

测试单个函数或类,隔离所有外部依赖。

优点

  • 运行速度快(毫秒级)
  • 容易定位问题
  • 强制你写可测试的代码(解耦)

缺点

  • 不能保证模块间的集成正确
  • 可能过度关注实现细节

适合测试的

  • 纯函数(无副作用)
  • 数据转换和验证
  • 业务规则和算法
// 纯函数,非常适合单元测试
function formatCurrency(amount: number, currency: string = 'USD'): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency,
  }).format(amount);
}

describe('formatCurrency', () => {
  it('格式化美元', () => {
    expect(formatCurrency(1234.56)).toBe('$1,234.56');
  });
  
  it('格式化欧元', () => {
    expect(formatCurrency(1234.56, 'EUR')).toBe('€1,234.56');
  });
  
  it('处理负数', () => {
    expect(formatCurrency(-100)).toBe('-$100.00');
  });
});

Mock外部依赖

// 有外部依赖的函数
async function getUserProfile(userId: string) {
  const response = await fetch(`/api/users/${userId}`);
  const data = await response.json();
  return {
    ...data,
    displayName: `${data.firstName} ${data.lastName}`,
  };
}

// 测试时mock fetch
import { vi } from 'vitest';

describe('getUserProfile', () => {
  it('正确获取和转换用户数据', async () => {
    // Mock fetch
    global.fetch = vi.fn().mockResolvedValue({
      json: async () => ({ firstName: 'John', lastName: 'Doe', email: 'john@example.com' }),
    });
    
    const profile = await getUserProfile('123');
    
    expect(profile.displayName).toBe('John Doe');
    expect(profile.email).toBe('john@example.com');
  });
});

2. 集成测试(Integration Tests)

测试多个模块的协作,包括数据库、API等真实依赖。

优点

  • 发现模块间的接口问题
  • 更接近真实使用场景
  • 覆盖更多代码路径

缺点

  • 运行较慢(秒级)
  • 需要配置测试环境(数据库、API mock)
  • 失败时定位问题较难

适合测试的

  • API端点
  • 数据库操作
  • 第三方服务集成
// API集成测试示例(使用Supertest)
import request from 'supertest';
import { app } from '../app';
import { db } from '../db';

describe('POST /api/users', () => {
  beforeEach(async () => {
    // 每次测试前清空数据库
    await db.users.deleteMany({});
  });
  
  it('创建新用户', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({
        email: 'test@example.com',
        name: 'Test User',
      })
      .expect(201);
    
    expect(response.body).toMatchObject({
      email: 'test@example.com',
      name: 'Test User',
    });
    
    // 验证数据库中确实创建了
    const user = await db.users.findOne({ email: 'test@example.com' });
    expect(user).toBeTruthy();
  });
  
  it('拒绝重复邮箱', async () => {
    // 先创建一个用户
    await db.users.create({ email: 'test@example.com', name: 'Existing' });
    
    // 尝试创建相同邮箱的用户
    await request(app)
      .post('/api/users')
      .send({
        email: 'test@example.com',
        name: 'New User',
      })
      .expect(409);  // Conflict
  });
});

3. 端到端测试(E2E Tests)

模拟真实用户操作,从浏览器层面测试整个应用。

优点

  • 最接近真实用户体验
  • 覆盖前后端完整流程
  • 发现UI和交互问题

缺点

  • 运行非常慢(分钟级)
  • 脆弱(UI变动就需要更新测试)
  • 调试困难

适合测试的

  • 关键用户流程(注册、登录、购买)
  • 跨页面的复杂操作
  • 浏览器兼容性
// E2E测试示例(使用Playwright)
import { test, expect } from '@playwright/test';

test('用户注册流程', async ({ page }) => {
  // 访问注册页面
  await page.goto('/signup');
  
  // 填写表单
  await page.fill('input[name="email"]', 'newuser@example.com');
  await page.fill('input[name="password"]', 'SecurePass123!');
  await page.fill('input[name="confirmPassword"]', 'SecurePass123!');
  
  // 提交表单
  await page.click('button[type="submit"]');
  
  // 等待跳转到欢迎页面
  await expect(page).toHaveURL('/welcome');
  
  // 验证欢迎消息
  await expect(page.locator('h1')).toContainText('欢迎');
});

test('购物流程', async ({ page }) => {
  // 登录
  await page.goto('/login');
  await page.fill('input[name="email"]', 'user@example.com');
  await page.fill('input[name="password"]', 'password');
  await page.click('button[type="submit"]');
  
  // 浏览商品
  await page.goto('/products');
  await page.click('text=iPhone 15');
  
  // 加入购物车
  await page.click('button:has-text("加入购物车")');
  
  // 查看购物车
  await page.click('a[href="/cart"]');
  await expect(page.locator('.cart-item')).toContainText('iPhone 15');
  
  // 结账
  await page.click('button:has-text("结账")');
  await page.fill('input[name="address"]', '123 Main St');
  await page.click('button:has-text("确认订单")');
  
  // 验证订单成功
  await expect(page.locator('.success-message')).toBeVisible();
});

独立开发者的实用测试策略

基于投入产出比,这是我推荐的测试优先级:

必须测试(高ROI)

  1. 支付和计费逻辑
describe('订单总价计算', () => {
  it('正确计算商品总价', () => {
    const items = [
      { price: 100, quantity: 2 },
      { price: 50, quantity: 1 },
    ];
    expect(calculateTotal(items)).toBe(250);
  });
  
  it('应用优惠券', () => {
    const items = [{ price: 100, quantity: 1 }];
    const coupon = { type: 'percentage', value: 20 };
    expect(calculateTotal(items, coupon)).toBe(80);
  });
  
  it('加上运费', () => {
    const items = [{ price: 100, quantity: 1 }];
    expect(calculateTotal(items, null, 10)).toBe(110);
  });
});
  1. 认证和授权
describe('JWT验证', () => {
  it('有效token返回用户信息', () => {
    const token = generateToken({ userId: '123' });
    const decoded = verifyToken(token);
    expect(decoded.userId).toBe('123');
  });
  
  it('过期token抛出错误', () => {
    const token = generateToken({ userId: '123' }, { expiresIn: '0s' });
    expect(() => verifyToken(token)).toThrow('Token expired');
  });
  
  it('无效token抛出错误', () => {
    expect(() => verifyToken('invalid.token.here')).toThrow();
  });
});
  1. 数据验证和清洗
describe('邮箱验证', () => {
  it('接受有效邮箱', () => {
    expect(isValidEmail('user@example.com')).toBe(true);
  });
  
  it('拒绝无效邮箱', () => {
    expect(isValidEmail('invalid')).toBe(false);
    expect(isValidEmail('user@')).toBe(false);
    expect(isValidEmail('@example.com')).toBe(false);
  });
});

应该测试(中ROI)

  1. 复杂的业务逻辑
  2. 数据转换函数
  3. 关键的API端点

可以不测试(低ROI)

  1. 简单的CRUD操作
  2. UI组件(除非有复杂交互)
  3. 配置和常量
  4. 第三方库的简单封装

测试驱动开发(TDD):适合独立开发者吗?

TDD的流程是:先写失败的测试 → 写代码让测试通过 → 重构。

TDD的优点

  • 强制思考需求和接口设计
  • 保证代码可测试性
  • 避免过度设计(只写让测试通过的代码)

TDD的缺点

  • 学习曲线陡峭
  • 初期开发速度慢
  • 需求不明确时很痛苦

我的建议

  • 对于核心算法和复杂逻辑,可以尝试TDD
  • 对于探索性开发和UI实现,不要强求TDD
  • 混合使用:先快速实现原型,再补充测试,最后重构

一个TDD的实际例子:

// 1. 先写测试(此时函数还不存在)
describe('密码强度检查', () => {
  it('拒绝短于8位的密码', () => {
    expect(checkPasswordStrength('abc123')).toEqual({
      valid: false,
      reason: '密码至少8位',
    });
  });
  
  it('拒绝没有数字的密码', () => {
    expect(checkPasswordStrength('abcdefgh')).toEqual({
      valid: false,
      reason: '密码必须包含数字',
    });
  });
  
  it('接受符合要求的密码', () => {
    expect(checkPasswordStrength('Abc12345')).toEqual({
      valid: true,
    });
  });
});

// 2. 写最简单的实现让测试通过
function checkPasswordStrength(password: string) {
  if (password.length < 8) {
    return { valid: false, reason: '密码至少8位' };
  }
  
  if (!/\d/.test(password)) {
    return { valid: false, reason: '密码必须包含数字' };
  }
  
  return { valid: true };
}

// 3. 重构(如果需要)
function checkPasswordStrength(password: string) {
  const rules = [
    { test: (p: string) => p.length >= 8, message: '密码至少8位' },
    { test: (p: string) => /\d/.test(p), message: '密码必须包含数字' },
    { test: (p: string) => /[A-Z]/.test(p), message: '密码必须包含大写字母' },
  ];
  
  for (const rule of rules) {
    if (!rule.test(password)) {
      return { valid: false, reason: rule.message };
    }
  }
  
  return { valid: true };
}

测试工具选择

JavaScript/TypeScript生态

  • 测试框架:Jest(最流行)、Vitest(更快、更现代)、Mocha(老牌)
  • 断言库:内置(Jest/Vitest)、Chai(配合Mocha)
  • Mock库:内置(Jest/Vitest)、Sinon
  • E2E测试:Playwright(推荐)、Cypress、Puppeteer
  • API测试:Supertest、MSW(Mock Service Worker)

推荐组合

  • 小型项目:Vitest + Playwright
  • 大型项目:Jest + Testing Library + Playwright
  • API项目:Vitest + Supertest

配置示例(Vitest):

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'jsdom',  // 用于测试React组件
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      exclude: [
        'node_modules/',
        'test/',
        '**/*.config.ts',
      ],
    },
  },
});

持续集成中的测试

测试的价值在于自动化运行。在GitHub Actions中配置:

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: 'npm'
      
      - run: npm ci
      
      - run: npm run test:unit
        name: 运行单元测试
      
      - run: npm run test:integration
        name: 运行集成测试
      
      - run: npm run test:e2e
        name: 运行E2E测试
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        # E2E测试只在主分支运行,因为太慢
      
      - uses: codecov/codecov-action@v3
        name: 上传覆盖率报告

最终建议:务实的测试哲学

对于独立开发者,我的测试哲学是:

  1. 不要追求100%覆盖率,60-70%的核心代码覆盖率就足够了
  2. 优先测试会导致严重后果的代码(支付、认证、数据丢失)
  3. 把测试当作投资而非成本,初期慢一点,长期会更快
  4. 不要为了测试而测试,如果某段代码很难测试,可能是设计有问题
  5. 测试应该增强信心而非增加负担,如果测试让你感到痛苦,说明方法不对

记住:没有测试的代码不一定是坏代码,有测试的代码也不一定是好代码。测试只是工具,目标是交付高质量的产品。根据你的项目阶段、时间预算、风险承受能力,做出最合理的选择。


接下来我将回答问题7:如何设计API接口才能既满足当前需求又具备良好的扩展性?

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐