Fail a Node test filter that matches nothing

John Burns

A focused test command is useful before a small dependency update or a narrow code change. Running one named behavior is faster than running an entire suite, and it gives a reviewer a direct answer about the part of the application that changed. The command is only useful, though, if the name filter actually selects a test.

Node’s built-in test runner accepts --test-name-pattern for that purpose. In a disposable Node 22.23.2 project, a pattern matching one top-level test returned zero. A pattern matching no test names also returned zero. Its TAP output began with 1..0, then reported the test file itself as a successful subtest. A CI job that treats the process exit status as the whole result can therefore accept a focused check that exercised none of the intended assertions.

This guide adds a small wrapper around node --test --test-name-pattern. It preserves ordinary test failures, but turns the no-match TAP plan into a separate nonzero result. The approach is useful for a review job that deliberately targets stable, uniquely named top-level tests. It is not a replacement for the full suite, coverage requirements, or a release test environment.

Node.js 24.21.0 was released on 8 September 2026. Check the Node version used by the application and CI runner before relying on exact reporter output. The behavior described here was tested with Node 22.23.2; the wrapper deliberately checks an observable TAP condition instead of depending on an undocumented exit-code convention.

Give the focused check a clear boundary

Start with a test file whose top-level names describe behaviors a reviewer can select. This example has no package dependencies and does not contact a service. It represents a small allowlist decision that might otherwise be hidden in a larger test suite.

import test from 'node:test';
import assert from 'node:assert/strict';

test('accepts a permitted mode', () => {
  assert.equal(['safe', 'review'].includes('safe'), true);
});

test('rejects an unlisted mode', () => {
  assert.equal(['safe', 'review'].includes('delete'), false);
});

Save it as test/filters.test.js in a project that uses ECMAScript modules, or adapt the imports to the project’s existing module style. The important part is not the sample allowlist. It is the name of the behavior: a focused command should select the same tested outcome a change request claims to affect.

Run the complete local suite before introducing a filter:

node --test

This is the baseline. If the unfiltered suite is already failing, do not add a name pattern to make the result look smaller or cleaner. Fix or classify the existing failure first. A focused command is an additional gate for quick feedback, not an alternative definition of a healthy branch.

Use a pattern as a regular expression

The Node test-runner documentation defines test-name patterns as JavaScript regular expressions. Quote the pattern so the shell does not interpret spaces, brackets, or other regular-expression characters before Node receives them.

node --test --test-name-pattern='accepts a permitted mode'

The tested command printed one passing subtest and ended with this summary:

# tests 1
# pass 1
# fail 0

A pattern can be broader when that is intentional. For example, --test-name-pattern='mode' can select both sample names. Do not use a broad word merely because it is convenient: a new test added later may accidentally join the focused run. A stable behavior name or a project prefix makes the CI intent easier to review.

Patterns filter test names, not test-file discovery. Node still starts the discovered test files. That distinction matters for slow module initialization, top-level side effects, and hooks. A filtered command can reduce executed assertions without making a project equivalent to a one-file test invocation. Keep test-file discovery and setup behavior in mind when estimating how fast the focused check should be.

Observe the empty selection before trusting it

Now run a pattern that cannot match either top-level test:

node --test --test-name-pattern='no such test name'
printf 'exit status: %s\n' "$?"

In the validation run, Node exited zero. The meaningful part of its TAP stream was the empty plan near the beginning:

TAP version 13
1..0
# Subtest: test/filters.test.js
ok 1 - test/filters.test.js

The final process result does not mean a named assertion passed. It means the runner completed without a test failure. The test file can still be reported as a successful container even though the selection contained no individual tests. This is easy to miss in a log that displays only the final exit code or collapses TAP output.

Do not try to solve this by making the filter optional in a shell expression. That turns a misspelled pattern into a full-suite run, changing the amount of work without making the job’s intent visible. A useful focused gate should either run its requested tests or fail distinctly.

Turn an empty TAP plan into a failure

Use a wrapper that captures the test output before deciding what the job should return. It accepts Node’s normal result when a matching test runs, preserves a real test failure, and reserves exit status 3 for an empty selection.

#!/bin/sh
set -eu

pattern=${1:?supply a test-name pattern}
set +e
output=$(node --test --test-name-pattern="$pattern" 2>&1)
status=$?
set -e
printf '%s\n' "$output"

if [ "$status" -ne 0 ]; then
  exit "$status"
fi

if printf '%s\n' "$output" | python3 -c '
import re
import sys
raise SystemExit(0 if re.search(r"(?m)^1\.\.0$", sys.stdin.read()) else 1)
'; then
  printf '%s\n' "focused test run matched no tests" >&2
  exit 3
fi

Keep the wrapper beside the CI scripts rather than embedding it in a YAML run block. A file can be reviewed, tested locally, and reused by a developer. It also avoids YAML quoting mistakes around the regular expression and the command substitution. The wrapper requires Python 3 only to inspect the captured text; replace that small parser with an equivalent project-standard tool if Python is not part of the runner image.

The set +e section is intentional. With set -e active, a failing test command would end the wrapper before it printed the test output or preserved Node’s exit status. The wrapper captures that status, restores strict shell behavior, prints the complete report, and returns Node’s status unchanged for a real assertion failure.

The 1..0 check is specific to the tested TAP output for an empty top-level name selection. Keep it narrow. If the suite uses nested tests, custom reporters, or test-runner features that can legitimately emit an empty plan, validate the wrapper against those cases before making it a required gate. Use Node’s default TAP output for this command; a reporter change may require a different machine-readable condition.

Put the focused check in review automation

Call the wrapper with a quoted, explicit behavior name:

./scripts/run-focused-tests.sh 'accepts a permitted mode'

In the validation run, that command returned zero. Running it with no such test name printed focused test run matched no tests and returned 3. The distinct status lets a CI system classify an invalid filter separately from a failing assertion while retaining the original TAP output for diagnosis.

Use the wrapper for pull-request checks that are deliberately scoped, such as a changed parser rule, a feature flag, or a migration guard. Keep a full node --test job for the branch or merge queue. The focused gate answers whether the named behavior was actually selected; the complete suite answers whether that change interacted badly with the rest of the application.

Verify the rule after changes to tests or CI

A small verification sequence catches the two cases the wrapper is meant to separate:

./scripts/run-focused-tests.sh 'accepts a permitted mode'
printf 'matched status: %s\n' "$?"

set +e
./scripts/run-focused-tests.sh 'no such test name'
printf 'empty-selection status: %s\n' "$?"
set -e

Expect the first command to return zero. Expect the second to return 3 and include the empty-selection message. Also run the full suite after renaming a test, moving it under a describe block, changing reporters, or updating Node. Those changes can alter the relationship between the filter and the TAP output even when the application behavior itself has not changed.

A name-filtered Node test run is a precise review tool only when it proves that a named test actually ran. Checking the empty TAP plan closes the gap between a clean process exit and a meaningful focused result.

Sources