On this page8 sections
ReferenceIntermediate6-8 min reference

Regex for Testers

Regular expressions are the universal pattern language for QA — useful for validating input fields, asserting log lines, matching URLs, parsing API responses, and writing tolerant test assertions. This sheet covers the syntax most testers actually use, plus a library of ready-to-paste patterns for common QA scenarios.

All examples use the JavaScript / ECMAScript flavour, which is what Cypress, Playwright, and most testing libraries run on.

Basics

Literal matching

A regex without metacharacters matches itself.

const re = /hello/;
re.test('hello world');     // true
re.test('Hello world');     // false — case-sensitive by default

The dot . — any character

Matches any single character except newlines (unless the s flag is set).

/c.t/.test('cat');          // true
/c.t/.test('cot');          // true
/c.t/.test('ct');           // false — needs exactly one char in between

Anchors ^ and $

^ anchors the match to the start of the string (or line, with m flag). $ anchors to the end.

/^hello/.test('hello world');       // true
/^hello/.test('say hello');         // false
/world$/.test('hello world');       // true
/^hello$/.test('hello');            // true — exact match
/^hello$/.test('hello world');      // false

Word boundaries \b

Matches the position between a word character and a non-word character. Useful when you want whole-word matches.

/\bcat\b/.test('the cat sat');      // true
/\bcat\b/.test('category');         // false — 'cat' is part of a word
/\bcat\b/.test('cat-like');         // true — hyphen is a word boundary

Character classes

Custom classes [...]

Match any one character from the set.

/[abc]/.test('apple');              // true — matches 'a'
/[0-9]/.test('order 42');           // true
/[a-zA-Z]/.test('Hello');           // true
/[a-zA-Z0-9_-]/                     // alphanumeric plus _ and -

Negation [^...]

Match any one character NOT in the set.

/[^0-9]/.test('123');               // false — only digits
/[^aeiou]/.test('rhythm');          // true — 'r' is not a vowel

Predefined classes

PatternMatchesEquivalent
\dAny digit[0-9]
\DAny non-digit[^0-9]
\wAny word character[A-Za-z0-9_]
\WAny non-word character[^A-Za-z0-9_]
\sAny whitespace[ \t\n\r\f\v]
\SAny non-whitespace[^ \t\n\r\f\v]
/\d{3}/.test('order 42');           // false — only 2 digits
/\d{3}/.test('order 142');          // true
/\w+/.test('hello_world');          // true

Quantifiers

Basic quantifiers

PatternMatches
*0 or more of the preceding element
+1 or more
?0 or 1 (optional)
{n}Exactly n
{n,}n or more
{n,m}Between n and m (inclusive)
/ab*/.test('a');             // true — zero b's allowed
/ab+/.test('a');             // false — at least one b required
/colou?r/.test('color');     // true — 'u' is optional
/colou?r/.test('colour');    // true
/\d{4}/.test('2025');        // true — exactly 4 digits
/\d{4,}/.test('20250');      // true — 4 or more
/\d{2,4}/.test('1');         // false — at least 2 required

Greedy vs lazy

By default quantifiers are greedy — they match as much as possible. Add ? to make them lazy (match as little as possible).

const html = '<b>bold</b> and <i>italic</i>';
 
html.match(/<.+>/);          // greedy:  matches '<b>bold</b> and <i>italic</i>'
html.match(/<.+?>/);          // lazy:    matches '<b>'
html.match(/<.+?>/g);         // all tags: ['<b>', '</b>', '<i>', '</i>']

This single distinction trips up more people than any other regex feature. When pulling structured fragments out of a longer string, you almost always want lazy.

Groups & alternation

Capturing group (...)

Wraps a sub-pattern. The matched substring is captured and accessible by index.

const m = 'order #1042 placed'.match(/order #(\d+)/);
m[0];     // 'order #1042' — full match
m[1];     // '1042' — first capture group

Non-capturing group (?:...)

Groups for quantification or alternation, but doesn't capture. Slightly faster and keeps your indexes clean.

const m = 'foo123bar'.match(/(?:foo)(\d+)(?:bar)/);
m[1];     // '123' — only the digits are captured

Alternation |

/cat|dog/.test('I have a cat');       // true
/(cat|dog)/.exec('I have a dog');     // captures 'dog'
/^(GET|POST|PUT|DELETE)$/.test('GET'); // true

Named groups (?<name>...)

Capture by name instead of index — much more readable for complex patterns.

const m = '2025-03-15'.match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/);
m.groups.year;      // '2025'
m.groups.month;     // '03'
m.groups.day;       // '15'

Backreferences \1, \2, …

Match the same text as a previously captured group.

/(\w+) \1/.test('hello hello');      // true — repeated word
/(\w+) \1/.test('hello world');      // false
 
// With named groups
/(?<word>\w+) \k<word>/.test('hello hello');  // true

Lookahead & lookbehind

These match a position based on what comes before or after — without consuming the surrounding characters.

Positive lookahead (?=...)

The position must be followed by the pattern.

/\d+(?=px)/.exec('font-size: 16px');     // matches '16' (not '16px')
/(?=.*\d)/.test('abc123');                // true — somewhere ahead is a digit

Negative lookahead (?!...)

The position must NOT be followed by the pattern.

/\d+(?!px)/.exec('100em');                // matches '100' (not followed by 'px')
/^(?!admin)/.test('admin');               // false
/^(?!admin)/.test('user');                // true

Positive lookbehind (?<=...)

The position must be preceded by the pattern.

/(?<=\$)\d+/.exec('price: $42');         // matches '42'
/(?<=\.)\w+/.exec('user.example.com');   // matches 'example'

Negative lookbehind (?<!...)

The position must NOT be preceded by the pattern.

/(?<!\d)\d{3}\b/.exec('codes 200 and 1500');   // matches '200' (no digit before)
/(?<!_)\b\w+/.exec('_private name');           // matches 'name'

Lookarounds are the right tool when you want to match X only if it's surrounded by Y, but you don't want Y in your match.

Common QA patterns

Ready-to-paste regex for fields you'll validate again and again. Each one notes its trade-offs — there is no truly "correct" regex for things like email, only good-enough.

Email

A pragmatic, good-enough pattern. The full RFC-5322 regex is famously enormous; this catches 99% of valid addresses without rejecting anything reasonable.

^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$
const email = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
email.test('jane.doe@example.com');     // true
email.test('foo@bar');                  // false — TLD must be 2+ chars
email.test('not an email');             // false

URL (http/https)

^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$
const url = /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/;
url.test('https://qa.codes');                       // true
url.test('http://www.example.com/path?q=1');        // true
url.test('not a url');                              // false

Phone numbers

US (lenient — allows formatting):

^\+?1?[-. ]?\(?\d{3}\)?[-. ]?\d{3}[-. ]?\d{4}$
const us = /^\+?1?[-. ]?\(?\d{3}\)?[-. ]?\d{3}[-. ]?\d{4}$/;
us.test('+1 (555) 123-4567');     // true
us.test('555-123-4567');          // true
us.test('5551234567');            // true

UK (lenient):

^\+?44\s?\d{2,5}\s?\d{3,8}$|^0\d{2,5}\s?\d{3,8}$
const uk = /^\+?44\s?\d{2,5}\s?\d{3,8}$|^0\d{2,5}\s?\d{3,8}$/;
uk.test('+44 20 7946 0958');      // true
uk.test('020 7946 0958');         // true

International (E.164):

^\+[1-9]\d{1,14}$
const e164 = /^\+[1-9]\d{1,14}$/;
e164.test('+14155551234');        // true
e164.test('+442079460958');       // true

Date formats

ISO 8601 date (YYYY-MM-DD):

^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$

ISO 8601 timestamp:

^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$
const iso = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/;
iso.test('2025-03-15T14:30:00Z');         // true
iso.test('2025-03-15T14:30:00.123+05:30'); // true

US (MM/DD/YYYY):

^(0[1-9]|1[0-2])\/(0[1-9]|[12]\d|3[01])\/\d{4}$

UK (DD/MM/YYYY):

^(0[1-9]|[12]\d|3[01])\/(0[1-9]|1[0-2])\/\d{4}$

These do calendar shape but not validity — they accept 02/30/2025. For real validation, parse with Date or a date library after the regex passes.

IPv4 address

^(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)$
const ipv4 = /^(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
ipv4.test('192.168.1.1');         // true
ipv4.test('255.255.255.255');     // true
ipv4.test('256.1.1.1');           // false — out of range

UUID v4

^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$
const uuid4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
uuid4.test('550e8400-e29b-41d4-a716-446655440000');   // true
uuid4.test('not-a-uuid');                             // false

The version digit (4) and the variant bits (8, 9, a, or b) are checked, not just the shape.

Semantic versioning

The official pattern from semver.org, trimmed:

^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$
const semver = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
semver.test('1.2.3');                 // true
semver.test('1.0.0-alpha.1');         // true
semver.test('1.0.0-rc.1+build.123');  // true
semver.test('1.0');                   // false — must have three parts

Credit card number (format only)

Shape check only — does not validate the Luhn checksum. Use a library or write the Luhn check separately.

^\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}$
const cc = /^\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}$/;
cc.test('4111 1111 1111 1111');     // true
cc.test('4111-1111-1111-1111');     // true
cc.test('4111111111111111');        // true

Password strength

8+ chars, with at least one uppercase, lowercase, digit, and special character:

^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]).{8,}$
const strong = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]).{8,}$/;
strong.test('Secret!23');     // true
strong.test('secret!23');     // false — no uppercase
strong.test('Secret123');     // false — no special char
strong.test('Sec!2');         // false — too short

The (?=...) lookaheads check each requirement independently of the others — much cleaner than trying to enforce four conditions in one linear pattern.

Testing regex in Cypress

cy.contains and most assertions accept regex directly.

// Find an element by text matching a regex
cy.contains(/Order #\d+/);
cy.contains('h2', /^Welcome/i);
 
// Assert the matched element's text
cy.get('[data-testid="order-id"]').should('match', /^ORD-\d{6}$/);
 
// Assert URL matches a pattern
cy.url().should('match', /\/orders\/\d+$/);
 
// Assert text content
cy.get('.timestamp').invoke('text').should('match', /^\d{4}-\d{2}-\d{2}/);
 
// Match against the value of an input
cy.get('input[name="orderId"]').invoke('val').should('match', /^ORD-\d+$/);
 
// Negative match — should NOT match
cy.get('h1').invoke('text').should('not.match', /error/i);

For inputs that should match a known regex pattern, store the pattern as a constant — keeps tests readable when the pattern is non-trivial.

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
 
cy.get('[data-testid="user-id"]')
  .invoke('text')
  .should('match', UUID_RE);

Testing regex in Playwright

Playwright's locator and expect APIs accept RegExp instances anywhere a string is accepted.

import { test, expect } from '@playwright/test';
 
test('order id matches expected shape', async ({ page }) => {
  // Locator by regex text
  await page.getByText(/Order #\d+/).click();
  await page.getByRole('heading', { name: /^Welcome/i }).waitFor();
 
  // Assert text matches a pattern
  await expect(page.getByTestId('order-id')).toHaveText(/^ORD-\d{6}$/);
  await expect(page.getByRole('alert')).toContainText(/invalid/i);
 
  // URL match
  await expect(page).toHaveURL(/\/orders\/\d+$/);
 
  // Title match
  await expect(page).toHaveTitle(/qa\.codes$/);
 
  // Attribute match
  await expect(page.getByRole('img')).toHaveAttribute('src', /\/avatars\/.+\.png$/);
 
  // Input value match
  await expect(page.getByLabel('Order ID')).toHaveValue(/^ORD-\d+$/);
});

For repeat use across a suite, declare patterns once and import them — same pattern as Cypress:

// fixtures/patterns.ts
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export const ORDER_ID_RE = /^ORD-\d{6}$/;
 
// in a test
import { ORDER_ID_RE } from '../fixtures/patterns';
await expect(page.getByTestId('order-id')).toHaveText(ORDER_ID_RE);

A small library of named patterns makes intent obvious and lets you fix all related tests when a pattern changes.