Skip to content

单元测试:使用 Bun Test 或 Vitest 覆盖边界用例

在构建一个可靠的工具库时,单元测试是不可或缺的环节。它不仅是功能正确性的证明,更是重构和维护的“安全网”。

使用现代测试框架如 Bun TestVitest,我们可以高效地编写、运行测试,并覆盖那些容易被忽略的边界用例(edge cases),例如:

  • debounceleading 首次立即执行
  • retry 的最大重试次数控制
  • 空值、异常输入的处理

选择测试框架:Bun Test vs Vitest

特性Bun TestVitest
运行时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.ts

1. 测试 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。
  • 覆盖 leadingcancel、上下文绑定等边界。

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 后,你敢重构、敢发布。

当你为 debounceleading 选项和 retry 的最大重试次数编写了测试,你不仅在验证代码,更在定义契约——这个函数在各种极端情况下应该如何表现。

这才是专业级工具库的底气所在。