The complete regular-expressions tutorial for automation testers: how patterns actually match, every flag, class and quantifier you will really use, the String methods that accept a regex, and every place Playwright accepts one. Companion reading for the live Regex 101 session, and the reference you come back to afterwards.
A regular expression is a pattern that describes a set of strings. Instead of asking "is this text equal to X", you ask "does this text have this shape". Test automation is full of shape questions:
Order TTA-1043 placed today and Order TTA-2210 placed tomorrow. Equality fails, the shape never changes./cart.html, not about the host, port, or query string in front of it.Add to cart on desktop and ADD TO CART on mobile is one pattern with the i flag.Two ways to write one in JavaScript:
// Literal: slashes instead of quotes. Compiled once, preferred. const re1 = /TTA-\d{4}/; // Constructor: for patterns built at runtime. Backslashes must be doubled. const re2 = new RegExp("TTA-\\d{4}");
"\d" is just d: the backslash is eaten by the string parser before the regex ever sees it. Inside new RegExp() you must write "\\d". This single fact explains most "my regex works in the literal but not from the config file" bugs.The engine walks the input one character at a time, trying to start a match at each position. The moment the whole pattern fits, it stops and reports. Nothing magical, just a very fast cursor.
/TTA-\d{4}/ fits.Two calls cover ninety percent of day-to-day use:
const toast = "Order TTA-1043 placed"; /TTA-\d{4}/.test(toast); // true (yes or no) toast.match(/TTA-\d{4}/); // ["TTA-1043", ...] (give me the text)
A class matches exactly one character out of a set. Everything bigger is built from these.
| Class | Matches | Testing example |
|---|---|---|
\d | one digit, 0 to 9 | /\d\d\d/ matches 404 in "status 404" |
\w | word character: letter, digit, underscore | usernames, ids: qa_user1 |
\s | whitespace: space, tab, newline | splitting log columns |
\D \W \S | the negations: NOT digit, NOT word, NOT space | /\S+/ grabs a token up to the next space |
[abc] | one of a, b, or c | /[YN]/ for a yes-no cell |
[a-z0-9] | one from the ranges | slug characters |
[^abc] | one character that is NOT a, b, c | /[^,]+/ a CSV field: everything up to the comma |
. | any character except newline | use sparingly, see pitfalls |
Special characters that mean something to the engine (. * + ? ( ) [ ] { } ^ $ | \ /) must be escaped with a backslash when you want them literally:
/cart\.html/.test("cart.html"); // true, the dot is literal /cart.html/.test("cartXhtml"); // ALSO true. Unescaped dot = any character
[...] the rules relax. [.+] matches a literal dot or plus, no escaping needed. Only ], \, ^ (first position) and - (between characters) stay special inside a class.A quantifier sits after a class or group and says how many repetitions are allowed.
| Quantifier | Meaning | Example |
|---|---|---|
* | zero or more | /\d*/ matches "" and "123" |
+ | one or more | /\d+/ the whole number: 1043 |
? | zero or one (optional) | /https?:/ matches http: and https: |
{4} | exactly 4 | /\d{4}/ a PIN or id block |
{2,} | 2 or more | /x{2,}/ |
{1,3} | 1 to 3 | /\d{1,3}/ one price group |
Quantifiers grab as much as they can and give back only if the rest of the pattern cannot fit. Add ? after the quantifier to make it lazy: grab as little as possible.
const html = '<td>Cart</td><td>Total</td>'; html.match(/<td>.*<\/td>/)[0]; // '<td>Cart</td><td>Total</td>' greedy ate both cells html.match(/<td>.*?<\/td>/)[0]; // '<td>Cart</td>' lazy stopped at the first close
/<td>[^<]*<\/td>/ ("everything that is not a <") is faster and clearer than .*?. Reaching for a negated class instead of a lazy dot is the single biggest habit that separates readable test regexes from fragile ones.Classes and quantifiers describe the text. Anchors describe the position. They consume no characters.
| Anchor | Meaning | Why testers care |
|---|---|---|
^ | start of the string | /^Error/: the line IS an error, not "0 Errors found" |
$ | end of the string | /\/cart\.html$/: the URL truly ends there |
\b | word boundary: edge between \w and not-\w | /\bpass\b/ matches "pass", refuses "passed" and "bypass" |
\B | NOT a word boundary | rarely needed, good interview trivia |
/^PASS$/.test("PASS"); // true: whole string is exactly PASS /^PASS$/.test("PASSED"); // false /PASS/.test("BYPASSED"); // true: unanchored = substring, this bites in assertions
^...$. Unanchored regexes express "the value should CONTAIN this". Playwright's toHaveText and toHaveURL follow exactly this logic, see section 10.Flags sit after the closing slash and change how the whole pattern behaves.
| Flag | Name | Effect |
|---|---|---|
i | ignore case | /add to cart/i matches any casing. The flag you will use most in locators. |
g | global | find ALL matches, not just the first. Required by matchAll and regex replaceAll. |
m | multiline | ^ and $ also match at every line break: per-line checks on logs. |
s | dotAll | . also matches newlines: patterns that span lines. |
u | unicode | correct handling of emoji and non-Latin scripts. |
y | sticky | match only at lastIndex, parser-building territory. |
const log = "12:01:04 login ok\n12:01:09 cart ok\n12:01:15 checkout FAIL"; log.match(/^\d\d:\d\d:\d\d/gm); // ["12:01:04", "12:01:09", "12:01:15"] // g = all of them, m = ^ works on every line, not just the first
Parentheses do two jobs at once: they group a sub-pattern so a quantifier or alternation can apply to it, and they capture what they matched so you can pull it out.
| Syntax | What it does |
|---|---|
(abc) | group + capture as number 1, 2, 3 in order of the opening bracket |
(?:abc) | group only, no capture. Use when you just need the grouping |
(?<name>abc) | named capture: read it back as groups.name |
a|b | alternation: a OR b. Combine with a group to limit its reach |
\1 | backreference: whatever group 1 matched, again |
const toast = "Order TTA-1043 placed for [email protected]"; // Numbered captures const m = toast.match(/Order (TTA-(\d+))/); m[0]; // "Order TTA-1043" the whole match m[1]; // "TTA-1043" group 1 m[2]; // "1043" group 2 // Named captures read like documentation const g = toast.match(/Order (?<orderId>TTA-\d+)/).groups; g.orderId; // "TTA-1043" // Alternation scoped by a group /\/(cart|checkout|order-confirmation)\.html/.test("/checkout.html"); // true
/^cart|checkout$/ means "starts with cart, OR ends with checkout", because | has the lowest precedence. You almost always want the group: /^(cart|checkout)$/.The regex is the pattern. These are the verbs.
| Call | Returns | Use when |
|---|---|---|
re.test(str) | true / false | yes-or-no checks, if-conditions |
str.match(re) | first match + captures, or null | extract one value |
str.match(reG) | array of ALL match texts (with g), no captures | count occurrences |
str.matchAll(reG) | iterator of full match objects, captures included | extract MANY values with groups; requires g or it throws |
str.replace(re, x) | new string, first match replaced (all, with g) | normalising, scrubbing |
str.replaceAll(reG, x) | new string, all matches replaced; regex form requires g | same, reads clearer |
str.split(re) | array of pieces | flexible delimiters: /\s*,\s*/ |
str.search(re) | index of first match or -1 | rarely, prefer test |
// Replacement can reference the captures: $1 or $<name> "TTA-1043".replace(/TTA-(\d+)/, "order #$1"); // "order #1043" // Scrub every timestamp before a snapshot comparison log.replaceAll(/\d{2}:\d{2}:\d{2}/g, "<TIME>"); // Pull every order id, with groups for (const m of text.matchAll(/TTA-(?<num>\d+)/g)) { console.log(m.groups.num); }
A lookaround checks what comes before or after the position without consuming it. The check happens, the cursor does not move.
| Syntax | Name | Reads as |
|---|---|---|
x(?=y) | lookahead | x, only if y follows |
x(?!y) | negative lookahead | x, only if y does NOT follow |
(?<=y)x | lookbehind | x, only if y precedes |
(?<!y)x | negative lookbehind | x, only if y does NOT precede |
// The number, only when a rupee marker precedes it "Total: Rs 1299 (3 items)".match(/(?<=Rs )\d+/)[0]; // "1299", not the 3 // Password rule: 8+ chars, at least one digit, at least one uppercase const strong = /^(?=.*\d)(?=.*[A-Z]).{8,}$/; strong.test("Playwright1"); // true strong.test("playwright"); // false: no digit, no uppercase
(?=...) runs from the same starting position, which is how several independent rules stack onto one string. That password pattern is three requirements AND-ed together.Playwright accepts a regex almost everywhere it accepts a string, and the two behave differently. Knowing which surface does what is the practical payoff of this whole page.
// Case-proof accessible name page.getByRole('button', { name: /add to cart/i }); // Text with a dynamic number in it page.getByText(/\d+ items in your cart/); // Filter rows by pattern page.locator('tr').filter({ hasText: /out of stock/i }); // Inside a CSS selector, the Playwright-only pseudo-class page.locator('td:text-matches("TTA-\\d+", "i")');
await expect(page).toHaveURL(/\/cart\.html$/); await expect(badge).toHaveText(/^\d+$/); await expect(row).toHaveClass(/\bselected\b/); await expect(input).toHaveValue(/@example\.com$/);
toHaveText("Cart") with a string = the element's normalised text must EQUAL "Cart".
toHaveText(/Cart/) with a regex = the text must CONTAIN a match, unless you anchor with ^$.
toHaveURL("/cart.html") with a string = the FULL resolved URL must equal it.
toHaveURL(/cart/) = the URL must contain "cart" anywhere.
// Wait for any checkout step await page.waitForURL(/\/checkout(-two)?\.html/); // Mock two endpoints with one route await page.route(/\/api\/(cart|orders)/, route => route.fulfill({ json: { ok: true } }) ); // Run only smoke-tagged tests: --grep takes a regex // npx playwright test --grep "@smoke" // npx playwright test --grep "@smoke" --grep-invert "@slow"
route()? Globs (**/api/cart) read nicer for one endpoint. The moment you need "this OR that", alternation in a regex beats registering two routes: one handler, one place to maintain the mock.| Call | Matches when the element's text |
|---|---|
getByText("Cart") | contains "Cart" (case-sensitive substring, whitespace normalised) |
getByText("Cart", { exact: true }) | is exactly "Cart" |
getByText(/cart/i) | contains a match, your flags decide the case rule |
getByText(/^Cart$/) | is exactly "Cart", regex-anchored form |
| Job | Pattern | Notes |
|---|---|---|
| Order id | /TTA-\d{4,}/ | at least 4 digits, room to grow |
| Email (test data sanity) | /^\S+@\S+\.\S+$/ | sanity check, not RFC validation. Do not fight the RFC in a test |
| Indian mobile | /^[6-9]\d{9}$/ | 10 digits, first 6 to 9 |
| Date DD-MM-YYYY shape | /^\d{2}-\d{2}-\d{4}$/ | shape only, 99-99-9999 passes. Real validity needs date logic |
| Time in logs | /\d{2}:\d{2}:\d{2}/g | pair with replaceAll to scrub before compare |
| UUID | /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i | scrub these too |
| Price like 1,299.00 | /\d{1,3}(,\d{3})*(\.\d{2})?/ | groups of 3 with commas, optional paise |
| SKU AAA-9999 | /^[A-Z]{3}-\d{4}$/ | anchored: the cell IS a SKU, not contains one |
// The scrub-then-compare pattern for anything dynamic const clean = (s) => s .replaceAll(/\d{2}:\d{2}:\d{2}/g, "<TIME>") .replaceAll(/TTA-\d+/g, "<ORDER>"); expect(clean(actualToast)).toBe(clean(expectedToast));
g flag. A /g regex remembers lastIndex between calls. Call re.test() twice on the same string and the second call starts where the first stopped, and can return false on a string that matches. Never put g on a regex you use with test().new RegExp("\d+") silently loses the backslash. Write "\\d+", or keep patterns as literals.toHaveText(/Cart/) passes on "Cart (3)" and "Discart". If you meant equality, write /^Cart$/..* across the whole line. Greedy dots swallow everything and backtrack char by char. Prefer negated classes: [^<]*, [^,]*, [^"]*./cart.html/ also matches "cartXhtml". Every literal dot in a URL pattern is \..matchAll without g throws. TypeError: matchAll must be called with a global RegExp. Same for replaceAll with a regex./(\w+\s*)+$/ can hang on a long non-matching input. If a pattern needs three levels of nesting, simplify it or split the check into two.Write each answer yourself before opening the solution. Every drill is a real test-code situation.
"Success! Order TTA-1043 placed""Success! Order TTA-1043 placed".match(/TTA-\d+/)[0]; // "TTA-1043"
\d+ rather than \d{4}: the id keeps working when order numbers grow a digit.
/cart.html, any hostawait expect(page).toHaveURL(/\/cart\.html$/);
Escaped dot, $ anchor. Without the anchor, /cart.html-old would pass.
await page.getByRole('button', { name: /^add to cart$/i }).click();
Anchors keep "Add to cart and save for later" out of the match.
1299.00? True or false./^\d+\.\d{2}$/.test(cellText);
Anchored: the cell IS a price. \d{2} pins exactly two decimals.
page.locator('tr').filter({ hasText: /out of stock/i });
"3 items in your cart" with a named groupconst { count } = "3 items in your cart".match(/(?<count>\d+) items/).groups; Number(count); // 3
HH:MM:SS timestamp in a log with <TIME>log.replaceAll(/\d{2}:\d{2}:\d{2}/g, "<TIME>");
The g flag is mandatory here: regex replaceAll throws without it.
/^[A-Z]{3}-\d{4}$/.test(sku);
Both anchors, or "XYZAB-12345" sneaks through on the substring.
"chromium, firefox ,webkit" into clean names"chromium, firefox ,webkit".split(/\s*,\s*/); // ["chromium", "firefox", "webkit"]
The regex eats the spaces around each comma during the split, no trim() pass needed.
@smoke except the ones tagged @slow# both options take a regex npx playwright test --grep "@smoke" --grep-invert "@slow"
| Piece | Syntax |
|---|---|
| One character | \d \w \s · negations \D \W \S · sets [abc] [a-z] [^x] · any: . |
| Repetition | * + ? · {n} {n,} {n,m} · lazy: add ? |
| Position | ^ $ \b \B |
| Grouping | ( ) (?: ) (?<name> ) · or: | · again: \1 |
| Only-if | (?= ) (?! ) (?<= ) (?<! ) |
| Flags | i case · g all · m per line · s dot spans lines |
| Verbs | test match matchAll replace replaceAll split |
| Playwright | getByText getByRole:name filter:hasText toHaveText toHaveURL toHaveClass waitForURL route --grep :text-matches() |
Regex describes the text. Locating the element that holds the text is its own craft: the CSS Selector Cheat Sheet and the XPath Master Cheat Sheet cover that side. Practice both against the tables practice page and the rest of the Playwright practice library.
Back to the practice library →