runstack
v1.2.2
Published
End-to-end testing framework for container-based integration tests
Readme
RunStack
Write integration tests that run inside Docker containers with real dependencies.
RunStack lets you define test scenarios as TypeScript code, automatically builds Docker images, orchestrates dependent services (databases, caches, APIs), and runs your tests in isolated environments. It's designed to work with Node.js's built-in test runner.
Why RunStack?
Integration testing often requires external services—databases, message queues, or third-party APIs. Setting these up manually is tedious and error-prone. RunStack automates this by:
- Defining infrastructure as code — Your test setup lives in the same file as your tests
- Managing container lifecycles — Services start before tests, clean up after
- Handling dependencies — Peer containers start in order with health checks
- Integrating with
node:test— Familiar test syntax, no new test runner to learn
Runtime Support
runstack works with both Node.js (≥20) and Bun (≥1.0). The library automatically detects your runtime and uses the native test runner:
- Node.js: Uses the built-in
node:testmodule - Bun: Uses the built-in
bun:testmodule
All features work identically across both runtimes.
Installation
Node.js
npm install runstack --save-dev
# or
yarn add runstack --dev
# or
pnpm add runstack --save-devBun
bun add runstack --devRequirements:
- Node.js >= 24
- Docker installed and running
Quick Start
Create a test file and use the describeDockerTests function:
import { describeDockerTests, PeerContainer } from 'runstack';
function createPostgresPeer(): PeerContainer {
const host = 'postgres';
const user = 'testuser';
const password = 'testpass';
const database = 'testdb';
return {
id: host,
service: {
image: 'postgres:16-alpine',
environment: {
POSTGRES_USER: user,
POSTGRES_PASSWORD: password,
POSTGRES_DB: database
},
healthcheck: {
test: ['CMD-SHELL', `pg_isready -U ${user}`],
interval: '5s',
timeout: '5s',
retries: 5
}
},
injectEnvironment: {
PGHOST: host,
PGUSER: user,
PGPASSWORD: password,
PGDATABASE: database
}
};
}
describeDockerTests('Database Integration', (it) => {
it('can query the database', {
baseImage: 'postgres:16-alpine',
testScript: `
psql -c "SELECT version();"
`,
peerContainers: [createPostgresPeer()]
}, async (result) => {
if (!result.ok) {
throw new Error(`Test failed: ${result.error.message}`);
}
if (!result.output.includes('PostgreSQL')) {
throw new Error('Expected PostgreSQL version in output');
}
});
});The injectEnvironment option passes configuration from the peer container into your test container as environment variables. Here, psql automatically uses PGHOST, PGUSER, PGPASSWORD, and PGDATABASE—no command-line flags needed.
Run with Node's test runner:
node --test your-test-file.test.tsHow It Works
Each test scenario defines:
- A base image — The Docker image your test runs in
- A test script — Shell commands to execute
- Optional peer containers — Services your test depends on
- Optional files — Configuration or code to inject
RunStack generates Dockerfiles, builds images, starts services in dependency order, waits for health checks, runs your test, and cleans up afterward.
Core Concepts
Test Scenarios
A TestScenario configures the test environment:
interface TestScenario {
baseImage: string; // Docker image for the test container
testScript: string; // Shell script to execute
addlSteps?: string; // Extra Dockerfile instructions
files?: Files; // Files to inject into the container
peerContainers?: PeerContainer[]; // Supporting services
containerExtensions?: TestContainerExtension[]; // Build modifications
}Peer Containers
Peer containers are services your test interacts with—databases, caches, message queues, etc. The injectEnvironment option exposes connection details to your test script as environment variables.
function createRedisPeer(): PeerContainer {
const host = 'redis';
const port = '6379';
return {
id: host,
service: {
image: 'redis:7-alpine',
healthcheck: {
test: ['CMD', 'redis-cli', 'ping'],
interval: '5s',
timeout: '3s',
retries: 3
}
},
injectEnvironment: {
REDIS_HOST: host,
REDIS_PORT: port
}
};
}Containers start in the order they appear in the array. Each container waits for previous ones to be healthy before starting.
File Injection
Inject files at build time:
{
baseImage: 'alpine',
files: {
'schema.sql': 'CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT);',
'scripts/setup.sh': {
content: '#!/bin/bash\npsql -f /schema.sql',
chmod: '755'
}
},
testScript: './scripts/setup.sh'
}Custom Build Steps
Add Dockerfile instructions with addlSteps:
{
baseImage: 'node:22-alpine',
addlSteps: `
RUN apk add --no-cache postgresql-client
WORKDIR /app
COPY package*.json ./
RUN npm ci
`,
testScript: 'npm test'
}Global Dockerfile Steps
Configure steps that apply to all test containers using setGlobalDockerfileSteps. This is useful for setup that should be consistent across your entire test suite, such as installing CA certificates:
import { setGlobalDockerfileSteps, describeDockerTests } from 'runstack';
// Configure global steps once, typically in a test setup file
setGlobalDockerfileSteps(`
COPY certs/ca.crt /usr/local/share/ca-certificates/ca.crt
RUN update-ca-certificates && \\
openssl verify -CAfile /usr/local/share/ca-certificates/ca.crt /usr/local/share/ca-certificates/ca.crt
`);
// All tests will now include these steps
describeDockerTests('My Tests', (it) => {
it('connects via TLS', {
baseImage: 'node:20',
testScript: 'node test-tls-connection.js',
}, async (result) => {
// ...
});
});Global steps are inserted after the base debugging tools and before any per-test addlSteps. To reset global steps, pass an empty string:
setGlobalDockerfileSteps('');Container Extensions
For more advanced builds, container extensions let you add multi-stage build logic or modify the final image:
const testHelperExtension = {
id: 'test-helper',
stages: `
FROM node:22-alpine AS test-helper
RUN npm install -g jest
`,
finalSteps: `
COPY --from=test-helper /usr/local/bin/jest /usr/local/bin/jest
`,
environment: {
NODE_ENV: 'test'
}
};Examples
Testing Database Migrations
import { describeDockerTests, PeerContainer } from 'runstack';
function createPostgresPeer(): PeerContainer {
const host = 'postgres';
const user = 'testuser';
const password = 'testpass';
const database = 'testdb';
return {
id: host,
service: {
image: 'postgres:16-alpine',
environment: {
POSTGRES_USER: user,
POSTGRES_PASSWORD: password,
POSTGRES_DB: database
},
healthcheck: {
test: ['CMD-SHELL', `pg_isready -U ${user}`],
interval: '5s',
timeout: '5s',
retries: 5
}
},
injectEnvironment: {
PGHOST: host,
PGUSER: user,
PGPASSWORD: password,
PGDATABASE: database
}
};
}
describeDockerTests('Database Migrations', (it) => {
it('applies schema and seeds data', {
baseImage: 'postgres:16-alpine',
files: {
'schema.sql': `
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
`,
'seed.sql': `
INSERT INTO users (email) VALUES ('[email protected]'), ('[email protected]');
`
},
testScript: `
psql -f /schema.sql
psql -f /seed.sql
psql -c "SELECT COUNT(*) FROM users;" | grep -q "2"
`,
peerContainers: [createPostgresPeer()]
}, async (result) => {
if (!result.ok) throw new Error(result.error.message);
});
});Testing with Multiple Services
import { describeDockerTests, PeerContainer } from 'runstack';
function createRedisPeer(): PeerContainer {
const host = 'redis';
const port = '6379';
return {
id: host,
service: {
image: 'redis:7-alpine',
healthcheck: {
test: ['CMD', 'redis-cli', 'ping'],
interval: '3s',
timeout: '2s',
retries: 3
}
},
injectEnvironment: {
REDIS_HOST: host,
REDIS_PORT: port
}
};
}
function createPostgresPeer(): PeerContainer {
const host = 'postgres';
const user = 'testuser';
const password = 'testpass';
const database = 'testdb';
return {
id: host,
service: {
image: 'postgres:16-alpine',
environment: {
POSTGRES_USER: user,
POSTGRES_PASSWORD: password,
POSTGRES_DB: database
},
healthcheck: {
test: ['CMD-SHELL', `pg_isready -U ${user}`],
interval: '5s',
timeout: '5s',
retries: 5
}
},
injectEnvironment: {
PGHOST: host,
PGUSER: user,
PGPASSWORD: password,
PGDATABASE: database
}
};
}
describeDockerTests('Multi-Service Integration', (it) => {
it('verifies Redis and PostgreSQL are both accessible', {
baseImage: 'alpine',
addlSteps: `
RUN apk add --no-cache postgresql-client redis
`,
testScript: `
# Test Redis using injected environment variables
redis-cli -h $REDIS_HOST -p $REDIS_PORT PING | grep -q PONG
redis-cli -h $REDIS_HOST -p $REDIS_PORT SET test:key "hello"
redis-cli -h $REDIS_HOST -p $REDIS_PORT GET test:key | grep -q hello
# Test PostgreSQL (psql uses PG* env vars automatically)
psql -c "SELECT 1;" | grep -q "1"
echo "Both services are working"
`,
peerContainers: [createRedisPeer(), createPostgresPeer()]
}, async (result) => {
if (!result.ok) throw new Error(result.error.message);
if (!result.output.includes('Both services are working')) {
throw new Error('Expected success message');
}
});
});Skipping and Focusing Tests
Use .only and .skip just like you would with node:test:
describeDockerTests('Feature Tests', (it) => {
it.only('runs only this test', { /* ... */ }, async (result) => {});
it.skip('skips this test', { /* ... */ }, async (result) => {});
it('runs normally', { /* ... */ }, async (result) => {});
});Test Results
The assertion callback receives either an ExecResult (success) or ExecFailure (failure):
// Success
interface ExecResult {
ok: true;
output: string; // stdout and stderr interleaved
exitCode: number;
signal: NodeJS.Signals | null;
timedOut: false;
maxBufferExceeded: false;
}
// Failure
interface ExecFailure {
ok: false;
output: string; // captured output before failure
exitCode: number | null;
signal: NodeJS.Signals | null;
timedOut: boolean;
maxBufferExceeded: boolean;
error: Error;
}Debugging
Enable verbose output to see Docker Compose commands:
DEBUG=1 node --test your-test-file.test.tsClean up Docker images after tests complete:
DOCKER_IMAGE_CLEANUP=1 node --test your-test-file.test.tsTear down all containers after each individual test for full isolation (by default, containers are only torn down once at the end of the suite):
RUNSTACK_TEST_TEARDOWN=1 node --test your-test-file.test.tsMarkdown Test Loader
In addition to the TypeScript API, RunStack supports writing tests directly in Markdown files. This allows you to document your tests in a readable format while still executing them in Docker containers.
Why Markdown Tests?
- Documentation as tests — Tests serve as living documentation
- Readable format — Tests are easy to understand for non-developers
- Version control friendly — Diff-friendly format for code reviews
- IDE support — Full IntelliSense via
test-types.d.ts
Writing Markdown Tests
Markdown test files use the .test.md or .spec.md extension.
File Structure
---
baseImage: 'debian:bookworm-slim'
---
# Suite Title
## Describe Block (H2)
### Test Case (H3)
Optional description text here.
```bash
echo "Test script to execute"expect(result.ok).to.be.true;
expect(result.output).to.include('Test script');
#### Document-Level Frontmatter (YAML)
Frontmatter at the top of the file applies to all tests:
```markdown
---
baseImage: 'node:20-alpine'
addlSteps: 'RUN apk add --no-cache postgresql-client'
files:
'/config/app.json': '{"env": "test"}'
peers:
- id: 'postgres'
service:
image: 'postgres:16-alpine'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U testuser']
interval: '5s'
timeout: '5s'
retries: 5
injectEnvironment:
PGHOST: 'postgres'
PGUSER: 'testuser'
---Test-Level Frontmatter (YAML)
Each test case can override settings from the document-level frontmatter:
---
addlSteps: 'RUN apt-get install -y redis-tools'
files:
'/data/test.txt': 'test content'
---
### specific test case
```bash
cat /data/test.txt
```
```typescript
expect(result.output).to.include('test content');
```Supported Frontmatter Fields
| Field | Type | Description |
|-------|------|-------------|
| baseImage | string | Docker image for the test container (required at document level) |
| testScript | string | Inline test script (alternative to bash code block) |
| addlSteps | string | Additional Dockerfile instructions |
| files | object | Files to inject into the container |
| peers | PeerContainer[] | Peer container definitions |
| environment | object | Environment variables for the test container |
| containerExtensions | array | Container extension configurations |
| only | boolean | Run only this test (.only modifier) |
| skip | boolean | Skip this test (.skip modifier) |
Code Blocks
Each test case contains two types of code blocks:
- Bash code block (
```bash) — The test script to execute in the container - TypeScript code block (
```typescript) — Assertions using Chai'sexpect
Both blocks are optional (though at least one is required), but TypeScript assertions are recommended for proper validation.
Complete Markdown Test Example
---
baseImage: 'debian:bookworm-slim'
addlSteps: 'RUN apt-get update && apt-get install -y redis-tools'
peers:
- id: 'redis'
service:
image: 'redis:7-alpine'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: '3s'
timeout: '2s'
retries: 5
injectEnvironment:
REDIS_HOST: 'redis'
REDIS_PORT: '6379'
---
# Redis Integration Tests
## Connection Tests
### connects to Redis peer container
This test verifies basic connectivity to the Redis peer.
```bash
redis-cli -h $REDIS_HOST -p $REDIS_PORT PING
```
```typescript
expect(result.ok).to.be.true;
expect(result.exitCode).to.equal(0);
expect(result.output).to.include('PONG');
```
### sets and retrieves values
Tests basic key-value operations.
---
files:
'/scripts/test.sh':
content: |-
#!/bin/bash
redis-cli -h $REDIS_HOST SET mykey "hello"
redis-cli -h $REDIS_HOST GET mykey
chmod: '755'
---
```bash
/scripts/test.sh
```
```typescript
expect(result.ok).to.be.true;
expect(result.output).to.include('hello');
```
## Environment Tests
### injects environment variables
Verifies that environment variables from peer containers are available.
```bash
echo "Redis host: $REDIS_HOST"
echo "Redis port: $REDIS_PORT"
```
```typescript
expect(result.output).to.include('Redis host: redis');
expect(result.output).to.include('Redis port: 6379');
```Running Markdown Tests
Node.js
Use the --import flag with the runstack/register module:
# Run all markdown tests
node --import runstack/register --test tests/*.test.md
# Run a specific markdown test file
node --import runstack/register --test tests/integration.test.md
# Run with pattern matching
node --import runstack/register --test --test-name-pattern="connection" tests/*.test.mdBun
Running markdown tests with Bun requires two flags:
--preloadregisters the markdown loader plugin before the test runner starts--loader .md:jstells Bun's test discovery to treat.mdfiles as valid test files
# Run all markdown tests
bun test --preload ./node_modules/runstack/register-bun.mjs --loader .md:js tests/*.test.md
# Run a specific markdown test file
bun test --preload ./node_modules/runstack/register-bun.mjs --loader .md:js tests/integration.test.md
# Run with pattern matching
bun test --preload ./node_modules/runstack/register-bun.mjs --loader .md:js --test-name-pattern="connection" tests/*.test.mdNote: Both flags must come after
bun test. Placing flags beforetest(e.g.bun --import ... test) causes Bun to interprettestas a package.json script name rather than the built-in test runner.
TypeScript IntelliSense
RunStack provides TypeScript type definitions via test-types.d.ts. Reference this file in your project for IntelliSense support:
tsconfig.json:
{
"include": ["test-types.d.ts", "tests/**/*.ts", "tests/**/*.test.md"]
}This provides:
- Type definitions for
expect(Chai) - Type definitions for
result(ExecResult | ExecFailure) - Auto-completion and type checking in your TypeScript assertions
Markdown vs TypeScript API
| Feature | TypeScript API | Markdown |
|---------|---------------|----------|
| Flexibility | Full JavaScript/TypeScript | Declarative YAML + code blocks |
| IDE Support | Excellent | Good (via test-types.d.ts) |
| Documentation | Comments | Native Markdown |
| Peer containers | Full PeerContainer objects | YAML configuration |
| File injection | Full Files type | YAML configuration |
| Nested suites | describe() blocks | H2/H3 headers |
Choose Markdown for documentation-heavy tests and TypeScript for complex test logic.
API Reference
describeDockerTests(description, callback)
Creates a test suite with automatic container lifecycle management. The callback receives it and describe functions that work like their node:test counterparts, but accept a TestScenario as the second argument.
describeDockerTests('My Tests', (it, describe) => {
it('test name', scenario, assertions);
describe('nested suite', () => {
it('nested test', scenario, assertions);
});
});Low-Level Utilities
For advanced use cases, these functions are exported:
| Function | Description |
|----------|-------------|
| prepare(scenarios) | Build all test containers |
| runTestScript(scenarios, testName) | Execute a specific test |
| teardown(scenarios) | Clean up containers |
| makeDockerComposeConfig(scenarios, testName?) | Generate docker-compose YAML |
| getInlineDockerfile(...) | Generate Dockerfile content |
| setGlobalDockerfileSteps(steps) | Configure Dockerfile steps for all test containers |
| dedent(...), stripEmptyLines(...) | String utilities |
License
MIT
