diff --git a/docs/guide/features.md b/docs/guide/features.md index 88e32ace0..498a5c30b 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -248,3 +248,75 @@ export default defineConfig({ } }) ``` + +## In-source testing + +Vitest also provides a way to run tests with in your source code along with the implementation, simliar to [Rust's module tests](https://doc.rust-lang.org/book/ch11-03-test-organization.html#the-tests-module-and-cfgtest). + +This makes the tests share the same closure as the implementations and able to test against private states without exporting. Meanwhile, it also brings the closer feedback loop for development. + +To get started, write a `if (import.meta.vitest)` block at the end of your source file, and write the tests inside it. For example: + +```ts +// src/index.ts + +// the implementation +export function add(...args: number[]) { + return args.reduce((a, b) => a + b, 0) +} + +// in-source test suites +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) + }) +} +``` + +Update the `includeSource` config for Vitest to grab the files under `src/`: + +```ts +// vite.config.ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + includeSource: ['src/**/*.{js,ts}'] + } +}) +``` + +Then you can start to test! + +```bash +$ npx vitest +``` + +For production build, you will need to set the `define` options in your config file, letting the bundler to do the dead code elimination. For example, in Vite + +```diff +// vite.config.ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ ++ define: { ++ 'import.meta.vitest': false, ++ }, + test: { + includeSource: ['src/**/*.{js,ts}'] + }, +}) +``` + +For reference to [`test/import-meta`](https://github.com/vitest-dev/vitest/tree/main/test/import-meta) for a full example. + +This feature could be useful for: + +- Unit testing for small-scoped functions or utilities +- Prototyping +- Inline Assertion + +It's recommended to use **separate test files instead** for more complex tests like components or E2E testing. diff --git a/examples/react-mui/vite.config.ts b/examples/react-mui/vite.config.ts index b034ea02c..12095ef61 100644 --- a/examples/react-mui/vite.config.ts +++ b/examples/react-mui/vite.config.ts @@ -1,13 +1,11 @@ -import { defineConfig } from 'vite' +import { defineConfig } from 'vitest/config' -export default defineConfig(() => { - return { - esbuild: { - jsxInject: 'import React from \'react\'', - }, - test: { - environment: 'jsdom', - globals: true, - }, - } +export default defineConfig({ + esbuild: { + jsxInject: 'import React from \'react\'', + }, + test: { + environment: 'jsdom', + globals: true, + }, }) diff --git a/packages/vitest/importMeta.d.ts b/packages/vitest/importMeta.d.ts new file mode 100644 index 000000000..689212677 --- /dev/null +++ b/packages/vitest/importMeta.d.ts @@ -0,0 +1,4 @@ +interface ImportMeta { + url: string + readonly vitest?: typeof import('vitest') +} diff --git a/packages/vitest/package.json b/packages/vitest/package.json index a3ebf332f..ee51f1159 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -32,6 +32,9 @@ "./globals": { "types": "./globals.d.ts" }, + "./importMeta": { + "types": "./importMeta.d.ts" + }, "./node": { "import": "./dist/node.js", "types": "./dist/node.d.ts" diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 8be6c6d20..e9cf8c87a 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'fs' +import { existsSync, promises as fs } from 'fs' import type { ViteDevServer } from 'vite' import fg from 'fast-glob' import mm from 'micromatch' @@ -310,9 +310,9 @@ export class Vitest { this.changedTests.delete(id) } } - const onAdd = (id: string) => { + const onAdd = async(id: string) => { id = slash(id) - if (this.isTargetFile(id)) { + if (await this.isTargetFile(id)) { this.changedTests.add(id) this.scheduleRerun(id) } @@ -394,25 +394,51 @@ export class Vitest { } async globTestFiles(filters?: string[]) { - let files = await fg( - this.config.include, - { - absolute: true, - cwd: this.config.dir || this.config.root, - ignore: this.config.exclude, - }, - ) + const globOptions = { + absolute: true, + cwd: this.config.dir || this.config.root, + ignore: this.config.exclude, + } + + let testFiles = await fg(this.config.include, globOptions) if (filters?.length) - files = files.filter(i => filters.some(f => i.includes(f))) + testFiles = testFiles.filter(i => filters.some(f => i.includes(f))) + + if (this.config.includeSource) { + let files = await fg(this.config.includeSource, globOptions) + if (filters?.length) + files = files.filter(i => filters.some(f => i.includes(f))) + + await Promise.all(files.map(async(file) => { + try { + const code = await fs.readFile(file, 'utf-8') + if (this.isInSourceTestFile(code)) + testFiles.push(file) + } + catch { + return null + } + })) + } - return files + return testFiles } - isTargetFile(id: string): boolean { + async isTargetFile(id: string, source?: string): Promise { if (mm.isMatch(id, this.config.exclude)) return false - return mm.isMatch(id, this.config.include) + if (mm.isMatch(id, this.config.include)) + return true + if (this.config.includeSource?.length && mm.isMatch(id, this.config.includeSource)) { + source = source || await fs.readFile(id, 'utf-8') + return this.isInSourceTestFile(source) + } + return false + } + + isInSourceTestFile(code: string) { + return code.includes('import.meta.vitest') } printError(err: unknown) { diff --git a/packages/vitest/src/node/execute.ts b/packages/vitest/src/node/execute.ts index 89b3ec2c3..71cdcd9da 100644 --- a/packages/vitest/src/node/execute.ts +++ b/packages/vitest/src/node/execute.ts @@ -1,5 +1,6 @@ import { ViteNodeRunner } from 'vite-node/client' import type { ModuleCache, ViteNodeRunnerOptions } from 'vite-node' +import { normalizePath } from 'vite' import type { SuiteMocks } from './mocker' import { VitestMocker } from './mocker' @@ -23,6 +24,7 @@ export async function executeInViteNode(options: ExecuteOptions) { export class VitestRunner extends ViteNodeRunner { mocker: VitestMocker + entries = new Set() constructor(public options: ExecuteOptions) { super(options) @@ -38,10 +40,15 @@ export class VitestRunner extends ViteNodeRunner { this.setCache(dep, module) }) + // support `import.meta.vitest` for test entry + if (__vitest_worker__.filepath && normalizePath(__vitest_worker__.filepath) === normalizePath(context.__filename)) { + // @ts-expect-error injected untyped global + Object.defineProperty(context.__vite_ssr_import_meta__, 'vitest', { get: () => globalThis.__vitest_index__ }) + } + return Object.assign(context, { __vite_ssr_import__: (dep: string) => mocker.requestWithMock(dep), __vite_ssr_dynamic_import__: (dep: string) => mocker.requestWithMock(dep), - __vitest_mocker__: mocker, }) } diff --git a/packages/vitest/src/node/plugins/index.ts b/packages/vitest/src/node/plugins/index.ts index b74ddb927..e7eb831a8 100644 --- a/packages/vitest/src/node/plugins/index.ts +++ b/packages/vitest/src/node/plugins/index.ts @@ -27,6 +27,9 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest()) const preOptions = deepMerge({}, configDefaults, options, viteConfig.test ?? {}) preOptions.api = resolveApiConfig(preOptions) + if (viteConfig.define) + delete viteConfig.define['import.meta.vitest'] + // store defines for globalThis to make them // reassignable when running in worker in src/runtime/setup.ts const defines: Record = {} diff --git a/packages/vitest/src/runtime/setup.ts b/packages/vitest/src/runtime/setup.ts index 6398426a5..3e1b94b1a 100644 --- a/packages/vitest/src/runtime/setup.ts +++ b/packages/vitest/src/runtime/setup.ts @@ -3,11 +3,17 @@ import { Writable } from 'stream' import { environments } from '../integrations/env' import type { ResolvedConfig } from '../types' import { toArray } from '../utils' +import * as VitestIndex from '../index' import { rpc } from './rpc' let globalSetup = false export async function setupGlobalEnv(config: ResolvedConfig) { - // should be redeclared for each test + Object.defineProperty(globalThis, '__vitest_index__', { + value: VitestIndex, + enumerable: false, + }) + + // should be re-declared for each test // if run with "threads: false" setupDefines(config.defines) diff --git a/packages/vitest/src/types/config.ts b/packages/vitest/src/types/config.ts index bedaac53b..3fac40aa6 100644 --- a/packages/vitest/src/types/config.ts +++ b/packages/vitest/src/types/config.ts @@ -34,6 +34,13 @@ export interface InlineConfig { */ exclude?: string[] + /** + * Include globs for in-source test files + * + * @default [] + */ + includeSource?: string[] + /** * Handling for dependencies inlining or externalizing */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57955d251..2a2636c42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -720,6 +720,12 @@ importers: execa: 6.1.0 vitest: link:../../packages/vitest + test/import-meta: + specifiers: + vitest: workspace:* + devDependencies: + vitest: link:../../packages/vitest + test/related: specifiers: vitest: workspace:* diff --git a/shims.d.ts b/shims.d.ts new file mode 100644 index 000000000..438671ebf --- /dev/null +++ b/shims.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/test/import-meta/package.json b/test/import-meta/package.json new file mode 100644 index 000000000..414ea66da --- /dev/null +++ b/test/import-meta/package.json @@ -0,0 +1,12 @@ +{ + "name": "@vitest/test-core", + "private": true, + "scripts": { + "test": "vitest", + "build": "vite build", + "coverage": "vitest run --coverage" + }, + "devDependencies": { + "vitest": "workspace:*" + } +} diff --git a/test/import-meta/src/add.ts b/test/import-meta/src/add.ts new file mode 100644 index 000000000..b24eb2fd4 --- /dev/null +++ b/test/import-meta/src/add.ts @@ -0,0 +1,13 @@ +export function add(...args: number[]) { + return args.reduce((a, b) => a + b, 0) +} + +// in-source test suites +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) + }) +} diff --git a/test/import-meta/src/fibonacci.ts b/test/import-meta/src/fibonacci.ts new file mode 100644 index 000000000..054ef9972 --- /dev/null +++ b/test/import-meta/src/fibonacci.ts @@ -0,0 +1,23 @@ +import { add } from './add' + +export function fibonacci(n: number): number { + if (n < 2) + return n + return add(fibonacci(n - 1), fibonacci(n - 2)) +} + +if (import.meta.vitest) { + const { it, expect } = import.meta.vitest + it('fibonacci', () => { + expect(fibonacci(0)).toBe(0) + expect(fibonacci(1)).toBe(1) + expect(fibonacci(2)).toBe(1) + expect(fibonacci(3)).toBe(2) + expect(fibonacci(4)).toBe(3) + expect(fibonacci(5)).toBe(5) + expect(fibonacci(6)).toBe(8) + expect(fibonacci(7)).toBe(13) + expect(fibonacci(8)).toBe(21) + expect(fibonacci(9)).toMatchInlineSnapshot('34') + }) +} diff --git a/test/import-meta/src/index.ts b/test/import-meta/src/index.ts new file mode 100644 index 000000000..448daa7fd --- /dev/null +++ b/test/import-meta/src/index.ts @@ -0,0 +1,2 @@ +export * from './add' +export * from './fibonacci' diff --git a/test/import-meta/vite.config.ts b/test/import-meta/vite.config.ts new file mode 100644 index 000000000..f82c49b28 --- /dev/null +++ b/test/import-meta/vite.config.ts @@ -0,0 +1,20 @@ +import { resolve } from 'pathe' +import { defineConfig } from 'vite' + +export default defineConfig({ + test: { + includeSource: [ + 'src/**/*.ts', + ], + }, + define: { + 'import.meta.vitest': false, + }, + build: { + lib: { + formats: ['es', 'cjs'], + entry: resolve(__dirname, 'src/index.ts'), + fileName: 'index', + }, + }, +})