The Testing Academy · Playwright Practice

Regex 101 for Playwright Testers

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.

JavaScript flavour 10 practice drills Playwright surfaces Cheat sheet included
On this page
  1. Why testers need regex
  2. Your first match
  3. Character classes
  4. Quantifiers, greedy vs lazy
  5. Anchors and boundaries
  6. The flags
  7. Groups, captures, alternation
  8. String methods that take a regex
  9. Lookarounds
  10. Regex in Playwright
  11. Recipes for test code
  12. Pitfalls
  13. 10 drills with solutions
  14. Cheat sheet

1. Why testers need regex

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:

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}");
The double-backslash trap. In a normal string, "\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.

2. Your first match

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.

Order TTA- 1043 shipped try, fail, move on ... match starts here: T T A - then four \d /TTA-\d{4}/ on "Order TTA-1043 shipped"
The engine tries every start position until /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)
Experiment as you read. regex101.com with the ECMAScript (JavaScript) flavour selected shows the match, the groups, and a step-by-step explanation live. Keep it open in a second tab while you work through the drills at the end.

3. Character classes: describing one character

A class matches exactly one character out of a set. Everything bigger is built from these.

ClassMatchesTesting example
\done digit, 0 to 9/\d\d\d/ matches 404 in "status 404"
\wword character: letter, digit, underscoreusernames, ids: qa_user1
\swhitespace: space, tab, newlinesplitting log columns
\D \W \Sthe 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 rangesslug characters
[^abc]one character that is NOT a, b, c/[^,]+/ a CSV field: everything up to the comma
.any character except newlineuse 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
Inside [...] the rules relax. [.+] matches a literal dot or plus, no escaping needed. Only ], \, ^ (first position) and - (between characters) stay special inside a class.

4. Quantifiers: how many times

A quantifier sits after a class or group and says how many repetitions are allowed.

QuantifierMeaningExample
*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

Greedy by default, lazy on request

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
Better than lazy: be specific. /<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.

5. Anchors and boundaries: where the match may sit

Classes and quantifiers describe the text. Anchors describe the position. They consume no characters.

AnchorMeaningWhy 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
\bword boundary: edge between \w and not-\w/\bpass\b/ matches "pass", refuses "passed" and "bypass"
\BNOT a word boundaryrarely 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
Rule of thumb for assertions. When a regex expresses "the value should BE this", anchor it with ^...$. Unanchored regexes express "the value should CONTAIN this". Playwright's toHaveText and toHaveURL follow exactly this logic, see section 10.

6. The flags

Flags sit after the closing slash and change how the whole pattern behaves.

FlagNameEffect
iignore case/add to cart/i matches any casing. The flag you will use most in locators.
gglobalfind ALL matches, not just the first. Required by matchAll and regex replaceAll.
mmultiline^ and $ also match at every line break: per-line checks on logs.
sdotAll. also matches newlines: patterns that span lines.
uunicodecorrect handling of emoji and non-Latin scripts.
ystickymatch 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

7. Groups, captures, and alternation

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.

SyntaxWhat 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|balternation: a OR b. Combine with a group to limit its reach
\1backreference: 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
Where alternation ends. /^cart|checkout$/ means "starts with cart, OR ends with checkout", because | has the lowest precedence. You almost always want the group: /^(cart|checkout)$/.

8. String methods that take a regex

The regex is the pattern. These are the verbs.

CallReturnsUse when
re.test(str)true / falseyes-or-no checks, if-conditions
str.match(re)first match + captures, or nullextract one value
str.match(reG)array of ALL match texts (with g), no capturescount occurrences
str.matchAll(reG)iterator of full match objects, captures includedextract 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 gsame, reads clearer
str.split(re)array of piecesflexible delimiters: /\s*,\s*/
str.search(re)index of first match or -1rarely, 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);
}

9. Lookarounds: match here, but only if

A lookaround checks what comes before or after the position without consuming it. The check happens, the cursor does not move.

SyntaxNameReads as
x(?=y)lookaheadx, only if y follows
x(?!y)negative lookaheadx, only if y does NOT follow
(?<=y)xlookbehindx, only if y precedes
(?<!y)xnegative lookbehindx, 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
Each (?=...) 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.

10. Regex in Playwright: every surface

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.

Locators

// 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")');

Assertions

await expect(page).toHaveURL(/\/cart\.html$/);
await expect(badge).toHaveText(/^\d+$/);
await expect(row).toHaveClass(/\bselected\b/);
await expect(input).toHaveValue(/@example\.com$/);
String vs regex is a semantic switch, not a convenience.
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.
Passing a regex silently switches the assertion from equality to containment. Anchor when you mean "exactly".

Navigation, network, and test selection

// 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"
Glob or regex for 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.

getByText semantics, precisely

CallMatches 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

11. Recipes: regex you will actually paste into test code

JobPatternNotes
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}/gpair 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}/iscrub 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));

12. Pitfalls that cost real debugging hours

13. Ten drills, with solutions

Write each answer yourself before opening the solution. Every drill is a real test-code situation.

01 Extract the order id from: "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.

02 Assert the page URL ends with /cart.html, any host
await expect(page).toHaveURL(/\/cart\.html$/);

Escaped dot, $ anchor. Without the anchor, /cart.html-old would pass.

03 Click the button named "Add to cart" regardless of casing
await page.getByRole('button', { name: /^add to cart$/i }).click();

Anchors keep "Add to cart and save for later" out of the match.

04 Does this cell hold a price like 1299.00? True or false.
/^\d+\.\d{2}$/.test(cellText);

Anchored: the cell IS a price. \d{2} pins exactly two decimals.

05 Locate all table rows showing "out of stock", any casing
page.locator('tr').filter({ hasText: /out of stock/i });
06 Pull the number out of "3 items in your cart" with a named group
const { count } = "3 items in your cart".match(/(?<count>\d+) items/).groups;
Number(count);   // 3
07 Replace every 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.

08 Validate a SKU: exactly 3 uppercase letters, hyphen, 4 digits
/^[A-Z]{3}-\d{4}$/.test(sku);

Both anchors, or "XYZAB-12345" sneaks through on the substring.

09 Split "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.

10 Run every test tagged @smoke except the ones tagged @slow
# both options take a regex
npx playwright test --grep "@smoke" --grep-invert "@slow"

14. Cheat sheet

PieceSyntax
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(?= ) (?! ) (?<= ) (?<! )
Flagsi case · g all · m per line · s dot spans lines
Verbstest match matchAll replace replaceAll split
PlaywrightgetByText 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 →