Component Testing with Playwright
Component testing in Neo.mjs differs significantly from Unit Testing. While unit tests run in a simulated, single-thread Node.js environment, Component Tests run in a real browser.
This allows you to verify:
- DOM Events: Real clicks, hovers, and focus events.
- Visual Layout: Computed styles, positioning, and visibility.
- Worker Communication: The actual
postMessagetraffic between the App Worker and the Main Thread. - Browser APIs: Interaction with
ResizeObserver,IntersectionObserver, and other browser-native features.
Architecture
Component tests use a unique "Remote Control" architecture:
- The Test Runner (Playwright): Launches a browser instance.
- The Test App (
apps/empty-viewport): A minimal Neo.mjs application that provides an emptyViewportcontainer. - The Bridge (
RmaHelpers): A set of global helpers injected into the browser that allow Playwright to send commands (like "Create Component") to the App Worker. - The
neoFixture: A convenient Playwright fixture that wraps the bridge, giving you a clean API in your tests.
Running Component Tests
To run the browser-based component tests:
npm run test-components
Note: Component tests run sequentially (single worker) to prevent state pollution in the shared empty-viewport app.
Developer Workflow
1. Running a Single File
To run a specific component test file, point to the component config:
npx playwright test test/playwright/component/my/file.spec.mjs -c test/playwright/playwright.config.component.mjs
2. Visual Debugging
Component tests are visual by nature. Use --headed to watch the browser execution:
<h1 class="neo-h1" data-record-id="7">Run with a visible browser window</h1>
npx playwright test test/playwright/component/my/file.spec.mjs -c test/playwright/playwright.config.component.mjs --headed
<h1 class="neo-h1" data-record-id="8">Run with the Inspector to step through</h1>
npx playwright test test/playwright/component/my/file.spec.mjs -c test/playwright/playwright.config.component.mjs --debug
3. Verify Isolation
Since component tests share the same apps/empty-viewport, ensure you run the full suite (npm run test-components) before committing to verify that your tests clean up after themselves (using neo.destroyComponent) and don't leave artifacts that break subsequent tests.
Writing a Component Test
The recommended way to write component tests is using the custom neo fixture provided by test/playwright/fixtures.mjs.
1. Basic Setup
Create a new file in test/playwright/component/. You must import test and expect from the fixtures file, NOT from @playwright/test directly.
import { test, expect } from '../../fixtures.mjs'; // <--- Import from fixtures
test.describe('My Component Test', () => {
// Navigate to the empty test app before each test
test.beforeEach(async ({ page }) => {
await page.goto('test/playwright/component/apps/empty-viewport/index.html');
await page.waitForSelector('#component-test-viewport');
});
test('should render and interact', async ({ neo, page }) => {
// ... test logic ...
});
});
2. Creating Components
Use neo.createComponent() to instantiate a component inside the App Worker. You pass a standard Neo.mjs config object.
test('should render a button', async ({ neo, page }) => {
// 1. Create the component
const result = await neo.createComponent({
className: 'Neo.button.Base',
parentId : 'component-test-viewport', // Render into the main viewport
text : 'Click Me',
iconCls : 'fa fa-home'
});
const buttonId = result.id;
// 2. Use Playwright locators to interact with the REAL DOM
const button = page.locator(`#${buttonId}`);
await expect(button).toBeVisible();
await expect(button).toHaveText('Click Me');
});
3. Interacting with State
You can read and write component configurations dynamically using the neo fixture.
test('should react to config changes', async ({ neo, page }) => {
// ... create button ...
// 1. Update config via App Worker
await neo.setConfig(buttonId, {
text: 'Updated Text',
iconCls: 'fa fa-user'
});
// 2. Verify DOM update
await expect(page.locator(`#${buttonId}`)).toHaveText('Updated Text');
await expect(page.locator(`#${buttonId} .fa-user`)).toBeVisible();
// 3. Read config from App Worker
const currentText = await neo.getConfig(buttonId, 'text');
expect(currentText).toBe('Updated Text');
});
4. Cleanup
Unlike Unit Tests, the empty-viewport app persists between tests in the same file if you don't clean up. It is best practice to destroy components in afterEach.
test.afterEach(async ({ neo }) => {
if (componentId) {
await neo.destroyComponent(componentId);
}
});
The neo Fixture API
The neo fixture provides the following methods:
| Method | Description |
|---|---|
createComponent(config) |
Creates a component in the App Worker. Returns { success, id }. |
destroyComponent(id) |
Destroys a component instance. |
setConfig(id, config) |
Updates one or more configs on an instance. |
getConfig(id, key) |
Retrieves a config value from an instance. |
waitForReady() |
Waits for the Neo framework to be fully initialized. |
page |
Access to the underlying Playwright Page object. |
Advanced: Loading Modules
Sometimes you need to test a component that isn't loaded by default in the empty-viewport app. You have two options to load modules dynamically.
Warning: These methods rely on dynamic imports that are ignored by webpack (/* webpackIgnore: true */). They work in development and testing environments but will fail in production builds if the chunks are not available.
Option 1: The loadModule Helper
You can manually load a module before using it.
// Load the class definition first
await neo.loadModule('../../src/button/Base.mjs');
// Then create the instance
await neo.createComponent({
className: 'Neo.button.Base',
text : 'Loaded Manually'
});
Option 2: The importPath Shortcut
The createComponent method accepts an importPath config. This is a convenient shortcut that loads the module before attempting to create the instance.
await neo.createComponent({
className : 'Neo.button.Base',
importPath: '../../src/button/Base.mjs', // <--- Lazy load and create in one step
text : 'Lazy Loaded Button'
});
When to use Component vs. Unit Tests?
| Feature | Unit Tests (test-unit) |
Component Tests (test-components) |
|---|---|---|
| Environment | Simulated Node.js | Real Browser (Chrome/Firefox/Webkit) |
| Speed | Instant (< 10ms) | Slower (Browser launch + Navigation) |
| Focus | Logic, State, VDOM diffing | DOM Events, CSS, Layout, Integration |
| Threads | Single Thread | Multi-Thread (Main + App + VDom + Data) |
Recommendation:
- Use Unit Tests for 90% of your logic (Controllers, Stores, VDOM logic).
- Use Component Tests only when you verify actual DOM interaction, CSS rendering, or complex browser-specific behaviors.
Beyond the Empty Viewport
While the empty-viewport app is great for isolated component testing, the Playwright harness is flexible enough to handle broader scenarios.
1. Testing Full Applications (E2E)
You are not limited to the test harness. You can point your test to any Neo.mjs application entry point to perform Integration or E2E tests.
test.beforeEach(async ({ page }) => {
// Load a real application
await page.goto('apps/realworld/index.html');
await page.waitForSelector('.neo-viewport');
});
This allows you to test user flows (e.g., "Login -> Navigate -> Submit Form") using the same API.
2. Custom Test Harnesses
If you find yourself repeatedly setting up complex component trees (e.g., a Grid with specific stores and plugins), you can create a dedicated test app (e.g., test/harness/my-grid-harness/index.html).
Instead of createComponent(), your test would simply load this custom app, which comes pre-configured with the state you want to verify.
Future Roadmap: Deep E2E
The current component testing bridge ("RMA Helpers") provides basic interactions but has limited visibility into the App Worker's internal state (Stores, Managers, Worker Threads).
We are actively exploring "Deep E2E" testing powered by the Neural Link. This will allow tests to:
- Inspect internal Store data directly (White-Box testing).
- Verify state across multiple windows seamlessly.
- Hot-patch code during runtime for advanced assertions.
This capability is tracked in Issue #8851. If this feature is important to your workflow, please leave a comment on the ticket to help us prioritize it.