mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
16 KiB
16 KiB
n8n Playwright Test Contribution Guide
For running tests, see README.md
🚀 Quick Start for Test Development
Prerequisites
- VS Code/Cursor Extension: Install "Playwright Test for VSCode"
- Local n8n Instance: Local server or Docker
Configuration
Add to your /.vscode/settings.json:
{
"playwright.env": {
"N8N_BASE_URL": "http://localhost:5679", // URL to test against (Don't use 5678 as that can wipe your dev instance DB)
"SHOW_BROWSER": "true", // Show browser (useful with n8n.page.pause())
"RESET_E2E_DB": "true" // Reset DB for fresh state
}
}
Running Tests
- Initial Setup: Click "Run global setup" in Playwright extension to reset database
- Run Tests: Click play button next to any test in the IDE
- Debug: Add
await n8n.page.pause()to hijack test execution
Troubleshooting:
- Why can't I run my test from the UI?
- The tests are separated by groups for tests that can run in parallel or tests that need a DB reset each time. You can select the project in the test explorer.
- Not all my tests ran from the CLI
- Currently the DB reset tests are a "dependency" of the parallel tests, this is to stop them running at the same time. So if the parallel tests fail the sequential tests won't run.
🏗️ Architecture Overview
Our test architecture follows a strict four-layer approach:
Tests (*.spec.ts)
↓ uses
Composables (*Composer.ts) - Business workflows
↓ orchestrates
Page Objects (*Page.ts) - UI interactions
↓ extends
BasePage - Common utilities
Core Principle: Separation of Concerns
- BasePage: Generic interaction methods
- Page Objects: Element locators and simple actions
- Composables: Complex business workflows
- Tests: Readable scenarios using composables
📐 Lexical Conventions
Page Objects: Three Types of Methods
1. Element Getters (No async, return Locator)
// From WorkflowsPage.ts
getSearchBar() {
return this.page.getByTestId('resources-list-search');
}
getWorkflowByName(name: string) {
return this.getWorkflowItems().filter({ hasText: name });
}
// From CanvasPage.ts
nodeByName(nodeName: string): Locator {
return this.page.locator(`[data-test-id="canvas-node"][data-node-name="${nodeName}"]`);
}
saveWorkflowButton(): Locator {
return this.page.getByRole('button', { name: 'Save' });
}
2. Simple Actions (async, return void)
// From WorkflowsPage.ts
async clickAddWorklowButton() {
await this.clickByTestId('add-resource-workflow');
}
async searchWorkflows(searchTerm: string) {
await this.clickByTestId('resources-list-search');
await this.fillByTestId('resources-list-search', searchTerm);
}
// From CanvasPage.ts
async deleteNodeByName(nodeName: string): Promise<void> {
await this.nodeDeleteButton(nodeName).click();
}
async openNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).dblclick();
}
3. Query Methods (async, return data)
// From CanvasPage.ts
async getPinnedNodeNames(): Promise<string[]> {
const pinnedNodesLocator = this.page
.getByTestId('canvas-node')
.filter({ has: this.page.getByTestId('canvas-node-status-pinned') });
const names: string[] = [];
const count = await pinnedNodesLocator.count();
for (let i = 0; i < count; i++) {
const node = pinnedNodesLocator.nth(i);
const name = await node.getAttribute('data-node-name');
if (name) {
names.push(name);
}
}
return names;
}
// From NotificationsPage.ts
async getNotificationCount(text?: string | RegExp): Promise<number> {
try {
const notifications = text
? this.notificationContainerByText(text)
: this.page.getByRole('alert');
return await notifications.count();
} catch {
return 0;
}
}
Composables: Business Workflows
// From WorkflowComposer.ts
export class WorkflowComposer {
async executeWorkflowAndWaitForNotification(notificationMessage: string) {
const responsePromise = this.n8n.page.waitForResponse(
(response) =>
response.url().includes('/rest/workflows/') &&
response.url().includes('/run') &&
response.request().method() === 'POST',
);
await this.n8n.canvas.clickExecuteWorkflowButton();
await responsePromise;
await this.n8n.notifications.waitForNotificationAndClose(notificationMessage);
}
async createWorkflow(name?: string) {
await this.n8n.workflows.clickAddWorklowButton();
const workflowName = name ?? 'My New Workflow';
await this.n8n.canvas.setWorkflowName(workflowName);
await this.n8n.canvas.saveWorkflow();
}
}
// From ProjectComposer.ts
export class ProjectComposer {
async createProject(projectName?: string) {
await this.n8n.page.getByTestId('universal-add').click();
await Promise.all([
this.n8n.page.waitForResponse('**/rest/projects/*'),
this.n8n.page.getByTestId('navigation-menu-item').filter({ hasText: 'Project' }).click(),
]);
await this.n8n.notifications.waitForNotificationAndClose('saved successfully');
await this.n8n.page.waitForLoadState();
const projectNameUnique = projectName ?? `Project ${Date.now()}`;
await this.n8n.projectSettings.fillProjectName(projectNameUnique);
await this.n8n.projectSettings.clickSaveButton();
const projectId = this.extractProjectIdFromPage('projects', 'settings');
return { projectName: projectNameUnique, projectId };
}
}
📁 File Structure & Naming
tests/
├── composables/ # Multi-page business workflows
│ ├── CanvasComposer.ts
│ ├── ProjectComposer.ts
│ └── WorkflowComposer.ts
├── pages/ # Page object models
│ ├── BasePage.ts
│ ├── CanvasPage.ts
│ ├── CredentialsPage.ts
│ ├── ExecutionsPage.ts
│ ├── NodeDisplayViewPage.ts
│ ├── NotificationsPage.ts
│ ├── ProjectSettingsPage.ts
│ ├── ProjectWorkflowsPage.ts
│ ├── SidebarPage.ts
│ ├── WorkflowSharingModal.ts
│ └── WorkflowsPage.ts
├── fixtures/ # Test fixtures and setup
├── services/ # API helpers
├── utils/ # Helper functions
├── config/ # Constants and configuration
│ ├── constants.ts
│ ├── intercepts.ts
│ └── test-users.ts
└── *.spec.ts # Test files
Naming Conventions
| Type | Pattern | Example |
|---|---|---|
| Page Objects | {PageName}Page.ts |
CredentialsPage.ts |
| Composables | {Domain}Composer.ts |
WorkflowComposer.ts |
| Test Files | {number}-{feature}.spec.ts |
1-workflows.spec.ts |
| Test IDs | kebab-case |
data-test-id="save-button" |
✅ Implementation Checklist
When Adding a Page Object Method
// From ExecutionsPage.ts - Good example
export class ExecutionsPage extends BasePage {
// ✅ Getter: Returns Locator, no async
getExecutionItems(): Locator {
return this.page.locator('div.execution-card');
}
getLastExecutionItem(): Locator {
const executionItems = this.getExecutionItems();
return executionItems.nth(0);
}
// ✅ Action: Async, descriptive verb, returns void
async clickDebugInEditorButton(): Promise<void> {
await this.clickButtonByName('Debug in editor');
}
async clickLastExecutionItem(): Promise<void> {
const executionItem = this.getLastExecutionItem();
await executionItem.click();
}
// ❌ AVOID: Mixed concerns (this should be in a composable)
async handlePinnedNodesConfirmation(action: 'Unpin' | 'Cancel'): Promise<void> {
// This involves business logic and should be moved to a composable
}
}
When Creating a Composable
// From CanvasComposer.ts - Good example
export class CanvasComposer {
/**
* Pin the data on a node. Then close the node.
* @param nodeName - The name of the node to pin the data on.
*/
async pinNodeData(nodeName: string) {
await this.n8n.canvas.openNode(nodeName);
await this.n8n.ndv.togglePinData();
await this.n8n.ndv.close();
}
}
// From ProjectComposer.ts - Good example with return data
export class ProjectComposer {
async addCredentialToProject(
projectName: string,
credentialType: string,
credentialFieldName: string,
credentialValue: string,
) {
await this.n8n.sideBar.openNewCredentialDialogForProject(projectName);
await this.n8n.credentials.openNewCredentialDialogFromCredentialList(credentialType);
await this.n8n.credentials.fillCredentialField(credentialFieldName, credentialValue);
await this.n8n.credentials.saveCredential();
await this.n8n.notifications.waitForNotificationAndClose('Credential successfully created');
await this.n8n.credentials.closeCredentialDialog();
}
}
When Writing Tests
// ✅ GOOD: From 1-workflows.spec.ts
test('should create a new workflow using add workflow button', async ({ n8n }) => {
await n8n.workflows.clickAddWorklowButton();
const workflowName = `Test Workflow ${Date.now()}`;
await n8n.canvas.setWorkflowName(workflowName);
await n8n.canvas.clickSaveWorkflowButton();
await expect(
n8n.notifications.notificationContainerByText('Workflow successfully created'),
).toBeVisible();
});
// ✅ GOOD: From 28-debug.spec.ts - Using helper functions
async function createBasicWorkflow(n8n, url = URLS.FAILING) {
await n8n.workflows.clickAddWorklowButton();
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('HTTP Request');
await n8n.ndv.fillParameterInput('URL', url);
await n8n.ndv.close();
await n8n.canvas.clickSaveWorkflowButton();
await n8n.notifications.waitForNotificationAndClose(NOTIFICATIONS.WORKFLOW_CREATED);
}
test('should enter debug mode for failed executions', async ({ n8n }) => {
await createBasicWorkflow(n8n, URLS.FAILING);
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(NOTIFICATIONS.PROBLEM_IN_NODE);
await importExecutionForDebugging(n8n);
expect(n8n.page.url()).toContain('/debug');
});
🎯 Best Practices
1. Always Use BasePage Methods
// ✅ GOOD - From NodeDisplayViewPage.ts
async fillParameterInput(labelName: string, value: string) {
await this.getParameterByLabel(labelName).getByTestId('parameter-input-field').fill(value);
}
async clickBackToCanvasButton() {
await this.clickByTestId('back-to-canvas');
}
// ❌ AVOID
async badExample() {
await this.page.getByTestId('back-to-canvas').click();
}
2. Keep Page Objects Simple
// ✅ GOOD - From CredentialsPage.ts
export class CredentialsPage extends BasePage {
async openCredentialSelector() {
await this.page.getByRole('combobox', { name: 'Select Credential' }).click();
}
async createNewCredential() {
await this.clickByText('Create new credential');
}
async fillCredentialField(fieldName: string, value: string) {
const field = this.page
.getByTestId(`parameter-input-${fieldName}`)
.getByTestId('parameter-input-field');
await field.click();
await field.fill(value);
}
}
3. Use Constants for Repeated Values
// From constants.ts
export const MANUAL_TRIGGER_NODE_NAME = 'Manual Trigger';
export const MANUAL_TRIGGER_NODE_DISPLAY_NAME = 'When clicking 'Execute workflow'';
export const CODE_NODE_NAME = 'Code';
export const SET_NODE_NAME = 'Set';
export const HTTP_REQUEST_NODE_NAME = 'HTTP Request';
// From 28-debug.spec.ts
const NOTIFICATIONS = {
WORKFLOW_CREATED: 'Workflow successfully created',
EXECUTION_IMPORTED: 'Execution data imported',
PROBLEM_IN_NODE: 'Problem in node',
SUCCESSFUL: 'Successful',
DATA_NOT_IMPORTED: "Some execution data wasn't imported",
};
4. Handle Dynamic Data
// From test-users.ts
export const INSTANCE_OWNER_CREDENTIALS: UserCredentials = {
email: 'nathan@n8n.io',
password: DEFAULT_USER_PASSWORD,
firstName: randFirstName(),
lastName: randLastName(),
};
// From tests
const projectName = `Test Project ${Date.now()}`;
const workflowName = `Archive Test ${Date.now()}`;
5. Proper Waiting Strategies
// ✅ GOOD - From ProjectComposer.ts
await Promise.all([
this.n8n.page.waitForResponse('**/rest/projects/*'),
this.n8n.page.getByTestId('navigation-menu-item').filter({ hasText: 'Project' }).click(),
]);
// From NotificationsPage.ts
async waitForNotification(text: string | RegExp, options: { timeout?: number } = {}): Promise<boolean> {
const { timeout = 5000 } = options;
try {
const notification = this.notificationContainerByText(text).first();
await notification.waitFor({ state: 'visible', timeout });
return true;
} catch {
return false;
}
}
🚨 Common Anti-Patterns
❌ Don't Mix Concerns
// BAD: From WorkflowsPage.ts - Should be in composable
async archiveWorkflow(workflowItem: Locator) {
await workflowItem.getByTestId('workflow-card-actions').click();
await this.getArchiveMenuItem().click();
}
// GOOD: Simple page object method
async clickArchiveMenuItem() {
await this.getArchiveMenuItem().click();
}
❌ Don't Use Raw Selectors in Tests
// BAD: From 1-workflows.spec.ts
await expect(n8n.page.getByText('No workflows found')).toBeVisible();
// GOOD: Add getter to page object
await expect(n8n.workflows.getEmptyStateMessage()).toBeVisible();
❌ Don't Create Overly Specific Methods
// BAD: Too specific
async createAndSaveNewCredentialForNotionApi(apiKey: string) {
// Too specific! Break it down
}
// GOOD: From CredentialsPage.ts - Reusable parts
async openNewCredentialDialogFromCredentialList(credentialType: string): Promise<void>
async fillCredentialField(fieldName: string, value: string)
async saveCredential()
📝 Code Review Checklist
Before submitting your PR, ensure:
- All page object methods follow the getter/action/query pattern
- Complex workflows are in composables, not page objects
- Tests use composables, not low-level page methods
- Used
BasePagemethods instead of raw Playwright selectors - Added JSDoc comments for non-obvious methods
- Test names clearly describe the business scenario
- No
waitForTimeout- used proper Playwright waiting - Constants used for repeated strings
- Dynamic data includes timestamps to avoid conflicts
- Methods are small and focused on one responsibility
🔍 Real Implementation Example
Here's a complete example from our codebase showing all layers:
// 1. Page Object (ProjectSettingsPage.ts)
export class ProjectSettingsPage extends BasePage {
// Simple action methods only
async fillProjectName(name: string) {
await this.page.getByTestId('project-settings-name-input').locator('input').fill(name);
}
async clickSaveButton() {
await this.clickButtonByName('Save');
}
}
// 2. Composable (ProjectComposer.ts)
export class ProjectComposer {
async createProject(projectName?: string) {
await this.n8n.page.getByTestId('universal-add').click();
await Promise.all([
this.n8n.page.waitForResponse('**/rest/projects/*'),
this.n8n.page.getByTestId('navigation-menu-item').filter({ hasText: 'Project' }).click(),
]);
await this.n8n.notifications.waitForNotificationAndClose('saved successfully');
await this.n8n.page.waitForLoadState();
const projectNameUnique = projectName ?? `Project ${Date.now()}`;
await this.n8n.projectSettings.fillProjectName(projectNameUnique);
await this.n8n.projectSettings.clickSaveButton();
const projectId = this.extractProjectIdFromPage('projects', 'settings');
return { projectName: projectNameUnique, projectId };
}
}
// 3. Test (39-projects.spec.ts)
test('should filter credentials by project ID', async ({ n8n, api }) => {
const { projectName, projectId } = await n8n.projectComposer.createProject();
await n8n.projectComposer.addCredentialToProject(
projectName,
'Notion API',
'apiKey',
NOTION_API_KEY,
);
const credentials = await getCredentialsForProject(api, projectId);
expect(credentials).toHaveLength(1);
});