PHPUnit
php-regex/regex-phpunit brings four assertions to your test suite, backed
by the parser: assertPatternMatches() runs one guarded match attempt and
judges a capture expectation, assertPatternDoesNotMatch() asserts no
match, assertPatternCompiles() asks the running engine, and
assertPatternIsLinear() asks the ReDoS analysis for a proof. The
expectation’s group keys are validated against the pattern’s own groups
before any value is compared, so a typo’d name is a failure that names the
pattern’s real groups — never a silent pass.
Installation
composer config minimum-stability dev
composer config prefer-stable true
composer require --dev php-regex/regex-phpunit:2.x-devThe assertions run on PHP 8.1 or later and PHPUnit 9.6 to 13 (10 on PHP 8.1, 11 on 8.2, 12 on 8.3, 13 on 8.4). Add the trait to your test case:
use PHPRegex\PHPUnit\RegexAssertions;
final class SlugTest extends TestCase
{
use RegexAssertions;
public function test_a_slug_matches(): void
{
self::assertPatternMatches('/^[a-z0-9-]{3,}$/', 'php-regex');
self::assertPatternDoesNotMatch('/^[a-z0-9-]{3,}$/', 'PHP Regex!');
}
}
The methods are static, so the trait also works in a bare Assert subclass
— arrange-only helpers included. The three constraints behind the
assertions (MatchesPattern, CompilesPattern, IsLinearPattern) compose
with assertThat(), LogicalNot and boolean composition.
The capture expectation
assertPatternMatches() takes an expectation keyed by group number or
name. An empty expectation asks only whether the pattern matched.
| expectation | asks |
|---|---|
true |
the group participated, any value |
false |
the group did not participate |
'value' |
exactly that captured string |
['value', byteOffset] |
that string, at that byte offset |
self::assertPatternMatches('/(?<alpha>a+)(?<beta>b+)?/', 'aa', [
'alpha' => 'aa', // exactly 'aa'
'beta' => false, // present in the pattern, did not participate
]);
self::assertPatternMatches('/(\d{4})-(\d{2})-(\d{2})/', '2026-10-17', [
1 => '2026',
2 => ['10', 5], // '10', starting at byte 5
]);
When only the captures differ, the failure renders the expectation against the engine’s answer, both in the expectation’s own spelling — here under PHPUnit 11; the wording of the surrounding frame moves with the PHPUnit release, the diff lines do not:
Failed asserting that '2026-10-17' matches the pattern '/(\\d{4})-(\\d{2})/'.
--- Expected
+++ Actual
@@ @@
-[1 => '2026', 2 => '01']
+[1 => '2026', 2 => '10']
Offsets are bytes
Offsets are byte offsets everywhere, PREG_OFFSET_CAPTURE semantics, under
/u as everywhere else — é is two bytes:
self::assertPatternMatches('/é(l+)/u', 'héllo', [1 => ['ll', 3]]);
One match attempt
Each assertPatternMatches() call runs one preg_match() attempt, never
preg_match_all(): repeated captures are not collected, and a group that
could match twice is judged on the attempt the engine made — the leftmost
one.
Validation failures
Every expectation key is resolved against the pattern’s own groups before any value is compared. An unknown name or number fails listing what the pattern really has:
Failed asserting that 'ab' matches the pattern '/(?<alpha>a)(?<beta>b)/'.
the pattern has no group named 'alpa'; its groups are 0 (the whole match), 1 'alpha', 2 'beta'.
A name that owns several group numbers — a (?J) pattern, where the engine
would fill the last participating duplicate — fails rather than trusting
one input:
the group name 'dup' owns several group numbers (1, 2); assert by number instead.
Group 0 is the whole match: it participates whenever the pattern matches,
so 0 => false is a contradiction and fails as one. A shape the vocabulary
does not spell fails naming what it expected. Under the n modifier the
engine keeps only the NAMED groups capturing, renumbered from 1 — in
/(a)(?<x>b)/n the group x is group 1 — so a name, or the number the
renumbering gave it, asserts as usual, and a number with no group behind
it fails saying what the modifier did:
the pattern has no group 2 under the n modifier; only named groups capture under it, renumbered from 1. Its groups are 0 (the whole match), 1 'x'.
A pattern that does not parse fails with the library’s message, byte
position and caret snippet; preg_match() answering false — invalid
UTF-8 under /u, a backtrack limit — fails with preg_last_error_msg(),
never as “no match”.
Compilation and linearity
self::assertPatternCompiles('/^\d{4}-\d{2}-\d{2}$/');
self::assertPatternIsLinear('/^[a-z0-9-]{3,}$/');
assertPatternCompiles() leaves the truth to the engine alone: a pattern
beyond the library’s own parse limits still passes here when the engine
takes it — while assertPatternMatches(), which parses to validate the
expectation, fails with the limit message. The asymmetry is deliberate:
each assertion asks its own question.
assertPatternIsLinear() fails unless one match attempt is proven linear
and the unanchored search carries no quadratic-cost witness — a pattern
that is safe per attempt but quadratic to search for, as /\s+$/ on a run
of spaces, fails too. The failure’s first line is a stable prefix, so a
quarantine can tell the two apart:
| first line | verdict |
|---|---|
proven unsafe: |
an unsafe attempt (with the witness input), or a quadratic unanchored search |
could not analyze: |
the analysis budget was exceeded, or the pattern sits outside the analyzer’s model |
proven unsafe: Exponential backtracking (proven). A witness input is "a" x n . "!".
What each verdict is built on, and what a witness means, is the ReDoS guide’s subject.
Cost
The assertions are dev-time tooling, not a runtime dependency: per
assertion, one parse (served from an in-memory cache of 1024 patterns, per
process — per worker under paratest) and one guarded match attempt.
assertPatternIsLinear() adds one bounded analysis — a state and step
budget, never wall-clock — memoized per pattern for the process; the
analysis is deterministic per release, so the memoization cannot be
observed, only a budget spent twice saved.