Verdict
Yes
Test every hand-written regular expression that validates or parses input in a product application, with a table of strings it must match and strings it must reject, run in CI.
Why
- Blast radiususers
- Change frequencyrarely
- Detectabilityeventually
- Reversibilitywith-effort
- Test costtrivial
Test a hand-written regular expression that validates or parses input, such as a postcode check on a signup form. Blast radius is users, because a wrong pattern turns away valid customers, and Change frequency is rarely, since the pattern changes a few times a year. Detectability is eventually: a regex fails only on inputs its author did not try, and nothing crashes. Reversibility is with-effort, since records saved with a wrong extracted value need a repair script. Test cost is trivial, because a regex is a pure function that a table of strings covers in minutes, and rule R11 gives Test.
| When | Decision | Why |
|---|---|---|
| The regex lives in a script that renames or searches your own files | Do not test: run the script and look at the result | Blast radius falls to none: a wrong match affects only the person who runs the script |
| The regex parses log lines for an internal dashboard that only your team reads | Test minimally: one test with a real log line | Blast radius falls to internal and Reversibility to trivial: a fix redraws the dashboard |
| A one-time migration uses the regex to rewrite phone numbers in the customer table | Test the regex differently: run the migration on a copy of the customer table and diff the result | Change frequency falls to once: a test in the suite would never run again |
| The regex extracts invoice numbers and amounts from bank statement lines to mark invoices as paid | Test mandatory: cover every statement format the bank sends | Blast radius rises to money and Reversibility to costly: a wrong match marks unpaid invoices as paid |
| The regex decides which redirect URLs or CORS origins the application trusts | Test mandatory: assert that lookalike hosts such as `example.com.evil.net` are rejected | Blast radius rises to safety-or-legal and Detectability to never: a missing `$` anchor lets another site read users' data |
What breaks if you don't test
Take a UK postcode check written as ^[A-Z]{2}\d \d[A-Z]{2}$. It accepts the LS1 4AP its author typed in and rejects M1 1AE and EC1A 1BB, so customers in Manchester and in London's EC and WC districts cannot finish signup. Nothing logs an error, because a rejected form looks like the regex doing its job. Someone notices weeks later, when a regional signup report shows a gap.
What you lose if you over-test
A table with every valid postcode you can find copies the format into a test file, and each row needs an edit when the format changes on purpose. Testing the same pattern again through each form adds slow browser tests that fail for unrelated reasons.
How to test
Write unit tests. Put the pattern behind a named function, such as is_valid_postcode(), and test that function with pytest's parametrize or JUnit's @CsvSource. The minimum set is one valid example per format, near-misses such as lowercase letters or a trailing character, and the empty string. When the regex runs on text that users send, add one long input with a time limit, because nested quantifiers can backtrack for seconds on a crafted string, as the OWASP page on ReDoS shows.
When the answer changes
- The matched value moves money or decides access: payment references, redirect allowlists, CORS origins.
- The regex runs once, in a migration or a cleanup script.
- Only you or your team read the output, as with a rename script or an internal log dashboard.
Real incident + Code example
The trim that took Stack Overflow down
On 20 July 2016, Stack Overflow was down for 34 minutes because of a regex that trimmed whitespace from each line of a post, according to the Stack Exchange postmortem. One post held about 20,000 consecutive whitespace characters followed by other text, and the engine made 199,990,000 checks on it. The post appeared on the home page, which served as the load balancer's health check, so every web server was marked down. A table of match cases passes on this pattern:
import re
import time
import pytest
TRIM = re.compile(r"^[\s\u200c]+|[\s\u200c]+$")
@pytest.mark.parametrize("raw, expected", [
(" hello ", "hello"),
("\u200chello", "hello"),
("a b", "a b"),
("", ""),
])
def test_trims_both_ends(raw, expected):
assert TRIM.sub("", raw) == expected
def test_long_inner_whitespace_is_fast():
raw = "x" + " " * 20_000 + "x"
start = time.perf_counter()
TRIM.sub("", raw)
assert time.perf_counter() - start < 0.1 # fails
I ran this file with Python 3.13 on my laptop: the four match cases pass, and the last test fails after 1.7 seconds. With 2,000 spaces the same call took 0.016 seconds, so a short input hides the problem. Stack Overflow replaced the regex with a substring function.
Related questions
FAQ
- Should there be unit tests for complex regular expressions?
Yes, a complex regular expression needs a parameterized unit test that lists strings it must match and strings it must reject. The test takes minutes to write, and a wrong pattern fails silently on inputs its author never tried.
- Should I unit test a simple regex?
Yes, when a simple regex validates customer input, add one test with a matching and a non-matching string. A missing
$anchor lets12345abcpass as a ZIP code. Skip the test only for a pattern in a script that affects nobody but you.- How do I unit test a regular expression?
Wrap the pattern in a named function and write one parameterized test for that function. List one valid example per format, near-misses such as a trailing character, and the empty string; for a parser, assert the captured groups.
- Should I test a regex for catastrophic backtracking?
Yes, if a regex runs on text that users send, add one test that runs it on a long input under a time limit. Nested quantifiers such as
(a+)+$can take seconds on a crafted string, and a table of short strings never shows it. A linear-time engine such as RE2 removes this failure where your language offers one.