A regex that passes three example strings in a code review is not a tested regex. The failure mode isn't usually "it doesn't match anything" — it's "it matches almost everything correctly, except for the 0.1% of inputs that look slightly different than what the author imagined," and that 0.1% shows up in production as a support ticket, not a failing test.
Why Regex Bugs Are Different From Other Bugs
Most code fails loudly — a null pointer, a thrown exception, a stack trace. A regex that's subtly wrong usually fails quietly: it matches when it shouldn't, or it doesn't match when it should, and the surrounding code just proceeds with wrong data. There's no exception to catch. That makes regex one of the few places where "it looked right when I glanced at it" is actively dangerous, and where a deliberate test pass matters more than usual.
There's also a second, less obvious failure mode: performance. A pattern can be functionally correct and still hang the process on the right (wrong) input.
Build a Real Test Set, Not Three Examples
Before treating a pattern as done, run it against four categories of input:
- Clear positives — inputs that obviously should match, covering the range of valid formats (e.g., emails with subdomains, plus-addressing, different TLD lengths).
- Clear negatives — inputs that obviously shouldn't match, including ones that are almost valid (a UUID with one extra hex digit, an email missing the
@). - Boundary cases — empty string, single character, maximum realistic length, leading/trailing whitespace, repeated delimiters.
- Adversarial cases — unicode homoglyphs, mixed encodings, strings designed to exploit greedy/backtracking behavior, and anything a previous production bug taught you to watch for.
Iterating on a pattern against a batch like this is faster in a dedicated tool than in application code with a debugger attached — paste the pattern and every test string into the Regex Tester and you get immediate per-string match feedback instead of a rebuild-and-rerun loop.
Keep the test cases in your codebase
A regex that lives only in a code review comment gets no protection against the next edit. Once a pattern is validated, commit the test cases as actual unit tests:
describe('slugPattern', () => {
const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
test.each([
['hello-world', true],
['hello', true],
['Hello-World', false], // uppercase
['hello--world', false], // double delimiter
['-hello', false], // leading delimiter
['hello-', false], // trailing delimiter
['', false], // empty
])('%s -> %s', (input, expected) => {
expect(slugPattern.test(input)).toBe(expected);
});
});
This turns "someone tweaked the regex to fix a bug and broke three other things" into a failing test in CI instead of a production incident.
Catastrophic Backtracking: The Bug That Only Shows Up Under Load
Some patterns are correct for every input you'll ever hand-test, and still capable of freezing your process. The classic shape is nested, ambiguous repetition:
// Looks reasonable, matches short strings fine
const badPattern = /^(a+)+$/;
// Against a string with no trailing match, the engine tries
// an exponential number of ways to partition the a's before
// giving up — this can hang for seconds to indefinitely
// depending on input length.
badPattern.test('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!');
The pattern (a+)+ is ambiguous — there are many ways to split a run of as across the inner and outer groups — and backtracking engines (which is most of them, including JavaScript's) explore that ambiguity combinatorially when the match ultimately fails. This is documented behavior of backtracking regex engines; see the MDN guide on regex performance pitfalls for how backtracking works under the hood.
How to catch it before it ships:
- Watch for nested quantifiers on overlapping character classes:
(a+)+,(a*)*,([a-z]+)*. - Test with long, non-matching strings specifically, not just long matching ones — the pathological case is usually the near-miss.
- Where the engine supports it, prefer atomic groups or possessive quantifiers to eliminate the ambiguity outright.
- For untrusted input (user-submitted strings validated against a regex on a server), consider a timeout around the match, or a regex engine with linear-time guarantees, as defense in depth.
Decision Point: When Testing Isn't Enough and You Should Simplify the Pattern
If a pattern needs more than a handful of test cases to feel confident about, or if you find yourself adding lookaheads to patch edge cases one at a time, that complexity is a signal, not just a testing burden. Two options beat continuing to harden the same regex:
- Split it into multiple, simpler patterns applied in sequence — easier to test each stage independently, and easier for the next person to read.
- Replace it with a small parser or an existing library for the format in question (email, URL, and phone number validation all have this problem — the "correct" regex for RFC-compliant email addresses is notoriously unreadable, and most codebases are better served by a maintained library or a simpler practical pattern with known limitations).
Automated Tools Beyond Manual Test Cases
Hand-picked test cases catch what you thought to check. Two categories of tooling catch what you didn't:
Property-based / fuzz testing. Instead of writing individual examples, a property-based testing library (like fast-check for JavaScript) generates hundreds of randomized inputs against invariants you define — "a string generated from this pattern should always match," "a string with a random inserted character should usually not match." This surfaces edge cases a human wouldn't think to write by hand, at the cost of needing to define the invariant rather than a fixed input/output pair.
import fc from 'fast-check';
test('valid slugs always match the slug pattern', () => {
fc.assert(
fc.property(
fc.stringMatching(/^[a-z0-9]+(-[a-z0-9]+)*$/),
(slug) => slugPattern.test(slug) === true
)
);
});
Static ReDoS detection. Tools like eslint-plugin-security's detect-unsafe-regex rule, or standalone packages like safe-regex, analyze a pattern's structure for the nested-quantifier shapes associated with catastrophic backtracking, without needing to find the pathological input yourself. These flag candidates for review, not certainties — they can both miss real problems and flag patterns that are fine in practice, so treat a warning as a prompt to reason through the pattern rather than an automatic rewrite.
Neither tool replaces the manual test-case workflow above; they extend it. Fuzzing needs your invariant to be correct in the first place, and static ReDoS detection needs a human to confirm whether a flagged pattern is actually exposed to untrusted input.
Testing Regex Extracted From User-Facing Config
Not every regex lives in your codebase. Patterns pulled from CMS fields, admin-configurable validation rules, or third-party webhook payloads deserve the same scrutiny — arguably more, since the person writing them may not be a developer and won't have run any of the above tooling. If your product lets non-engineers define patterns (custom field validation, URL redirect rules, content filters), validate the pattern itself at save time: check it compiles, run it against a handful of stored sample inputs, and consider rejecting patterns that match known catastrophic-backtracking shapes before they're ever persisted and executed against live traffic.
Before You Ship
Run the pattern against your real dataset if you have one — production log samples, a CSV export of actual user input — not just hand-picked examples. Regex bugs concentrate in the gap between what the author imagined and what users actually type, and the only way to close that gap is to test against real strings, including the ugly ones.