← Back to DevBytes

Testing Rollup Components: From Unit to E2E Tests

Introduction to Testing Rollup Components

Rollup has become one of the most popular bundlers for building JavaScript libraries and component-based applications. Its tree-shaking capabilities and ES module-first approach make it ideal for producing lean, production-ready component bundles. However, a component is only as reliable as the tests that back it. In this tutorial, we will walk through a complete testing strategy for Rollup-bundled components — from isolated unit tests to full end-to-end (E2E) tests — so you can ship with confidence.

Why Testing Rollup Components Matters

When you build components with Rollup, you are often targeting multiple consumers: a design system consumed by several apps, a plugin consumed by a platform, or a UI library published to npm. Each consumer expects the component to behave consistently. Without a layered testing strategy, regressions slip through, edge cases break, and integration points fail silently.

A robust test suite gives you three concrete benefits:

Project Structure

Before diving into tests, let's establish a typical project layout for a Rollup component library. This structure separates source, tests, and build configuration cleanly.

my-rollup-lib/
├── src/
│   ├── components/
│   │   ├── Button/
│   │   │   ├── Button.js
│   │   │   └── Button.test.js
│   │   └── Modal/
│   │       ├── Modal.js
│   │       └── Modal.test.js
│   └── index.js
├── e2e/
│   └── modal.spec.js
├── rollup.config.js
├── jest.config.js
├── playwright.config.js
└── package.json

The src folder holds the component source and co-located unit tests. The e2e folder holds Playwright specs that run against a built demo page. This separation keeps fast unit tests distinct from slower browser-driven tests.

Configuring Rollup for Testability

Your Rollup configuration affects how tests consume the component. The key is to expose both an ESM build (for modern test runners and bundlers) and a CJS build (for Node-based runners like Jest). Here is a typical configuration:

// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import babel from '@rollup/plugin-babel';

export default {
  input: 'src/index.js',
  output: [
    {
      file: 'dist/my-rollup-lib.esm.js',
      format: 'esm',
      sourcemap: true,
    },
    {
      file: 'dist/my-rollup-lib.cjs.js',
      format: 'cjs',
      sourcemap: true,
    },
  ],
  plugins: [
    resolve(),
    commonjs(),
    babel({ babelHelpers: 'bundled' }),
  ],
  external: [],
};

Source maps are essential. When a test fails inside your bundled output, source maps let the test runner point back to the original src file, dramatically improving debuggability.

Unit Testing with Jest

Unit tests verify individual component behavior in isolation. Jest is a natural fit because it runs in Node, supports module mocking, and integrates well with Babel-transformed Rollup output. The goal is to test the component's pure logic — its methods, state transitions, and event emissions — without a real DOM when possible.

Installing Dependencies

npm install --save-dev jest babel-jest @testing-library/dom @testing-library/jest-dom

Jest Configuration

// jest.config.js
export default {
  testEnvironment: 'jsdom',
  testMatch: ['**/*.test.js'],
  transform: {
    '^.+\\.js$': 'babel-jest',
  },
  moduleNameMapper: {
    '^@components/(.*)$': '<rootDir>/src/components/$1',
  },
  setupFilesAfterEach: ['<rootDir>/jest.setup.js'],
};

Writing a Unit Test

Consider a simple Button component that emits a click event and supports a disabled state. The unit test should cover both the default behavior and the disabled state.

// src/components/Button/Button.js
export class Button {
  constructor({ label, disabled = false } = {}) {
    this.label = label;
    this.disabled = disabled;
    this.el = document.createElement('button');
    this.el.textContent = this.label;
    this.el.disabled = this.disabled;
    this.el.addEventListener('click', () => this._onClick());
  }

  _onClick() {
    if (this.disabled) return;
    this.el.dispatchEvent(new CustomEvent('press', { bubbles: true }));
  }

  setDisabled(value) {
    this.disabled = value;
    this.el.disabled = value;
  }
}
// src/components/Button/Button.test.js
import { Button } from './Button';

describe('Button', () => {
  let button;

  afterEach(() => {
    if (button) button.el.remove();
  });

  it('renders the provided label', () => {
    button = new Button({ label: 'Submit' });
    expect(button.el.textContent).toBe('Submit');
  });

  it('emits a press event when clicked', () => {
    button = new Button({ label: 'Save' });
    const handler = jest.fn();
    button.el.addEventListener('press', handler);
    button.el.click();
    expect(handler).toHaveBeenCalledTimes(1);
  });

  it('does not emit press when disabled', () => {
    button = new Button({ label: 'Save', disabled: true });
    const handler = jest.fn();
    button.el.addEventListener('press', handler);
    button.el.click();
    expect(handler).not.toHaveBeenCalled();
  });

  it('toggles disabled state at runtime', () => {
    button = new Button({ label: 'Save' });
    button.setDisabled(true);
    expect(button.el.disabled).toBe(true);
  });
});

Notice that each test is small, focused, and independent. We clean up the DOM element in afterEach to prevent leakage between tests. This discipline keeps the suite fast and deterministic.

Integration Testing Component Composition

Unit tests prove that a component works alone. Integration tests prove that components work together. For a Rollup library, integration tests typically mount several components inside a container and assert on their combined behavior.

// src/components/Modal/Modal.js
import { Button } from '../Button/Button';

export class Modal {
  constructor({ title, confirmLabel = 'OK' }) {
    this.el = document.createElement('div');
    this.el.className = 'modal';
    this.el.innerHTML = `
      <div class="modal__title"></div>
      <div class="modal__body"></div>
      <div class="modal__actions"></div>
    `;
    this.el.querySelector('.modal__title').textContent = title;
    this.confirmButton = new Button({ label: confirmLabel });
    this.el.querySelector('.modal__actions').appendChild(this.confirmButton.el);
  }

  onConfirm(handler) {
    this.confirmButton.el.addEventListener('press', handler);
  }

  open() {
    this.el.classList.add('modal--open');
  }

  close() {
    this.el.classList.remove('modal--open');
  }
}
// src/components/Modal/Modal.test.js
import { Modal } from './Modal';

describe('Modal integration', () => {
  let modal;

  afterEach(() => {
    if (modal) modal.el.remove();
  });

  it('renders a title and confirm button', () => {
    modal = new Modal({ title: 'Delete file', confirmLabel: 'Confirm' });
    expect(modal.el.querySelector('.modal__title').textContent).toBe('Delete file');
    expect(modal.el.querySelector('button').textContent).toBe('Confirm');
  });

  it('invokes onConfirm handler when confirm button is pressed', () => {
    modal = new Modal({ title: 'Delete file' });
    const handler = jest.fn();
    modal.onConfirm(handler);
    modal.confirmButton.el.click();
    expect(handler).toHaveBeenCalledTimes(1);
  });

  it('toggles open class', () => {
    modal = new Modal({ title: 'Hello' });
    modal.open();
    expect(modal.el.classList.contains('modal--open')).toBe(true);
    modal.close();
    expect(modal.el.classList.contains('modal--open')).toBe(false);
  });
});

These tests exercise the wiring between Modal and Button. If a future refactor changes how the two interact, the integration test will fail and surface the regression immediately.

Snapshot Testing for Structural Output

For components with complex rendered output, snapshot tests provide a fast way to detect unintended structural changes. Jest stores a serialized representation of the DOM and compares it on subsequent runs.

it('matches the expected structure', () => {
  modal = new Modal({ title: 'Welcome', confirmLabel: 'Got it' });
  expect(modal.el.outerHTML).toMatchSnapshot();
});

Use snapshots sparingly. They are excellent for catching accidental markup changes but poor at expressing intent. Pair them with explicit behavioral assertions so failures are easy to interpret.

End-to-End Testing with Playwright

E2E tests run the built Rollup bundle inside a real browser, interacting with the component exactly as a user would. This catches issues that unit and integration tests miss: CSS regressions, real event timing, accessibility problems, and bundling errors.

Setting Up Playwright

npm install --save-dev @playwright/test
npx playwright install
// playwright.config.js
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  use: {
    headless: true,
    baseURL: 'http://localhost:4173',
  },
  webServer: {
    command: 'npm run preview',
    url: 'http://localhost:4173',
    reuseExistingServer: !process.env.CI,
  },
});

Building a Demo Page

E2E tests need a host page that imports the built bundle. Create a small demo HTML file served by Vite or any static server.

<!-- demo/index.html -->
<!DOCTYPE html>
<html>
<body>
  <div id="app"></div>
  <script type="module">
    import { Modal } from '../dist/my-rollup-lib.esm.js';
    const modal = new Modal({ title: 'Confirm deletion', confirmLabel: 'Delete' });
    modal.onConfirm(() => {
      document.getElementById('result').textContent = 'deleted';
    });
    document.getElementById('app').appendChild(modal.el);
    modal.open();
  </script>
  <p id="result"></p>
</body>
</html>

Writing the E2E Spec

// e2e/modal.spec.js
import { test, expect } from '@playwright/test';

test('modal opens and confirm triggers result', async ({ page }) => {
  await page.goto('/');
  await expect(page.locator('.modal--open')).toBeVisible();
  await expect(page.locator('.modal__title')).toHaveText('Confirm deletion');
  await page.locator('.modal__actions button').click();
  await expect(page.locator('#result')).toHaveText('deleted');
});

test('modal is keyboard accessible', async ({ page }) => {
  await page.goto('/');
  const button = page.locator('.modal__actions button');
  await button.focus();
  await page.keyboard.press('Enter');
  await expect(page.locator('#result')).toHaveText('deleted');
});

This spec validates the real bundled output in a real browser. If Rollup misconfigures an external dependency, or if a CSS class is stripped during minification, the E2E test will fail where unit tests would pass.

Testing the Rollup Build Itself

An often-overlooked layer is testing the build configuration. A broken Rollup config can produce an empty or malformed bundle that ships to consumers. A simple smoke test that imports the built output and checks for expected exports prevents this.

// tests/build.test.js
import * as bundle from '../dist/my-rollup-lib.esm.js';

describe('Rollup build output', () => {
  it('exports Modal and Button', () => {
    expect(typeof bundle.Modal).toBe('function');
    expect(typeof bundle.Button).toBe('function');
  });

  it('produces a non-empty ESM file', async () => {
    const fs = await import('fs');
    const stats = fs.statSync('dist/my-rollup-lib.esm.js');
    expect(stats.size).toBeGreaterThan(100);
  });
});

Run this test in CI after the build step. It is cheap, fast, and catches the most catastrophic build failures.

Best Practices

CI Pipeline Integration

Wire the test layers into your CI pipeline so every pull request is validated. A typical GitHub Actions workflow runs lint, unit, build, and E2E stages in sequence.

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run lint
      - run: npm test -- --coverage
      - run: npm run build
      - run: npm run test:build
      - run: npx playwright test

Ordering matters. Run the fastest feedback first (lint, unit), then the build, then the slowest (E2E). This fails fast and saves CI minutes.

Conclusion

Testing Rollup components effectively requires a layered approach: unit tests for individual logic, integration tests for composition, build smoke tests for bundling integrity, and E2E tests for real-world browser behavior. By co-locating tests, configuring Rollup with source maps, and integrating each layer into CI, you create a safety net that catches regressions at the right level of granularity. The result is a component library that is not only fast and tree-shakeable but also dependable enough to ship with confidence, refactor without fear, and scale as your consumer base grows.

— Ad —

Google AdSense will appear here after approval

← Back to all articles