Skip to content

Commit ce820fc

Browse files
authored
chore: Add mockserver for e2e testing (#19104)
1 parent e822cf5 commit ce820fc

File tree

12 files changed

+1028
-411
lines changed

12 files changed

+1028
-411
lines changed

packages/testing/containers/n8n-test-container-creation.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
setupRedis,
2323
setupCaddyLoadBalancer,
2424
pollContainerHttpEndpoint,
25+
setupProxyServer,
2526
} from './n8n-test-container-dependencies';
2627
import { createSilentLogConsumer } from './n8n-test-container-utils';
2728

@@ -31,6 +32,7 @@ const POSTGRES_IMAGE = 'postgres:16-alpine';
3132
const REDIS_IMAGE = 'redis:7-alpine';
3233
const CADDY_IMAGE = 'caddy:2-alpine';
3334
const N8N_E2E_IMAGE = 'n8nio/n8n:local';
35+
const MOCKSERVER_IMAGE = 'mockserver/mockserver:5.15.0';
3436

3537
// Default n8n image (can be overridden via N8N_DOCKER_IMAGE env var)
3638
const N8N_IMAGE = process.env.N8N_DOCKER_IMAGE ?? N8N_E2E_IMAGE;
@@ -78,6 +80,7 @@ export interface N8NConfig {
7880
memory?: number; // in GB
7981
cpu?: number; // in cores
8082
};
83+
proxyServerEnabled?: boolean;
8184
}
8285

8386
export interface N8NStack {
@@ -109,15 +112,22 @@ export interface N8NStack {
109112
* });
110113
*/
111114
export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack> {
112-
const { postgres = false, queueMode = false, env = {}, projectName, resourceQuota } = config;
115+
const {
116+
postgres = false,
117+
queueMode = false,
118+
env = {},
119+
proxyServerEnabled = false,
120+
projectName,
121+
resourceQuota,
122+
} = config;
113123
const queueConfig = normalizeQueueConfig(queueMode);
114124
const usePostgres = postgres || !!queueConfig;
115125
const uniqueProjectName = projectName ?? `n8n-stack-${Math.random().toString(36).substring(7)}`;
116126
const containers: StartedTestContainer[] = [];
117127

118128
const mainCount = queueConfig?.mains ?? 1;
119129
const needsLoadBalancer = mainCount > 1;
120-
const needsNetwork = usePostgres || !!queueConfig || needsLoadBalancer;
130+
const needsNetwork = usePostgres || !!queueConfig || needsLoadBalancer || proxyServerEnabled;
121131

122132
let network: StartedNetwork | undefined;
123133
if (needsNetwork) {
@@ -182,6 +192,31 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
182192
}
183193
}
184194

195+
if (proxyServerEnabled) {
196+
assert(network, 'Network should be created for ProxyServer');
197+
const hostname = 'proxyserver';
198+
const port = 1080;
199+
const url = `http://${hostname}:${port}`;
200+
const proxyServerContainer: StartedTestContainer = await setupProxyServer({
201+
proxyServerImage: MOCKSERVER_IMAGE,
202+
projectName: uniqueProjectName,
203+
network,
204+
hostname,
205+
port,
206+
});
207+
208+
containers.push(proxyServerContainer);
209+
210+
environment = {
211+
...environment,
212+
// Configure n8n to proxy all HTTP requests through ProxyServer
213+
HTTP_PROXY: url,
214+
HTTPS_PROXY: url,
215+
// Ensure https requests can be proxied without SSL issues
216+
...(proxyServerEnabled ? { NODE_TLS_REJECT_UNAUTHORIZED: '0' } : {}),
217+
};
218+
}
219+
185220
let baseUrl: string;
186221

187222
if (needsLoadBalancer) {

packages/testing/containers/n8n-test-container-dependencies.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,40 @@ export async function pollContainerHttpEndpoint(
316316
);
317317
}
318318

319+
export async function setupProxyServer({
320+
proxyServerImage,
321+
projectName,
322+
network,
323+
hostname,
324+
port,
325+
}: {
326+
proxyServerImage: string;
327+
projectName: string;
328+
network: StartedNetwork;
329+
hostname: string;
330+
port: number;
331+
}): Promise<StartedTestContainer> {
332+
const { consumer, throwWithLogs } = createSilentLogConsumer();
333+
334+
try {
335+
return await new GenericContainer(proxyServerImage)
336+
.withNetwork(network)
337+
.withNetworkAliases(hostname)
338+
.withExposedPorts(port)
339+
// Wait.forListeningPorts strategy did not work here for some reason
340+
.withWaitStrategy(Wait.forLogMessage(`INFO ${port} started on port: ${port}`))
341+
.withLabels({
342+
'com.docker.compose.project': projectName,
343+
'com.docker.compose.service': 'proxyserver',
344+
})
345+
.withName(`${projectName}-proxyserver`)
346+
.withReuse()
347+
.withLogConsumer(consumer)
348+
.start();
349+
} catch (error) {
350+
return throwWithLogs(error);
351+
}
352+
}
353+
319354
// TODO: Look at Ollama container?
320355
// TODO: Look at MariaDB container?
321-
// TODO: Look at MockServer container, could we use this for mocking out external services?

packages/testing/playwright/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ test('postgres only @mode:postgres', ...) // Mode-specific
3737
test('needs clean db @db:reset', ...) // Sequential per worker
3838
test('chaos test @mode:multi-main @chaostest', ...) // Isolated per worker
3939
test('cloud resource test @cloud:trial', ...) // Cloud resource constraints
40+
test('proxy test @capability:proxy', ...) // Requires proxy server capability
4041
```
4142

4243
## Fixture Selection
@@ -73,5 +74,48 @@ test('Performance under constraints @cloud:trial', async ({ n8n, api }) => {
7374
- **utils**: Utility functions (string manipulation, helpers, etc.)
7475
- **workflows**: Test workflow JSON files for import/reuse
7576

77+
## Writing Tests with Proxy
78+
79+
You can use ProxyServer to mock API requests.
80+
81+
```typescript
82+
import { test, expect } from '../fixtures/base';
83+
84+
// The `@capability:proxy` tag ensures tests only run when proxy infrastructure is available.
85+
test.describe('Proxy tests @capability:proxy', () => {
86+
test('should mock HTTP requests', async ({ proxyServer, n8n }) => {
87+
// Create mock expectations
88+
await proxyServer.createGetExpectation('/api/data', { result: 'mocked' });
89+
90+
// Execute workflow that makes HTTP requests
91+
await n8n.canvas.openNewWorkflow();
92+
// ... test implementation
93+
94+
// Verify requests were proxied
95+
expect(await proxyServer.wasGetRequestMade('/api/data')).toBe(true);
96+
});
97+
});
98+
```
99+
100+
### Recording and replaying requests
101+
102+
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).
103+
104+
```typescript
105+
// Record all requests
106+
await proxyServer.recordExpectations();
107+
108+
// Record requests with matching criteria
109+
await proxyServer.recordExpectations({
110+
method: 'POST',
111+
path: '/api/workflows',
112+
queryStringParameters: {
113+
'userId': ['123']
114+
}
115+
});
116+
```
117+
118+
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.
119+
76120
## Writing Tests
77121
For guidelines on writing new tests, see [CONTRIBUTING.md](./CONTRIBUTING.md).
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"httpRequest": {
3+
"method": "GET",
4+
"path": "/mock-endpoint"
5+
},
6+
"httpResponse": {
7+
"statusCode": 200,
8+
"headers": {
9+
"Content-Type": ["application/json"]
10+
},
11+
"body": {
12+
"userId": 1,
13+
"id": 1,
14+
"title": "delectus aut autem",
15+
"completed": false
16+
}
17+
},
18+
"id": "511d9c87-9ee3-4af8-8729-ca13b0a70e89",
19+
"priority": 0,
20+
"timeToLive": {
21+
"unlimited": true
22+
},
23+
"times": {
24+
"remainingTimes": 1
25+
}
26+
}

packages/testing/playwright/fixtures/base.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ContainerTestHelpers } from 'n8n-containers/n8n-test-container-helpers'
66
import { setupDefaultInterceptors } from '../config/intercepts';
77
import { n8nPage } from '../pages/n8nPage';
88
import { ApiHelpers } from '../services/api-helper';
9+
import { ProxyServer } from '../services/proxy-server';
910
import { TestError, type TestRequirements } from '../Types';
1011
import { setupTestRequirements } from '../utils/requirements';
1112

@@ -14,6 +15,7 @@ type TestFixtures = {
1415
api: ApiHelpers;
1516
baseURL: string;
1617
setupRequirements: (requirements: TestRequirements) => Promise<void>;
18+
proxyServer: ProxyServer;
1719
};
1820

1921
type WorkerFixtures = {
@@ -31,6 +33,7 @@ interface ContainerConfig {
3133
workers: number;
3234
};
3335
env?: Record<string, string>;
36+
proxyServerEnabled?: boolean;
3437
}
3538

3639
/**
@@ -158,6 +161,31 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
158161

159162
await use(setupFunction);
160163
},
164+
165+
proxyServer: async ({ n8nContainer }, use) => {
166+
// n8nContainer is "null" if running tests in "local" mode
167+
if (!n8nContainer) {
168+
throw new TestError(
169+
'Testing with Proxy server is not supported when using N8N_BASE_URL environment variable. Remove N8N_BASE_URL to use containerized testing.',
170+
);
171+
}
172+
173+
const proxyServerContainer = n8nContainer.containers.find((container) =>
174+
container.getName().endsWith('proxyserver'),
175+
);
176+
177+
// proxy server is not initialized in local mode (it be only supported in container modes)
178+
// tests that require proxy server should have "@capability:proxy" so that they are skipped in local mode
179+
if (!proxyServerContainer) {
180+
throw new TestError('Proxy server container not initialized. Cannot initialize client.');
181+
}
182+
183+
const serverUrl = `http://${proxyServerContainer?.getHost()}:${proxyServerContainer?.getFirstMappedPort()}`;
184+
const proxyServer = new ProxyServer(serverUrl);
185+
await proxyServer.loadExpectations();
186+
187+
await use(proxyServer);
188+
},
161189
});
162190

163191
export { expect };

packages/testing/playwright/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"n8n-workflow": "workspace:*",
3737
"nanoid": "catalog:",
3838
"tsx": "catalog:",
39+
"mockserver-client": "^5.15.0",
3940
"zod": "catalog:"
4041
}
4142
}

packages/testing/playwright/pages/CanvasPage.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Locator } from '@playwright/test';
22
import { nanoid } from 'nanoid';
33

44
import { BasePage } from './BasePage';
5+
import { ROUTES } from '../config/constants';
56
import { resolveFromRoot } from '../utils/path-helper';
67

78
export class CanvasPage extends BasePage {
@@ -494,14 +495,18 @@ export class CanvasPage extends BasePage {
494495
// Set fixed time using Playwright's clock API
495496
await this.page.clock.setFixedTime(timestamp);
496497

497-
await this.page.goto('/workflow/new');
498+
await this.openNewWorkflow();
498499
}
499500

500501
async addNodeWithSubItem(searchText: string, subItemText: string): Promise<void> {
501502
await this.addNode(searchText);
502503
await this.nodeCreatorSubItem(subItemText).click();
503504
}
504505

506+
async openNewWorkflow() {
507+
await this.page.goto(ROUTES.NEW_WORKFLOW_PAGE);
508+
}
509+
505510
getRagCalloutTip(): Locator {
506511
return this.page.getByText('Tip: Get a feel for vector stores in n8n with our');
507512
}

packages/testing/playwright/playwright-projects.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ const CONTAINER_ONLY = new RegExp(`@capability:(${CONTAINER_ONLY_TAGS.join('|')}
1111
// In local run they are a "dependency" which means they will be skipped if earlier tests fail, not ideal but needed for isolation
1212
const SERIAL_EXECUTION = /@db:reset/;
1313

14+
// Tags that require proxy server
15+
const REQUIRES_PROXY_SERVER = /@capability:proxy/;
16+
1417
const CONTAINER_CONFIGS: Array<{ name: string; config: N8NConfig }> = [
15-
{ name: 'standard', config: {} },
18+
{ name: 'standard', config: { proxyServerEnabled: true } },
1619
{ name: 'postgres', config: { postgres: true } },
1720
{ name: 'queue', config: { queueMode: true } },
1821
{ name: 'multi-main', config: { queueMode: { mains: 2, workers: 1 } } },
@@ -35,17 +38,23 @@ export function getProjects(): Project[] {
3538
name: 'ui:isolated',
3639
testDir: './tests/ui',
3740
grep: SERIAL_EXECUTION,
41+
grepInvert: REQUIRES_PROXY_SERVER,
3842
workers: 1,
3943
use: { baseURL: process.env.N8N_BASE_URL },
4044
},
4145
);
4246
} else {
4347
for (const { name, config } of CONTAINER_CONFIGS) {
48+
const grepInvertPatterns = [SERIAL_EXECUTION.source];
49+
if (!config.proxyServerEnabled) {
50+
grepInvertPatterns.push(REQUIRES_PROXY_SERVER.source);
51+
}
52+
4453
projects.push(
4554
{
4655
name: `${name}:ui`,
4756
testDir: './tests/ui',
48-
grepInvert: SERIAL_EXECUTION,
57+
grepInvert: new RegExp(grepInvertPatterns.join('|')),
4958
timeout: name === 'standard' ? 60000 : 180000, // 60 seconds for standard container test, 180 for containers to allow startup etc
5059
fullyParallel: true,
5160
use: { containerConfig: config },
@@ -54,6 +63,7 @@ export function getProjects(): Project[] {
5463
name: `${name}:ui:isolated`,
5564
testDir: './tests/ui',
5665
grep: SERIAL_EXECUTION,
66+
grepInvert: !config.proxyServerEnabled ? REQUIRES_PROXY_SERVER : undefined,
5767
workers: 1,
5868
use: { containerConfig: config },
5969
},

0 commit comments

Comments
 (0)