Testing Strategies
"Testing shows the presence, not the absence of bugs. But not testing shows the presence of unemployment.">
— Edsger W. Dijkstra (with career advice added)
Testing is the art of proving your code works before users prove it doesn't. It's insurance against regressions, documentation that stays up-to-date, and confidence that your refactoring didn't break everything.
In this chapter, we'll explore testing strategies for LARC applications: unit testing web components, integration testing message flows, end-to-end testing with complete PAN applications, mocking the message bus, testing async operations, and building test utilities that make testing a joy rather than a chore.
By the end of this chapter, you'll have a comprehensive testing strategy that catches bugs early, runs fast, and doesn't make you want to skip writing tests.
The Testing Pyramid for LARC
The testing pyramid guides our testing strategy:
/\
/ \ E2E Tests (Few)
/____\ - Full application
/ \ - Real browser
/ \ - Slow, brittle
/----------\
/ \ Integration Tests (Some)
/ \ - Multiple components
/ \ - Message flows
/------------------\- Medium speed
/ \
/______________________\ Unit Tests (Many)
- Single components
- Pure functions
- Fast, focused
Most tests should be unit tests. Fewer integration tests. Even fewer E2E tests.
Unit Testing Components
Unit tests verify individual components in isolation. Let's use Vitest (modern, fast) or Mocha (classic, reliable).
Setting Up Vitest
npm install -D vitest happy-dom
Create a test configuration:
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./tests/setup.js']
}
});
Create test setup:
// tests/setup.js
import { beforeEach, afterEach } from 'vitest';
// Clean up DOM after each test
afterEach(() => {
document.body.innerHTML = '';
});
Testing a Simple Component
Let's test a counter component:
// components/counter-button.mjs
class CounterButton extends HTMLElement {
constructor() {
super();
this.count = 0;
}
connectedCallback() {
this.render();
}
increment() {
this.count++;
this.render();
}
render() {
this.innerHTML = `
<button id="increment">
Count: ${this.count}
</button>
`;
this.querySelector('#increment').addEventListener('click', () => {
this.increment();
});
}
}
customElements.define('counter-button', CounterButton);
export { CounterButton };
Test it:
// tests/counter-button.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { CounterButton } from '../components/counter-button.mjs';
describe('CounterButton', () => {
let element;
beforeEach(() => {
element = document.createElement('counter-button');
document.body.appendChild(element);
});
it('should render with initial count of 0', () => {
expect(element.count).toBe(0);
expect(element.textContent).toContain('Count: 0');
});
it('should increment count when button is clicked', () => {
const button = element.querySelector('button');
button.click();
expect(element.count).toBe(1);
expect(element.textContent).toContain('Count: 1');
button.click();
expect(element.count).toBe(2);
expect(element.textContent).toContain('Count: 2');
});
it('should call increment method when clicked', () => {
const incrementSpy = vi.spyOn(element, 'increment');
const button = element.querySelector('button');
button.click();
expect(incrementSpy).toHaveBeenCalledTimes(1);
});
});
Run tests:
npm test
Testing Components with Attributes
// components/user-badge.mjs
class UserBadge extends HTMLElement {
static get observedAttributes() {
return ['username', 'role'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render();
}
}
connectedCallback() {
this.render();
}
render() {
const username = this.getAttribute('username') || 'Anonymous';
const role = this.getAttribute('role') || 'User';
this.innerHTML = `
<div class="user-badge">
<span class="username">${username}</span>
<span class="role">${role}</span>
</div>
`;
}
}
customElements.define('user-badge', UserBadge);
export { UserBadge };
Test it:
// tests/user-badge.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { UserBadge } from '../components/user-badge.mjs';
describe('UserBadge', () => {
let element;
beforeEach(() => {
element = document.createElement('user-badge');
document.body.appendChild(element);
});
it('should render with default values', () => {
expect(element.textContent).toContain('Anonymous');
expect(element.textContent).toContain('User');
});
it('should render with provided attributes', () => {
element.setAttribute('username', 'Alice');
element.setAttribute('role', 'Admin');
expect(element.textContent).toContain('Alice');
expect(element.textContent).toContain('Admin');
});
it('should update when attributes change', () => {
element.setAttribute('username', 'Bob');
expect(element.textContent).toContain('Bob');
element.setAttribute('username', 'Charlie');
expect(element.textContent).toContain('Charlie');
expect(element.textContent).not.toContain('Bob');
});
it('should have correct CSS classes', () => {
element.setAttribute('username', 'Alice');
const badge = element.querySelector('.user-badge');
const username = element.querySelector('.username');
const role = element.querySelector('.role');
expect(badge).not.toBeNull();
expect(username).not.toBeNull();
expect(role).not.toBeNull();
});
});
Mocking the PAN Bus
Testing message-driven components requires mocking the message bus.
Creating a Mock Bus
// tests/mocks/mock-bus.js
class MockBus {
constructor() {
this.subscriptions = new Map();
this.published = [];
}
// Mock publish function
publish(topic, data) {
this.published.push({ topic, data, timestamp: Date.now() });
// Trigger subscriptions
const handlers = this.subscriptions.get(topic) || [];
handlers.forEach(handler => {
handler({ topic, data });
});
// Trigger wildcard subscriptions
const wildcardHandlers = this.getWildcardHandlers(topic);
wildcardHandlers.forEach(handler => {
handler({ topic, data });
});
}
// Mock subscribe function
subscribe(pattern, handler) {
if (!this.subscriptions.has(pattern)) {
this.subscriptions.set(pattern, []);
}
this.subscriptions.get(pattern).push(handler);
// Return unsubscribe function
return () => {
const handlers = this.subscriptions.get(pattern);
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
};
}
// Get handlers for wildcard patterns
getWildcardHandlers(topic) {
const handlers = [];
for (const [pattern, patternHandlers] of this.subscriptions) {
if (this.matchesPattern(topic, pattern)) {
handlers.push(...patternHandlers);
}
}
return handlers;
}
// Simple wildcard matching
matchesPattern(topic, pattern) {
if (pattern === '*') return true;
if (pattern === topic) return false; // Exact match handled separately
const patternParts = pattern.split('.');
const topicParts = topic.split('.');
if (patternParts.length !== topicParts.length) {
return false;
}
return patternParts.every((part, i) => {
return part === '*' || part === topicParts[i];
});
}
// Reset the bus
reset() {
this.subscriptions.clear();
this.published = [];
}
// Test helpers
getPublished(topic) {
return this.published.filter(msg => msg.topic === topic);
}
getLastPublished(topic) {
const messages = this.getPublished(topic);
return messages[messages.length - 1];
}
wasPublished(topic, data) {
return this.published.some(msg =>
msg.topic === topic &&
JSON.stringify(msg.data) === JSON.stringify(data)
);
}
}
export { MockBus };
Using the Mock Bus
// tests/setup.js
import { beforeEach, afterEach } from 'vitest';
import { MockBus } from './mocks/mock-bus.js';
let mockBus;
beforeEach(() => {
mockBus = new MockBus();
// Replace global publish and subscribe
global.publish = mockBus.publish.bind(mockBus);
global.subscribe = mockBus.subscribe.bind(mockBus);
});
afterEach(() => {
mockBus.reset();
document.body.innerHTML = '';
});
// Export for use in tests
export function getMockBus() {
return mockBus;
}
Testing Message-Driven Components
// components/notification-display.mjs
import { subscribe } from '../pan.js';
class NotificationDisplay extends HTMLElement {
constructor() {
super();
this.notifications = [];
}
connectedCallback() {
this.unsubscribe = subscribe('notification.show', (msg) => {
this.addNotification(msg.data);
});
this.render();
}
addNotification(notification) {
this.notifications.push(notification);
this.render();
}
render() {
this.innerHTML = `
<div class="notifications">
${this.notifications.map(n => `
<div class="notification notification-${n.type}">
${n.message}
</div>
`).join('')}
</div>
`;
}
disconnectedCallback() {
if (this.unsubscribe) {
this.unsubscribe();
}
}
}
customElements.define('notification-display', NotificationDisplay);
export { NotificationDisplay };
Test it:
// tests/notification-display.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { NotificationDisplay } from '../components/notification-display.mjs';
import { getMockBus } from './setup.js';
describe('NotificationDisplay', () => {
let element;
let mockBus;
beforeEach(() => {
mockBus = getMockBus();
element = document.createElement('notification-display');
document.body.appendChild(element);
});
it('should start with no notifications', () => {
expect(element.notifications).toHaveLength(0);
expect(element.querySelector('.notification')).toBeNull();
});
it('should display notification when message is published', () => {
publish('notification.show', {
type: 'info',
message: 'Hello, World!'
});
expect(element.notifications).toHaveLength(1);
expect(element.textContent).toContain('Hello, World!');
expect(element.querySelector('.notification-info')).not.toBeNull();
});
it('should display multiple notifications', () => {
publish('notification.show', { type: 'info', message: 'First' });
publish('notification.show', { type: 'warning', message: 'Second' });
publish('notification.show', { type: 'error', message: 'Third' });
expect(element.notifications).toHaveLength(3);
expect(element.textContent).toContain('First');
expect(element.textContent).toContain('Second');
expect(element.textContent).toContain('Third');
});
it('should unsubscribe when disconnected', () => {
element.remove();
// Publish after removal
publish('notification.show', { type: 'info', message: 'After removal' });
// Should not have been added
expect(element.notifications).toHaveLength(0);
});
});
Integration Testing Message Flows
Integration tests verify multiple components working together through message flows.
// tests/integration/shopping-cart.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { ProductCatalog } from '../../components/product-catalog.mjs';
import { ShoppingCart } from '../../components/shopping-cart.mjs';
import { CartBadge } from '../../components/cart-badge.mjs';
import { getMockBus } from '../setup.js';
describe('Shopping Cart Integration', () => {
let catalog;
let cart;
let badge;
let mockBus;
beforeEach(() => {
mockBus = getMockBus();
// Create components
catalog = document.createElement('product-catalog');
cart = document.createElement('shopping-cart');
badge = document.createElement('cart-badge');
// Add to DOM
document.body.appendChild(catalog);
document.body.appendChild(cart);
document.body.appendChild(badge);
});
it('should update cart and badge when product is added', () => {
// Simulate adding product
publish('cart.item.added', {
productId: 1,
name: 'Widget',
price: 10,
quantity: 1
});
// Cart should contain the item
expect(cart.items).toHaveLength(1);
expect(cart.items[0].name).toBe('Widget');
// Badge should show count
expect(badge.itemCount).toBe(1);
expect(badge.textContent).toContain('1');
});
it('should publish cart.updated when item is added', () => {
publish('cart.item.added', {
productId: 1,
name: 'Widget',
price: 10,
quantity: 1
});
// Verify cart.updated was published
const updated = mockBus.getLastPublished('cart.updated');
expect(updated).not.toBeUndefined();
expect(updated.data.items).toHaveLength(1);
expect(updated.data.total).toBe(10);
});
it('should handle multiple items', () => {
publish('cart.item.added', {
productId: 1,
name: 'Widget',
price: 10,
quantity: 2
});
publish('cart.item.added', {
productId: 2,
name: 'Gadget',
price: 20,
quantity: 1
});
expect(cart.items).toHaveLength(2);
expect(badge.itemCount).toBe(3); // 2 widgets + 1 gadget
});
it('should update quantities for duplicate items', () => {
publish('cart.item.added', {
productId: 1,
name: 'Widget',
price: 10,
quantity: 1
});
publish('cart.item.added', {
productId: 1,
name: 'Widget',
price: 10,
quantity: 1
});
// Should have one item with quantity 2
expect(cart.items).toHaveLength(1);
expect(cart.items[0].quantity).toBe(2);
});
});
Testing Async Operations
Many LARC operations are async. Test them properly.
Testing Promises
// components/data-loader.mjs
import { publish } from '../pan.js';
class DataLoader extends HTMLElement {
async connectedCallback() {
try {
publish('data.loading', { loading: true });
const response = await fetch('/api/data');
const data = await response.json();
publish('data.loaded', { data });
} catch (error) {
publish('data.error', { error: error.message });
} finally {
publish('data.loading', { loading: false });
}
}
}
customElements.define('data-loader', DataLoader);
export { DataLoader };
Test it:
// tests/data-loader.test.js
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { DataLoader } from '../components/data-loader.mjs';
import { getMockBus } from './setup.js';
describe('DataLoader', () => {
let mockBus;
beforeEach(() => {
mockBus = getMockBus();
// Mock fetch
global.fetch = vi.fn();
});
it('should publish loading state', async () => {
fetch.mockResolvedValueOnce({
json: async () => ({ items: [] })
});
const element = document.createElement('data-loader');
document.body.appendChild(element);
// Wait for async operations
await new Promise(resolve => setTimeout(resolve, 0));
// Check loading messages
const loadingMessages = mockBus.getPublished('data.loading');
expect(loadingMessages).toHaveLength(2);
expect(loadingMessages[0].data.loading).toBe(true);
expect(loadingMessages[1].data.loading).toBe(false);
});
it('should publish data when loaded successfully', async () => {
const mockData = { items: [1, 2, 3] };
fetch.mockResolvedValueOnce({
json: async () => mockData
});
const element = document.createElement('data-loader');
document.body.appendChild(element);
// Wait for async operations
await new Promise(resolve => setTimeout(resolve, 0));
const loaded = mockBus.getLastPublished('data.loaded');
expect(loaded).not.toBeUndefined();
expect(loaded.data.data).toEqual(mockData);
});
it('should publish error when fetch fails', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
const element = document.createElement('data-loader');
document.body.appendChild(element);
// Wait for async operations
await new Promise(resolve => setTimeout(resolve, 0));
const error = mockBus.getLastPublished('data.error');
expect(error).not.toBeUndefined();
expect(error.data.error).toBe('Network error');
});
});
Testing with Async/Await
Use async/await in tests:
it('should load data', async () => {
fetch.mockResolvedValueOnce({
json: async () => ({ data: 'test' })
});
const element = document.createElement('data-loader');
document.body.appendChild(element);
// Wait for component to finish loading
await vi.waitFor(() => {
expect(mockBus.wasPublished('data.loaded', { data: { data: 'test' } })).toBe(true);
});
});
Testing Timeouts and Intervals
// components/auto-saver.mjs
import { subscribe } from '../pan.js';
class AutoSaver extends HTMLElement {
constructor() {
super();
this.saveInterval = 5000; // 5 seconds
this.intervalId = null;
}
connectedCallback() {
this.intervalId = setInterval(() => {
publish('data.save', { timestamp: Date.now() });
}, this.saveInterval);
}
disconnectedCallback() {
clearInterval(this.intervalId);
}
}
customElements.define('auto-saver', AutoSaver);
export { AutoSaver };
Test it:
// tests/auto-saver.test.js
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { AutoSaver } from '../components/auto-saver.mjs';
import { getMockBus } from './setup.js';
describe('AutoSaver', () => {
let mockBus;
beforeEach(() => {
mockBus = getMockBus();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should save at regular intervals', () => {
const element = document.createElement('auto-saver');
document.body.appendChild(element);
// Fast-forward 5 seconds
vi.advanceTimersByTime(5000);
expect(mockBus.getPublished('data.save')).toHaveLength(1);
// Fast-forward another 5 seconds
vi.advanceTimersByTime(5000);
expect(mockBus.getPublished('data.save')).toHaveLength(2);
// Fast-forward another 5 seconds
vi.advanceTimersByTime(5000);
expect(mockBus.getPublished('data.save')).toHaveLength(3);
});
it('should stop saving when disconnected', () => {
const element = document.createElement('auto-saver');
document.body.appendChild(element);
vi.advanceTimersByTime(5000);
expect(mockBus.getPublished('data.save')).toHaveLength(1);
element.remove();
// Should not save after removal
vi.advanceTimersByTime(5000);
expect(mockBus.getPublished('data.save')).toHaveLength(1);
});
});
End-to-End Testing
E2E tests verify the entire application in a real browser. Use Playwright or Cypress.
Setting Up Playwright
npm install -D @playwright/test
npx playwright install
Create a test:
// tests/e2e/shopping-cart.spec.js
import { test, expect } from '@playwright/test';
test.describe('Shopping Cart', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:3000');
});
test('should add item to cart', async ({ page }) => {
// Click add to cart button
await page.click('button:has-text("Add to Cart")');
// Verify cart badge updates
const badge = page.locator('cart-badge');
await expect(badge).toContainText('1');
// Verify cart displays item
const cart = page.locator('shopping-cart');
await expect(cart).toContainText('Widget');
await expect(cart).toContainText('$10');
});
test('should calculate total correctly', async ({ page }) => {
// Add multiple items
await page.click('button:has-text("Add to Cart")').first();
await page.click('button:has-text("Add to Cart")').nth(1);
// Verify total
const cart = page.locator('shopping-cart');
await expect(cart).toContainText('Total: $30');
});
test('should persist cart across page reloads', async ({ page }) => {
// Add item to cart
await page.click('button:has-text("Add to Cart")');
// Reload page
await page.reload();
// Verify cart still has item
const cart = page.locator('shopping-cart');
await expect(cart).toContainText('Widget');
});
});
Run E2E tests:
npx playwright test
Testing Theme Switching
// tests/e2e/theme.spec.js
import { test, expect } from '@playwright/test';
test.describe('Theme Switching', () => {
test('should toggle between light and dark mode', async ({ page }) => {
await page.goto('http://localhost:3000');
// Check initial theme
const html = page.locator('html');
await expect(html).toHaveAttribute('data-theme', 'light');
// Click dark mode button
await page.click('button:has-text("Dark")');
// Verify theme changed
await expect(html).toHaveAttribute('data-theme', 'dark');
// Verify styles applied
const body = page.locator('body');
const bgColor = await body.evaluate(el =>
getComputedStyle(el).backgroundColor
);
expect(bgColor).toBe('rgb(17, 24, 39)'); // Dark background
});
test('should persist theme preference', async ({ page }) => {
await page.goto('http://localhost:3000');
// Switch to dark mode
await page.click('button:has-text("Dark")');
// Reload page
await page.reload();
// Verify theme persisted
const html = page.locator('html');
await expect(html).toHaveAttribute('data-theme', 'dark');
});
});
Test Utilities and Helpers
Build reusable utilities to make testing easier.
Component Test Harness
// tests/utils/component-harness.js
class ComponentHarness {
constructor(tagName, attributes = {}) {
this.element = document.createElement(tagName);
// Set attributes
for (const [key, value] of Object.entries(attributes)) {
this.element.setAttribute(key, value);
}
document.body.appendChild(this.element);
}
// Query within component
query(selector) {
return this.element.querySelector(selector);
}
queryAll(selector) {
return this.element.querySelectorAll(selector);
}
// Get text content
text() {
return this.element.textContent.trim();
}
// Click element
click(selector) {
const el = selector ? this.query(selector) : this.element;
el.click();
return this;
}
// Type into input
type(selector, value) {
const input = this.query(selector);
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
return this;
}
// Wait for condition
async waitFor(condition, timeout = 1000) {
const start = Date.now();
while (Date.now() - start < timeout) {
if (condition(this.element)) {
return;
}
await new Promise(resolve => setTimeout(resolve, 50));
}
throw new Error('Timeout waiting for condition');
}
// Clean up
destroy() {
this.element.remove();
}
}
export { ComponentHarness };
Use it:
// tests/user-profile.test.js
import { describe, it, expect } from 'vitest';
import { ComponentHarness } from './utils/component-harness.js';
import { UserProfile } from '../components/user-profile.mjs';
describe('UserProfile', () => {
it('should display user information', async () => {
const harness = new ComponentHarness('user-profile', {
'user-id': '123'
});
// Publish user data
publish('user.data', {
userId: '123',
name: 'Alice',
email: 'alice@example.com'
});
// Wait for render
await harness.waitFor(el => el.textContent.includes('Alice'));
expect(harness.text()).toContain('Alice');
expect(harness.text()).toContain('alice@example.com');
harness.destroy();
});
});
Message Bus Test Helper
// tests/utils/message-helper.js
import { getMockBus } from '../setup.js';
class MessageHelper {
constructor() {
this.bus = getMockBus();
}
// Publish and wait for response
async publishAndWait(publishTopic, publishData, waitTopic, timeout = 1000) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
unsubscribe();
reject(new Error(`Timeout waiting for ${waitTopic}`));
}, timeout);
const unsubscribe = subscribe(waitTopic, (msg) => {
clearTimeout(timeoutId);
unsubscribe();
resolve(msg.data);
});
publish(publishTopic, publishData);
});
}
// Wait for specific message
async waitForMessage(topic, predicate = null, timeout = 1000) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
unsubscribe();
reject(new Error(`Timeout waiting for ${topic}`));
}, timeout);
const unsubscribe = subscribe(topic, (msg) => {
if (!predicate || predicate(msg.data)) {
clearTimeout(timeoutId);
unsubscribe();
resolve(msg.data);
}
});
});
}
// Assert message was published
assertPublished(topic, data = null) {
const messages = this.bus.getPublished(topic);
if (messages.length === 0) {
throw new Error(`Expected message on topic "${topic}" but none were published`);
}
if (data !== null) {
const match = messages.some(msg =>
JSON.stringify(msg.data) === JSON.stringify(data)
);
if (!match) {
throw new Error(
`Expected message on topic "${topic}" with data ${JSON.stringify(data)} ` +
`but received: ${JSON.stringify(messages.map(m => m.data))}`
);
}
}
}
// Assert message was NOT published
assertNotPublished(topic) {
const messages = this.bus.getPublished(topic);
if (messages.length > 0) {
throw new Error(
`Expected no messages on topic "${topic}" but ${messages.length} were published`
);
}
}
}
export { MessageHelper };
Use it:
import { MessageHelper } from './utils/message-helper.js';
it('should respond to data request', async () => {
const helper = new MessageHelper();
const element = document.createElement('data-provider');
document.body.appendChild(element);
// Publish request and wait for response
const response = await helper.publishAndWait(
'data.request',
{ id: 123 },
'data.response'
);
expect(response.id).toBe(123);
expect(response.data).toBeDefined();
});
Test Coverage
Measure test coverage to identify untested code:
npm install -D @vitest/coverage-v8
Run with coverage:
npx vitest --coverage
Aim for:
- 80%+ overall coverage
- 100% coverage for critical paths (auth, payments, data loss scenarios)
- Lower coverage for UI glue code (it's okay)
Wrapping Up
Testing LARC applications is straightforward once you understand the patterns:
The key insight: message-driven architecture makes testing easier, not harder. Components are decoupled, dependencies are explicit (subscriptions), and side effects are observable (publications).
Write tests. Run them often. Trust them completely. And when a test fails, thank it for catching a bug before your users did.
You've now completed a comprehensive tour of LARC development: theming and styling for beautiful UIs, performance optimization for fast applications, and testing strategies for reliable software. You have all the tools to build production-ready LARC applications.
Now go forth and build something amazing. And remember: untested code is legacy code the moment you write it.