技术栈选型之后做什么之问题6:独立开发者需要写测试吗?如果需要,应该写到什么程度?
测试是软件开发中最容易引发争议的话题之一。大公司强调测试覆盖率,TDD(测试驱动开发)的拥护者认为不写测试就是不专业,但很多独立开发者觉得写测试是浪费时间。。
测试是软件开发中最容易引发争议的话题之一。大公司强调测试覆盖率,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):
- 支付和计费逻辑
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);
});
});
- 认证和授权
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();
});
});
- 数据验证和清洗
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):
- 复杂的业务逻辑
- 数据转换函数
- 关键的API端点
可以不测试(低ROI):
- 简单的CRUD操作
- UI组件(除非有复杂交互)
- 配置和常量
- 第三方库的简单封装
测试驱动开发(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: 上传覆盖率报告
最终建议:务实的测试哲学
对于独立开发者,我的测试哲学是:
- 不要追求100%覆盖率,60-70%的核心代码覆盖率就足够了
- 优先测试会导致严重后果的代码(支付、认证、数据丢失)
- 把测试当作投资而非成本,初期慢一点,长期会更快
- 不要为了测试而测试,如果某段代码很难测试,可能是设计有问题
- 测试应该增强信心而非增加负担,如果测试让你感到痛苦,说明方法不对
记住:没有测试的代码不一定是坏代码,有测试的代码也不一定是好代码。测试只是工具,目标是交付高质量的产品。根据你的项目阶段、时间预算、风险承受能力,做出最合理的选择。
接下来我将回答问题7:如何设计API接口才能既满足当前需求又具备良好的扩展性?
更多推荐



所有评论(0)