-
-
Notifications
You must be signed in to change notification settings - Fork 35.5k
test_runner: add experimental tag-based test filtering #63054
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -479,6 +479,131 @@ Test name patterns do not change the set of files that the test runner executes. | |||||
| If both `--test-name-pattern` and `--test-skip-pattern` are supplied, | ||||||
| tests must satisfy **both** requirements in order to be executed. | ||||||
|
|
||||||
| ## Test tags | ||||||
|
|
||||||
| <!-- YAML | ||||||
| added: REPLACEME | ||||||
| --> | ||||||
|
|
||||||
| > Stability: 1.0 - Early development | ||||||
|
|
||||||
| Tags annotate tests and suites with arbitrary string labels. The | ||||||
| [`--experimental-test-tag-filter`][] CLI flag (or the `testTagFilters` | ||||||
| option on [`run()`][]) selects tests by a boolean expression over those | ||||||
| labels. | ||||||
|
|
||||||
| Tags are an alternative to encoding metadata into test names. They are | ||||||
| useful for cross-cutting axes such as subsystem, speed bucket, flakiness, | ||||||
| or environment, where a name pattern would be brittle. | ||||||
|
|
||||||
| ### Authoring tagged tests | ||||||
|
|
||||||
| Pass a `tags` array on any of `test()`, `it()`, `suite()`, or `describe()`. | ||||||
| Tags inherit from a suite to its child tests by union — a test inside a | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why? (genuinely asking, English is not my first language)
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The second part is an interjection, like you're interrupting yourself to clarify something. That's separated by an emdash. Basically what you did is correct except you used the wrong character (and an emdash is not padded by whitespace—it spears the two phrases together like a kebab). I used it there as another example of how it works 🙂 Basically:
|
||||||
| suite tagged `['db']` that declares its own `tags: ['integration']` | ||||||
| effectively has both tags. | ||||||
|
|
||||||
| ```mjs | ||||||
| import { describe, it } from 'node:test'; | ||||||
|
|
||||||
| describe('database', { tags: ['db'] }, () => { | ||||||
| it('reads a row'); // tags: ['db'] | ||||||
| it('writes a row', { tags: ['integration'] }); // tags: ['db', 'integration'] | ||||||
| it('reconnects after disconnect', { tags: ['flaky'] }); // tags: ['db', 'flaky'] | ||||||
| }); | ||||||
| ``` | ||||||
|
|
||||||
| ```cjs | ||||||
| const { describe, it } = require('node:test'); | ||||||
|
|
||||||
| describe('database', { tags: ['db'] }, () => { | ||||||
| it('reads a row'); // tags: ['db'] | ||||||
| it('writes a row', { tags: ['integration'] }); // tags: ['db', 'integration'] | ||||||
| it('reconnects after disconnect', { tags: ['flaky'] }); // tags: ['db', 'flaky'] | ||||||
| }); | ||||||
| ``` | ||||||
|
|
||||||
| Tag values must be non-empty strings that contain no whitespace, no | ||||||
| operator characters (`& | ! ( ) *`), and are not the reserved words | ||||||
| `'and'`, `'or'`, or `'not'` in any casing. Tags are matched | ||||||
| case-insensitively; the canonical form is lowercase. Duplicates within a | ||||||
| single `tags` array are collapsed on the lowercased form, preserving the | ||||||
| first-seen declaration order. | ||||||
|
|
||||||
| Hooks (`before`, `after`, `beforeEach`, `afterEach`) do not declare their | ||||||
| own tags. They run as part of their owning suite, which carries the | ||||||
| suite's tags. | ||||||
|
|
||||||
| ### Filtering syntax | ||||||
|
|
||||||
| The filter expression supports: | ||||||
|
|
||||||
| * Identifiers — any non-whitespace, non-operator characters. A literal | ||||||
| identifier matches a tag of the same value (case-insensitive). | ||||||
| * `*` wildcards inside an identifier match any sequence of characters. | ||||||
| A bare `*` matches any tagged test. | ||||||
| * Boolean operators with two equivalent forms: | ||||||
| * `and` / `&&` | ||||||
| * `or` / `||` | ||||||
| * `not` / `!` | ||||||
| * Parentheses for grouping. | ||||||
|
|
||||||
| The word forms (`and`, `or`, `not`) require whitespace separation; the | ||||||
| punctuation forms do not. | ||||||
|
|
||||||
| #### Operator precedence | ||||||
|
|
||||||
| The expression is evaluated with the standard precedence | ||||||
| `not > and > or`. Binary operators are left-associative. | ||||||
|
|
||||||
| | Expression | Equivalent grouping | | ||||||
| | -------------- | ------------------- | | ||||||
| | `a or b and c` | `a or (b and c)` | | ||||||
| | `not a and b` | `(not a) and b` | | ||||||
|
|
||||||
| Use parentheses to override: | ||||||
|
|
||||||
| | Expression | Selects | | ||||||
| | ------------------------------ | ------------------------------------------ | | ||||||
| | `(unit or smoke) and not slow` | unit-or-smoke tests that are not also slow | | ||||||
| | `db && !flaky` | db tests that are not flaky | | ||||||
| | `*` | every tagged test | | ||||||
|
|
||||||
| #### Untagged tests | ||||||
|
|
||||||
| Untagged tests behave as if they have an empty tag set. As a result: | ||||||
|
|
||||||
| * Any include expression (a tag, wildcard, `and`, or `or`) is **false** | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think
Suggested change
Otherwise, I don't understand what it intends. |
||||||
| for an untagged test, so untagged tests are excluded under any positive | ||||||
| filter. | ||||||
| * `not X` is **true** for an untagged test, so excluding tags does not | ||||||
| accidentally remove untagged tests. | ||||||
|
Comment on lines
+576
to
+580
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is generally difficult to follow. I think a table of examples would be more straightforward. |
||||||
|
|
||||||
| For example, `--experimental-test-tag-filter='not flaky'` runs every test | ||||||
| that is not tagged `flaky`, including all untagged tests. | ||||||
|
|
||||||
| #### Composing multiple filters | ||||||
|
|
||||||
| [`--experimental-test-tag-filter`][] may be specified more than once on the | ||||||
| command line. Multiple expressions compose by AND — a test must satisfy | ||||||
| every expression to run. The same applies to passing an array to | ||||||
| `testTagFilters` on [`run()`][]. The tag filter is also AND'd with | ||||||
| [`--test-name-pattern`][], [`--test-skip-pattern`][], and `.only` | ||||||
| filtering. | ||||||
|
|
||||||
| #### Reading tags from inside a test | ||||||
|
|
||||||
| The [`TestContext`][] object exposes the test's tags as a frozen array | ||||||
| through [`context.tags`][], so tests can branch on their own metadata. | ||||||
|
|
||||||
| #### Errors | ||||||
|
|
||||||
| A tag value that violates the validation rules above throws | ||||||
| `ERR_INVALID_ARG_VALUE` at the registration site, before any test runs. | ||||||
| A non-array `tags` value throws `ERR_INVALID_ARG_TYPE`. A malformed | ||||||
| filter expression on the CLI causes the test runner to exit with a | ||||||
| non-zero status before running any test files. | ||||||
|
|
||||||
| ## Extraneous asynchronous activity | ||||||
|
|
||||||
| Once a test function finishes executing, the results are reported as quickly | ||||||
|
|
@@ -750,6 +875,8 @@ test runner functionality: | |||||
|
|
||||||
| * `--test` - Prevented to avoid recursive test execution | ||||||
| * `--experimental-test-coverage` - Managed by the test runner | ||||||
| * `--experimental-test-tag-filter` - Filter expressions are validated by the parent | ||||||
| process and re-emitted to child processes | ||||||
| * `--watch` - Watch mode is handled at the parent level | ||||||
| * `--experimental-default-config-file` - Config file loading is handled by the parent | ||||||
| * `--test-reporter` - Reporting is managed by the parent process | ||||||
|
|
@@ -1569,6 +1696,9 @@ added: | |||||
| - v18.9.0 | ||||||
| - v16.19.0 | ||||||
| changes: | ||||||
| - version: REPLACEME | ||||||
| pr-url: https://github.com/nodejs/node/pull/63054 | ||||||
| description: Added the `testTagFilters` option. | ||||||
| - version: | ||||||
| - v25.6.0 | ||||||
| - v24.14.0 | ||||||
|
|
@@ -1657,6 +1787,11 @@ changes: | |||||
| For each test that is executed, any corresponding test hooks, such as | ||||||
| `beforeEach()`, are also run. | ||||||
| **Default:** `undefined`. | ||||||
| * `testTagFilters` {string|string\[]} A boolean expression, or an array of | ||||||
| boolean expressions, used to filter tests by their declared tags. | ||||||
| Multiple expressions compose by AND. Equivalent to passing | ||||||
| [`--experimental-test-tag-filter`][] on the command line. See | ||||||
| [Test tags][]. **Default:** `undefined`. | ||||||
| * `timeout` {number} A number of milliseconds the test execution will | ||||||
| fail after. | ||||||
| If unspecified, subtests inherit this value from their parent. | ||||||
|
|
@@ -1800,6 +1935,9 @@ added: | |||||
| - v18.0.0 | ||||||
| - v16.17.0 | ||||||
| changes: | ||||||
| - version: REPLACEME | ||||||
| pr-url: https://github.com/nodejs/node/pull/63054 | ||||||
| description: Added the `tags` option. | ||||||
| - version: | ||||||
| - v20.2.0 | ||||||
| - v18.17.0 | ||||||
|
|
@@ -1843,6 +1981,10 @@ changes: | |||||
| * `skip` {boolean|string} If truthy, the test is skipped. If a string is | ||||||
| provided, that string is displayed in the test results as the reason for | ||||||
| skipping the test. **Default:** `false`. | ||||||
| * `tags` {string\[]} An array of string labels associated with the test. | ||||||
| Used together with [`--experimental-test-tag-filter`][] to filter which | ||||||
| tests run. Tags inherit from suites to nested tests by union. See | ||||||
| [Test tags][]. **Default:** `[]`. | ||||||
| * `todo` {boolean|string} If truthy, the test marked as `TODO`. If a string | ||||||
| is provided, that string is displayed in the test results as the reason why | ||||||
| the test is `TODO`. **Default:** `false`. | ||||||
|
|
@@ -3431,6 +3573,9 @@ Emitted when code coverage is enabled and all tests have completed. | |||||
| `undefined` if the test was run through the REPL. | ||||||
| * `name` {string} The test name. | ||||||
| * `nesting` {number} The nesting level of the test. | ||||||
| * `tags` {string\[]} The flattened lowercased tags declared on the test | ||||||
| and its ancestor suites, in declaration order. Empty for untagged tests. | ||||||
| See [Test tags][]. | ||||||
| * `testId` {number} A numeric identifier for this test instance, unique | ||||||
| within the test file's process. Consistent across all events for the same | ||||||
| test instance, enabling reliable correlation in custom reporters. | ||||||
|
|
@@ -3454,6 +3599,9 @@ The corresponding declaration ordered events are `'test:pass'` and `'test:fail'` | |||||
| `undefined` if the test was run through the REPL. | ||||||
| * `name` {string} The test name. | ||||||
| * `nesting` {number} The nesting level of the test. | ||||||
| * `tags` {string\[]} The flattened lowercased tags declared on the test | ||||||
| and its ancestor suites, in declaration order. Empty for untagged tests. | ||||||
| See [Test tags][]. | ||||||
| * `testId` {number} A numeric identifier for this test instance, unique | ||||||
| within the test file's process. Consistent across all events for the same | ||||||
| test instance, enabling reliable correlation in custom reporters. | ||||||
|
|
@@ -3495,6 +3643,9 @@ defined. | |||||
| `undefined` if the test was run through the REPL. | ||||||
| * `name` {string} The test name. | ||||||
| * `nesting` {number} The nesting level of the test. | ||||||
| * `tags` {string\[]} The flattened lowercased tags declared on the test | ||||||
| and its ancestor suites, in declaration order. Empty for untagged tests. | ||||||
| See [Test tags][]. | ||||||
| * `testId` {number} A numeric identifier for this test instance, unique | ||||||
| within the test file's process. Consistent across all events for the same | ||||||
| test instance, enabling reliable correlation in custom reporters. | ||||||
|
|
@@ -3521,6 +3672,9 @@ Emitted when a test is enqueued for execution. | |||||
| `undefined` if the test was run through the REPL. | ||||||
| * `name` {string} The test name. | ||||||
| * `nesting` {number} The nesting level of the test. | ||||||
| * `tags` {string\[]} The flattened lowercased tags declared on the test | ||||||
| and its ancestor suites, in declaration order. Empty for untagged tests. | ||||||
| See [Test tags][]. | ||||||
| * `testId` {number} A numeric identifier for this test instance, unique | ||||||
| within the test file's process. Consistent across all events for the same | ||||||
| test instance, enabling reliable correlation in custom reporters. | ||||||
|
|
@@ -3580,6 +3734,9 @@ since the parent runner only knows about file-level tests. When using | |||||
| `undefined` if the test was run through the REPL. | ||||||
| * `name` {string} The test name. | ||||||
| * `nesting` {number} The nesting level of the test. | ||||||
| * `tags` {string\[]} The flattened lowercased tags declared on the test | ||||||
| and its ancestor suites, in declaration order. Empty for untagged tests. | ||||||
| See [Test tags][]. | ||||||
| * `testId` {number} A numeric identifier for this test instance, unique | ||||||
| within the test file's process. Consistent across all events for the same | ||||||
| test instance, enabling reliable correlation in custom reporters. | ||||||
|
|
@@ -3619,6 +3776,9 @@ defined. | |||||
| `undefined` if the test was run through the REPL. | ||||||
| * `name` {string} The test name. | ||||||
| * `nesting` {number} The nesting level of the test. | ||||||
| * `tags` {string\[]} The flattened lowercased tags declared on the test | ||||||
| and its ancestor suites, in declaration order. Empty for untagged tests. | ||||||
| See [Test tags][]. | ||||||
| * `testId` {number} A numeric identifier for this test instance, unique | ||||||
| within the test file's process. Consistent across all events for the same | ||||||
| test instance, enabling reliable correlation in custom reporters. | ||||||
|
|
@@ -4122,6 +4282,20 @@ The attempt number of the test. This value is zero-based, so the first attempt i | |||||
| the second attempt is `1`, and so on. This property is useful in conjunction with the | ||||||
| `--test-rerun-failures` option to determine which attempt the test is currently running. | ||||||
|
|
||||||
| ### `context.tags` | ||||||
|
|
||||||
| <!-- YAML | ||||||
| added: REPLACEME | ||||||
| --> | ||||||
|
|
||||||
| > Stability: 1.0 - Early development | ||||||
|
|
||||||
| * Type: {string\[]} | ||||||
|
|
||||||
| A frozen array of the test's flattened lowercased tags, in declaration | ||||||
| order, including any tags inherited from ancestor suites. Empty when the | ||||||
| test has no tags. See [Test tags][]. | ||||||
|
|
||||||
| ### `context.workerId` | ||||||
|
|
||||||
| <!-- YAML | ||||||
|
|
@@ -4339,6 +4513,9 @@ added: | |||||
| - v18.0.0 | ||||||
| - v16.17.0 | ||||||
| changes: | ||||||
| - version: REPLACEME | ||||||
| pr-url: https://github.com/nodejs/node/pull/63054 | ||||||
| description: Added the `tags` option. | ||||||
| - version: | ||||||
| - v18.8.0 | ||||||
| - v16.18.0 | ||||||
|
|
@@ -4369,6 +4546,10 @@ changes: | |||||
| * `skip` {boolean|string} If truthy, the test is skipped. If a string is | ||||||
| provided, that string is displayed in the test results as the reason for | ||||||
| skipping the test. **Default:** `false`. | ||||||
| * `tags` {string\[]} An array of string labels associated with the subtest. | ||||||
| Used together with [`--experimental-test-tag-filter`][] to filter which | ||||||
| tests run. Tags inherit from the parent test or suite by union. See | ||||||
| [Test tags][]. **Default:** `[]`. | ||||||
| * `todo` {boolean|string} If truthy, the test marked as `TODO`. If a string | ||||||
| is provided, that string is displayed in the test results as the reason why | ||||||
| the test is `TODO`. **Default:** `false`. | ||||||
|
|
@@ -4517,8 +4698,10 @@ test.describe('my suite', (suite) => { | |||||
| ``` | ||||||
|
|
||||||
| [TAP]: https://testanything.org/ | ||||||
| [Test tags]: #test-tags | ||||||
| [`--experimental-test-coverage`]: cli.md#--experimental-test-coverage | ||||||
| [`--experimental-test-module-mocks`]: cli.md#--experimental-test-module-mocks | ||||||
| [`--experimental-test-tag-filter`]: cli.md#--experimental-test-tag-filterexpr | ||||||
| [`--import`]: cli.md#--importmodule | ||||||
| [`--no-strip-types`]: cli.md#--no-strip-types | ||||||
| [`--test-concurrency`]: cli.md#--test-concurrency | ||||||
|
|
@@ -4544,6 +4727,7 @@ test.describe('my suite', (suite) => { | |||||
| [`assert.throws`]: assert.md#assertthrowsfn-error-message | ||||||
| [`context.diagnostic`]: #contextdiagnosticmessage | ||||||
| [`context.skip`]: #contextskipmessage | ||||||
| [`context.tags`]: #contexttags | ||||||
| [`context.todo`]: #contexttodomessage | ||||||
| [`describe()`]: #describename-options-fn | ||||||
| [`diagnostics_channel`]: diagnostics_channel.md | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isnt there a better way then this tag filtering syntax?.
i.e accept in the
runmethod astring[]orfunction(string) => booleanand in case you want some complex logic - use therunapi without the node cli - That makes much more sense to meThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
that is more consistent to our name filter flag
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Without the boolean syntax this feature mostly collapses into name-pattern...
The goal (I had in mind) of this feature is to introduce a easy (and common, see prior art) way to filter without the need for a programmatic API. Sure, you could already achieve complex filtering via
run()API, but the goal here is to add a built-in logic for this.Vitest, mocha-tags, and jest-runner-groups all expose such a boolean composition syntax.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Before I even saw Moshe's comment, I was thinking the same thing. But after a minute, I can see the use of it.
I know
&&and||align with javascript, but it seems a little long, and the simpler&and|look pretty straightforward to me (and aligns with others, like query params and old skool forum query syntax). What happens when you combine them though?Is that "foo and bar and (qux or zed)" or "(foo and bar and qux) or zed"? For some reason, my eyes see the former, but syntactically, it's usually the latter. IMO the original spec for syntax made a huge mistake not requiring parentheses when combining operators. If we support combining operators, I think parentheses should be required.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@JakobJingleheimer I feel that
&vs&&is not really "a little long", and I feel using&and|could be confusing with bitwise operators, while removing just a single character...There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I considered that before posting: I think nobody would be confused here because bitwise is not appropriate.
&&vs&is a thought musing.My bigger concern would be parens for combined operators.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thats why I think we should release a initial version that does not include this new langauge