|

SEO Meta Tag Testing with Playwright

A single broken <link rel="canonical"> or a missing Open Graph image can quietly tank your rankings and turn every social share into an ugly, untitled blob. The problem is that SEO tags live in the document <head> where no human ever looks, so they rot silently between deploys. This guide shows you exactly how Playwright SEO meta tag testing works in TypeScript: asserting titles and descriptions, validating canonical and robots directives, checking Open Graph and Twitter card metadata, parsing JSON-LD structured data, and wiring it all into CI so a marketing-killing regression fails the build instead of your traffic.

🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.

Contents

Why SEO meta tags need automated tests

SEO tags are invisible by design. They sit inside <head>, never render on screen, and rarely have a visual diff when they break. A framework upgrade, a botched template refactor, or a stray environment variable can flip a production page to <meta name="robots" content="noindex"> and you might not notice for weeks, by which point Google has dropped the page from its index. Manual review does not scale across hundreds of routes, and Lighthouse runs are too slow and too coarse to gate every pull request.

Playwright is a strong fit here because it loads pages in a real browser, so it sees the head after client-side hydration, framework meta managers, and redirects have all settled. That matters for React, Next.js, Nuxt, and similar stacks where tags are injected at runtime and a raw curl of the HTML would show the wrong thing. With the same locators and assertions you already use for UI tests, you can lock down every tag that search engines and social crawlers actually read.

Asserting title and meta description

Start with the two tags every page must get right: the <title> and the meta description. Playwright exposes page.title() for the title, and for any meta tag you read the content attribute of the matching element. A robust description test checks not just presence but length, since Google typically truncates descriptions past roughly 155 to 160 characters.

import { test, expect } from '@playwright/test';

test('homepage title and description are SEO-friendly', async ({ page }) => {
  await page.goto('/');

  // Title: present, within Google's ~60-char display limit, and branded.
  await expect(page).toHaveTitle(/Acme/);
  const title = await page.title();
  expect(title.length).toBeGreaterThan(10);
  expect(title.length).toBeLessThanOrEqual(60);

  // Meta description: read the content attribute directly.
  const description = page.locator('head meta[name="description"]');
  await expect(description).toHaveAttribute('content', /.+/);

  const descText = await description.getAttribute('content');
  expect(descText!.length).toBeGreaterThanOrEqual(50);
  expect(descText!.length).toBeLessThanOrEqual(160);
});

Two details make this reliable. First, scope locators to head so a stray meta in the body cannot satisfy the assertion by accident. Second, toHaveAttribute auto-retries, so it waits out client-side frameworks that inject the description a tick after navigation, which a one-shot getAttribute would miss.

Validating canonical URLs and robots directives

The canonical link tells search engines which URL is the source of truth, and the robots meta controls indexing. These are the two tags most likely to cause catastrophic, silent damage: a wrong canonical can deindex a whole section, and an accidental noindex shipped to production removes pages from search entirely. Test them explicitly on every important route.

import { test, expect } from '@playwright/test';

test('product page has a correct canonical and is indexable', async ({ page, baseURL }) => {
  await page.goto('/products/widget-42');

  // Canonical must be absolute, https, and self-referencing.
  const canonical = page.locator('head link[rel="canonical"]');
  await expect(canonical).toHaveCount(1); // never more than one
  const href = await canonical.getAttribute('href');
  expect(href).toBe(`${baseURL}/products/widget-42`);
  expect(href).toMatch(/^https:\/\//);

  // Robots must NOT contain noindex/nofollow on a public page.
  const robots = page.locator('head meta[name="robots"]');
  const robotsCount = await robots.count();
  if (robotsCount > 0) {
    const content = (await robots.getAttribute('content')) ?? '';
    expect(content).not.toMatch(/noindex/i);
    expect(content).not.toMatch(/nofollow/i);
  }
});

The toHaveCount(1) check is deceptively important. Duplicate canonical tags, often caused by two plugins or layout components both injecting one, confuse crawlers and are a common real-world bug. Asserting exactly one canonical catches that class of regression immediately.

Checking Open Graph and Twitter card metadata

Open Graph (og:) and Twitter card tags control how your page looks when shared on LinkedIn, Facebook, Slack, and X. A missing og:image produces a blank, low-trust preview that crushes click-through. Because these come in a predictable set, drive the test from a data table so adding a new required tag is a one-line change.

import { test, expect } from '@playwright/test';

const requiredOg: Record<string, RegExp> = {
  'og:title': /.{5,}/,
  'og:description': /.{20,}/,
  'og:type': /^(website|article|product)$/,
  'og:url': /^https:\/\//,
  'og:image': /^https:\/\/.+\.(png|jpe?g|webp)/i,
};

const requiredTwitter: Record<string, RegExp> = {
  'twitter:card': /^(summary|summary_large_image)$/,
  'twitter:title': /.{5,}/,
  'twitter:image': /^https:\/\//,
};

test('article exposes valid Open Graph and Twitter tags', async ({ page }) => {
  await page.goto('/blog/playwright-seo-meta-tag-testing');

  // Open Graph uses the `property` attribute, not `name`.
  for (const [property, pattern] of Object.entries(requiredOg)) {
    const tag = page.locator(`head meta[property="${property}"]`);
    const content = await tag.getAttribute('content');
    expect.soft(content, `missing/invalid ${property}`).toMatch(pattern);
  }

  // Twitter cards use the `name` attribute.
  for (const [name, pattern] of Object.entries(requiredTwitter)) {
    const tag = page.locator(`head meta[name="${name}"]`);
    const content = await tag.getAttribute('content');
    expect.soft(content, `missing/invalid ${name}`).toMatch(pattern);
  }
});

Note the attribute difference: Open Graph tags use property="og:title" while Twitter tags use name="twitter:card". Mixing these up is the single most common reason an og assertion fails to find a tag that is actually present. Using expect.soft here means one missing tag does not abort the loop, so a single run reports every social tag that is wrong rather than just the first.

Verifying the og:image actually loads

Asserting that og:image has a URL is not enough; crawlers will skip a preview image that returns a 404 or is too small. Playwright can fetch the image through its request context and verify the response status and dimensions without rendering it, giving you a true end-to-end check on the social preview.

import { test, expect } from '@playwright/test';

test('og:image resolves to a real, large-enough asset', async ({ page, request }) => {
  await page.goto('/blog/playwright-seo-meta-tag-testing');

  const imageUrl = await page
    .locator('head meta[property="og:image"]')
    .getAttribute('content');
  expect(imageUrl, 'og:image content missing').toBeTruthy();

  // Fetch the asset with Playwright's API request context.
  const response = await request.get(imageUrl!);
  expect(response.status(), 'og:image must not 404').toBe(200);
  expect(response.headers()['content-type']).toMatch(/^image\//);

  // Twitter/Facebook recommend at least 1200x630; a tiny file is a red flag.
  const bytes = (await response.body()).length;
  expect(bytes, 'og:image looks suspiciously small').toBeGreaterThan(5_000);
});

This single test would have caught countless real incidents where an image CDN path changed and every share preview broke at once. Because the fetch uses Playwright’s request fixture, it carries the same base URL and cookies as the browser context, so authenticated or environment-scoped image hosts work without extra setup.

Parsing and validating JSON-LD structured data

Structured data in <script type="application/ld+json"> powers rich results: star ratings, FAQ accordions, breadcrumbs. Because it is JSON embedded in the DOM, you can extract the script text, parse it, and assert on the object graph. This catches malformed JSON (which silently disables the rich result) and missing required fields.

import { test, expect } from '@playwright/test';

test('article ships valid Article + Breadcrumb JSON-LD', async ({ page }) => {
  await page.goto('/blog/playwright-seo-meta-tag-testing');

  // Grab every JSON-LD block and parse it; invalid JSON throws here.
  const blocks = await page
    .locator('script[type="application/ld+json"]')
    .allTextContents();
  expect(blocks.length).toBeGreaterThan(0);

  const parsed = blocks.map((raw) => JSON.parse(raw));

  // Find the Article (or BlogPosting) node and assert required fields.
  const article = parsed.find(
    (node) => node['@type'] === 'Article' || node['@type'] === 'BlogPosting',
  );
  expect(article, 'no Article/BlogPosting schema found').toBeTruthy();
  expect(article['@context']).toBe('https://schema.org');
  expect(article.headline?.length).toBeGreaterThan(0);
  expect(article.author).toBeTruthy();
  expect(article.datePublished).toMatch(/^\d{4}-\d{2}-\d{2}/);

  // Confirm a BreadcrumbList exists too.
  const breadcrumb = parsed.find((node) => node['@type'] === 'BreadcrumbList');
  expect(breadcrumb?.itemListElement?.length).toBeGreaterThanOrEqual(2);
});

The JSON.parse call doubles as a validation step: if a template ever emits a trailing comma or an unescaped quote, the test fails with a clear parse error pointing you at the broken block. For deeper validation you can pipe the parsed object into a schema validator like Ajv with the schema.org definitions, but even this lightweight shape check catches the majority of structured-data regressions.

🚀 Level Up Your Playwright

From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.

Scaling across many pages with a fixture

SEO tests earn their keep when they run across every route. Rather than copy-pasting assertions, extract a reusable helper that returns a normalized snapshot of a page’s head, then loop your routes through it with test.describe and a parameterized list. This keeps one source of truth for what “good” looks like.

import { test, expect, Page } from '@playwright/test';

async function readHead(page: Page) {
  return {
    title: await page.title(),
    description: await page
      .locator('head meta[name="description"]')
      .getAttribute('content'),
    canonical: await page
      .locator('head link[rel="canonical"]')
      .getAttribute('href'),
    ogImage: await page
      .locator('head meta[property="og:image"]')
      .getAttribute('content'),
  };
}

const routes = ['/', '/pricing', '/blog', '/contact'];

for (const route of routes) {
  test(`SEO essentials present on ${route}`, async ({ page }) => {
    await page.goto(route);
    const head = await readHead(page);

    expect.soft(head.title, `${route}: empty title`).toBeTruthy();
    expect.soft(head.description, `${route}: no description`).toBeTruthy();
    expect.soft(head.canonical, `${route}: no canonical`).toBeTruthy();
    expect.soft(head.ogImage, `${route}: no og:image`).toBeTruthy();
  });
}

Generating one test per route (rather than one giant test with a loop inside) gives you a clear per-page pass or fail in the report, so a regression on /pricing is obvious at a glance. Combined with expect.soft, each page reports all of its missing tags in a single run.

SEO tags and the right Playwright API at a glance

SEO elementSelector / sourcePlaywright API to assert it
Title<title>page.title() / toHaveTitle()
Meta descriptionmeta[name="description"]toHaveAttribute('content', ...)
Canonicallink[rel="canonical"]toHaveCount(1) + getAttribute('href')
Robotsmeta[name="robots"]getAttribute('content') + regex
Open Graphmeta[property="og:*"]getAttribute('content')
Twitter cardmeta[name="twitter:*"]getAttribute('content')
og:image assetimage URLrequest.get() + status/body
Structured datascript[type="application/ld+json"]allTextContents() + JSON.parse
hreflanglink[rel="alternate"][hreflang]locator().all() + attribute checks

Wiring Playwright SEO meta tag testing into CI

The whole point of Playwright SEO meta tag testing is to make invisible regressions loud. Put these specs in their own @seo tag or project so they run on every pull request and on a post-deploy smoke run against production, where you can catch environment-specific breakage like a staging canonical leaking live. Keep assertions data-driven so adding a route or a required tag is a one-line edit, lean on expect.soft so each run surfaces every issue at once, and verify that real assets like og:image actually resolve. Start with your highest-traffic pages, lock down title, description, canonical, robots, and the social trio, then expand to JSON-LD and hreflang. The result is a suite that fails the build the moment someone ships a noindex or a broken canonical, instead of letting your search traffic find out first.

FAQ

Can Playwright test meta tags that are injected by client-side frameworks?

Yes, and that is one of its biggest advantages over fetching raw HTML. Playwright loads the page in a real browser, so React Helmet, Next.js metadata, Nuxt useHead, and similar runtime meta managers have all executed by the time you assert. Use the auto-retrying matchers like toHaveAttribute or add a brief page.waitForLoadState('networkidle') if a tag is injected after an async data fetch, so the assertion waits for the final head state.

Why use Playwright instead of Lighthouse or an SEO crawler?

Lighthouse and crawlers are great for periodic audits but are slow, coarse, and not designed to gate individual pull requests. Playwright SEO meta tag testing gives you fast, deterministic, per-assertion checks that run in the same CI pipeline as your other tests and fail the build on the exact tag that regressed. The two are complementary: use crawlers for breadth and discovery, and Playwright to lock down the specific tags you already know must never break.

How do I assert that a page is correctly set to noindex?

For pages that should be hidden from search, such as internal dashboards or staging routes, flip the assertion: locate meta[name="robots"] and assert its content matches /noindex/i. This positive check is just as valuable as the public-page check, because it prevents the opposite failure where a private page accidentally becomes indexable and leaks into search results.

🎓 Master Playwright End to End

Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.