[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

fix!: use `>` as separator in `-t`, calculate `only` once

Vladimir Sheremet (Jun 30, 2026, 7:18 PM +0200) 55b0f8bc 7109482c

+156 -41
+16
docs/config/testnamepattern.md
··· 24 24 expect(true).toBe(true) 25 25 }) 26 26 ``` 27 + 28 + The pattern is matched against the test's full name: the enclosing suite names and the test name joined with `' > '` (the same string shown in the reporter output). For example, the test below has the full name `math > adds`, so it is matched by `-t 'math > adds'` or `-t adds`: 29 + 30 + ```js 31 + import { describe, expect, test } from 'vitest' 32 + 33 + describe('math', () => { 34 + test('adds', () => { 35 + expect(1 + 1).toBe(2) 36 + }) 37 + }) 38 + ``` 39 + 40 + ::: warning 41 + Before Vitest 5, the segments were joined with a single space (`math adds`) to mirror Jest. See the [migration guide](/guide/migration#vitest-5) for details. 42 + :::
+1 -1
docs/guide/filtering.md
··· 44 44 45 45 ## Filtering by Test Name 46 46 47 - Sometimes the test you care about is buried in a file with many other tests. The `-t` (or `--testNamePattern`) option filters by the test's name rather than the filename. It accepts a regex pattern and matches against the full test name, which includes any `describe` block names: 47 + Sometimes the test you care about is buried in a file with many other tests. The `-t` (or `--testNamePattern`) option filters by the test's name rather than the filename. It accepts a regex pattern and matches against the full test name, which is the enclosing `describe` block names and the test name joined with `' > '` (for example `math > adds`): 48 48 49 49 ```bash 50 50 vitest -t "handles empty input"
+26
docs/guide/migration.md
··· 52 52 }) 53 53 ``` 54 54 55 + ### `testNamePattern` Matches the `>`-Joined Full Name 56 + 57 + [`testNamePattern`](/config/testnamepattern) (the `-t` CLI flag) now matches against the test's full name with the suite chain and test name joined by `' > '`, the same string shown in the reporter output. Previously the segments were joined with a single space, mirroring Jest. 58 + 59 + This only affects patterns that span the boundary between a suite and a test (or between nested suites). Patterns that match within a single name segment, and patterns that use `.`/`.*` between segments, are unaffected. 60 + 61 + ```ts 62 + describe('math', () => { 63 + test('adds', () => {}) 64 + }) 65 + ``` 66 + 67 + ```bash 68 + vitest -t 'math adds' # [!code --] 69 + vitest -t 'math > adds' # [!code ++] 70 + ``` 71 + 72 + To keep a pattern working regardless of the separator, match a single segment (`-t adds`) or use a wildcard between segments (`-t 'math.*adds'`). 73 + 55 74 ### Benchmarking API Rewrite 56 75 57 76 The benchmarking API has been rewritten. `bench` is no longer a top-level import from `vitest`; it is a [test-context fixture](/guide/test-context#bench) accessed from inside a regular `test()`. See the [Benchmarking guide](/guide/benchmarking) for the new API. ··· 836 855 ```diff 837 856 - `${describeTitle} ${testTitle}` 838 857 + `${describeTitle} > ${testTitle}` 858 + ``` 859 + 860 + The same applies to [`testNamePattern`](/config/testnamepattern) (the `-t` flag): Vitest matches against the `>`-joined full name, while Jest matches the space-joined name. Update patterns that span a suite and a test accordingly, or match a single segment (`-t adds`) or use a wildcard between segments (`-t 'math.*adds'`). 861 + 862 + ```diff 863 + - vitest -t 'math adds' 864 + + vitest -t 'math > adds' 839 865 ``` 840 866 841 867 ### Envs
+17
packages/vitest/src/node/ast-collect.ts
··· 459 459 } 460 460 definition.task = task 461 461 latestSuite.tasks.push(task) 462 + if (mode === 'only') { 463 + markAncestorsContainOnly(latestSuite) 464 + } 462 465 lastSuite = task 463 466 return 464 467 } ··· 488 491 } 489 492 definition.task = task 490 493 latestSuite.tasks.push(task) 494 + if (mode === 'only') { 495 + markAncestorsContainOnly(latestSuite) 496 + } 491 497 }) 492 498 calculateSuiteHash(file) 493 499 markDynamicTests(file.tasks) ··· 567 573 const transformResult = await env.transformRequest(filepath) 568 574 569 575 return transformResult ? { ...transformResult, fileTags } : null 576 + } 577 + 578 + // Walk up from the suite a task was added to, marking each ancestor as 579 + // containing an `only` task. Stops at the first already-marked ancestor (its 580 + // own ancestors are already marked), keeping the total work linear. 581 + function markAncestorsContainOnly(suite: Suite) { 582 + let current: Suite | undefined = suite 583 + while (current && !current.containsOnly) { 584 + current.containsOnly = true 585 + current = current.suite 586 + } 570 587 } 571 588 572 589 function markDynamicTests(tasks: Task[]) {
+3 -3
packages/vitest/src/node/core.ts
··· 29 29 import { wildcardPatternToRegExp } from '../utils/base' 30 30 import { limitConcurrency } from '../utils/limit-concurrency' 31 31 import { NativeModuleRunner } from '../utils/nativeModuleRunner' 32 - import { convertTasksToEvents, getTasks, hasFailed, interpretTaskModes, someTasksAreOnly } from '../utils/tasks' 32 + import { convertTasksToEvents, getTasks, hasFailed, interpretTaskModes } from '../utils/tasks' 33 33 import { Traces } from '../utils/traces' 34 34 import { astCollectTests, createFailedFileTask } from './ast-collect' 35 35 import { BrowserSessions } from './browser/sessions' ··· 1095 1095 ? createTagsFilter(this.config.tagsFilter, this.config.tags) 1096 1096 : undefined 1097 1097 // Phase 2: cross-file .only resolution 1098 - const globalHasOnly = results.some(({ file }) => someTasksAreOnly(file)) 1098 + const globalHasOnly = results.some(({ file }) => !!file.containsOnly) 1099 1099 for (const { file, specification } of results) { 1100 1100 const config = specification.project.config 1101 1101 interpretTaskModes( ··· 1119 1119 return createFailedFileTask(specification.project, specification.moduleId, error) 1120 1120 }) 1121 1121 const config = specification.project.config 1122 - const hasOnly = someTasksAreOnly(file) 1122 + const hasOnly = !!file.containsOnly 1123 1123 const tagsFilter = this.config.tagsFilter 1124 1124 ? createTagsFilter(this.config.tagsFilter, this.config.tags) 1125 1125 : undefined
+10 -2
packages/vitest/src/runtime/runner/collect.ts
··· 5 5 calculateSuiteHash, 6 6 createFileTask, 7 7 interpretTaskModes, 8 - someTasksAreOnly, 9 8 } from '../../utils/tasks' 10 9 import { collectorContext } from './context' 11 10 import { getHooks, setHooks } from './map' ··· 126 125 127 126 calculateSuiteHash(file) 128 127 129 - const hasOnlyTasks = someTasksAreOnly(file) 128 + // the file's children are assembled here (not in a suite collector), so 129 + // roll up the collection-time flags for the file itself 130 + file.containsOnly = file.tasks.some( 131 + t => t.mode === 'only' || (t.type === 'suite' && t.containsOnly), 132 + ) 133 + file.containsTest = file.tasks.some( 134 + t => t.type === 'test' || (t.type === 'suite' && t.containsTest), 135 + ) 136 + 137 + const hasOnlyTasks = file.containsOnly 130 138 if (!testTagsFilter && !defaultTagsFilter && config.tagsFilter) { 131 139 defaultTagsFilter = createTagsFilter(config.tagsFilter, config.tags) 132 140 }
+2 -2
packages/vitest/src/runtime/runner/run.ts
··· 24 24 import { shuffle } from '@vitest/utils/helpers' 25 25 import { getSafeTimers } from '@vitest/utils/timers' 26 26 import { limitConcurrency } from '../../utils/limit-concurrency' 27 - import { hasFailed, hasTests } from '../../utils/tasks' 27 + import { hasFailed } from '../../utils/tasks' 28 28 import { collectTests } from './collect' 29 29 import { abortContextSignal } from './context' 30 30 import { AroundHookMultipleCallsError, AroundHookSetupError, AroundHookTeardownError, PendingError, TestRunAbortError } from './errors' ··· 949 949 } 950 950 951 951 if (suite.mode === 'run' || suite.mode === 'queued') { 952 - if (!runner.config.passWithNoTests && !hasTests(suite)) { 952 + if (!runner.config.passWithNoTests && !suite.containsTest) { 953 953 suite.result.state = 'fail' 954 954 if (!suite.result.errors?.length) { 955 955 const error = processError(
+12 -1
packages/vitest/src/runtime/runner/suite.ts
··· 553 553 554 554 const allChildren: Task[] = [] 555 555 556 + let containsOnly = false 557 + let containsTest = false 556 558 for (const i of tasks) { 557 - allChildren.push(i.type === 'collector' ? await i.collect(file) : i) 559 + const child = i.type === 'collector' ? await i.collect(file) : i 560 + allChildren.push(child) 561 + if (child.mode === 'only' || (child.type === 'suite' && child.containsOnly)) { 562 + containsOnly = true 563 + } 564 + if (child.type === 'test' || (child.type === 'suite' && child.containsTest)) { 565 + containsTest = true 566 + } 558 567 } 559 568 560 569 suite.tasks = allChildren 570 + suite.containsOnly = containsOnly 571 + suite.containsTest = containsTest 561 572 562 573 return suite 563 574 }
+12
packages/vitest/src/runtime/runner/types.ts
··· 289 289 * An array of tasks that are part of the suite. 290 290 */ 291 291 tasks: Task[] 292 + /** 293 + * Whether this suite's subtree contains a task in `only` mode. Computed during 294 + * collection and consumed when resolving test modes (`interpretTaskModes`). 295 + * @internal 296 + */ 297 + containsOnly?: boolean 298 + /** 299 + * Whether this suite's subtree contains at least one test. Computed during 300 + * collection and consumed to detect empty suites at run time. 301 + * @internal 302 + */ 303 + containsTest?: boolean 292 304 } 293 305 294 306 export interface File extends Suite {
+6 -32
packages/vitest/src/utils/tasks.ts
··· 52 52 } 53 53 54 54 /* @__NO_SIDE_EFFECTS__ */ 55 - export function hasTests(suite: Arrayable<Suite>): boolean { 56 - return toArray(suite).some(s => 57 - s.tasks.some(c => isTestCase(c) || hasTests(c)), 58 - ) 59 - } 60 - 61 - /* @__NO_SIDE_EFFECTS__ */ 62 55 export function hasFailed(suite: Arrayable<Task>): boolean { 63 56 return toArray(suite).some( 64 57 s => ··· 98 91 /* @__NO_SIDE_EFFECTS__ */ 99 92 export function createTaskName(names: readonly (string | undefined)[], separator = ' > '): string { 100 93 return names.filter(name => name !== undefined).join(separator) 101 - } 102 - 103 - /* @__NO_SIDE_EFFECTS__ */ 104 - export function hasBenchmark(suite: Arrayable<Suite>): boolean { 105 - return toArray(suite).some(s => 106 - s?.tasks?.some(c => c.meta?.benchmark || hasBenchmark(c as Suite)), 107 - ) 108 94 } 109 95 110 96 /* @__NO_SIDE_EFFECTS__ */ ··· 261 247 const traverseSuite = (suite: Suite, parentIsOnly?: boolean, parentMatchedWithLocation?: boolean) => { 262 248 const suiteIsOnly = parentIsOnly || suite.mode === 'only' 263 249 264 - // Check if any tasks in this suite have `.only` - if so, only those should run 265 - const hasSomeTasksOnly = onlyMode && suite.tasks.some( 266 - t => t.mode === 'only' || (t.type === 'suite' && someTasksAreOnly(t)), 267 - ) 250 + // Check if any tasks in this suite have `.only` - if so, only those should run. 251 + // `containsOnly` is computed during collection (in the runtime/AST collectors). 252 + const hasSomeTasksOnly = !!(onlyMode && suite.containsOnly) 268 253 269 254 suite.tasks.forEach((t) => { 270 255 // Check if either the parent suite or the task itself are marked as included 271 256 // If there are tasks with `.only` in this suite, only include those (not all tasks from describe.only) 272 257 const includeTask = hasSomeTasksOnly 273 - ? (t.mode === 'only' || (t.type === 'suite' && someTasksAreOnly(t))) 258 + ? (t.mode === 'only' || (t.type === 'suite' && !!t.containsOnly)) 274 259 : (suiteIsOnly || t.mode === 'only') 275 260 if (onlyMode) { 276 - if (t.type === 'suite' && (includeTask || someTasksAreOnly(t))) { 261 + if (t.type === 'suite' && (includeTask || t.containsOnly)) { 277 262 // Don't skip this suite 278 263 if (t.mode === 'only') { 279 264 checkAllowOnly(t, allowOnly) ··· 308 293 } 309 294 310 295 if (t.type === 'test') { 311 - if (namePattern && !getTaskFullName(t).match(namePattern)) { 296 + if (namePattern && !t.fullTestName.match(namePattern)) { 312 297 t.mode = 'skip' 313 298 } 314 299 if (testIdsSet && !testIdsSet.has(t.id)) { ··· 364 349 stack: error.stack, 365 350 }) 366 351 } 367 - } 368 - 369 - function getTaskFullName(task: TaskBase): string { 370 - return `${task.suite ? `${getTaskFullName(task.suite)} ` : ''}${task.name}` 371 - } 372 - 373 - /* @__NO_SIDE_EFFECTS__ */ 374 - export function someTasksAreOnly(suite: Suite): boolean { 375 - return suite.tasks.some( 376 - t => t.mode === 'only' || (t.type === 'suite' && someTasksAreOnly(t)), 377 - ) 378 352 } 379 353 380 354 function skipAllTasks(suite: Suite) {
+51
test/e2e/test/test-name-pattern.test.ts
··· 1 + import { expect, test } from 'vitest' 2 + import { runInlineTests } from '../../test-utils' 3 + 4 + // `testNamePattern` matches against the task's `fullTestName`, i.e. the suite 5 + // chain and the test name joined with ` > ` (the same string shown in reporters). 6 + // Before Vitest 4 the segments were joined with a single space, mirroring Jest. 7 + const files = { 8 + 'a.test.js': ` 9 + import { describe, test } from 'vitest' 10 + describe('group', () => { 11 + test('matches', () => {}) 12 + }) 13 + test('group matches', () => {}) 14 + `, 15 + } 16 + 17 + test('testNamePattern matches the " > "-joined full name', async () => { 18 + const { buildTree, stderr } = await runInlineTests(files, { 19 + testNamePattern: 'group > matches', 20 + }) 21 + 22 + expect(stderr).toBe('') 23 + expect(buildTree(t => t.result().state)).toMatchInlineSnapshot(` 24 + { 25 + "a.test.js": { 26 + "group": { 27 + "matches": "passed", 28 + }, 29 + "group matches": "skipped", 30 + }, 31 + } 32 + `) 33 + }) 34 + 35 + test('testNamePattern no longer matches the space-joined chain across suites', async () => { 36 + const { buildTree, stderr } = await runInlineTests(files, { 37 + testNamePattern: 'group matches', 38 + }) 39 + 40 + expect(stderr).toBe('') 41 + expect(buildTree(t => t.result().state)).toMatchInlineSnapshot(` 42 + { 43 + "a.test.js": { 44 + "group": { 45 + "matches": "skipped", 46 + }, 47 + "group matches": "passed", 48 + }, 49 + } 50 + `) 51 + })