React component test harness for MemberJunction using Playwright
npm install @memberjunction/react-test-harnessA powerful test harness for React components using Playwright, designed specifically for MemberJunction's React runtime components with support for dynamic library configurations.
This package provides a comprehensive testing solution for React components, allowing you to:
- Load and execute React components in a real browser environment
- Dynamically configure external libraries for testing different scenarios
- Run assertions on rendered output
- Execute tests via CLI or programmatically
- Capture screenshots and console output
- Run in headless or headed mode for debugging
``bash`
npm install @memberjunction/react-test-harness
`bashBasic usage
mj-react-test run MyComponent.jsx
$3
`bash
Run a test file with multiple test cases
mj-react-test test my-tests.jsWith options
mj-react-test test my-tests.js --headed --debug
`$3
`bash
Create example component and test files
mj-react-test create-exampleCreate in specific directory
mj-react-test create-example --dir ./my-tests
`Dynamic Library Configuration (New)
The test harness now supports dynamic library configuration, allowing you to test components with different sets of external libraries.
$3
`typescript
import { ReactTestHarness } from '@memberjunction/react-test-harness';
import type { LibraryConfiguration } from '@memberjunction/react-runtime';const customLibraryConfig: LibraryConfiguration = {
libraries: [
// Runtime libraries (always needed)
{
id: 'react',
name: 'React',
category: 'runtime',
globalVariable: 'React',
version: '18',
cdnUrl: 'https://unpkg.com/react@18/umd/react.development.js',
isEnabled: true,
isCore: true,
isRuntimeOnly: true
},
// Component libraries (available to components)
{
id: 'lodash',
name: 'lodash',
displayName: 'Lodash',
category: 'utility',
globalVariable: '_',
version: '4.17.21',
cdnUrl: 'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js',
isEnabled: true,
isCore: false
},
{
id: 'chart-js',
name: 'Chart',
displayName: 'Chart.js',
category: 'charting',
globalVariable: 'Chart',
version: '4.4.0',
cdnUrl: 'https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.js',
isEnabled: true,
isCore: false
}
],
metadata: {
version: '1.0.0',
lastUpdated: '2024-01-01'
}
};
// Test with custom libraries
const harness = new ReactTestHarness({ headless: true });
await harness.initialize();
const result = await harness.testComponent(
const Component = () => {,
{},
{ libraryConfiguration: customLibraryConfig }
);
`$3
`typescript
// Test with minimal libraries
const minimalConfig: LibraryConfiguration = {
libraries: [
// Only runtime essentials
{ id: 'react', category: 'runtime', isEnabled: true, isRuntimeOnly: true, ... },
{ id: 'react-dom', category: 'runtime', isEnabled: true, isRuntimeOnly: true, ... },
{ id: 'babel', category: 'runtime', isEnabled: true, isRuntimeOnly: true, ... },
// Just one utility library
{ id: 'lodash', category: 'utility', isEnabled: true, ... }
],
metadata: { version: '1.0.0', lastUpdated: '2024-01-01' }
};// Test with full library suite
const fullConfig: LibraryConfiguration = {
libraries: [
// All runtime libraries
// All UI libraries (antd, react-bootstrap)
// All charting libraries (chart.js, d3)
// All utilities (lodash, dayjs)
],
metadata: { version: '1.0.0', lastUpdated: '2024-01-01' }
};
// Test component behavior with different configurations
const componentCode =
Chart.js: {hasChart ? 'Available' : 'Not Available'}
Ant Design: {hasAntd ? 'Available' : 'Not Available'}
;const minimalResult = await harness.testComponent(componentCode, {}, {
libraryConfiguration: minimalConfig
});
const fullResult = await harness.testComponent(componentCode, {}, {
libraryConfiguration: fullConfig
});
`Programmatic Usage via TypeScript/JavaScript
The test harness is designed to be used as a library in your TypeScript/JavaScript code, not just via CLI. All classes and types are fully exported for programmatic use.
$3
`typescript
// Import main classes
import {
ReactTestHarness,
BrowserManager,
ComponentRunner,
AssertionHelpers,
FluentMatcher
} from '@memberjunction/react-test-harness';// Import types for TypeScript
import type {
TestHarnessOptions,
ComponentExecutionResult,
ComponentExecutionOptions,
BrowserContextOptions,
TestCase,
TestSummary
} from '@memberjunction/react-test-harness';
`$3
`typescript
import { ReactTestHarness } from '@memberjunction/react-test-harness';async function testMyComponent() {
const harness = new ReactTestHarness({
headless: true,
debug: false
});
try {
await harness.initialize();
// Test component code directly
const result = await harness.testComponent(
, { message: 'Hello World' }); console.log('Success:', result.success);
console.log('HTML:', result.html);
console.log('Console logs:', result.console);
return result;
} finally {
await harness.close();
}
}
`$3
`typescript
import { ReactTestHarness, AssertionHelpers } from '@memberjunction/react-test-harness';
import { describe, it, beforeAll, afterAll } from 'vitest';describe('My React Components', () => {
let harness: ReactTestHarness;
beforeAll(async () => {
harness = new ReactTestHarness({ headless: true });
await harness.initialize();
});
afterAll(async () => {
await harness.close();
});
it('should render greeting component', async () => {
const result = await harness.testComponent(
, { name: 'World' }); AssertionHelpers.assertSuccess(result);
AssertionHelpers.assertContainsText(result.html, 'Hello, World!');
});
it('should handle click events', async () => {
const result = await harness.testComponent(
); AssertionHelpers.assertContainsText(result.html, 'Count: 0');
});
});
`$3
`typescript
import {
ReactTestHarness,
BrowserManager,
ComponentRunner,
AssertionHelpers
} from '@memberjunction/react-test-harness';class ComponentTestSuite {
private harness: ReactTestHarness;
private browserManager: BrowserManager;
private componentRunner: ComponentRunner;
constructor() {
// You can also use the underlying classes directly
this.browserManager = new BrowserManager({
viewport: { width: 1920, height: 1080 },
headless: true
});
this.componentRunner = new ComponentRunner(this.browserManager);
// Or use the high-level harness
this.harness = new ReactTestHarness({
headless: true,
debug: true
});
}
async initialize() {
await this.harness.initialize();
}
async testComponent(code: string, props?: any) {
const result = await this.harness.testComponent(code, props);
// Use static assertion methods
AssertionHelpers.assertSuccess(result);
// Or create a fluent matcher
const matcher = AssertionHelpers.createMatcher(result.html);
matcher.toContainText('Expected text');
return result;
}
async cleanup() {
await this.harness.close();
}
}
// Usage
const suite = new ComponentTestSuite();
await suite.initialize();
await suite.testComponent(
const Component = () => );
await suite.cleanup();
`$3
`typescript
const result = await harness.testComponentFromFile(
'./MyComponent.jsx',
{ title: 'Test', value: 123 },
{
waitForSelector: '.loaded',
timeout: 10000
}
);
`$3
`typescript
const harness = new ReactTestHarness({ debug: true });await harness.runTest('Component renders correctly', async () => {
const result = await harness.testComponent(
);
const { AssertionHelpers } = harness;
AssertionHelpers.assertSuccess(result);
AssertionHelpers.assertContainsText(result.html, 'Test');
});// Run multiple tests
const summary = await harness.runTests([
{
name: 'Has correct elements',
fn: async () => {
const result = await harness.testComponent(
);
const matcher = harness.createMatcher(result.html);
matcher.toHaveElement('#title');
matcher.toHaveElement('.action');
}
},
{
name: 'Handles props correctly',
fn: async () => {
const result = await harness.testComponent(, { items: ['A', 'B', 'C'] });
const { AssertionHelpers } = harness;
AssertionHelpers.assertElementCount(result.html, 'li', 3);
}
}
]);console.log(
Tests passed: ${summary.passed}/${summary.total});
`Complete API Reference
$3
The main class for testing React components.
`typescript
class ReactTestHarness {
constructor(options?: TestHarnessOptions);
// Lifecycle methods
async initialize(): Promise;
async close(): Promise;
// Component testing methods
async testComponent(
componentCode: string,
props?: Record,
options?: Partial
): Promise;
async testComponentFromFile(
filePath: string,
props?: Record,
options?: Partial
): Promise;
// Test running methods
async runTest(name: string, fn: () => Promise): Promise;
async runTests(tests: TestCase[]): Promise;
// Utility methods
getAssertionHelpers(): typeof AssertionHelpers;
createMatcher(html: string): FluentMatcher;
async screenshot(path?: string): Promise;
async evaluateInPage(fn: () => T): Promise;
}
`$3
Manages the Playwright browser instance.
`typescript
class BrowserManager {
constructor(options?: BrowserContextOptions);
async initialize(): Promise;
async close(): Promise;
async getPage(): Promise;
async navigate(url: string): Promise;
async evaluateInPage(fn: () => T): Promise;
async screenshot(path?: string): Promise;
}
`$3
Executes React components in the browser.
`typescript
class ComponentRunner {
constructor(browserManager: BrowserManager);
async executeComponent(options: ComponentExecutionOptions): Promise;
async executeComponentFromFile(
filePath: string,
props?: Record,
options?: Partial
): Promise;
}
`$3
Provides assertion methods for testing.
`typescript
class AssertionHelpers {
// Result assertions
static assertSuccess(result: ComponentExecutionResult): void;
static assertNoErrors(result: ComponentExecutionResult): void;
static assertNoConsoleErrors(console: Array<{ type: string; text: string }>): void;
// Content assertions
static assertContainsText(html: string, text: string): void;
static assertNotContainsText(html: string, text: string): void;
static assertHasElement(html: string, selector: string): void;
static assertElementCount(html: string, tagName: string, expectedCount: number): void;
// Utility methods
static containsText(html: string, text: string): boolean;
static hasElement(html: string, selector: string): boolean;
static countElements(html: string, tagName: string): number;
static hasAttribute(html: string, selector: string, attribute: string, value?: string): boolean;
// Fluent matcher creation
static createMatcher(html: string): FluentMatcher;
}
`$3
Provides fluent assertions for better readability.
`typescript
interface FluentMatcher {
toContainText(text: string): void;
toHaveElement(selector: string): void;
toHaveElementCount(tagName: string, count: number): void;
toHaveAttribute(selector: string, attribute: string, value?: string): void;
}
`Parallel Testing
$3
The ReactTestHarness uses a single browser page instance and is NOT safe for parallel test execution on the same instance. This is due to Playwright's internal limitations with
exposeFunction and potential race conditions when multiple tests try to modify the same page context simultaneously.$3
For sequential test execution, you can safely reuse a single harness instance:
`typescript
const harness = new ReactTestHarness({ headless: true });
await harness.initialize();// ✅ CORRECT - Sequential testing on same instance
for (const test of tests) {
const result = await harness.testComponent(test.code, test.props);
// Each test runs one after another, no conflicts
}
await harness.close();
`$3
For parallel test execution, you MUST create separate ReactTestHarness instances:
`typescript
// ✅ CORRECT - Parallel testing with separate instances
const results = await Promise.all(tests.map(async (test) => {
const harness = new ReactTestHarness({ headless: true });
await harness.initialize();
try {
return await harness.testComponent(test.code, test.props);
} finally {
await harness.close(); // Clean up each instance
}
}));
`$3
`typescript
// ❌ WRONG - DO NOT DO THIS
const harness = new ReactTestHarness({ headless: true });
await harness.initialize();// This will cause conflicts and errors!
const results = await Promise.all(
tests.map(test => harness.testComponent(test.code, test.props))
);
`This approach will fail with errors like:
- "Function '__mjGetEntityObject' has been already registered"
- "Cannot read properties of undefined (reading 'addBinding')"
$3
While creating multiple harness instances has some overhead (each launches its own browser context), the benefits of parallel execution typically outweigh this cost:
- Sequential (1 instance): Lower memory usage, but tests run one by one
- Parallel (N instances): Higher memory usage, but tests complete much faster
$3
`typescript
class TestRunner {
async runTests(tests: TestCase[], parallel = false) {
if (parallel) {
// Create new instance for each test
return Promise.all(tests.map(async (test) => {
const harness = new ReactTestHarness({ headless: true });
await harness.initialize();
try {
return await harness.testComponent(test.code, test.props);
} finally {
await harness.close();
}
}));
} else {
// Reuse single instance for all tests
const harness = new ReactTestHarness({ headless: true });
await harness.initialize();
const results = [];
for (const test of tests) {
results.push(await harness.testComponent(test.code, test.props));
}
await harness.close();
return results;
}
}
}
`Usage Examples for TypeScript Projects
$3
`typescript
// test-utils.ts
import { ReactTestHarness, AssertionHelpers } from '@memberjunction/react-test-harness';
import type { ComponentExecutionResult } from '@memberjunction/react-test-harness';export class ReactComponentTester {
private harness: ReactTestHarness;
constructor() {
this.harness = new ReactTestHarness({
headless: process.env.HEADED !== 'true',
debug: process.env.DEBUG === 'true'
});
}
async setup() {
await this.harness.initialize();
}
async teardown() {
await this.harness.close();
}
async testMJComponent(
componentCode: string,
data: any,
userState?: any,
callbacks?: any,
utilities?: any,
styles?: any
): Promise {
// Test with MJ-style props structure
const props = { data, userState, callbacks, utilities, styles };
return this.harness.testComponent(componentCode, props);
}
expectSuccess(result: ComponentExecutionResult) {
AssertionHelpers.assertSuccess(result);
return this;
}
expectText(result: ComponentExecutionResult, text: string) {
AssertionHelpers.assertContainsText(result.html, text);
return this;
}
expectNoText(result: ComponentExecutionResult, text: string) {
AssertionHelpers.assertNotContainsText(result.html, text);
return this;
}
}
// Usage in tests
const tester = new ReactComponentTester();
await tester.setup();
const result = await tester.testMJComponent(
componentCode,
{ title: 'Test', items: [] },
{ viewMode: 'grid' }
);
tester
.expectSuccess(result)
.expectText(result, 'Test')
.expectNoText(result, 'Error');
await tester.teardown();
`$3
`typescript
import { ReactTestHarness } from '@memberjunction/react-test-harness';
import type { LibraryConfiguration } from '@memberjunction/react-runtime';class LibraryCompatibilityTester {
private harness: ReactTestHarness;
constructor() {
this.harness = new ReactTestHarness({ headless: true });
}
async testWithLibraries(
componentCode: string,
enabledLibraries: string[]
) {
const config: LibraryConfiguration = {
libraries: [
// Always include runtime
{ id: 'react', category: 'runtime', isEnabled: true, isRuntimeOnly: true, ... },
{ id: 'react-dom', category: 'runtime', isEnabled: true, isRuntimeOnly: true, ... },
{ id: 'babel', category: 'runtime', isEnabled: true, isRuntimeOnly: true, ... },
// Conditionally enable other libraries
{ id: 'lodash', category: 'utility', isEnabled: enabledLibraries.includes('lodash'), ... },
{ id: 'chart-js', category: 'charting', isEnabled: enabledLibraries.includes('chart-js'), ... },
{ id: 'antd', category: 'ui', isEnabled: enabledLibraries.includes('antd'), ... },
],
metadata: { version: '1.0.0', lastUpdated: '2024-01-01' }
};
return this.harness.testComponent(componentCode, {}, {
libraryConfiguration: config
});
}
}
`$3
`typescript
// ci-test-runner.ts
import { ReactTestHarness } from '@memberjunction/react-test-harness';
import * as fs from 'fs';
import * as path from 'path';export async function runComponentTests(testDir: string) {
const harness = new ReactTestHarness({
headless: true,
screenshotOnError: true,
screenshotPath: './test-failures/'
});
const results = {
total: 0,
passed: 0,
failed: 0,
failures: [] as Array<{ component: string; error: string }>
};
await harness.initialize();
try {
const files = fs.readdirSync(testDir)
.filter(f => f.endsWith('.jsx') || f.endsWith('.tsx'));
for (const file of files) {
results.total++;
try {
const result = await harness.testComponentFromFile(
path.join(testDir, file)
);
if (result.success) {
results.passed++;
} else {
results.failed++;
results.failures.push({
component: file,
error: result.error || 'Unknown error'
});
}
} catch (error) {
results.failed++;
results.failures.push({
component: file,
error: String(error)
});
}
}
} finally {
await harness.close();
}
return results;
}
// Run in CI
const results = await runComponentTests('./components');
console.log(
Tests: ${results.passed}/${results.total} passed);if (results.failed > 0) {
console.error('Failures:', results.failures);
process.exit(1);
}
`Component Execution Options
`typescript
interface ComponentExecutionOptions {
componentSpec: ComponentSpec;
props?: Record;
setupCode?: string; // Additional setup code
timeout?: number; // Default: 30000ms
waitForSelector?: string; // Wait for element before capture
waitForLoadState?: 'load' | 'domcontentloaded' | 'networkidle';
contextUser: UserInfo;
libraryConfiguration?: LibraryConfiguration; // New: Custom library configuration
}
`Test Harness Options
`typescript
interface TestHarnessOptions {
headless?: boolean; // Default: true
viewport?: { // Default: 1280x720
width: number;
height: number;
};
debug?: boolean; // Default: false
screenshotOnError?: boolean; // Default: true
screenshotPath?: string; // Default: './error-screenshot.png'
userAgent?: string;
deviceScaleFactor?: number;
locale?: string;
timezoneId?: string;
}
`Writing Test Files
Test files should export a default async function:
`javascript
// my-component.test.js
export default async function runTests(harness) {
const { AssertionHelpers } = harness; await harness.runTest('Component renders', async () => {
const result = await harness.testComponentFromFile('./MyComponent.jsx');
AssertionHelpers.assertSuccess(result);
});
await harness.runTest('Component handles props', async () => {
const result = await harness.testComponentFromFile(
'./MyComponent.jsx',
{ value: 100 }
);
AssertionHelpers.assertContainsText(result.html, '100');
});
}
`Advanced Usage
$3
`typescript
import { BrowserManager } from '@memberjunction/react-test-harness';const browser = new BrowserManager({
viewport: { width: 1920, height: 1080 },
locale: 'en-US',
timezoneId: 'America/New_York'
});
await browser.initialize();
const page = await browser.getPage();
`$3
`typescript
const harness = new ReactTestHarness();
await harness.initialize();// Evaluate JavaScript in the page context
const result = await harness.evaluateInPage(() => {
return document.querySelector('h1')?.textContent;
});
// Take screenshots
const screenshot = await harness.screenshot('./output.png');
`Limitations
Due to the architecture of the test harness (Node.js controlling a browser via Playwright), there are some important limitations to be aware of. See docs/limitations.md for details on:
- Serialization requirements between Node.js and browser
- BaseEntity method access limitations
- Differences between test and production environments
Best Practices
1. Always close the harness after tests to free resources:
`typescript
try {
// Run tests
} finally {
await harness.close();
}
`2. Use waitForSelector for dynamic content:
`typescript
const result = await harness.testComponent(componentCode, props, {
waitForSelector: '.async-content',
timeout: 5000
});
`3. Enable debug mode during development:
`typescript
const harness = new ReactTestHarness({ debug: true });
`4. Group related tests for better organization:
`typescript
await harness.runTests([
{ name: 'Feature A - Test 1', fn: async () => { / ... / } },
{ name: 'Feature A - Test 2', fn: async () => { / ... / } },
{ name: 'Feature B - Test 1', fn: async () => { / ... / } },
]);
`5. Test with different library configurations to ensure compatibility:
`typescript
// Test with minimal libraries
const minimalResult = await harness.testComponent(code, props, {
libraryConfiguration: minimalLibraryConfig
});
// Test with full libraries
const fullResult = await harness.testComponent(code, props, {
libraryConfiguration: fullLibraryConfig
});
`Troubleshooting
$3
- Ensure your component is named Component or modify the execution template
- Check for syntax errors in your component code
- Enable debug mode to see console output
- Verify required libraries are included in libraryConfiguration$3
- Increase timeout value: --timeout 60000
- Use waitForLoadState: 'networkidle' for components that load external resources
- Check if the selector in waitForSelector` actually existsISC