# n8n Playwright Test Contribution Guide > For running tests, see [README.md](./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`: ```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 1. **Initial Setup**: Click "Run global setup" in Playwright extension to reset database 2. **Run Tests**: Click play button next to any test in the IDE 3. **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`) ```typescript // 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`) ```typescript // 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 { await this.nodeDeleteButton(nodeName).click(); } async openNode(nodeName: string): Promise { await this.nodeByName(nodeName).dblclick(); } ``` #### 3. Query Methods (`async`, return data) ```typescript // From CanvasPage.ts async getPinnedNodeNames(): Promise { 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 { try { const notifications = text ? this.notificationContainerByText(text) : this.page.getByRole('alert'); return await notifications.count(); } catch { return 0; } } ``` ### Composables: Business Workflows ```typescript // 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 ```typescript // 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 { await this.clickButtonByName('Debug in editor'); } async clickLastExecutionItem(): Promise { const executionItem = this.getLastExecutionItem(); await executionItem.click(); } // ❌ AVOID: Mixed concerns (this should be in a composable) async handlePinnedNodesConfirmation(action: 'Unpin' | 'Cancel'): Promise { // This involves business logic and should be moved to a composable } } ``` ### When Creating a Composable ```typescript // 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 ```typescript // ✅ 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 ```typescript // ✅ 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 ```typescript // ✅ 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 ```typescript // 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 ```typescript // 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 ```typescript // ✅ 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 { 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 ```typescript // 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 ```typescript // 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 ```typescript // BAD: Too specific async createAndSaveNewCredentialForNotionApi(apiKey: string) { // Too specific! Break it down } // GOOD: From CredentialsPage.ts - Reusable parts async openNewCredentialDialogFromCredentialList(credentialType: string): Promise 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 `BasePage` methods 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: ```typescript // 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); }); ```