test: Add workflow api testing for webhooks (#18400)

This commit is contained in:
shortstacked
2025-08-18 12:41:13 +01:00
committed by GitHub
parent 7c80086a6b
commit f769d5588e
7 changed files with 266 additions and 5 deletions

View File

@@ -35,8 +35,9 @@ Troubleshooting:
## 🏗️ Architecture Overview
Our test architecture follows a strict four-layer approach:
Our test architecture supports both UI-driven and API-driven testing:
### UI Testing (Four-Layer Approach)
```
Tests (*.spec.ts)
↓ uses
@@ -47,11 +48,19 @@ Page Objects (*Page.ts) - UI interactions
BasePage - Common utilities
```
### API Testing (Two-Layer Approach)
```
Tests (*.spec.ts)
↓ uses
API Services (ApiHelpers + specialized helpers)
```
### 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
- **API Services**: REST API interactions, workflow management
- **Tests**: Readable scenarios using composables or API services
---
@@ -293,6 +302,7 @@ export class ProjectComposer {
### When Writing Tests
#### UI Tests
```typescript
// ✅ GOOD: From 1-workflows.spec.ts
test('should create a new workflow using add workflow button', async ({ n8n }) => {
@@ -326,6 +336,29 @@ test('should enter debug mode for failed executions', async ({ n8n }) => {
});
```
#### API Tests
```typescript
// ✅ GOOD: API-driven workflow testing
test('should create workflow via API, activate it, trigger webhook externally @auth:owner', async ({ api }) => {
const workflowDefinition = JSON.parse(
readFileSync(resolveFromRoot('workflows', 'simple-webhook-test.json'), 'utf8'),
);
const createdWorkflow = await api.workflowApi.createWorkflow(workflowDefinition);
await api.workflowApi.setActive(createdWorkflow.id, true);
const testPayload = { message: 'Hello from Playwright test' };
const webhookResponse = await api.workflowApi.triggerWebhook('test-webhook', { data: testPayload });
expect(webhookResponse.ok()).toBe(true);
const execution = await api.workflowApi.waitForExecution(createdWorkflow.id, 10000);
expect(execution.status).toBe('success');
const executionDetails = await api.workflowApi.getExecution(execution.id);
expect(executionDetails.data).toContain('Hello from Playwright test');
});
```
---
## 🎯 Best Practices

View File

@@ -69,7 +69,7 @@ test('Performance under constraints @cloud:trial', async ({ n8n, api }) => {
- `base.ts`: Standard fixtures with worker-scoped containers
- `cloud-only.ts`: Cloud resource testing with test-scoped containers only
- **pages**: Page Object Models for UI interactions
- **services**: API helpers for E2E controller, REST calls, etc.
- **services**: API helpers for E2E controller, REST calls, workflow management, etc.
- **utils**: Utility functions (string manipulation, helpers, etc.)
- **workflows**: Test workflow JSON files for import/reuse

View File

@@ -146,8 +146,9 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
await use(n8nInstance);
},
api: async ({ context }, use) => {
api: async ({ context }, use, testInfo) => {
const api = new ApiHelpers(context.request);
await api.setupFromTags(testInfo.tags);
await use(api);
},

View File

@@ -9,6 +9,7 @@ import {
INSTANCE_ADMIN_CREDENTIALS,
} from '../config/test-users';
import { TestError } from '../Types';
import { WorkflowApiHelper } from './workflow-api-helper';
export interface LoginResponseData {
id: string;
@@ -30,10 +31,12 @@ const DB_TAGS = {
} as const;
export class ApiHelpers {
private request: APIRequestContext;
request: APIRequestContext;
workflowApi: WorkflowApiHelper;
constructor(requestContext: APIRequestContext) {
this.request = requestContext;
this.workflowApi = new WorkflowApiHelper(this);
}
// ===== MAIN SETUP METHODS =====

View File

@@ -0,0 +1,124 @@
import type { ApiHelpers } from './api-helper';
import { TestError } from '../Types';
export class WorkflowApiHelper {
constructor(private api: ApiHelpers) {}
async createWorkflow(workflow: object) {
const response = await this.api.request.post('/rest/workflows', { data: workflow });
if (!response.ok()) {
throw new TestError(`Failed to create workflow: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
async setActive(workflowId: string, active: boolean) {
const response = await this.api.request.patch(`/rest/workflows/${workflowId}`, {
data: { active },
});
if (!response.ok()) {
throw new TestError(
`Failed to ${active ? 'activate' : 'deactivate'} workflow: ${await response.text()}`,
);
}
}
async getExecutions(workflowId?: string, limit = 20) {
const params = new URLSearchParams();
if (workflowId) params.set('workflowId', workflowId);
params.set('limit', limit.toString());
const response = await this.api.request.get('/rest/executions', { params });
if (!response.ok()) {
throw new TestError(`Failed to get executions: ${await response.text()}`);
}
const result = await response.json();
if (Array.isArray(result)) return result;
if (result.data?.results) return result.data.results;
if (result.data) return result.data;
return [];
}
async getExecution(executionId: string) {
const response = await this.api.request.get(`/rest/executions/${executionId}`);
if (!response.ok()) {
throw new TestError(`Failed to get execution: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
async waitForExecution(workflowId: string, timeoutMs = 10000) {
const initialExecutions = await this.getExecutions(workflowId, 50);
const initialCount = initialExecutions.length;
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const executions = await this.getExecutions(workflowId, 50);
if (executions.length > initialCount) {
for (const execution of executions.slice(0, executions.length - initialCount)) {
if (execution.status === 'success' || execution.status === 'error') {
return execution;
}
}
}
for (const execution of executions) {
const isCompleted = execution.status === 'success' || execution.status === 'error';
if (isCompleted && execution.mode === 'webhook') {
const executionTime = new Date(execution.startedAt ?? execution.createdAt).getTime();
if (executionTime >= startTime - 5000) {
return execution;
}
}
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
throw new TestError(`Execution did not complete within ${timeoutMs}ms`);
}
async triggerWebhook(
path: string,
options: { method?: 'GET' | 'POST'; data?: object; params?: Record<string, string> } = {},
) {
const { method = 'POST', data, params } = options;
let url = `/webhook/${path}`;
if (params && Object.keys(params).length > 0) {
const searchParams = new URLSearchParams(params);
url += `?${searchParams.toString()}`;
}
const requestOptions: Record<string, unknown> = {
headers: { 'Content-Type': 'application/json' },
};
if (data && method === 'POST') {
requestOptions.data = data;
}
const response =
method === 'GET'
? await this.api.request.get(url)
: await this.api.request.post(url, requestOptions);
if (!response.ok()) {
throw new TestError(`Webhook trigger failed: ${await response.text()}`);
}
return response;
}
}

View File

@@ -0,0 +1,33 @@
import { readFileSync } from 'fs';
import { test, expect } from '../../fixtures/base';
import { resolveFromRoot } from '../../utils/path-helper';
test.describe('External Webhook Triggering @auth:owner', () => {
test('should create workflow via API, activate it, trigger webhook externally, and verify execution', async ({
api,
}) => {
const workflowDefinition = JSON.parse(
readFileSync(resolveFromRoot('workflows', 'simple-webhook-test.json'), 'utf8'),
);
const createdWorkflow = await api.workflowApi.createWorkflow(workflowDefinition);
expect(createdWorkflow.id).toBeDefined();
const workflowId = createdWorkflow.id;
await api.workflowApi.setActive(workflowId, true);
const testPayload = { message: 'Hello from Playwright test' };
const webhookResponse = await api.workflowApi.triggerWebhook('test-webhook', {
data: testPayload,
});
expect(webhookResponse.ok()).toBe(true);
const execution = await api.workflowApi.waitForExecution(workflowId, 10000);
expect(execution.status).toBe('success');
const executionDetails = await api.workflowApi.getExecution(execution.id);
expect(executionDetails.data).toContain('Hello from Playwright test');
});
});

View File

@@ -0,0 +1,67 @@
{
"name": "Simple Webhook Test",
"active": false,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "test-webhook",
"options": {}
},
"id": "webhook-trigger",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [300, 300],
"webhookId": "test-webhook-id"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "result-field",
"name": "result",
"value": "=Webhook received: {{ $json.body }}",
"type": "string"
},
{
"id": "timestamp-field",
"name": "timestamp",
"value": "={{ new Date().toISOString() }}",
"type": "string"
}
]
},
"options": {}
},
"id": "set-response",
"name": "Set Response",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [500, 300]
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Set Response",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"meta": null,
"pinData": {},
"versionId": null,
"triggerCount": 0,
"tags": []
}