Skip to main content
Ask your AI

walkerOS Testing Strategy

Overview​

walkerOS uses a layered testing approach with built-in patterns for mocking and documentation sync. This skill ensures tests are reliable, efficient, and maintainable.

Core principle: Test real behavior using the env pattern, link to dev examples, verify before claiming complete.

The Rules​

Rule 1: Use env for Mocking, Not Jest​

walkerOS has a built-in dependency injection pattern via env in context. This is lighter than Jest mocks, enables documentation generation, and keeps tests in sync with examples.

Wrong:

jest.mock('../ga4', () => ({ initGA4: jest.fn() }));
expect(initGA4).toHaveBeenCalledWith(...);

Right:

import { examples } from '../dev';
import { mockEnv } from '@walkeros/core';

const calls: Array<{ path: string[]; args: unknown[] }> = [];
const testEnv = mockEnv(examples.env.push, (path, args) => {
calls.push({ path, args });
});

await destination.push(event, { ...context, env: testEnv });
expect(calls).toContainEqual({
path: ['window', 'gtag'],
args: ['event', 'page_view', { page_title: 'Home' }],
});

The dev.ts export provides examples.env, examples.events, examples.mapping, and examples.step. Using these in tests ensures documentation stays in sync.

import { examples } from '../dev';

// Use examples.env for mock environment
const testEnv = mockEnv(examples.env.push, interceptor);

// Assert against examples.events (documented expected output)
expect(calls[0].args).toEqual(examples.events.ga4PageView());

// Test with examples.mapping configurations
const config = { mapping: examples.mapping.ecommerce };

Step Examples with it.each​

Step examples (examples.step) provide { in, out } pairs for each step. Use it.each to iterate over them:

import { examples } from '../dev';

describe('step examples', () => {
it.each(Object.entries(examples.step))(
'%s',
async (name, { in: input, out: expected }) => {
const result = await step.push(input, context);
if (expected === false) {
expect(result).toBe(false);
} else {
expect(result).toEqual(expected);
}
},
);
});

See using-step-examples for the full lifecycle including the Three Type Zones and naming conventions.

Rule 3: Test Real Behavior, Not Mock Behavior​

If you're asserting that a mock was called, you're testing the mock works, not the code.

Red flags:

  • expect(mockFn).toHaveBeenCalled() without verifying the mock produces real effects
  • Assertions on *-mock test IDs
  • Tests that pass when mock is present, fail when removed

Fix: Test what the code actually does. If external APIs must be mocked, verify the real API would receive correct data.

Rule 4: Test First, Watch It Fail​

If you didn't see the test fail, you don't know it tests the right thing.

Process:

  1. Write failing test
  2. Verify it fails for expected reason (missing feature, not typo)
  3. Write minimal code to pass
  4. Verify it passes
  5. Refactor if needed

Red flags:

  • Test passes immediately when written
  • Can't explain why test failed
  • "I'll add tests later"

Rule 5: No Test-Only Methods in Production Code​

Production classes shouldn't have methods only tests use.

Wrong:

class Session {
destroy() {
/* only used in tests */
}
}

Right:

// In test-utils/
export function cleanupSession(session: Session) { ... }

Rule 6: Verify Before Claiming Complete​

"Should pass now" is not verification.

Process:

  1. Run the actual test command
  2. Read the output
  3. Confirm pass/fail count
  4. Only then claim status

When to Use Each Test Type​

TypeWhen to AddExample
IntegrationNew usage pattern, new external API interaction, new data flow pathCollector → Destination → gtag()
UnitCombinatorics, edge cases, pure function logicMapping variations, core utilities
ContractBoundary validationDestination output matches vendor API, source input validation

Guideline: Integration tests prove things work when stuck together. Unit tests efficiently cover variations. Contract tests catch API drift.

Simulation Testing​

Simulation testing uses the CLI push command with --simulate flags. The collector does not export a simulate() function — simulation is a CLI concern that maps to mock/disabled config properties at runtime.

CLI usage:

# Simulate a destination (mocks its push, captures API calls)
walkeros push flow.json -e '{"entity":"page","action":"view"}' --simulate destination.ga4

# Simulate a source (captures events it pushes, disables all destinations)
walkeros push flow.json --simulate source.browser

# Mock a destination with a specific return value
walkeros push flow.json -e event.json --mock destination.ga4='{"status":"ok"}'

Programmatic usage:

import { simulateDestination, simulateSource } from '@walkeros/cli';

// Simulate a destination: returns one Simulation.Result
const dest = await simulateDestination(
'flow.json',
{ name: 'page view' },
{ destinationId: 'ga4', ingest: { userAgent: 'Mozilla/5.0' } },
);
// dest.calls = recorded vendor calls ({ fn, args, ts })
// dest.mappingKey = matched rule, e.g. "page view"

// Simulate a source: input is { content, trigger? }
const src = await simulateSource(
'flow.json',
{ content: { name: 'page view' } },
{ sourceId: 'browser' },
);
// src.events = events captured from source env.push

simulateTransformer and simulateCollector follow the same shape. The CLI push --simulate path collects these results into PushResult.simulations and prints them with formatPushResult; programmatic push() does not simulate.

Key points:

  • --simulate destination.X starts only the target destination (no source, no other destination); the flow's transformers still start
  • --simulate source.X starts only that source and captures its events at the collector boundary; they reach no destination. A source package declaring examples.env.simulation (SQS, Pub/Sub pull) runs on its mock client, calls recorded
  • Simulate options: --consent (starting collector consent), --command (destination runs a collector command instead of a push), --page-url (web source page), --ingest (request context). Programmatic: simulateDestination(config, event, { destinationId, consent, command }), simulateSource(config, input, { sourceId, pageUrl })
  • Destination /dev env.push is auto-loaded to provide mock globals (fake window.gtag, a mock SDK client, a mock sendServer), resolved per export (exportExamples). Without one, simulate refuses a package destination before the flow starts instead of calling the real vendor (an inline code step has no package and runs as given). A flow store's mock env is injected when its package ships one; a store without one runs for real.
  • Returns PushResult with simulations (one Simulation.Result per simulated step: events, calls, mappingKey?, skipped?, error?; skipped says why a destination sent nothing: pending on its require, or a consent skip)
  • Trace-mode limit: simulate injects the mock env before init, so a client built in init is recorded. Runtime trace mode wraps the env per push only (collector/src/destination.ts), so calls through a client built in init are not recorded there.
  • The mockEnv() and env pattern examples above remain correct for unit testing individual step functions directly

Package-Specific Approaches​

PackageApproach
coreUnit tests only - pure functions, no env needed
collectorIntegration tests critical - input/output consistency is paramount
browser sourceMaintain walker algorithm coverage
web destinationsIntegration tests per unique pattern + unit tests for mappings, use env pattern
server destinationsSame as web destinations
cli/dockerIntegration tests for spawn behavior, explore dev pattern to reduce duplication
sourcesContract tests for input validation, integration tests for event capture

The env Pattern Deep Dive​

How env Works​

Each destination/source defines an env type that specifies external dependencies:

// Destination-specific env type
export interface Env extends DestinationWeb.Env {
window: {
gtag: Gtag.Gtag;
dataLayer: unknown[];
};
document: {
createElement: (tagName: string) => HTMLElement;
head: { appendChild: (node: unknown) => void };
};
}

mockEnv() Function​

The mockEnv() function from @walkeros/core creates a Proxy that intercepts all function calls:

import { mockEnv } from '@walkeros/core';

const calls: Array<{ path: string[]; args: unknown[] }> = [];
const testEnv = mockEnv(examples.env.push, (path, args) => {
calls.push({ path, args });
// Optionally return a value
});

// Now use testEnv in your destination context
await destination.push(event, { ...context, env: testEnv });

// Assert on captured calls
expect(calls).toContainEqual({
path: ['window', 'gtag'],
args: ['event', 'purchase', expect.objectContaining({ value: 99.99 })],
});

Rules for a package's env​

  • init keeps an injected dependency. Every external dependency (SDK client, constructor or module, fetch, sendServer, auth client) is reached through env, and init creates one only when none is injected: env?.x ?? (await createX(...)). Overwriting an injected client means tests and simulate hit the real vendor.
  • Every mock env key is read. Each key a package's examples.env.push declares must be read as env.<key> somewhere in the package's src; otherwise the mock is dead and the real module runs (jest may pass only through __mocks__).
  • simulation names the request call and is pinned with expectSimulationResolves(examples.env) from @walkeros/core/dev (see using-step-examples).

dev.ts Structure​

Each package with external dependencies should have:

// src/dev.ts
export * as schemas from './schemas';
export * as examples from './examples';

// src/examples/index.ts
export * as env from './env';
export * as events from './events';
export * as mapping from './mapping';
export * as step from './step'; // Step examples { in, out }

Testing Sources with Injected env​

Sources accept platform dependencies via env. Mock window, document, or library imports by passing them through env instead of mocking globals.

Type the mock against the source's own Env type, not against the global Window. A source narrows Env.window to only the members it touches, so the mock satisfies that narrowed shape directly with no as unknown as Window cast:

import type { Env } from '../types'; // the source's narrowed Env

// Instead of mocking window.performance globally:
const env: Env = {
window: {
performance: {
getEntriesByType: jest.fn().mockReturnValue([{ type: 'navigate' }]),
},
location: { href: 'https://test.com/' },
},
};

await createSessionSource(collector, undefined, env);

// Instead of mocking express import, type the binding against the source's
// injected dependency type so the mock is assignable without a cast:
import type { SourceEnv } from './types';

const express: SourceEnv['express'] = Object.assign(
jest.fn().mockReturnValue(mockApp),
{ json: jest.fn().mockReturnValue(middleware) },
);
await sourceExpress(createSourceContext({}, { express }));

This pattern avoids global state pollution between tests, enables simulation in non-browser environments, and stays cast-free because the source narrows its own Env to exactly the members it uses (the same declare-global + narrowed-Env approach destinations use — see create-destination §3.3.1).

Red Flags - Stop and Fix​

  • Using jest.mock() for internal modules when env pattern is available
  • Tests that don't import from ../dev
  • Assertions only checking mock call counts
  • Tests with extensive mock setup (>50% of test is setup)
  • Test-only methods added to production classes
  • Claiming tests pass without running them

Commands​

Verification tier (per /workspaces/developer/AGENT.md rule 11):

# L1, the default during a task: typecheck, lint, test for the touched package
cd /workspaces/developer/walkerOS
npm run verify:touched -- core
npm run verify:touched -- web-destination-gtag

# L2, at plan completion: only packages affected since origin/main
git fetch origin main --depth=1
npm run verify:affected

# L3, before pushing or marking PR-ready: critical path + affected
npm run test:smoke

# Single test file (still useful while iterating)
cd packages/<name> && npm run test -- path/to/file.test.ts

# Watch mode (single package)
cd packages/<name> && npm run test -- --watch

Avoid bare npm run test at root inside per-task steps. That is L4 (full suite, 10-15 min) and is reserved for plan completion when the plan touched shared infra, or for explicit user request.

Reading a Step Back from the Collector​

When a test calls a step's raw push directly through the collector bag (collector.sources.X.push, collector.destinations.X.push, etc.), use the typed accessors from @walkeros/core instead of casting:

import { Source } from '@walkeros/core';

const src = Source.getSource<TestSourceTypes>(collector, 'testSource');
await src.push({ method: 'GET', path: '/api/data' });

Available helpers: Source.getSource, Destination.getDestination, Transformer.getTransformer, Store.getStore. Each accepts an optional type parameter to recover the per-step generic that the bag's index signature erases on read. Each throws <Kind> not found: <id> when the id is unknown.

Do not write collector.sources.X.push as any or collector.sources.X.push as (rawData: ...) => Promise<...>. The accessor exists exactly to remove that boundary cast.

Destination Test Duplication, Pending Cleanup​

As of 2026-04-29, web and server destination tests share substantial scaffolding (init validation, missing-settings rejection, mock setup, push assertions) with no shared harness. Audit found roughly 1500-2500 lines of repetition across 47 destinations. A typed shared harness is planned as a follow-on initiative.

When writing a new destination test today:

  • Keep destination-specific behavior (mapping rules, vendor-specific batch shapes, edge cases) in the test file.
  • The boilerplate sections (env clone, jest.clearAllMocks, missing-required-settings, init returns valid contract) are candidates for extraction. Mirror the shape used by an existing destination of the same family (web vs server) so the future migration is mechanical.
  • Do NOT invent a new private harness inside the destination. If the missing harness blocks you, flag it.

Reference:

💡 Need implementation support?
elbwalker offers hands-on support: setup review, measurement planning, destination mapping, and live troubleshooting. Book a 2-hour session (€399)