From c238072fdf27816511c3a9261bb05ebf5992988a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 11 Jun 2024 18:58:58 +0900 Subject: [PATCH] feat(runner): implement `test.for` (#5861) Co-authored-by: Vladimir --- docs/api/index.md | 48 +++++- packages/runner/src/fixture.ts | 8 +- packages/runner/src/suite.ts | 32 +++- packages/runner/src/types/tasks.ts | 26 +++ .../test/__snapshots__/test-for.test.ts.snap | 149 ++++++++++++++++++ test/core/test/test-for.test.ts | 97 ++++++++++++ 6 files changed, 356 insertions(+), 4 deletions(-) create mode 100644 test/core/test/__snapshots__/test-for.test.ts.snap create mode 100644 test/core/test/test-for.test.ts diff --git a/docs/api/index.md b/docs/api/index.md index d2303a8c9d0b..3b6a98a3eec9 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -306,6 +306,11 @@ You cannot use this syntax, when using Vitest as [type checker](/guide/testing-t - **Alias:** `it.each` +::: tip +While `test.each` is provided for Jest compatibility, +Vitest also has [`test.for`](#test-for) with an additional feature to integrate [`TestContext`](/guide/test-context). +::: + Use `test.each` when you need to run the same test with different variables. You can inject parameters with [printf formatting](https://nodejs.org/api/util.html#util_util_format_format_args) in the test name in the order of the test function parameters. @@ -392,8 +397,6 @@ test.each` }) ``` -If you want to have access to `TestContext`, use `describe.each` with a single test. - ::: tip Vitest processes `$values` with Chai `format` method. If the value is too truncated, you can increase [chaiConfig.truncateThreshold](/config/#chaiconfig-truncatethreshold) in your config file. ::: @@ -402,6 +405,47 @@ Vitest processes `$values` with Chai `format` method. If the value is too trunca You cannot use this syntax, when using Vitest as [type checker](/guide/testing-types). ::: +### test.for + +- **Alias:** `it.for` + +Alternative of `test.each` to provide [`TestContext`](/guide/test-context). + +The difference from `test.each` is how array case is provided in the arguments. +Other non array case (including template string usage) works exactly same. + +```ts +// `each` spreads array case +test.each([ + [1, 1, 2], + [1, 2, 3], + [2, 1, 3], +])('add(%i, %i) -> %i', (a, b, expected) => { // [!code --] + expect(a + b).toBe(expected) +}) + +// `for` doesn't spread array case +test.for([ + [1, 1, 2], + [1, 2, 3], + [2, 1, 3], +])('add(%i, %i) -> %i', ([a, b, expected]) => { // [!code ++] + expect(a + b).toBe(expected) +}) +``` + +2nd argument is [`TestContext`](/guide/test-context) and it can be used for concurrent snapshot, for example, + +```ts +test.concurrent.for([ + [1, 1], + [1, 2], + [2, 1], +])('add(%i, %i)', ([a, b], { expect }) => { + expect(a + b).matchSnapshot() +}) +``` + ## bench - **Type:** `(name: string | Function, fn: BenchFunction, options?: BenchOptions) => void` diff --git a/packages/runner/src/fixture.ts b/packages/runner/src/fixture.ts index b10b3f7ddf15..0d9d0a63557e 100644 --- a/packages/runner/src/fixture.ts +++ b/packages/runner/src/fixture.ts @@ -177,7 +177,13 @@ function getUsedProps(fn: Function) { if (!args.length) return [] - const first = args[0] + let first = args[0] + if ('__VITEST_FIXTURE_INDEX__' in fn) { + first = args[(fn as any).__VITEST_FIXTURE_INDEX__] + if (!first) + return [] + } + if (!(first.startsWith('{') && first.endsWith('}'))) throw new Error(`The first argument inside a fixture must use object destructuring pattern, e.g. ({ test } => {}). Instead, received "${first}".`) diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index cec5144b296a..4a07c0901ff4 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -1,4 +1,4 @@ -import { format, isNegativeNaN, isObject, objDisplay, objectAttr } from '@vitest/utils' +import { format, isNegativeNaN, isObject, objDisplay, objectAttr, toArray } from '@vitest/utils' import { parseSingleStack } from '@vitest/utils/source-map' import type { Custom, CustomAPI, File, Fixtures, RunMode, Suite, SuiteAPI, SuiteCollector, SuiteFactory, SuiteHooks, Task, TaskCustomOptions, Test, TestAPI, TestFunction, TestOptions } from './types' import type { VitestRunner } from './types/runner' @@ -383,6 +383,36 @@ export function createTaskCollector( } } + taskFn.for = function ( + this: { + withContext: () => SuiteAPI + setContext: (key: string, value: boolean | undefined) => SuiteAPI + }, + cases: ReadonlyArray, + ...args: any[] + ) { + const test = this.withContext() + + if (Array.isArray(cases) && args.length) + cases = formatTemplateString(cases, args) + + return ( + name: string | Function, + optionsOrFn: ((...args: T[]) => void) | TestOptions, + fnOrOptions?: ((...args: T[]) => void) | number | TestOptions, + ) => { + const _name = formatName(name) + const { options, handler } = parseArguments(optionsOrFn, fnOrOptions) + cases.forEach((item, idx) => { + // monkey-patch handler to allow parsing fixture + const handlerWrapper = (ctx: any) => handler(item, ctx); + (handlerWrapper as any).__VITEST_FIXTURE_INDEX__ = 1; + (handlerWrapper as any).toString = () => handler.toString() + test(formatTitle(_name, toArray(item), idx), options, handlerWrapper) + }) + } + } + taskFn.skipIf = function (this: TestAPI, condition: any) { return condition ? this.skip : this } diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index d22367841a53..9aa0c2226d7d 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -143,6 +143,31 @@ interface TestEachFunction { (...args: [TemplateStringsArray, ...any]): EachFunctionReturn } +interface TestForFunctionReturn { + ( + name: string | Function, + fn: (arg: Arg, context: Context) => Awaitable, + ): void + ( + name: string | Function, + options: TestOptions, + fn: (args: Arg, context: Context) => Awaitable, + ): void +} + +interface TestForFunction { + // test.for([1, 2, 3]) + // test.for([[1, 2], [3, 4, 5]]) + (cases: ReadonlyArray): TestForFunctionReturn & ExtraContext> + + // test.for` + // a | b + // {1} | {2} + // {3} | {4} + // ` + (strings: TemplateStringsArray, ...values: any[]): TestForFunctionReturn & ExtraContext> +} + interface TestCollectorCallable { /** * @deprecated Use options as the second argument instead @@ -157,6 +182,7 @@ type ChainableTestAPI = ChainableFunction< TestCollectorCallable, { each: TestEachFunction + for: TestForFunction } > diff --git a/test/core/test/__snapshots__/test-for.test.ts.snap b/test/core/test/__snapshots__/test-for.test.ts.snap new file mode 100644 index 000000000000..3e5a97fe02e5 --- /dev/null +++ b/test/core/test/__snapshots__/test-for.test.ts.snap @@ -0,0 +1,149 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`[docs] add(1, 1) 1`] = `2`; + +exports[`[docs] add(1, 2) 1`] = `3`; + +exports[`[docs] add(2, 1) 1`] = `3`; + +exports[`array case1-x case1-y 1`] = ` +{ + "args": [ + "case1-x", + "case1-y", + ], + "myFixture": 1234, +} +`; + +exports[`array case2-x case2-y 1`] = ` +{ + "args": [ + "case2-x", + "case2-y", + ], + "myFixture": 1234, +} +`; + +exports[`array destructure case1-x case1-y 1`] = ` +{ + "myFixture": 1234, + "x": "case1-x", + "y": "case1-y", +} +`; + +exports[`array destructure case2-x case2-y 1`] = ` +{ + "myFixture": 1234, + "x": "case2-x", + "y": "case2-y", +} +`; + +exports[`basic case1 1`] = ` +{ + "args": "case1", +} +`; + +exports[`basic case2 1`] = ` +{ + "args": "case2", +} +`; + +exports[`concurrent case1 1`] = ` +{ + "args": "case1", + "myFixture": 1234, +} +`; + +exports[`concurrent case2 1`] = ` +{ + "args": "case2", + "myFixture": 1234, +} +`; + +exports[`const case1 1`] = ` +{ + "args": "case1", + "myFixture": 1234, +} +`; + +exports[`const case2 1`] = ` +{ + "args": "case2", + "myFixture": 1234, +} +`; + +exports[`fixture case1 1`] = ` +{ + "args": "case1", + "myFixture": 1234, +} +`; + +exports[`fixture case2 1`] = ` +{ + "args": "case2", + "myFixture": 1234, +} +`; + +exports[`object 'case1' 1`] = ` +{ + "args": { + "k": "case1", + }, + "myFixture": 1234, +} +`; + +exports[`object 'case2' 1`] = ` +{ + "args": { + "k": "case2", + }, + "myFixture": 1234, +} +`; + +exports[`object destructure 'case1' 1`] = ` +{ + "myFixture": 1234, + "v": "case1", +} +`; + +exports[`object destructure 'case2' 1`] = ` +{ + "myFixture": 1234, + "v": "case2", +} +`; + +exports[`template 'x' true 1`] = ` +{ + "args": { + "a": "x", + "b": true, + }, + "myFixture": 1234, +} +`; + +exports[`template 'y' false 1`] = ` +{ + "args": { + "a": "y", + "b": false, + }, + "myFixture": 1234, +} +`; diff --git a/test/core/test/test-for.test.ts b/test/core/test/test-for.test.ts new file mode 100644 index 000000000000..34b8063133ee --- /dev/null +++ b/test/core/test/test-for.test.ts @@ -0,0 +1,97 @@ +import { expect, expectTypeOf, test } from 'vitest' + +const myTest = test.extend<{ myFixture: number }>({ + myFixture: async ({}, use) => { + await use(1234) + }, +}) + +test.for(['case1', 'case2'])( + 'basic %s', + (args) => { + expectTypeOf(args).toEqualTypeOf() + expect({ args }).matchSnapshot() + }, +) + +myTest.for(['case1', 'case2'])( + 'fixture %s', + (args, { myFixture }) => { + expectTypeOf(args).toEqualTypeOf() + expectTypeOf(myFixture).toEqualTypeOf() + expect({ args, myFixture }).matchSnapshot() + }, +) + +myTest.concurrent.for(['case1', 'case2'])( + 'concurrent %s', + (args, { expect, myFixture }) => { + expectTypeOf(args).toEqualTypeOf() + expectTypeOf(myFixture).toEqualTypeOf() + expect({ args, myFixture }).matchSnapshot() + }, +) + +myTest.concurrent.for(['case1', 'case2'] as const)( + 'const %s', + (args, { expect, myFixture }) => { + expectTypeOf(args).toEqualTypeOf<'case1' | 'case2'>() + expectTypeOf(myFixture).toEqualTypeOf() + expect({ args, myFixture }).matchSnapshot() + }, +) + +myTest.concurrent.for([['case1-x', 'case1-y'], ['case2-x', 'case2-y']])( + 'array %s %s', + (args, { expect, myFixture }) => { + expectTypeOf(args).toEqualTypeOf() + expectTypeOf(myFixture).toEqualTypeOf() + expect({ args, myFixture }).matchSnapshot() + }, +) + +myTest.concurrent.for([['case1-x', 'case1-y'], ['case2-x', 'case2-y']])( + 'array destructure %s %s', + ([x, y], { expect, myFixture }) => { + expectTypeOf(myFixture).toEqualTypeOf() + expect({ x, y, myFixture }).matchSnapshot() + }, +) + +myTest.concurrent.for([{ k: 'case1' }, { k: 'case2' }])( + 'object $k', + (args, { expect, myFixture }) => { + expectTypeOf(args).toEqualTypeOf<{ k: string }>() + expectTypeOf(myFixture).toEqualTypeOf() + expect({ args, myFixture }).matchSnapshot() + }, +) + +myTest.concurrent.for([{ k: 'case1' }, { k: 'case2' }])( + 'object destructure $k', + ({ k: v }, { expect, myFixture }) => { + expectTypeOf(myFixture).toEqualTypeOf() + expect({ v, myFixture }).matchSnapshot() + }, +) + +myTest.concurrent.for` + a | b + ${'x'} | ${true} + ${'y'} | ${false} +`( + 'template $a $b', + (args, { expect, myFixture }) => { + expectTypeOf(args).toEqualTypeOf() + expectTypeOf(myFixture).toEqualTypeOf() + expect({ args, myFixture }).toMatchSnapshot() + }, +) + +test.concurrent.for([ + [1, 1], + [1, 2], + [2, 1], +])('[docs] add(%i, %i)', ([a, b], { expect }) => { + expect(a + b).matchSnapshot() +})