| name | walkeros-testing-strategy |
| description | Use when writing tests, reviewing test code, or discussing testing approach for walkerOS packages. Covers env pattern, dev examples, and package-specific strategies. |
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' }],
});
Rule 2: Link Tests to dev Examples
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';
const testEnv = mockEnv(examples.env.push, interceptor);
expect(calls[0].args).toEqual(examples.events.ga4PageView());
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:
- Write failing test
- Verify it fails for expected reason (missing feature, not typo)
- Write minimal code to pass
- Verify it passes
- 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() {
}
}
Right:
export function cleanupSession(session: Session) { ... }
Rule 6: Verify Before Claiming Complete
"Should pass now" is not verification.
Process:
- Run the actual test command
- Read the output
- Confirm pass/fail count
- Only then claim status
When to Use Each Test Type
| Type | When to Add | Example |
|---|
| Integration | New usage pattern, new external API interaction, new data flow path | Collector → Destination → gtag() |
| Unit | Combinatorics, edge cases, pure function logic | Mapping variations, core utilities |
| Contract | Boundary validation | Destination 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:
walkeros push flow.json -e '{"entity":"page","action":"view"}' --simulate destination.ga4
walkeros push flow.json --simulate source.browser
walkeros push flow.json -e event.json --mock destination.ga4='{"status":"ok"}'
Programmatic usage:
import { push } from '@walkeros/cli';
const result = await push(
'flow.json',
{ entity: 'page', action: 'view' },
{
simulate: ['destination.ga4'],
},
);
const result = await push('flow.json', undefined, {
simulate: ['source.browser'],
});
Key points:
--simulate destination.X sets config.mock = {} on the target and
config.disabled = true on all other destinations
--simulate source.X wraps env.push with a capture function and disables
all destinations
- Destination
/dev env.push is auto-loaded to provide mock globals (fake
window.gtag, etc.)
- Returns
PushResult with result, captured (source), and usage
(destination)
- The
mockEnv() and env pattern examples above remain correct for unit testing
individual step functions directly
Package-Specific Approaches
| Package | Approach |
|---|
| core | Unit tests only - pure functions, no env needed |
| collector | Integration tests critical - input/output consistency is paramount |
| browser source | Maintain walker algorithm coverage |
| web destinations | Integration tests per unique pattern + unit tests for mappings, use env pattern |
| server destinations | Same as web destinations |
| cli/docker | Integration tests for spawn behavior, explore dev pattern to reduce duplication |
| sources | Contract 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:
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 });
});
await destination.push(event, { ...context, env: testEnv });
expect(calls).toContainEqual({
path: ['window', 'gtag'],
args: ['event', 'purchase', expect.objectContaining({ value: 99.99 })],
});
dev.ts Structure
Each package with external dependencies should have:
export * as schemas from './schemas';
export * as examples from './examples';
export * as env from './env';
export * as events from './events';
export * as mapping from './mapping';
export * as step from './step';
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';
const env: Env = {
window: {
performance: {
getEntriesByType: jest.fn().mockReturnValue([{ type: 'navigate' }]),
},
location: { href: 'https://test.com/' },
},
};
await createSessionSource(collector, undefined, env);
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):
cd /workspaces/developer/walkerOS
npm run verify:touched -- core
npm run verify:touched -- web-destination-gtag
git fetch origin main --depth=1
npm run verify:affected
npm run test:smoke
cd packages/<name> && npm run test -- path/to/file.test.ts
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.
Related Skills
Reference: