# n8n Test Containers A composable container stack for n8n testing. Describe what you need, it builds the environment. ## Quick Start ```bash #build the container pnpm build:docker ``` alternatively, you can set `N8N_DOCKER_IMAGE=n8nio/n8n:latest` ```bash # Basic n8n (SQLite) pnpm stack # With PostgreSQL pnpm stack --postgres # Queue mode (Redis + PostgreSQL + worker) pnpm stack --queue # Multi-main cluster pnpm stack --mains 2 --workers 1 # Cloud plan simulation pnpm stack --plan starter # Public tunnel for webhook testing pnpm stack --tunnel ``` When started, you'll see the URL: `http://localhost:[port]` ## Using in Playwright Tests ### Basic Test ```typescript import { test, expect } from '../fixtures/base'; test('my test', async ({ n8n }) => { await n8n.page.goto('/workflow/new'); // ... }); ``` ### Enabling Services Use `test.use()` to request services: ```typescript // Single service test.use({ capability: { services: ['mailpit'], }, }); // Multiple services test.use({ capability: { services: ['mailpit', 'keycloak', 'victoriaLogs', 'victoriaMetrics', 'vector'], }, }); // Queue mode with services test.use({ capability: { mains: 2, workers: 1, services: ['victoriaLogs', 'victoriaMetrics', 'vector'], }, }); ``` ### Using Service Helpers Services provide type-safe helpers via `n8nContainer.services.*`: ```typescript test('email test', async ({ n8nContainer }) => { // Wait for email const email = await n8nContainer.services.mailpit.waitForMessage({ to: 'test@example.com', }); expect(email.subject).toBe('Welcome'); }); test('source control', async ({ n8nContainer }) => { // Create git repo const repo = await n8nContainer.services.gitea.createRepo('my-repo'); await repo.createBranch('develop'); }); test('metrics', async ({ n8nContainer }) => { // Query Prometheus metrics const result = await n8nContainer.services.observability.metrics.query('up'); expect(result[0].value).toBe(1); }); ``` ### Capability Shortcuts Common combinations have shortcuts in `fixtures/capabilities.ts`: ```typescript // Instead of: { services: ['mailpit'] } test.use({ capability: 'email' }); // Instead of: { services: ['keycloak'] } test.use({ capability: 'oidc' }); // Instead of: { services: ['gitea'] } test.use({ capability: 'source-control' }); ``` ## Architecture ``` ┌─────────────────────────────────────────────────────────────┐ │ Test Code │ │ n8nContainer.services.gitea.createRepo('my-repo') │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ N8NStack │ │ services: ServiceHelpers ← Proxy with lazy instantiation │ │ baseUrl, stop(), findContainers() │ └─────────────────────────────────────────────────────────────┘ │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ GiteaHelper │ │MailpitHelper│ │ Observability│ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ ▼ ▼ ▼ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Container │ │ Container │ │ Container │ └─────────────┘ └─────────────┘ └─────────────┘ ``` ### Key Concepts | Concept | Description | |---------|-------------| | **Service** | Container definition with `start()`, optional `env()`, optional helper | | **Registry** | Central manifest of all services (`services/registry.ts`) | | **Stack** | Orchestrator that builds the environment from config | | **Helper** | Type-safe API for interacting with a service in tests | ### Service Activation Services activate in two ways: | Mode | When | Example | |------|------|---------| | **Auto-start** | Service has `shouldStart()` returning true | Redis auto-starts in queue mode | | **User-enabled** | Listed in `services: []` array | `services: ['mailpit']` | ## Adding a New Service ### Do I Need a Helper? Helpers let tests interact with a service **outside of the n8n UI**. Ask yourself: > "Will tests need to arrange or assert data in this service directly?" | Scenario | Helper Needed? | Example | |----------|---------------|---------| | **Test arrangement** - Set up data before test | Yes | Create a git repo before testing source control sync | | **Test assertion** - Verify side effects | Yes | Check an email was sent after workflow execution | | **Infrastructure only** - n8n connects, tests don't | No | PostgreSQL, Redis - n8n uses them, tests don't touch them | | **Observability** - Query metrics/logs | Yes | Assert memory usage, check for error logs | **Examples:** ```typescript // Mailpit helper - ARRANGE: no emails exist, ASSERT: email was sent const emails = await n8nContainer.services.mailpit.getMessages(); expect(emails).toHaveLength(1); // Gitea helper - ARRANGE: create repo before test const repo = await n8nContainer.services.gitea.createRepo('test-repo'); // Now test source control connection via UI // Observability helper - ASSERT: check metrics after load test const memory = await n8nContainer.services.observability.metrics.query('process_resident_memory_bytes'); expect(memory[0].value).toBeLessThan(500_000_000); // Redis/Postgres - no helper needed, n8n connects automatically // Tests don't need to interact with these directly ``` **Rule of thumb:** If you'd otherwise need `docker exec` or raw HTTP calls in your test, you need a helper. ### Minimal Service (No Helper) **1. Create `services/my-service.ts`:** ```typescript import { GenericContainer, Wait } from 'testcontainers'; import type { Service, ServiceResult } from './types'; const HOSTNAME = 'myservice'; const PORT = 8080; export interface MyServiceMeta { host: string; port: number; } export type MyServiceResult = ServiceResult; export const myService: Service = { description: 'My service description', async start(network, projectName) { const container = await new GenericContainer('myimage:latest') .withNetwork(network) .withNetworkAliases(HOSTNAME) .withExposedPorts(PORT) .withWaitStrategy(Wait.forListeningPorts()) .withLabels({ 'com.docker.compose.project': projectName, 'com.docker.compose.service': HOSTNAME, }) .withName(`${projectName}-${HOSTNAME}`) .withReuse() .start(); return { container, meta: { host: HOSTNAME, port: PORT }, }; }, // Optional: env vars for n8n env(result) { return { MY_SERVICE_HOST: result.meta.host, MY_SERVICE_PORT: String(result.meta.port), }; }, }; ``` **2. Register in `services/types.ts` and `services/registry.ts`:** ```typescript // types.ts - add to SERVICE_NAMES array export const SERVICE_NAMES = [ // ...existing 'myService', ] as const; // registry.ts - add to services object import { myService } from './my-service'; export const services: Record> = { // ...existing myService, }; ``` **Done.** Use with `services: ['myService']` in tests. > **Note:** The `ServiceName` type is derived from `SERVICE_NAMES`, and `Record` ensures the registry includes all services. TypeScript will error if they're out of sync. ### Service With Helper Add a helper class and factory to the service file: ```typescript // ... service definition from above ... // Helper class export class MyServiceHelper { constructor( private readonly container: StartedTestContainer, private readonly meta: MyServiceMeta, ) {} async doSomething(): Promise { // Interact with the service const response = await fetch(`http://${this.container.getHost()}:${this.container.getMappedPort(PORT)}/api`); return response.text(); } } // Factory function export function createMyServiceHelper(ctx: HelperContext): MyServiceHelper { const result = ctx.serviceResults.myService; if (!result) { throw new Error('MyService not running. Add services: ["myService"] to test.use()'); } return new MyServiceHelper(result.container, result.meta as MyServiceMeta); } // Type registration (enables autocomplete) declare module './types' { interface ServiceHelpers { myService: MyServiceHelper; } } ``` **Register in `services/types.ts` and `services/registry.ts`:** ```typescript // types.ts - add to SERVICE_NAMES array export const SERVICE_NAMES = [ // ...existing 'myService', ] as const; // registry.ts - add service and helper factory import { myService, createMyServiceHelper } from './my-service'; export const services = { ...existing, myService }; export const helperFactories = { ...existing, myService: createMyServiceHelper }; ``` **Use in tests:** ```typescript test('my test', async ({ n8nContainer }) => { const result = await n8nContainer.services.myService.doSomething(); }); ``` ### Optional: Add Capability Shortcut In `fixtures/capabilities.ts`: ```typescript export const CAPABILITIES = { // ...existing 'my-capability': { services: ['myService'] }, }; ``` Now usable as `test.use({ capability: 'my-capability' })`. ## Available Services | Service | Helper | Description | |---------|--------|-------------| | `postgres` | - | PostgreSQL database | | `redis` | - | Redis for queue mode | | `mailpit` | ✓ | Email testing (SMTP + UI) | | `gitea` | ✓ | Git server for source control | | `keycloak` | ✓ | OIDC/SSO provider | | `victoriaLogs` | - | VictoriaLogs for log storage | | `victoriaMetrics` | - | VictoriaMetrics for metrics | | `vector` | - | Vector log collector (depends on victoriaLogs) | | `tracing` | ✓ | Jaeger for distributed tracing | | `kafka` | ✓ | Kafka broker for message queue testing | | `proxy` | - | HTTP proxy (MockServer) | | `taskRunner` | - | External task runner | | `loadBalancer` | - | Caddy for multi-main | | `cloudflared` | - | Cloudflare Tunnel for public webhook URLs | **Note:** For observability (logs + metrics), enable all three: `['victoriaLogs', 'victoriaMetrics', 'vector']`. The `observability` capability shortcut handles this automatically: `test.use({ capability: 'observability' })`. ## CLI Options | Option | Description | |--------|-------------| | `--postgres` | Use PostgreSQL instead of SQLite | | `--queue` | Enable queue mode (adds Redis + PostgreSQL) | | `--mains ` | Number of main instances | | `--workers ` | Number of worker instances | | `--plan ` | Cloud plan preset (trial, starter, pro-1, pro-2, enterprise) | | `--name ` | Custom project name for parallel runs | | `--env KEY=VALUE` | Set environment variables | | `--observability` | Enable metrics/logs stack | | `--tracing` | Enable tracing stack (Jaeger) | | `--tunnel` | Enable Cloudflare Tunnel for public webhook URLs | | `--oidc` | Enable Keycloak | | `--source-control` | Enable Gitea | | `--mailpit` | Enable email testing (Mailpit) | ## Telemetry Container stack telemetry tracks startup timing, configuration, and runner info. Useful for monitoring CI performance and debugging slow stacks. ### Environment Variables | Variable | Description | |----------|-------------| | `QA_METRICS_WEBHOOK_URL` | POST telemetry JSON to this URL | | `CONTAINER_TELEMETRY_VERBOSE` | Set to `1` for JSON output to console | ### What's Collected ```typescript { timestamp: string; // ISO timestamp git: { sha, branch, pr? }; // Git context from CI env vars ci: { runId, job, workflow }; // GitHub Actions context runner: { provider, cpuCores, memoryGb }; // github | blacksmith | local stack: { type, mains, workers, postgres, services }; timing: { total, network, n8nStartup, services: Record }; containers: { total, services, n8n }; success: boolean; errorMessage?: string; } ``` ### Usage ```bash # Verbose output locally CONTAINER_TELEMETRY_VERBOSE=1 pnpm stack # Send to webhook (CI) QA_METRICS_WEBHOOK_URL=https://n8n.example.com/webhook/telemetry ``` ## Cleanup ```bash # Remove all containers and networks pnpm stack:clean:all ``` ## Tips - **Container Reuse**: Set `TESTCONTAINERS_REUSE_ENABLE=true` for faster restarts - **Parallel Testing**: Use `--name` to run multiple stacks without conflicts - **Custom Image**: Set `TEST_IMAGE_N8N=n8nio/n8n:dev` to use a different image - **Multi-Main**: Requires queue mode and license key in `N8N_LICENSE_ACTIVATION_KEY` - **Using podman**: This does not work with podman out of the box - you need to ensure testcontainers is set correctly [https://podman-desktop.io/tutorial/testcontainers-with-podman](https://podman-desktop.io/tutorial/testcontainers-with-podman)