Introduction to Testing ESBuild Components
ESBuild has rapidly become one of the most popular JavaScript bundlers thanks to its extraordinary speed, written in Go and designed for modern web development workflows. However, while ESBuild itself is battle-tested, the components you build around it — custom plugins, loaders, build pipelines, and integrations — require their own robust testing strategy. This tutorial walks you through a complete testing approach for ESBuild components, from isolated unit tests all the way to end-to-end (E2E) tests that validate your entire build pipeline.
What Are ESBuild Components?
ESBuild components typically fall into several categories: custom plugins that hook into the build lifecycle, configuration modules that define build options, wrapper scripts that orchestrate builds, and CLI tools that expose build functionality to developers. Each of these components has different testing needs. A plugin might need unit tests for its individual hook handlers, while a full build pipeline benefits from E2E tests that verify the final output bundle behaves correctly in a browser or Node.js environment.
Why Testing ESBuild Components Matters
Build tools sit at the foundation of your development workflow. A bug in a custom ESBuild plugin can silently corrupt your production bundle, introduce subtle runtime errors, or break tree-shaking in ways that only manifest under specific conditions. Because ESBuild processes code transformations at high speed, errors can propagate quickly across many files before anyone notices. Comprehensive testing catches these issues early, ensures your plugins behave consistently across ESBuild version upgrades, and gives you confidence when refactoring build logic. Additionally, well-tested build components serve as living documentation, showing exactly how your build pipeline is expected to behave.
Setting Up the Testing Environment
Before writing tests, you need a well-structured project with the right dependencies. We will use Vitest for unit and integration tests because of its speed and native ESM support, and Playwright for E2E tests that validate build output in a real browser context.
Project Structure
Organize your project so that source code, tests, and fixtures are clearly separated. A typical layout looks like this:
esbuild-project/
├── src/
│ ├── plugins/
│ │ ├── env-plugin.js
│ │ └── html-plugin.js
│ ├── config.js
│ └── build.js
├── tests/
│ ├── unit/
│ │ └── env-plugin.test.js
│ ├── integration/
│ │ └── build.test.js
│ ├── e2e/
│ │ └── full-build.test.js
│ └── fixtures/
│ ├── basic-app/
│ │ ├── src/
│ │ │ └── index.js
│ │ └── package.json
│ └── expected-output/
└── package.json
Installing Dependencies
Install ESBuild along with your testing tools. Run the following commands in your project root:
npm install --save-dev esbuild vitest @vitest/ui playwright @playwright/test
npx playwright install
Create a vitest.config.js file at the project root to configure your test environment:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['tests/unit/**/*.test.js', 'tests/integration/**/*.test.js'],
testTimeout: 30000,
hookTimeout: 30000,
},
});
For E2E tests, create a playwright.config.js file:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
timeout: 60000,
use: {
headless: true,
},
});
Unit Testing ESBuild Plugins
Unit tests focus on individual components in isolation. For ESBuild plugins, this means testing each plugin's setup function and the callbacks registered on various build hooks. The key challenge is that ESBuild hooks receive a build object with methods like onResolve and onLoad, so you need to mock or stub these to verify your plugin registers the correct handlers.
Writing a Custom Plugin
Let us start with a simple environment variable plugin that injects values at build time. Create src/plugins/env-plugin.js:
export function createEnvPlugin(options = {}) {
const { envVars = {}, prefix = 'import.meta.env' } = options;
return {
name: 'env-plugin',
setup(build) {
const filter = new RegExp(
`^${prefix.replace(/\./g, '\\.')}\\.(\\w+)$`
);
build.onResolve({ filter }, (args) => {
return { path: args.path, namespace: 'env' };
});
build.onLoad({ filter: /.*/, namespace: 'env' }, (args) => {
const key = args.path.replace(`${prefix}.`, '');
const value = envVars[key];
if (value === undefined) {
return {
errors: [{
text: `Environment variable "${key}" is not defined`,
}],
};
}
return {
contents: `export default ${JSON.stringify(value)};`,
loader: 'js',
};
});
},
};
}
Unit Testing the Plugin
Now write a unit test that verifies the plugin registers the correct hooks and produces the expected output. Create tests/unit/env-plugin.test.js:
import { describe, it, expect, vi } from 'vitest';
import { createEnvPlugin } from '../../src/plugins/env-plugin.js';
describe('env-plugin', () => {
function createMockBuild() {
const handlers = {};
return {
onResolve: vi.fn((options, callback) => {
handlers.onResolve = { options, callback };
}),
onLoad: vi.fn((options, callback) => {
handlers.onLoad = { options, callback };
}),
getHandlers: () => handlers,
};
}
it('should register onResolve and onLoad hooks', () => {
const mockBuild = createMockBuild();
const plugin = createEnvPlugin({
envVars: { API_URL: 'https://api.example.com' },
});
plugin.setup(mockBuild);
expect(mockBuild.onResolve).toHaveBeenCalledTimes(1);
expect(mockBuild.onLoad).toHaveBeenCalledTimes(1);
});
it('should resolve env paths to the env namespace', async () => {
const mockBuild = createMockBuild();
const plugin = createEnvPlugin({
envVars: { API_URL: 'https://api.example.com' },
});
plugin.setup(mockBuild);
const { callback } = mockBuild.getHandlers().onResolve;
const result = await callback({
path: 'import.meta.env.API_URL',
importer: '/src/index.js',
});
expect(result).toEqual({
path: 'import.meta.env.API_URL',
namespace: 'env',
});
});
it('should return env value as exported default', async () => {
const mockBuild = createMockBuild();
const plugin = createEnvPlugin({
envVars: { API_URL: 'https://api.example.com', DEBUG: true },
});
plugin.setup(mockBuild);
const { callback } = mockBuild.getHandlers().onLoad;
const result = await callback({
path: 'import.meta.env.API_URL',
});
expect(result.contents).toBe(
'export default "https://api.example.com";'
);
expect(result.loader).toBe('js');
});
it('should return an error for undefined env vars', async () => {
const mockBuild = createMockBuild();
const plugin = createEnvPlugin({
envVars: {},
});
plugin.setup(mockBuild);
const { callback } = mockBuild.getHandlers().onLoad;
const result = await callback({
path: 'import.meta.env.MISSING_VAR',
});
expect(result.errors).toBeDefined();
expect(result.errors[0].text).toContain('MISSING_VAR');
});
it('should handle boolean and number values correctly', async () => {
const mockBuild = createMockBuild();
const plugin = createEnvPlugin({
envVars: { COUNT: 42, ENABLED: true },
});
plugin.setup(mockBuild);
const { callback } = mockBuild.getHandlers().onLoad;
const countResult = await callback({ path: 'import.meta.env.COUNT' });
expect(countResult.contents).toBe('export default 42;');
const boolResult = await callback({ path: 'import.meta.env.ENABLED' });
expect(boolResult.contents).toBe('export default true;');
});
});
This approach gives you fine-grained control over what your plugin does at each hook. By mocking the build object, you can test the plugin's registration logic and callback behavior without running a full ESBuild process, making tests fast and deterministic.
Integration Testing with Real ESBuild Builds
While unit tests validate individual plugin logic, integration tests verify that your plugins work correctly within an actual ESBuild build. These tests run real builds against fixture projects and inspect the output. This layer of testing catches issues that mocks cannot, such as plugin interaction problems, incorrect filter patterns, or loader mismatches.
Creating Test Fixtures
First, create a fixture project that your integration tests will build. Create tests/fixtures/basic-app/src/index.js:
import { apiUrl } from 'import.meta.env.API_URL';
export function getConfig() {
return {
apiUrl,
version: '1.0.0',
};
}
console.log(getConfig());
Writing Integration Tests
Now write an integration test that runs a real ESBuild build using your plugin and verifies the output. Create tests/integration/build.test.js:
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { build } from 'esbuild';
import { createEnvPlugin } from '../../src/plugins/env-plugin.js';
import { rmSync, existsSync, readFileSync } from 'fs';
import { join } from 'path';
const OUTPUT_DIR = join(process.cwd(), 'tests', 'tmp', 'integration-output');
const FIXTURE_DIR = join(process.cwd(), 'tests', 'fixtures', 'basic-app');
describe('integration: env-plugin with esbuild', () => {
beforeEach(() => {
if (existsSync(OUTPUT_DIR)) {
rmSync(OUTPUT_DIR, { recursive: true });
}
});
afterEach(() => {
if (existsSync(OUTPUT_DIR)) {
rmSync(OUTPUT_DIR, { recursive: true });
}
});
it('should inject env vars during a real build', async () => {
const result = await build({
entryPoints: [join(FIXTURE_DIR, 'src', 'index.js')],
bundle: true,
write: false,
plugins: [
createEnvPlugin({
envVars: { API_URL: 'https://api.example.com' },
}),
],
});
const output = result.outputFiles[0].text;
expect(output).toContain('https://api.example.com');
expect(output).toContain('1.0.0');
});
it('should write output files to disk correctly', async () => {
await build({
entryPoints: [join(FIXTURE_DIR, 'src', 'index.js')],
bundle: true,
outfile: join(OUTPUT_DIR, 'bundle.js'),
plugins: [
createEnvPlugin({
envVars: { API_URL: 'https://test.example.com' },
}),
],
});
const outputPath = join(OUTPUT_DIR, 'bundle.js');
expect(existsSync(outputPath)).toBe(true);
const content = readFileSync(outputPath, 'utf-8');
expect(content).toContain('https://test.example.com');
});
it('should fail the build when env var is missing', async () => {
await expect(
build({
entryPoints: [join(FIXTURE_DIR, 'src', 'index.js')],
bundle: true,
write: false,
plugins: [createEnvPlugin({ envVars: {} })],
})
).rejects.toThrow();
});
it('should work alongside other plugins', async () => {
const definePlugin = {
name: 'define-version',
setup(build) {
build.onLoad({ filter: /index\.js$/ }, async (args) => {
const contents = readFileSync(args.path, 'utf-8');
return {
contents: contents.replace(
"'1.0.0'",
"'2.0.0-beta'"
),
loader: 'js',
};
});
},
};
const result = await build({
entryPoints: [join(FIXTURE_DIR, 'src', 'index.js')],
bundle: true,
write: false,
plugins: [
createEnvPlugin({
envVars: { API_URL: 'https://api.example.com' },
}),
definePlugin,
],
});
const output = result.outputFiles[0].text;
expect(output).toContain('2.0.0-beta');
expect(output).toContain('https://api.example.com');
});
});
Integration tests bridge the gap between isolated unit tests and full system tests. They run quickly because ESBuild is fast, yet they exercise real code paths through the actual bundler, giving you high confidence that your plugins function correctly in practice.
Testing Build Configuration Modules
Beyond plugins, you likely have configuration modules that define ESBuild options. These deserve their own tests to ensure the configuration produces the expected build behavior under different environments (development, production, etc.).
Creating a Config Module
Create src/config.js that exports a function returning build options based on the environment:
export function createBuildConfig(env = 'development') {
const isProduction = env === 'production';
return {
entryPoints: ['src/index.js'],
bundle: true,
minify: isProduction,
sourcemap: !isProduction,
target: isProduction ? ['es2020'] : ['esnext'],
define: {
'process.env.NODE_ENV': JSON.stringify(env),
},
outdir: isProduction ? 'dist' : 'build',
splitting: true,
format: 'esm',
logLevel: 'info',
};
}
Testing the Config Module
Create tests/unit/config.test.js to verify the configuration logic:
import { describe, it, expect } from 'vitest';
import { createBuildConfig } from '../../src/config.js';
describe('createBuildConfig', () => {
it('should return development config by default', () => {
const config = createBuildConfig();
expect(config.minify).toBe(false);
expect(config.sourcemap).toBe(true);
expect(config.target).toEqual(['esnext']);
expect(config.outdir).toBe('build');
});
it('should return production config when env is production', () => {
const config = createBuildConfig('production');
expect(config.minify).toBe(true);
expect(config.sourcemap).toBe(false);
expect(config.target).toEqual(['es2020']);
expect(config.outdir).toBe('dist');
});
it('should set NODE_ENV in define object', () => {
const devConfig = createBuildConfig('development');
expect(devConfig.define['process.env.NODE_ENV']).toBe('"development"');
const prodConfig = createBuildConfig('production');
expect(prodConfig.define['process.env.NODE_ENV']).toBe('"production"');
});
it('should always use ESM format and splitting', () => {
const config = createBuildConfig('production');
expect(config.format).toBe('esm');
expect(config.splitting).toBe(true);
});
});
End-to-End Testing the Full Build Pipeline
E2E tests validate the entire build pipeline from start to finish, including the final output running in a real environment. For web applications, this means building the project, serving the output, and verifying the application works in a browser using Playwright. For libraries, it means building the package and importing it in a Node.js script to verify the exported API.
Building a Build Script
First, create a build script that orchestrates the full pipeline. Create src/build.js:
import { build } from 'esbuild';
import { createEnvPlugin } from './plugins/env-plugin.js';
import { createBuildConfig } from './config.js';
export async function runBuild(options = {}) {
const env = options.env || process.env.NODE_ENV || 'development';
const envVars = options.envVars || {
API_URL: process.env.API_URL || 'http://localhost:3000',
};
const config = {
...createBuildConfig(env),
plugins: [createEnvPlugin({ envVars })],
...options.esbuildOptions,
};
try {
const result = await build(config);
console.log(`Build completed successfully in ${env} mode`);
return result;
} catch (error) {
console.error('Build failed:', error.message);
throw error;
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
runBuild();
}
Creating an E2E Fixture Application
Create a more complete fixture application for E2E testing. First, create tests/fixtures/basic-app/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>E2E Test App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="https://agentechip.com/dist/index.js"></script>
</body>
</html>
Create tests/fixtures/basic-app/src/index.js with a simple application:
import { apiUrl } from 'import.meta.env.API_URL';
const app = document.getElementById('app');
function render() {
app.innerHTML = `
<h1>E2E Test App</h1>
<p>API URL: <span id="api-url">${apiUrl}</span></p>
<button id="test-btn">Click Me</button>
`;
const btn = document.getElementById('test-btn');
btn.addEventListener('click', () => {
app.innerHTML += '<p id="clicked">Button was clicked!</p>';
});
}
render();
Writing E2E Tests with Playwright
Create tests/e2e/full-build.test.js. This test builds the fixture app, serves it, and verifies the application works in a real browser:
import { test, expect } from '@playwright/test';
import { runBuild } from '../../src/build.js';
import { createServer } from 'http';
import { join } from 'path';
import { statSync, createReadStream } from 'fs';
import { rmSync, existsSync } from 'fs';
const FIXTURE_DIR = join(process.cwd(), 'tests', 'fixtures', 'basic-app');
const PORT = 4321;
let server;
test.describe('Full build pipeline E2E', () => {
test.beforeAll(async () => {
// Clean previous build output
const distDir = join(FIXTURE_DIR, 'dist');
if (existsSync(distDir)) {
rmSync(distDir, { recursive: true });
}
// Run the actual build
await runBuild({
env: 'development',
envVars: { API_URL: 'https://e2e-test.example.com' },
esbuildOptions: {
entryPoints: [join(FIXTURE_DIR, 'src', 'index.js')],
outdir: join(FIXTURE_DIR, 'dist'),
sourcemap: true,
},
});
// Start a static file server
server = createServer((req, res) => {
let filePath = join(FIXTURE_DIR, req.url === '/' ? 'index.html' : req.url);
try {
const stat = statSync(filePath);
res.setHeader('Content-Length', stat.size);
const stream = createReadStream(filePath);
stream.pipe(res);
} catch {
res.statusCode = 404;
res.end('Not found');
}
});
await new Promise((resolve) => server.listen(PORT, resolve));
});
test.afterAll(async () => {
if (server) {
await new Promise((resolve) => server.close(resolve));
}
const distDir = join(FIXTURE_DIR, 'dist');
if (existsSync(distDir)) {
rmSync(distDir, { recursive: true });
}
});
test('should render the app with injected env var', async ({ page }) => {
await page.goto(`http://localhost:${PORT}/`);
await expect(page.locator('h1')).toHaveText('E2E Test App');
await expect(page.locator('#api-url')).toHaveText(
'https://e2e-test.example.com'
);
});
test('should handle button click interaction', async ({ page }) => {
await page.goto(`http://localhost:${PORT}/`);
await page.click('#test-btn');
await expect(page.locator('#clicked')).toHaveText(
'Button was clicked!'
);
});
test('should load JavaScript without console errors', async ({ page }) => {
const consoleErrors = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
await page.goto(`http://localhost:${PORT}/`);
await page.waitForLoadState('networkidle');
expect(consoleErrors).toHaveLength(0);
});
test('should serve sourcemaps in development mode', async ({ page }) => {
const responses = [];
page.on('response', (response) => {
responses.push(response.url());
});
await page.goto(`http://localhost:${PORT}/`);
await page.waitForLoadState('networkidle');
const hasSourceMap = responses.some((url) => url.endsWith('.map'));
expect(hasSourceMap).toBe(true);
});
});
Testing Error Handling and Edge Cases
A robust testing strategy must cover error scenarios and edge cases. Build tools fail in production for reasons that unit tests of happy paths never catch. Here are important edge cases to test.
Testing Invalid Plugin Options
Create tests/unit/edge-cases.test.js:
import { describe, it, expect } from 'vitest';
import { createEnvPlugin } from '../../src/plugins/env-plugin.js';
describe('env-plugin edge cases', () => {
it('should handle empty envVars object', () => {
const plugin = createEnvPlugin({ envVars: {} });
expect(plugin.name).toBe('env-plugin');
expect(typeof plugin.setup).toBe('function');
});
it('should use default options when none provided', () => {
const plugin = createEnvPlugin();
expect(plugin.name).toBe('env-plugin');
});
it('should handle special characters in env values', async () => {
const mockBuild = {
onResolve: vi.fn(),
onLoad: vi.fn(),
};
// Re-import vi at top of file in real usage
const { vi } = await import('vitest');
mockBuild.onResolve = vi.fn();
mockBuild.onLoad = vi.fn();
const plugin = createEnvPlugin({
envVars: {
MESSAGE: 'Hello "World" with \\ backslash and \n newline',
},
});
plugin.setup(mockBuild);
const loadCallback = mockBuild.onLoad.mock.calls[0][1];
const result = await loadCallback({
path: 'import.meta.env.MESSAGE',
});
expect(result.contents).toContain('Hello');
expect(result.contents).toContain('World');
expect(result.contents).toContain('backslash');
});
it('should handle null values gracefully', async () => {
const mockBuild = {
onResolve: () => {},
onLoad: () => {},
};
let loadCallback;
mockBuild.onResolve = (opts, cb) => {};
mockBuild.onLoad = (opts, cb) => {
loadCallback = cb;
};
const plugin = createEnvPlugin({
envVars: { NULL_VALUE: null },
});
plugin.setup(mockBuild);
const result = await loadCallback({
path: 'import.meta.env.NULL_VALUE',
});
expect(result.contents).toBe('export default null;');
});
});
Testing Build Failures
It is equally important to test that your build pipeline fails correctly when it should. Add these tests to your integration test file:
import { describe, it, expect } from 'vitest';
import { build } from 'esbuild';
import { createEnvPlugin } from '../../src/plugins/env-plugin.js';
import { join } from 'path';
const FIXTURE_DIR = join(process.cwd(), 'tests', 'fixtures', 'basic-app');
describe('integration: build failure scenarios', () => {
it('should report meaningful errors for missing entry points', async () => {
await expect(
build({
entryPoints: [join(FIXTURE_DIR, 'src', 'nonexistent.js')],
bundle: true,
write: false,
plugins: [createEnvPlugin({ envVars: {} })],
})
).rejects.toThrow(/Could not resolve/);
});
it('should handle syntax errors in source files', async () => {
const syntaxErrorPlugin = {
name: 'syntax-error',
setup(build) {
build.onLoad({ filter: /index\.js$/ }, () => ({
contents: 'const === invalid syntax',
loader: 'js',
}));
},
};
await expect(
build({
entryPoints: [join(FIXTURE_DIR, 'src', 'index.js')],
bundle: true,
write: false,
plugins: [syntaxErrorPlugin],
})
).rejects.toThrow();
});
});
Best Practices for Testing ESBuild Components
Use Fixtures Consistently
Maintain a dedicated fixtures directory with realistic test projects. These fixtures should represent the kinds of projects your build tool will actually process. Keep fixtures small and focused — each fixture should test a specific scenario. Avoid sharing fixtures across too many tests, as changes to shared fixtures can cause cascading test failures that are hard to debug.
Test in Isolation Layers
Structure your tests in clear layers: unit tests for individual plugin logic, integration tests for plugin interaction with ESBuild, and E2E tests for the full pipeline. Each layer catches different types of bugs. Unit tests are fast and pinpoint exact failures. Integration tests catch plugin compatibility issues. E2E tests validate the final user experience. Do not skip layers — each serves a distinct purpose.
Clean Up Test Artifacts
Always clean up temporary build output and files created during tests. Use beforeEach and afterEach hooks to remove temporary directories. This prevents test pollution where artifacts from one test run affect another. Consider using a dedicated temporary directory that gets wiped at the start of each test run.
Snapshot Test Build Output
For stable build outputs, consider using snapshot tests to catch unexpected changes. Vitest supports snapshot testing natively:
import { describe, it, expect } from 'vitest';
import { build } from 'esbuild';
import { createEnvPlugin } from '../../src/plugins/env-plugin.js';
describe('build output snapshots', () => {
it('should match expected output snapshot', async () => {
const result = await build({
stdin: {
contents: 'export const x = 42;',
resolveDir: '.',
},
bundle: true,
write: false,
minify: true,
});
expect(result.outputFiles[0].text).toMatchSnapshot();
});
});
Test Across ESBuild Versions
ESBuild evolves rapidly, and plugin APIs can change between major versions. Run your test suite against multiple ESBuild versions in your CI pipeline to catch breaking changes early. Use a matrix build strategy in GitHub Actions or your preferred CI tool:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
esbuild-version: ['0.18.x', '0.19.x', '0.20.x', 'latest']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm install esbuild@${{ matrix.esbuild-version }}
- run: npm test
- run: npx playwright test
Mock External Dependencies
If your plugins fetch data from external sources (network requests, file system watchers, databases), mock these dependencies in unit tests. Use tools like msw for HTTP mocking and fs mocking utilities for file system operations. Reserve real external calls for integration and E2E tests where the interaction matters.
Measure and Optimize Test Performance
ESBuild is fast, but test suites can still become slow if you are not careful. Profile your tests regularly. If integration tests are slow, consider caching build outputs between test runs using a hash of the input files. For E2E tests, reuse browser contexts and server instances across tests where possible. Set reasonable timeouts — ESBuild builds should complete in seconds, so a 30-second timeout for integration tests is generous.
Conclusion
Testing ESBuild components thoroughly requires a multi-layered approach that spans unit tests for individual plugin logic, integration tests for real build behavior, and end-to-end tests for the complete pipeline. By mocking the ESBuild build object in unit tests, you achieve fast and focused feedback on plugin internals. Integration tests then validate that your plugins work correctly within actual ESBuild builds, catching issues that mocks cannot reveal. Finally, E2E tests with Playwright confirm that the entire build pipeline produces output that works in real-world environments. By following the patterns and best practices outlined in this tutorial — using consistent fixtures, cleaning up test artifacts, snapshotting stable outputs, testing across ESBuild versions, and covering error cases — you can build a robust test suite that gives you confidence in your build tooling. A well-tested build pipeline is not just about preventing bugs; it is about enabling fearless iteration, confident refactoring, and reliable releases for everyone who depends on your tools.