Writing a regex is easy. Trusting one is hard. The gap between “it looks right” and “it does what I mean” is where most regex bugs live, and the only reliable way to close that gap is to test the pattern against real input before you ship it. This guide walks through a repeatable way to test a regex: build sample cases, read the output honestly, understand the flags, and iterate without fooling yourself.
The companion post on why your regex matches more than you think catalogs the specific bug patterns. This one is the practical drill: how to actually run the tests.
Build positive and negative cases first
Before you touch the pattern, write down what should match and what should not. Two lists. The positive list is easy; it is the input you designed the regex for. The negative list is where the real work is, because a regex that accepts everything on your positive list is worthless if it also accepts garbage.
For a US phone validator, positive cases might be 555-1234 and 800-5309. Negative cases are the interesting ones: 555-1234extra, 55-1234, an empty string, abc-defg, and 555-1234 with a trailing newline. Paste each case into a regex tester and confirm the match state is what you expect. A pattern that passes all your positive cases but also passes half your negative cases is not done; it is dangerous, because it will pass your spot checks and fail in production.
The discipline here is simple: a test that only ever confirms success is not a test. Every regex needs at least one input that must be rejected, and you need to watch it get rejected.
Read matches and capture groups carefully
Running the pattern is only half of testing. The other half is reading the result correctly. A good tester shows you the full match plus every capture group, and those are different things.
Given (\d{3})-(\d{4}) against 555-1234, the full match is 555-1234, group 1 is 555, and group 2 is 1234. If your surrounding code calls group(1) expecting the whole match, it silently gets 555 instead. Testing catches this only if you actually look at each numbered group, not just the overall match state.
Named groups make this safer to read and safer to refactor. (?<area>\d{3})-(?<line>\d{4}) gives you area and line by name, so adding a group elsewhere in the pattern cannot shift your indexes out from under you. When you test, verify that each group holds the substring you expect, and prefer names once more than one group is in play.
Know what each flag changes
Flags quietly change what a pattern means. Test with the exact flags your production code uses, because a pattern that passes without them can fail with them, and the reverse.
g(global): find all matches instead of stopping at the first. Changes iteration, not whether a single match succeeds.i(ignore case):catalso matchesCATandCat.m(multiline):^and$match at every line boundary, not just the start and end of the whole string.s(dotall):.also matches newlines, so a pattern can span lines instead of stopping at the first one.u(unicode): enables proper handling of code points and Unicode-aware classes; without it, some escapes and surrogate pairs behave differently.y(sticky): the match must start exactly at the current position, which matters for tokenizers that scan left to right.
Toggle each flag in your tester and watch a case flip. That is the fastest way to build intuition for which flag you actually need.
Watch for the common mistakes
Four failures account for most surprises, and each has a test that exposes it.
Unanchored patterns match substrings. \d{3}-\d{4} accepts 555-1234garbage because it matches somewhere inside. Add a negative case with trailing junk; if it passes, you need ^...$.
Greedy quantifiers grab too much. <.*> against <a>x<b> matches the whole string. Test with input where the closing character appears more than once, and switch to lazy <.*?> or a narrow class like <[^>]*>.
Unescaped metacharacters mean something you did not intend. 3.14 uses . as “any character,” so it also matches 3x14. If you meant a literal dot, escape it as 3\.14 and test with 3x14 on your negative list.
Catastrophic backtracking hangs on adversarial input. Nested quantifiers like (a+)+b can take exponential time. Test any user-facing pattern against a long non-matching string such as forty as followed by X. If the tester stalls, reshape the pattern before it reaches a server.
Iterate safely
Change one thing at a time. When a case fails, adjust the pattern, then re-run your entire case list, not just the case you were chasing. It is common to fix one input and break two others, and you only notice if the whole suite runs every time. Keep your positive and negative lists in a scratch file so a later edit has something to check against.
When a pattern resists your intuition, hand it to the AI regex explainer. It walks the tokens in plain English and calls out greediness, anchoring, and class semantics, which often reveals why a case behaves the way it does. Explanation plus a full case list is a faster loop than staring at the syntax. Test first, read every group, then trust the pattern.