把小型 TypeScript 单元测试与实现放在同一文件

原文作者:VoidZero Inc. 与 Vitest 文档贡献者(原页无个人署名)。中文译写与编辑整理:未完纪。

测试一个十来行的工具函数时,来回跳转到另一个文件,有时比阅读实现本身还费事。Vitest 提供源码内测试:把测试写在实现所在文件中,测试与实现共享词法作用域,因此能检查未导出的内部状态,也让修改与反馈挨得更近。这种组织方式与 Rust 的模块测试类似。

本文译写自 Vitest 官方 In-Source Testing,保留全部实质章节及构建器配置。它讨论的是源码内测试;常规独立测试文件的写法见官方 Writing Tests。以下命令与输出逻辑用于阅读,本文仅作静态审核,未运行测试或构建。

源码中的实现和条件测试块进入开发测试路径;生产路径先将 import.meta.vitest 替换为 undefined,再经死代码移除并核对发布产物。
源码测试的两条路径(原创技术示意图)

先让实现与测试共处一处

在源码文件底部添加 if (import.meta.vitest),把测试放进该条件块。下面是原文的完整求和示例,文件为 src/index.ts。

// 实现
export function add(...args: number[]) {
  return args.reduce((a, b) => a + b, 0)
}

// 源码内测试
if (import.meta.vitest) {
  const { it, expect } = import.meta.vitest
  it('add', () => {
    expect(add()).toBe(0)
    expect(add(1)).toBe(1)
    expect(add(1, 2, 3)).toBe(6)
  })
}

reduce 的初始值是 0,因此空参数应得到 0;单个参数保留原值;多个参数相加得到总和。三条断言分别固定了这三种基本行为。示例为了展示可复用函数而使用 export,但源码内测试本身并不要求把内部函数或状态导出。

只加条件块还不够。还要在 vitest.config.ts 中设置 test.includeSource,让 Vitest 发现 src/ 下的 JavaScript 与 TypeScript 源文件。

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    includeSource: ['src/**/*.{js,ts}'],
  },
})

配置就绪后,原文用以下命令启动测试:

npx vitest

补充说明:当前官方入门页要求 Node 不低于 22.12.0、Vite 不低于 6.4.0。宜把 Vitest 安装为项目开发依赖并随锁文件管理;npx 找不到本地工具时可能临时下载再执行。源码扫描范围应按项目目录收敛,测试代码仍然属于会在开发环境执行的程序。

生产构建必须明确移除测试分支

开发时能运行,并不意味着发布时会自动消失。原文要求在构建配置中设置 define,让打包器把 import.meta.vitest 替换成 JavaScript 的 undefined 表达式,再由死代码消除删除条件分支。配置值 'undefined' 是用于代码替换的字符串,不是把测试标记设置成字符串值。

使用 Vite 时,vite.config.ts 可以把测试发现与构建替换放在一起:

/// <reference types="vitest/config" />

import { defineConfig } from 'vite'

export default defineConfig({
  test: {
    includeSource: ['src/**/*.{js,ts}'],
  },
  define: {
    'import.meta.vitest': 'undefined',
  },
})

这里的类型引用让配置中的 test 字段有对应类型。若项目把 Vite 配置与 Vitest 配置分开,仍要确保实际负责发布的构建配置包含替换项;给一份并未被生产构建读取的文件添加配置,不会改变发布结果。

Rolldown

Rolldown 把替换项放在 transform.define 下,文件为 rolldown.config.js:

import { defineConfig } from 'rolldown/config'

export default defineConfig({
  transform: {
    define: {
      'import.meta.vitest': 'undefined',
    },
  },
})

Rollup

Rollup 示例使用 @rollup/plugin-replace,文件为 rollup.config.js。其余输入、输出等选项继续沿用项目现有配置。

import replace from '@rollup/plugin-replace'

export default {
  plugins: [
    replace({
      'import.meta.vitest': 'undefined',
    }),
  ],
  // 其余项目配置
}

unbuild

unbuild 的配置文件 build.config.js 使用顶层 replace:

import { defineBuildConfig } from 'unbuild'

export default defineBuildConfig({
  replace: {
    'import.meta.vitest': 'undefined',
  },
  // 其余项目配置
})

webpack

webpack 通过 DefinePlugin 完成替换,文件为 webpack.config.js:

const webpack = require('webpack')

module.exports = {
  plugins: [
    new webpack.DefinePlugin({
      'import.meta.vitest': 'undefined',
    }),
  ],
}

四种写法表达的是同一目标,但配置位置取决于构建工具。可分别查阅 Rolldown、Rollup、unbuild 和 webpack DefinePlugin 的文档。本文保留原页配置,没有把它们拼成需要同时启用的插件链。

编辑补充:应在自己的构建环境确认最终 JavaScript 中没有测试块及测试依赖,并同时检查打算发布的源码文件与 sourcemap。死代码移除针对构建产物,不能自动保证源码包、调试映射或其他入口没有带出测试数据;不要把密钥写进任何测试夹具。这里没有执行构建,因而不把“应当移除”表述成“已经移除”。

给 TypeScript 补上 import.meta 类型

为了让 TypeScript 识别 import.meta.vitest,把 vitest/importMeta 加入 tsconfig.json 的 compilerOptions.types:

{
  "compilerOptions": {
    "types": [
      "vitest/importMeta"
    ]
  }
}

如果 types 已经包含 node 或其他条目,应合并新增这一项。显式设置 types 会限制自动纳入的全局类型包,不能为了粘贴示例而无意删除项目原有配置。官方还提供 完整源码内测试示例。

assert 的类型收窄限制

原文特别提示:assert 这类断言函数通过源码内测试 API 使用时,存在 TypeScript 限制。官方 assert 说明 将其解释为 TS2775:断言函数需要通过具有显式类型注解的名称调用。可以给变量标上 Chai.Assert,或直接经 import.meta.vitest 调用。下例是在官方修正形式上换用求和变量的编辑补充:

if (import.meta.vitest) {
  const { test } = import.meta.vitest
  const assert: Chai.Assert = import.meta.vitest.assert

  test('sum is positive', () => {
    const sum = add(1, 2)
    assert(sum > 0, 'sum should be positive')
  })
}

它不是另外一种测试框架,而是为 TypeScript 的断言签名提供明确标注。原始求和示例用 expect(...).toBe(...),不需要因为这个提醒而改写所有断言。

什么时候值得这样组织

  • 范围很小的函数或工具函数:实现与几条输入输出约束放在一起,阅读时即可看到约定。
  • 原型阶段:快速改变实现并就地观察反馈。
  • 内联断言:需要在同一作用域检验内部状态,又不希望仅为测试扩大导出接口。

当测试需要复杂组件装配、大量夹具、跨模块场景或端到端环境时,官方建议使用独立测试文件。源码内测试的价值在于局部性;如果测试本身已经盖过了实现,拆出独立文件往往更便于维护。

来源、版本与译写说明

原文:In-Source Testing,作者归属 VoidZero Inc. 与 Vitest contributors;原页未列个人署名或明确发表日期。本文按 2026-10-05 读取的英文正文完整译写,删除页面导航和重复的代码高亮标记,补充配置合并、发布产物检查和断言类型示例;未运行文中代码。原文及代码适用 MIT 许可证,代码版权与许可原文在文末公开保留。示意图为未完纪原创绘制,不是产品截图。

上游代码版权与完整许可

以下保留对应上游版本的完整声明;第三方例外与附加通知按原文保留。

MIT License

Copyright (c) 2021-Present VoidZero Inc. and Vitest contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容