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:
- Refactor safety: You can restructure internals or upgrade Rollup plugins without fear of breaking the public API.
- Consumer confidence: Published components come with guarantees documented as executable tests.
- Faster feedback loops: Unit tests catch issues in milliseconds, while E2E tests catch integration issues before release.
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
- Co-locate tests with source: Keep
Component.test.jsnext toComponent.jsso tests are easy to find and maintain. - Test behavior, not implementation: Assert on observable outputs and events rather than internal variables. This keeps tests resilient to refactors.
- Keep unit tests pure: Avoid network calls, timers, and real DOM dependencies where possible. Mock them explicitly when needed.
- Use the testing pyramid: Have many unit tests, fewer integration tests, and a small set of focused E2E tests. This keeps the suite fast and reliable.
- Run E2E against the built bundle: Always test the actual Rollup output, not the source, so bundling bugs surface before release.
- Enable source maps in every build: Both development and production builds should emit source maps for accurate stack traces in test failures.
- Isolate flaky tests: If an E2E test flakes, quarantine it immediately and fix the root cause. Flaky tests erode trust in the entire suite.
- Measure coverage, but do not worship it: Use coverage reports to find untested code paths, but prioritize meaningful assertions over hitting a percentage target.
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.