chore: Add mockserver for e2e testing (#19104)

This commit is contained in:
Mutasem Aldmour
2025-09-04 10:36:15 +02:00
committed by GitHub
parent e822cf58d0
commit ce820fc98c
12 changed files with 1027 additions and 410 deletions

View File

@@ -22,6 +22,7 @@ import {
setupRedis,
setupCaddyLoadBalancer,
pollContainerHttpEndpoint,
setupProxyServer,
} from './n8n-test-container-dependencies';
import { createSilentLogConsumer } from './n8n-test-container-utils';
@@ -31,6 +32,7 @@ const POSTGRES_IMAGE = 'postgres:16-alpine';
const REDIS_IMAGE = 'redis:7-alpine';
const CADDY_IMAGE = 'caddy:2-alpine';
const N8N_E2E_IMAGE = 'n8nio/n8n:local';
const MOCKSERVER_IMAGE = 'mockserver/mockserver:5.15.0';
// Default n8n image (can be overridden via N8N_DOCKER_IMAGE env var)
const N8N_IMAGE = process.env.N8N_DOCKER_IMAGE ?? N8N_E2E_IMAGE;
@@ -78,6 +80,7 @@ export interface N8NConfig {
memory?: number; // in GB
cpu?: number; // in cores
};
proxyServerEnabled?: boolean;
}
export interface N8NStack {
@@ -109,7 +112,14 @@ export interface N8NStack {
* });
*/
export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack> {
const { postgres = false, queueMode = false, env = {}, projectName, resourceQuota } = config;
const {
postgres = false,
queueMode = false,
env = {},
proxyServerEnabled = false,
projectName,
resourceQuota,
} = config;
const queueConfig = normalizeQueueConfig(queueMode);
const usePostgres = postgres || !!queueConfig;
const uniqueProjectName = projectName ?? `n8n-stack-${Math.random().toString(36).substring(7)}`;
@@ -117,7 +127,7 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
const mainCount = queueConfig?.mains ?? 1;
const needsLoadBalancer = mainCount > 1;
const needsNetwork = usePostgres || !!queueConfig || needsLoadBalancer;
const needsNetwork = usePostgres || !!queueConfig || needsLoadBalancer || proxyServerEnabled;
let network: StartedNetwork | undefined;
if (needsNetwork) {
@@ -182,6 +192,31 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
}
}
if (proxyServerEnabled) {
assert(network, 'Network should be created for ProxyServer');
const hostname = 'proxyserver';
const port = 1080;
const url = `http://${hostname}:${port}`;
const proxyServerContainer: StartedTestContainer = await setupProxyServer({
proxyServerImage: MOCKSERVER_IMAGE,
projectName: uniqueProjectName,
network,
hostname,
port,
});
containers.push(proxyServerContainer);
environment = {
...environment,
// Configure n8n to proxy all HTTP requests through ProxyServer
HTTP_PROXY: url,
HTTPS_PROXY: url,
// Ensure https requests can be proxied without SSL issues
...(proxyServerEnabled ? { NODE_TLS_REJECT_UNAUTHORIZED: '0' } : {}),
};
}
let baseUrl: string;
if (needsLoadBalancer) {

View File

@@ -316,6 +316,40 @@ export async function pollContainerHttpEndpoint(
);
}
export async function setupProxyServer({
proxyServerImage,
projectName,
network,
hostname,
port,
}: {
proxyServerImage: string;
projectName: string;
network: StartedNetwork;
hostname: string;
port: number;
}): Promise<StartedTestContainer> {
const { consumer, throwWithLogs } = createSilentLogConsumer();
try {
return await new GenericContainer(proxyServerImage)
.withNetwork(network)
.withNetworkAliases(hostname)
.withExposedPorts(port)
// Wait.forListeningPorts strategy did not work here for some reason
.withWaitStrategy(Wait.forLogMessage(`INFO ${port} started on port: ${port}`))
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'proxyserver',
})
.withName(`${projectName}-proxyserver`)
.withReuse()
.withLogConsumer(consumer)
.start();
} catch (error) {
return throwWithLogs(error);
}
}
// TODO: Look at Ollama container?
// TODO: Look at MariaDB container?
// TODO: Look at MockServer container, could we use this for mocking out external services?

View File

@@ -37,6 +37,7 @@ test('postgres only @mode:postgres', ...) // Mode-specific
test('needs clean db @db:reset', ...) // Sequential per worker
test('chaos test @mode:multi-main @chaostest', ...) // Isolated per worker
test('cloud resource test @cloud:trial', ...) // Cloud resource constraints
test('proxy test @capability:proxy', ...) // Requires proxy server capability
```
## Fixture Selection
@@ -73,5 +74,48 @@ test('Performance under constraints @cloud:trial', async ({ n8n, api }) => {
- **utils**: Utility functions (string manipulation, helpers, etc.)
- **workflows**: Test workflow JSON files for import/reuse
## Writing Tests with Proxy
You can use ProxyServer to mock API requests.
```typescript
import { test, expect } from '../fixtures/base';
// The `@capability:proxy` tag ensures tests only run when proxy infrastructure is available.
test.describe('Proxy tests @capability:proxy', () => {
test('should mock HTTP requests', async ({ proxyServer, n8n }) => {
// Create mock expectations
await proxyServer.createGetExpectation('/api/data', { result: 'mocked' });
// Execute workflow that makes HTTP requests
await n8n.canvas.openNewWorkflow();
// ... test implementation
// Verify requests were proxied
expect(await proxyServer.wasGetRequestMade('/api/data')).toBe(true);
});
});
```
### Recording and replaying requests
The ProxyServer service supports recording HTTP requests for test mocking and replay. All proxied requests are automatically recorded by the mock server as described in the [Mock Server documentation](https://www.mock-server.com/proxy/record_and_replay.html).
```typescript
// Record all requests
await proxyServer.recordExpectations();
// Record requests with matching criteria
await proxyServer.recordExpectations({
method: 'POST',
path: '/api/workflows',
queryStringParameters: {
'userId': ['123']
}
});
```
Recorded expectations are saved as JSON files in the `expectations/` directory with unique names based on the request details. When the ProxyServer fixture initializes, all saved expectations are automatically loaded and mocked for subsequent test runs.
## Writing Tests
For guidelines on writing new tests, see [CONTRIBUTING.md](./CONTRIBUTING.md).

View File

@@ -0,0 +1,26 @@
{
"httpRequest": {
"method": "GET",
"path": "/mock-endpoint"
},
"httpResponse": {
"statusCode": 200,
"headers": {
"Content-Type": ["application/json"]
},
"body": {
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
},
"id": "511d9c87-9ee3-4af8-8729-ca13b0a70e89",
"priority": 0,
"timeToLive": {
"unlimited": true
},
"times": {
"remainingTimes": 1
}
}

View File

@@ -6,6 +6,7 @@ import { ContainerTestHelpers } from 'n8n-containers/n8n-test-container-helpers'
import { setupDefaultInterceptors } from '../config/intercepts';
import { n8nPage } from '../pages/n8nPage';
import { ApiHelpers } from '../services/api-helper';
import { ProxyServer } from '../services/proxy-server';
import { TestError, type TestRequirements } from '../Types';
import { setupTestRequirements } from '../utils/requirements';
@@ -14,6 +15,7 @@ type TestFixtures = {
api: ApiHelpers;
baseURL: string;
setupRequirements: (requirements: TestRequirements) => Promise<void>;
proxyServer: ProxyServer;
};
type WorkerFixtures = {
@@ -31,6 +33,7 @@ interface ContainerConfig {
workers: number;
};
env?: Record<string, string>;
proxyServerEnabled?: boolean;
}
/**
@@ -158,6 +161,31 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
await use(setupFunction);
},
proxyServer: async ({ n8nContainer }, use) => {
// n8nContainer is "null" if running tests in "local" mode
if (!n8nContainer) {
throw new TestError(
'Testing with Proxy server is not supported when using N8N_BASE_URL environment variable. Remove N8N_BASE_URL to use containerized testing.',
);
}
const proxyServerContainer = n8nContainer.containers.find((container) =>
container.getName().endsWith('proxyserver'),
);
// proxy server is not initialized in local mode (it be only supported in container modes)
// tests that require proxy server should have "@capability:proxy" so that they are skipped in local mode
if (!proxyServerContainer) {
throw new TestError('Proxy server container not initialized. Cannot initialize client.');
}
const serverUrl = `http://${proxyServerContainer?.getHost()}:${proxyServerContainer?.getFirstMappedPort()}`;
const proxyServer = new ProxyServer(serverUrl);
await proxyServer.loadExpectations();
await use(proxyServer);
},
});
export { expect };

View File

@@ -36,6 +36,7 @@
"n8n-workflow": "workspace:*",
"nanoid": "catalog:",
"tsx": "catalog:",
"mockserver-client": "^5.15.0",
"zod": "catalog:"
}
}

View File

@@ -2,6 +2,7 @@ import type { Locator } from '@playwright/test';
import { nanoid } from 'nanoid';
import { BasePage } from './BasePage';
import { ROUTES } from '../config/constants';
import { resolveFromRoot } from '../utils/path-helper';
export class CanvasPage extends BasePage {
@@ -494,7 +495,7 @@ export class CanvasPage extends BasePage {
// Set fixed time using Playwright's clock API
await this.page.clock.setFixedTime(timestamp);
await this.page.goto('/workflow/new');
await this.openNewWorkflow();
}
async addNodeWithSubItem(searchText: string, subItemText: string): Promise<void> {
@@ -502,6 +503,10 @@ export class CanvasPage extends BasePage {
await this.nodeCreatorSubItem(subItemText).click();
}
async openNewWorkflow() {
await this.page.goto(ROUTES.NEW_WORKFLOW_PAGE);
}
getRagCalloutTip(): Locator {
return this.page.getByText('Tip: Get a feel for vector stores in n8n with our');
}

View File

@@ -11,8 +11,11 @@ const CONTAINER_ONLY = new RegExp(`@capability:(${CONTAINER_ONLY_TAGS.join('|')}
// In local run they are a "dependency" which means they will be skipped if earlier tests fail, not ideal but needed for isolation
const SERIAL_EXECUTION = /@db:reset/;
// Tags that require proxy server
const REQUIRES_PROXY_SERVER = /@capability:proxy/;
const CONTAINER_CONFIGS: Array<{ name: string; config: N8NConfig }> = [
{ name: 'standard', config: {} },
{ name: 'standard', config: { proxyServerEnabled: true } },
{ name: 'postgres', config: { postgres: true } },
{ name: 'queue', config: { queueMode: true } },
{ name: 'multi-main', config: { queueMode: { mains: 2, workers: 1 } } },
@@ -35,17 +38,23 @@ export function getProjects(): Project[] {
name: 'ui:isolated',
testDir: './tests/ui',
grep: SERIAL_EXECUTION,
grepInvert: REQUIRES_PROXY_SERVER,
workers: 1,
use: { baseURL: process.env.N8N_BASE_URL },
},
);
} else {
for (const { name, config } of CONTAINER_CONFIGS) {
const grepInvertPatterns = [SERIAL_EXECUTION.source];
if (!config.proxyServerEnabled) {
grepInvertPatterns.push(REQUIRES_PROXY_SERVER.source);
}
projects.push(
{
name: `${name}:ui`,
testDir: './tests/ui',
grepInvert: SERIAL_EXECUTION,
grepInvert: new RegExp(grepInvertPatterns.join('|')),
timeout: name === 'standard' ? 60000 : 180000, // 60 seconds for standard container test, 180 for containers to allow startup etc
fullyParallel: true,
use: { containerConfig: config },
@@ -54,6 +63,7 @@ export function getProjects(): Project[] {
name: `${name}:ui:isolated`,
testDir: './tests/ui',
grep: SERIAL_EXECUTION,
grepInvert: !config.proxyServerEnabled ? REQUIRES_PROXY_SERVER : undefined,
workers: 1,
use: { containerConfig: config },
},

View File

@@ -0,0 +1,239 @@
/**
* ProxyServer service helper functions for Playwright tests
*/
import crypto from 'crypto';
import { promises as fs } from 'fs';
import type { Expectation, HttpRequest } from 'mockserver-client';
import { mockServerClient as proxyServerClient } from 'mockserver-client';
import type { MockServerClient, RequestResponse } from 'mockserver-client/mockServerClient';
import { join } from 'path';
export interface ProxyServerRequest {
method: string;
path: string;
queryStringParameters?: Record<string, string[]>;
headers?: Record<string, string[]>;
body?: string | { type?: string; [key: string]: unknown };
}
export interface ProxyServerResponse {
statusCode: number;
headers?: Record<string, string[]>;
body?: string;
delay?: {
timeUnit: 'MICROSECONDS' | 'MILLISECONDS' | 'SECONDS' | 'MINUTES';
value: number;
};
}
export interface ProxyServerExpectation {
httpRequest: ProxyServerRequest;
httpResponse: ProxyServerResponse;
times?: {
remainingTimes?: number;
unlimited?: boolean;
};
}
export interface RequestLog {
method: string;
path: string;
headers: Record<string, string[]>;
queryStringParameters?: Record<string, string[]>;
body?: string;
timestamp: string;
}
export class ProxyServer {
private client: MockServerClient;
url: string;
private expectationsDir = './expectations';
/**
* Create a ProxyServer client instance from a URL
*/
constructor(proxyServerUrl: string) {
this.url = proxyServerUrl;
const parsedURL = new URL(proxyServerUrl);
this.client = proxyServerClient(parsedURL.hostname, parseInt(parsedURL.port, 10));
}
/**
* Load all expectations from the expectations directory and mock them
*/
async loadExpectations(): Promise<void> {
try {
const files = await fs.readdir(this.expectationsDir);
const jsonFiles = files.filter((file) => file.endsWith('.json'));
const expectations: Expectation[] = [];
for (const file of jsonFiles) {
try {
const filePath = join(this.expectationsDir, file);
const fileContent = await fs.readFile(filePath, 'utf8');
const expectation = JSON.parse(fileContent);
expectations.push(expectation);
} catch (parseError) {
console.log(`Error parsing expectation from ${file}:`, parseError);
}
}
if (expectations.length > 0) {
console.log('Loading expectations:', expectations.length);
await this.client.mockAnyResponse(expectations);
}
} catch (error) {
console.log('Error loading expectations:', error);
}
}
/**
* Create an expectation in ProxyServer
*/
async createExpectation(expectation: ProxyServerExpectation): Promise<RequestResponse> {
try {
return await this.client.mockAnyResponse({
httpRequest: expectation.httpRequest,
httpResponse: expectation.httpResponse,
times: expectation.times,
});
} catch (error) {
throw new Error(
`Failed to create expectation: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Verify that a request was received by ProxyServer
*/
async verifyRequest(request: ProxyServerRequest, numberOfRequests: number): Promise<boolean> {
try {
await this.client.verify(request, numberOfRequests);
return true;
} catch (error) {
return false;
}
}
/**
* Clear all expectations and logs from ProxyServer
*/
async clearProxyServer(): Promise<void> {
try {
await this.client.clear(null, 'ALL');
} catch (error) {
throw new Error(`Failed to clear ProxyServer: ${JSON.stringify(error)}`);
}
}
/**
* Create a simple GET request expectation with JSON response
*/
async createGetExpectation(
path: string,
responseBody: unknown,
queryParams?: Record<string, string>,
statusCode: number = 200,
): Promise<RequestResponse> {
const queryStringParameters = queryParams
? Object.entries(queryParams).reduce<Record<string, string[]>>((acc, [key, value]) => {
acc[key] = [value];
return acc;
}, {})
: undefined;
return await this.createExpectation({
httpRequest: {
method: 'GET',
path,
...(queryStringParameters && { queryStringParameters }),
},
httpResponse: {
statusCode,
headers: {
'Content-Type': ['application/json'],
},
body: JSON.stringify(responseBody),
},
});
}
/**
* Verify a GET request was made to ProxyServer
*/
async wasGetRequestMade(
path: string,
queryParams?: Record<string, string>,
numberOfRequests = 1,
): Promise<boolean> {
const queryStringParameters = queryParams
? Object.entries(queryParams).reduce<Record<string, string[]>>((acc, [key, value]) => {
acc[key] = [value];
return acc;
}, {})
: undefined;
return await this.verifyRequest(
{
method: 'GET',
path,
...(queryStringParameters && { queryStringParameters }),
},
numberOfRequests,
);
}
/**
* Retrieve recorded expectations and write to files
*/
async recordExpectations(request?: HttpRequest): Promise<void> {
try {
// Retrieve recorded expectations from the mock server
const recordedExpectations = await this.client.retrieveRecordedExpectations(request);
// Ensure expectations directory exists
await fs.mkdir(this.expectationsDir, { recursive: true });
for (const expectation of recordedExpectations) {
if (
!expectation.httpRequest ||
!(
'method' in expectation.httpRequest &&
typeof expectation.httpRequest.method === 'string' &&
typeof expectation.httpRequest.path === 'string'
)
) {
continue;
}
// Generate unique filename based on request details
const requestData = {
method: expectation.httpRequest?.method,
path: expectation.httpRequest?.path,
queryStringParameters: expectation.httpRequest?.queryStringParameters,
headers: expectation.httpRequest?.headers,
};
const hash = crypto
.createHash('sha256')
.update(JSON.stringify(requestData))
.digest('hex')
.substring(0, 8);
const filename = `${expectation.httpRequest?.method?.toString()}-${expectation.httpRequest?.path?.replace(/[^a-zA-Z0-9]/g, '_')}-${hash}.json`;
const filePath = join(this.expectationsDir, filename);
// Write expectation to JSON file
await fs.writeFile(filePath, JSON.stringify(expectation, null, 2));
}
} catch (error) {
throw new Error(`Failed to record expectations: ${JSON.stringify(error)}`);
}
}
async getActiveExpectations() {
return await this.client.retrieveActiveExpectations({ method: 'GET' });
}
}

View File

@@ -0,0 +1,67 @@
import assert from 'node:assert';
import { test, expect } from '../../fixtures/base';
// @capability:proxy tag ensures that test suite is only run when proxy is available
test.describe('Proxy server @capability:proxy', () => {
test('should verify ProxyServer container is running', async ({ proxyServer }) => {
const mockResponse = await proxyServer.createGetExpectation('/health', {
status: 'healthy',
});
assert(typeof mockResponse !== 'string');
expect(mockResponse.statusCode).toBe(201);
expect(await proxyServer.wasGetRequestMade('/health')).toBe(false);
// Verify the mock endpoint works
const healthResponse = await fetch(`${proxyServer.url}/health`);
expect(healthResponse.ok).toBe(true);
const healthData = await healthResponse.json();
expect(healthData.status).toBe('healthy');
expect(await proxyServer.wasGetRequestMade('/health')).toBe(true);
});
test('should run a simple workflow calling http endpoint', async ({ n8n, proxyServer }) => {
const mockResponse = { data: 'Hello from ProxyServer!', test: '1' };
// Create expectation in mockserver to handle the request
await proxyServer.createGetExpectation('/data', mockResponse, { test: '1' });
await n8n.canvas.openNewWorkflow();
// This is calling a random endpoint http://mock-api.com
await n8n.canvas.importWorkflow('Simple_workflow_with_http_node.json', 'Test');
// Execute workflow - this should now proxy through mockserver
await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Successful');
await n8n.canvas.openNode('HTTP Request');
await expect(n8n.ndv.getOutputTbodyCell(0, 0)).toContainText('Hello from ProxyServer!');
// Verify the request was handled by mockserver
expect(
await proxyServer.wasGetRequestMade('/data', {
test: '1',
}),
).toBe(true);
});
test('should use stored expectations respond to api request', async ({ proxyServer }) => {
const response = await fetch(`${proxyServer.url}/mock-endpoint`);
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.title).toBe('delectus aut autem');
expect(await proxyServer.wasGetRequestMade('/mock-endpoint')).toBe(true);
});
test('should run a simple workflow proxying HTTPS request', async ({ n8n }) => {
await n8n.canvas.openNewWorkflow();
await n8n.canvas.importWorkflow('Simple_workflow_with_http_node.json', 'Test');
await n8n.canvas.openNode('HTTP Request');
await n8n.ndv.setParameterInput('url', 'https://jsonplaceholder.typicode.com/todos/1');
await n8n.ndv.execute();
await expect(n8n.ndv.getOutputTbodyCell(0, 0)).toContainText('1');
});
});

View File

@@ -0,0 +1,51 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-688, 272],
"id": "2edde9d2-09a3-46dd-8d56-19a0c92654fc",
"name": "When clicking Execute workflow"
},
{
"parameters": {
"url": "http://mock-api.com/data",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "test",
"value": "1"
}
]
},
"options": {
"allowUnauthorizedCerts": true
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [-480, 272],
"id": "99de0e7f-20d1-441e-8e8b-2f3cabcacb0c",
"name": "HTTP Request"
}
],
"connections": {
"When clicking Execute workflow": {
"main": [
[
{
"node": "HTTP Request",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"meta": {
"instanceId": "cb484ba7b742928a2048bf8829668bed5b5ad9787579adea888f05980292a4a7"
}
}

885
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff