单元测试:使用 Bun Test 或 Vitest 覆盖边界用例
在构建一个可靠的工具库时,单元测试是不可或缺的环节。它不仅是功能正确性的证明,更是重构和维护的“安全网”。
使用现代测试框架如 Bun Test 或 Vitest,我们可以高效地编写、运行测试,并覆盖那些容易被忽略的边界用例(edge cases),例如:
debounce的leading首次立即执行retry的最大重试次数控制- 空值、异常输入的处理
选择测试框架:Bun Test vs Vitest
| 特性 | Bun Test | Vitest |
|---|---|---|
| 运行时 | Bun(超快启动) | Node.js / Vite |
| 速度 | ⚡ 极快(Bun 原生) | 🚀 很快(HMR 优化) |
| 生态 | 新兴,轻量 | 成熟,插件丰富 |
| 语法兼容性 | 兼容 Jest | 完全兼容 Jest |
| 推荐场景 | 纯 JS/TS 工具库 | Vite 项目、复杂测试 |
建议:若项目已用 Vite,选 Vitest;否则可尝试 Bun Test 体验极致速度。
项目结构
src/
utils/
debounce.ts
retry.ts
pipe.ts
tests/
debounce.test.ts
retry.test.ts
pipe.test.ts1. 测试 debounce:覆盖 leading 边界
debounce.ts 实现
ts
// src/utils/debounce.ts
export function debounce<T extends (...args: any[]) => any>(
fn: T,
wait: number,
options: { leading?: boolean } = {}
): T & { cancel: () => void } {
let timeout: ReturnType<typeof setTimeout> | null = null
let result: any
const debounced = function (this: any, ...args: any[]) {
const callNow = options.leading && !timeout
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
timeout = null
if (!options.leading) result = fn.apply(this, args)
}, wait)
if (callNow) result = fn.apply(this, args)
return result
}
debounced.cancel = () => {
if (timeout) clearTimeout(timeout)
timeout = null
}
return debounced as T & { cancel: () => void }
}测试用例:debounce.test.ts
ts
// tests/debounce.test.ts
import { describe, it, expect, vi } from 'vitest' // 或 bun:test
import { debounce } from '../src/utils/debounce'
// 模拟时间(避免真实等待)
vi.useFakeTimers()
describe('debounce', () => {
it('should debounce function calls', () => {
const fn = vi.fn()
const debounced = debounce(fn, 100)
debounced()
debounced()
debounced()
expect(fn).not.toHaveBeenCalled() // 还未执行
vi.advanceTimersByTime(100)
expect(fn).toHaveBeenCalledTimes(1) // 只执行一次
})
it('should execute immediately when leading = true', () => {
const fn = vi.fn()
const debounced = debounce(fn, 100, { leading: true })
debounced() // 第一次调用立即执行
expect(fn).toHaveBeenCalledTimes(1)
debounced()
debounced()
expect(fn).toHaveBeenCalledTimes(1) // 后续调用不立即执行
vi.advanceTimersByTime(100)
expect(fn).toHaveBeenCalledTimes(2) // 最后一次在延迟后执行
})
it('should support cancel', () => {
const fn = vi.fn()
const debounced = debounce(fn, 100)
debounced()
debounced.cancel()
vi.advanceTimersByTime(100)
expect(fn).not.toHaveBeenCalled()
})
it('should preserve context and return value', () => {
const obj = {
value: 1,
method: function () { return this.value }
}
const debounced = debounce(obj.method, 100)
const result = debounced.call(obj)
expect(result).toBe(1)
})
})关键点
vi.useFakeTimers():模拟时间,避免sleep等待。vi.advanceTimersByTime(100):快进 100ms。- 覆盖
leading、cancel、上下文绑定等边界。
2. 测试 retry:最大重试次数
retry.ts 实现
ts
// src/utils/retry.ts
export async function retry<T>(
fn: () => Promise<T>,
maxRetries = 3,
delay = 0
): Promise<T> {
let lastError: any
for (let i = 0; i <= maxRetries; i++) {
try {
return await fn()
} catch (error) {
lastError = error
if (i < maxRetries && delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay))
}
}
}
throw lastError
}测试用例:retry.test.ts
ts
// tests/retry.test.ts
import { describe, it, expect, vi } from 'vitest'
import { retry } from '../src/utils/retry'
vi.useFakeTimers()
describe('retry', () => {
it('should succeed on first try', async () => {
const fn = vi.fn(() => Promise.resolve('success'))
const result = await retry(fn, 3)
expect(result).toBe('success')
expect(fn).toHaveBeenCalledTimes(1)
})
it('should retry up to maxRetries times', async () => {
const fn = vi.fn(() => Promise.reject(new Error('fail')))
await expect(retry(fn, 2)).rejects.toThrow('fail')
expect(fn).toHaveBeenCalledTimes(3) // 1 + 2 retries
})
it('should resolve if succeeds within retries', async () => {
const fn = vi.fn()
.mockRejectedValueOnce(new Error('fail'))
.mockResolvedValue('success')
const result = await retry(fn, 3)
expect(result).toBe('success')
expect(fn).toHaveBeenCalledTimes(2) // 第一次失败,第二次成功
})
it('should wait with delay between retries', async () => {
const fn = vi.fn(() => Promise.reject(new Error('fail')))
const promise = retry(fn, 1, 100)
// 立即检查:第一次调用
expect(fn).toHaveBeenCalledTimes(1)
// 快进 100ms:第一次重试
vi.advanceTimersByTime(100)
expect(fn).toHaveBeenCalledTimes(2)
// 再快进 100ms:达到最大重试,抛出错误
vi.advanceTimersByTime(100)
await expect(promise).rejects.toThrow('fail')
})
})关键点
- 验证
maxRetries是否精确控制尝试次数。 - 使用
.mockRejectedValueOnce()模拟阶段性失败。 - 测试
delay是否生效。
3. 测试 pipe:类型安全的函数链
pipe.test.ts
ts
// tests/pipe.test.ts
import { describe, it, expect } from 'vitest'
import { pipe } from '../src/function/pipe'
describe('pipe', () => {
it('should compose functions left to right', () => {
const add1 = (x: number) => x + 1
const mul2 = (x: number) => x * 2
const toString = (x: number) => x.toString()
const fn = pipe(add1, mul2, toString)
expect(fn(3)).toBe('8') // (3+1)*2 = 8
})
it('should handle single function', () => {
const fn = pipe((x: string) => x.toUpperCase())
expect(fn('hello')).toBe('HELLO')
})
it('should handle empty pipe', () => {
// 可选:支持空参数
// const fn = pipe()
// expect(fn(42)).toBe(42)
})
})运行测试
使用 Vitest
bash
# 安装
npm install -D vitest @vitest/coverage-v8
# 运行
npx vitest使用 Bun Test
bash
# 运行(Bun 环境)
bun test
# 监听模式
bun test --watch测试覆盖率:确保边界覆盖
配置 vitest.config.ts 生成覆盖率报告:
ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['node_modules', 'tests/']
}
}
})运行:
bash
npx vitest run --coverage确保:
leading: true分支被覆盖maxRetries达到上限时正确抛出cancel()能阻止执行
结语:测试是质量的基石
单元测试的价值不仅在于“现在能跑”,更在于:
- 预防回归:修改代码时,测试会立刻告诉你是否破坏了原有功能。
- 文档化行为:测试用例是最真实的“使用示例”。
- 提升信心:覆盖边界 case 后,你敢重构、敢发布。
当你为 debounce 的 leading 选项和 retry 的最大重试次数编写了测试,你不仅在验证代码,更在定义契约——这个函数在各种极端情况下应该如何表现。
这才是专业级工具库的底气所在。