← Back to DevBytes

Testing Puppeteer Components: From Unit to E2E Tests

Testing Puppeteer Components: From Unit to E2E Tests

Puppeteer is a powerful Node.js library that provides a high-level API to control Chrome or Chromium over the DevTools Protocol. While Puppeteer is typically used as a testing tool itself, building robust applications and libraries that wrap or extend Puppeteer requires a thoughtful testing strategy. This tutorial walks you through testing Puppeteer-based components — from isolated unit tests to full end-to-end (E2E) browser automation tests — so you can ship reliable automation code with confidence.

What Does "Testing Puppeteer Components" Mean?

When we talk about testing Puppeteer components, we're referring to two distinct but complementary layers. First, there are unit tests that validate individual functions, classes, or modules that use Puppeteer's API — often without launching a real browser. Second, there are integration and E2E tests that launch an actual browser, navigate pages, interact with DOM elements, and assert on real outcomes. A mature test suite combines both layers to balance speed, reliability, and coverage.

Why It Matters

Setting Up the Project

Let's start by initializing a project and installing the necessary dependencies. We'll use Jest as the test runner, but the concepts apply equally to Mocha, Vitest, or Node's built-in test runner.

mkdir puppeteer-testing
cd puppeteer-testing
npm init -y
npm install puppeteer jest jest-puppeteer @types/jest --save-dev

Next, configure Jest to use the jest-puppeteer preset, which manages the browser lifecycle for you:

// jest.config.js
module.exports = {
  preset: 'jest-puppeteer',
  testTimeout: 30000,
  testMatch: ['**/*.test.js'],
  verbose: true,
};

Create a configuration file for jest-puppeteer to control browser launch options:

// jest-puppeteer.config.js
module.exports = {
  launch: {
    headless: 'new',
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
  },
  browserContext: 'incognito',
};

Building a Puppeteer Component to Test

Before writing tests, we need a component worth testing. Let's build a small module that wraps Puppeteer to scrape article titles from a blog. This module will have pure logic (ideal for unit testing) and browser interaction logic (ideal for E2E testing).

// src/scraper.js
const { parseTitle } = require('./utils');

class BlogScraper {
  constructor(page) {
    this.page = page;
  }

  async scrapeTitles(url) {
    if (!url || typeof url !== 'string') {
      throw new Error('A valid URL string is required');
    }
    await this.page.goto(url, { waitUntil: 'networkidle2' });
    await this.page.waitForSelector('article h2');

    const titles = await this.page.$$eval('article h2', (elements) =>
      elements.map((el) => el.textContent.trim())
    );

    return titles.map(parseTitle);
  }

  async scrapeMetadata(url) {
    await this.page.goto(url, { waitUntil: 'domcontentloaded' });
    const meta = await this.page.evaluate(() => {
      const getMeta = (name) => {
        const el = document.querySelector(`meta[name="${name}"]`);
        return el ? el.getAttribute('content') : null;
      };
      return {
        title: document.title,
        description: getMeta('description'),
        author: getMeta('author'),
      };
    });
    return meta;
  }
}

module.exports = { BlogScraper };

Now let's extract the pure parsing logic into a separate utility file so it can be unit tested independently:

// src/utils.js
function parseTitle(rawTitle) {
  if (typeof rawTitle !== 'string') {
    throw new TypeError('Title must be a string');
  }
  return rawTitle
    .replace(/\s+/g, ' ')
    .trim()
    .replace(/^Article:\s*/i, '');
}

function isValidUrl(url) {
  try {
    const parsed = new URL(url);
    return parsed.protocol === 'http:' || parsed.protocol === 'https:';
  } catch {
    return false;
  }
}

module.exports = { parseTitle, isValidUrl };

Unit Testing Pure Logic

Unit tests focus on the pure functions that don't require a browser. These tests are fast, deterministic, and should form the bulk of your test suite. Let's test the utility functions:

// tests/utils.test.js
const { parseTitle, isValidUrl } = require('../src/utils');

describe('parseTitle', () => {
  test('trims whitespace and collapses multiple spaces', () => {
    expect(parseTitle('  Hello    World  ')).toBe('Hello World');
  });

  test('removes "Article:" prefix case-insensitively', () => {
    expect(parseTitle('Article: My First Post')).toBe('My First Post');
    expect(parseTitle('ARTICLE: Another Post')).toBe('Another Post');
  });

  test('returns empty string for empty input', () => {
    expect(parseTitle('')).toBe('');
  });

  test('throws TypeError for non-string input', () => {
    expect(() => parseTitle(null)).toThrow(TypeError);
    expect(() => parseTitle(123)).toThrow(TypeError);
    expect(() => parseTitle(undefined)).toThrow(TypeError);
  });
});

describe('isValidUrl', () => {
  test('accepts valid HTTP URLs', () => {
    expect(isValidUrl('http://example.com')).toBe(true);
  });

  test('accepts valid HTTPS URLs', () => {
    expect(isValidUrl('https://example.com/path?q=1')).toBe(true);
  });

  test('rejects non-HTTP protocols', () => {
    expect(isValidUrl('ftp://example.com')).toBe(false);
    expect(isValidUrl('file:///tmp/test')).toBe(false);
  });

  test('rejects malformed strings', () => {
    expect(isValidUrl('not a url')).toBe(false);
    expect(isValidUrl('')).toBe(false);
    expect(isValidUrl(null)).toBe(false);
  });
});

Unit Testing Puppeteer Components with Mocks

The BlogScraper class depends on a Puppeteer page object. For unit tests, we don't want to launch a real browser — we want to mock the page object and verify that our class calls the expected Puppeteer methods with the correct arguments. This keeps tests fast and focused on our logic rather than browser behavior.

// tests/scraper.unit.test.js
const { BlogScraper } = require('../src/scraper');

function createMockPage() {
  const handlers = {};
  return {
    goto: jest.fn().mockResolvedValue(undefined),
    waitForSelector: jest.fn().mockResolvedValue(undefined),
    $$eval: jest.fn(),
    evaluate: jest.fn(),
    // Capture any other methods you might need
    on: jest.fn((event, cb) => { handlers[event] = cb; }),
    _handlers: handlers,
  };
}

describe('BlogScraper (unit tests with mocked page)', () => {
  let page;
  let scraper;

  beforeEach(() => {
    page = createMockPage();
    scraper = new BlogScraper(page);
  });

  describe('scrapeTitles', () => {
    test('throws error for invalid URL', async () => {
      await expect(scraper.scrapeTitles(null)).rejects.toThrow('valid URL');
      await expect(scraper.scrapeTitles(123)).rejects.toThrow('valid URL');
      await expect(scraper.scrapeTitles('')).rejects.toThrow('valid URL');
    });

    test('navigates to the URL with networkidle2', async () => {
      page.$$eval.mockResolvedValue(['Article: First Post', 'Second Post']);
      await scraper.scrapeTitles('https://blog.example.com');
      expect(page.goto).toHaveBeenCalledWith(
        'https://blog.example.com',
        { waitUntil: 'networkidle2' }
      );
    });

    test('waits for article headings to appear', async () => {
      page.$$eval.mockResolvedValue(['Title One']);
      await scraper.scrapeTitles('https://blog.example.com');
      expect(page.waitForSelector).toHaveBeenCalledWith('article h2');
    });

    test('parses each title through parseTitle', async () => {
      page.$$eval.mockResolvedValue([
        'Article: First Post',
        '  Second   Post  ',
      ]);
      const result = await scraper.scrapeTitles('https://blog.example.com');
      expect(result).toEqual(['First Post', 'Second Post']);
    });

    test('returns empty array when no articles found', async () => {
      page.$$eval.mockResolvedValue([]);
      const result = await scraper.scrapeTitles('https://blog.example.com');
      expect(result).toEqual([]);
    });
  });

  describe('scrapeMetadata', () => {
    test('extracts title, description, and author from page', async () => {
      page.evaluate.mockResolvedValue({
        title: 'My Blog',
        description: 'A blog about testing',
        author: 'Jane Doe',
      });
      const result = await scraper.scrapeMetadata('https://blog.example.com');
      expect(result).toEqual({
        title: 'My Blog',
        description: 'A blog about testing',
        author: 'Jane Doe',
      });
      expect(page.goto).toHaveBeenCalledWith(
        'https://blog.example.com',
        { waitUntil: 'domcontentloaded' }
      );
    });
  });
});

By mocking the page object, we can test every branch of our scraper logic — including error paths — without ever launching Chrome. This is the foundation of a fast, reliable test suite.

Integration Testing with a Local HTML Fixture

Between unit tests and full E2E tests, integration tests use a real browser but against controlled, local HTML fixtures. This gives you real DOM behavior without network variability. Let's create a simple HTML fixture and test against it:

<!-- tests/fixtures/sample-blog.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="description" content="A sample blog for testing">
  <meta name="author" content="Test Author">
  <title>Sample Blog</title>
</head>
<body>
  <article>
    <h2>Article: First Post</h2>
    <p>Content of the first post.</p>
  </article>
  <article>
    <h2>Second Post</h2>
    <p>Content of the second post.</p>
  </article>
  <article>
    <h2>Article: Third Post</h2>
    <p>Content of the third post.</p>
  </article>
</body>
</html>

Now write an integration test that launches a real browser and loads this fixture:

// tests/scraper.integration.test.js
const path = require('path');
const { BlogScraper } = require('../src/scraper');

const FIXTURE_PATH = path.resolve(__dirname, 'fixtures/sample-blog.html');
const FIXTURE_URL = `file://${FIXTURE_PATH}`;

describe('BlogScraper (integration with real browser)', () => {
  let scraper;

  beforeEach(async () => {
    const page = await browser.newPage();
    scraper = new BlogScraper(page);
  });

  test('scrapes and parses all article titles from fixture', async () => {
    const titles = await scraper.scrapeTitles(FIXTURE_URL);
    expect(titles).toEqual(['First Post', 'Second Post', 'Third Post']);
  });

  test('scrapes metadata from fixture', async () => {
    const meta = await scraper.scrapeMetadata(FIXTURE_URL);
    expect(meta.title).toBe('Sample Blog');
    expect(meta.description).toBe('A sample blog for testing');
    expect(meta.author).toBe('Test Author');
  });

  test('handles page with no articles gracefully', async () => {
    const emptyFixture = `file://${path.resolve(__dirname, 'fixtures/empty.html')}`;
    // Create a minimal empty page inline
    await page.setContent('<html><body><p>No articles here</p></body></html>');
    await expect(scraper.scrapeTitles(FIXTURE_URL)).rejects.toThrow();
  });
});

Integration tests against local fixtures are excellent for verifying that your selectors, DOM traversal, and evaluation logic work correctly with real browser rendering — all without depending on external servers.

End-to-End Testing Against Real Pages

E2E tests validate the entire flow against real websites or a locally running server. These tests are the most realistic but also the slowest and most prone to flakiness. Use them sparingly for critical user journeys. Let's set up a local Express server and test against it:

// tests/server.js
const express = require('express');
const path = require('path');

function createServer(port = 3000) {
  const app = express();
  app.use(express.static(path.join(__dirname, 'fixtures')));
  app.get('/api/posts', (req, res) => {
    res.json([
      { id: 1, title: 'Article: API Post One' },
      { id: 2, title: 'API Post Two' },
    ]);
  });
  return app.listen(port);
}

module.exports = { createServer };

Now write a full E2E test that starts the server, launches the browser, and exercises the scraper end to end:

// tests/scraper.e2e.test.js
const { BlogScraper } = require('../src/scraper');
const { createServer } = require('./server');

describe('BlogScraper E2E', () => {
  let server;
  let scraper;
  let page;

  beforeAll(() => {
    server = createServer(3001);
  });

  afterAll((done) => {
    server.close(done);
  });

  beforeEach(async () => {
    page = await browser.newPage();
    scraper = new BlogScraper(page);
  });

  afterEach(async () => {
    await page.close();
  });

  test('scrapes titles from a live local server', async () => {
    const titles = await scraper.scrapeTitles('http://localhost:3001/sample-blog.html');
    expect(titles).toHaveLength(3);
    expect(titles[0]).toBe('First Post');
    expect(titles[2]).toBe('Third Post');
  });

  test('scrapes metadata from a live local server', async () => {
    const meta = await scraper.scrapeMetadata('http://localhost:3001/sample-blog.html');
    expect(meta.title).toBe('Sample Blog');
    expect(meta.author).toBe('Test Author');
  });

  test('handles navigation to a non-existent page', async () => {
    await expect(
      scraper.scrapeTitles('http://localhost:3001/nonexistent.html')
    ).rejects.toThrow();
  });
});

Testing Interactions and User Flows

Many Puppeteer components go beyond scraping — they simulate user interactions like clicking, typing, and form submission. Here's an example component that fills out and submits a login form:

// src/auth.js
class AuthFlow {
  constructor(page) {
    this.page = page;
  }

  async login(url, credentials) {
    if (!credentials.email || !credentials.password) {
      throw new Error('Email and password are required');
    }
    await this.page.goto(url, { waitUntil: 'networkidle2' });
    await this.page.waitForSelector('#login-form');

    await this.page.type('#email', credentials.email, { delay: 50 });
    await this.page.type('#password', credentials.password, { delay: 50 });
    await this.page.click('#submit-button');

    await this.page.waitForNavigation({ waitUntil: 'networkidle2' });
    return this.page.url();
  }

  async isLoggedIn() {
    const logoutButton = await this.page.$('#logout-button');
    return logoutButton !== null;
  }
}

module.exports = { AuthFlow };

And the corresponding test using a local fixture with a login form:

// tests/auth.e2e.test.js
const { AuthFlow } = require('../src/auth');

describe('AuthFlow E2E', () => {
  let auth;
  let page;

  beforeEach(async () => {
    page = await browser.newPage();
    auth = new AuthFlow(page);

    // Set up a mock login page with client-side validation
    await page.setContent(`
      <html>
      <body>
        <form id="login-form" action="#" method="post">
          <input id="email" type="email" />
          <input id="password" type="password" />
          <button id="submit-button" type="submit">Login</button>
        </form>
        <script>
          document.getElementById('login-form').addEventListener('submit', (e) => {
            e.preventDefault();
            const email = document.getElementById('email').value;
            const password = document.getElementById('password').value;
            if (email === 'user@test.com' && password === 'secret123') {
              document.body.innerHTML = '<button id="logout-button">Logout</button>';
            } else {
              document.body.innerHTML = '<p id="error">Invalid credentials</p>';
            }
          });
        </script>
      </body>
      </html>
    `);
  });

  afterEach(async () => {
    await page.close();
  });

  test('successfully logs in with valid credentials', async () => {
    // Override waitForNavigation since we're using client-side JS
    page.waitForNavigation = jest.fn().mockResolvedValue(undefined);

    await auth.login('about:blank', {
      email: 'user@test.com',
      password: 'secret123',
    });

    expect(await auth.isLoggedIn()).toBe(true);
  });

  test('throws when credentials are missing', async () => {
    await expect(
      auth.login('about:blank', { email: '', password: '' })
    ).rejects.toThrow('Email and password are required');
  });
});

Handling Flakiness and Waits

Browser tests are inherently asynchronous and can be flaky if you don't handle timing correctly. Here are key strategies to make your Puppeteer tests reliable:

// Example: waiting for dynamic content
async waitForDynamicContent(page) {
  await page.waitForFunction(
    () => document.querySelectorAll('.loaded-item').length >= 5,
    { timeout: 10000 }
  );
}

Best Practices Summary

Conclusion

Testing Puppeteer components effectively requires a deliberate, layered approach. By isolating pure logic into unit-testable functions, mocking the page object for component-level tests, using local HTML fixtures for integration tests, and reserving full E2E tests for critical user journeys, you get the best of all worlds: speed, reliability, and real-world confidence. Start with unit tests for your parsing and validation logic, add integration tests for DOM interactions, and sprinkle in E2E tests for the flows that matter most. With consistent waits, stable selectors, and careful browser lifecycle management, your Puppeteer test suite will become a reliable safety net that catches regressions early and documents your automation behavior for the entire team.

— Ad —

Google AdSense will appear here after approval

← Back to all articles