Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 069 / 100

Testing SEO basics with Playwright: a step-by-step guide

Catch missing tags before release.

Expona Admin8 min read

Testing SEO basics with Playwright: a step-by-step guide

SEO issues slip into production every day—a missing meta description here, a broken canonical link there. Most teams catch these only after launch, when search engines have already indexed the broken pages. Testing SEO basics with Playwright gives us a way to catch these problems before release, treating SEO tags as real requirements rather than an afterthought.

In this guide, we'll walk through automating checks for the elements that matter most: page titles, meta descriptions, canonical links, structured data, and robots directives. By the end, you'll have a reusable test suite that runs against your staging environment and flags SEO regressions before they reach production.

Why automated SEO testing matters

Manual SEO audits are slow and easy to forget. Browser DevTools can show us the head tags on a single page, but validating them across dozens of critical pages—and catching regressions after every release—requires automation. Playwright gives us the tools to inspect the DOM, parse HTML attributes, and assert on the presence and correctness of SEO metadata with the same rigor we apply to functional tests.

The goal isn't to replace professional SEO tools like Screaming Frog or Ahrefs. Instead, we're automating smoke tests that catch the obvious issues: missing titles, duplicate canonicals, malformed JSON-LD, and robots rules that block indexing by mistake.

Assert title and meta description

The page title and meta description are foundational. The title appears in search results and browser tabs; the description influences click-through rates. Both should be unique, non-empty, and appropriate for the page.

Here's a practical test that checks these on a product page:

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

test('product page has valid title and meta description', async ({ page }) => {
  await page.goto('/products/widget-pro');

  // Assert title exists and is not generic
  const title = await page.title();
  expect(title).not.toBe('');
  expect(title).not.toContain('New Page');
  expect(title.length).toBeLessThanOrEqual(60);

  // Assert meta description exists and is reasonable length
  const metaDescription = await page.locator('meta[name="description"]').getAttribute('content');
  expect(metaDescription).not.toBeNull();
  expect(metaDescription.length).toBeGreaterThan(50);
  expect(metaDescription.length).toBeLessThanOrEqual(160);
});

This test checks three things: the title isn't empty or generic, it's under 60 characters (roughly the limit before truncation in search results), and the description exists with a reasonable length. In production, you might expand this to check character counts per locale or assert that descriptions include target keywords.

If you're testing multiple pages, create a helper function to avoid repetition:

typescript
async function assertSeoTags(page, expectedTitle, minDescLength = 50, maxDescLength = 160) {
  const title = await page.title();
  expect(title).toBe(expectedTitle);

  const description = await page.locator('meta[name="description"]').getAttribute('content');
  expect(description).not.toBeNull();
  expect(description.length).toBeGreaterThan(minDescLength);
  expect(description.length).toBeLessThanOrEqual(maxDescLength);
}

test('homepage title and description', async ({ page }) => {
  await page.goto('/');
  await assertSeoTags(page, 'Widget Shop – Premium Tools for Builders', 60, 160);
});

test('about page title and description', async ({ page }) => {
  await page.goto('/about');
  await assertSeoTags(page, 'About Us – Widget Shop', 50, 160);
});

This scales much better when you're checking dozens of pages.

Canonical links tell search engines which version of a page is the authoritative one. They prevent duplicate content penalties and consolidate ranking signals. A missing canonical or a self-pointing broken canonical can confuse crawlers and dilute SEO value.

Here's how to verify canonicals on key pages:

typescript
test('canonical link is present and correct', async ({ page }) => {
  const productId = '12345';
  await page.goto(`/products/${productId}`);

  const canonical = await page.locator('link[rel="canonical"]').getAttribute('href');
  expect(canonical).not.toBeNull();

  // Ensure it points to the expected URL (not a parameter-heavy version)
  const expectedCanonical = `https://mysite.com/products/${productId}`;
  expect(canonical).toBe(expectedCanonical);
});

test('homepage does not have unnecessary canonical', async ({ page }) => {
  await page.goto('/');

  // Some sites omit canonical from homepage; others include it pointing to itself
  const canonical = await page.locator('link[rel="canonical"]').getAttribute('href');
  if (canonical) {
    expect(canonical).toBe('https://mysite.com/');
  }
});

A real-world scenario: you have product pages accessible via /products/widget?color=blue&size=large and /products/widget?size=large&color=blue. Without a canonical, search engines treat these as separate pages. The test ensures the canonical always points to the clean, parameter-free version.

For sites with pagination or filtered lists, you might also check that canonical links are absent when they shouldn't exist:

typescript
test('filtered product list should not have canonical', async ({ page }) => {
  await page.goto('/products?category=electronics&price=100-500');

  const canonical = await page.locator('link[rel="canonical"]').getAttribute('href');
  expect(canonical).toBeNull();
});

Verify structured data JSON-LD

Structured data (JSON-LD) helps search engines understand your content better. It powers rich snippets, knowledge panels, and feeds. Missing or malformed structured data won't break your site, but it leaves SEO value on the table—especially for e-commerce sites that could show pricing, availability, and ratings in search results.

Here's how to extract and validate JSON-LD from a page:

typescript
test('product page has valid JSON-LD structured data', async ({ page }) => {
  await page.goto('/products/widget-pro');

  // Extract JSON-LD script tag
  const jsonLdScript = await page.locator('script[type="application/ld+json"]').first().textContent();
  expect(jsonLdScript).not.toBeNull();

  const jsonLd = JSON.parse(jsonLdScript);

  // Assert it's a Product schema
  expect(jsonLd['@type']).toBe('Product');
  expect(jsonLd.name).not.toBe('');
  expect(jsonLd.description).not.toBe('');

  // Assert pricing is present
  expect(jsonLd.offers).not.toBeUndefined();
  expect(jsonLd.offers.price).not.toBe('');
  expect(jsonLd.offers.priceCurrency).toBe('USD');

  // Assert review aggregation if applicable
  if (jsonLd.aggregateRating) {
    expect(jsonLd.aggregateRating.ratingValue).toBeGreaterThanOrEqual(0);
    expect(jsonLd.aggregateRating.ratingValue).toBeLessThanOrEqual(5);
  }
});

This test verifies that the JSON-LD is valid (parseable), has the correct schema type, and includes key fields like name, description, and pricing. If you're selling internationally, you'd also check that priceCurrency matches the site's language or region.

For organization pages, organization schema is common:

typescript
test('homepage has organization JSON-LD', async ({ page }) => {
  await page.goto('/');

  const jsonLdScript = await page.locator('script[type="application/ld+json"]').first().textContent();
  const jsonLd = JSON.parse(jsonLdScript);

  expect(jsonLd['@type']).toBe('Organization');
  expect(jsonLd.name).toBe('Widget Shop');
  expect(jsonLd.url).toBe('https://mysite.com');
  expect(jsonLd.logo).not.toBeUndefined();
  expect(jsonLd.contactPoint).not.toBeUndefined();
  expect(jsonLd.contactPoint.contactType).toBe('Customer Service');
});

Check robots meta on key pages

The robots meta tag controls whether a page should be indexed and followed by search engines. A common mistake is accidentally setting robots="noindex" on production pages during development, or forgetting to remove it from staging URLs.

Here's how to verify robots directives:

typescript
test('production pages allow indexing', async ({ page }) => {
  const pagesToIndex = [
    '/',
    '/products',
    '/about',
    '/blog/getting-started',
  ];

  for (const path of pagesToIndex) {
    await page.goto(path);

    const robots = await page.locator('meta[name="robots"]').getAttribute('content');
    
    // Should either be absent or not contain 'noindex'
    if (robots) {
      expect(robots).not.toContain('noindex');
    }
  }
});

test('staging environment blocks indexing', async ({ page }) => {
  // Change base URL to staging in your Playwright config or via environment variable
  await page.goto('/');

  const robots = await page.locator('meta[name="robots"]').getAttribute('content');
  expect(robots).toContain('noindex');
});

test('admin and private pages block indexing', async ({ page }) => {
  const privatePages = [
    '/admin/dashboard',
    '/user/settings',
    '/checkout',
  ];

  for (const path of privatePages) {
    // Use try-catch if these pages might require auth
    try {
      await page.goto(path, { waitUntil: 'domcontentloaded' });
      
      const robots = await page.locator('meta[name="robots"]').getAttribute('content');
      expect(robots).toContain('noindex');
    } catch (e) {
      // Page might redirect to login; that's fine
    }
  }
});

This approach catches the common issue where staging environments accidentally get indexed. By running one suite against production and another against staging, you ensure the right robots directives are in place everywhere.

Common mistakes

  • Assuming meta tags render on page load: If your site renders meta tags client-side with JavaScript, Playwright might catch them, but search engine crawlers won't. Always render SEO metadata server-side or via pre-rendering.
  • Not testing across multiple pages: Testing only the homepage misses regressions on category pages, product pages, and blog posts. Parameterize your tests or use test data to cover a representative sample.
  • Ignoring trailing slashes: If your site treats /about and /about/ as different URLs, canonicals should account for this. Playwright tests can surface this inconsistency.
  • Forgetting to check page load timing: If a meta tag is injected after a delay, Playwright might find it, but a fast crawler might not. Always wait for the page to be fully loaded before extracting SEO metadata.
  • Not validating JSON-LD syntax: Malformed JSON will throw an error during JSON.parse(). Wrap the parse in a try-catch if you expect it might fail, or let the test fail loudly to catch the error.

Key takeaways

  • Automate SEO smoke tests alongside functional tests to catch missing or broken tags before production.
  • Assert on page title length, meta description presence and length, and canonical link correctness for every critical page.
  • Parse and validate JSON-LD structured data to ensure it's well-formed and includes required fields for your schema type.
  • Verify robots meta directives are correct across environments—production pages should allow indexing, staging should block it.
  • Parameterize tests to cover multiple pages without duplicating code; use helpers to keep tests DRY.
  • Remember that SEO tests complement professional audit tools; focus on automating the checks that prevent regressions.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles