Testing Helper Modules Locally
Language: JavaScript
This guide is the canonical reference for running module tests on a developer machine. It focuses on the operational how-to plus the why behind healthcheck and lifecycle rules. The journal of real failures that produced these rules lives in pitfalls.md → Local Module Testing - read that before assuming a "weird" failure is unique.
Companion docs.
../languages/js/module-testing.md- the strategy (testing tiers, badges, env vars, when to run what, module categories).../languages/js/unit-test-authoring.md- how to author a single unit test (rules, naming, assertions).../languages/js/integration-testing.md- integration testing against real cloud services.
Use this file for how to run the tests on your machine; use the strategy doc for which tier to run and why.
Prerequisites
- Docker Desktop running
~/.npmrcconfigured for GitHub Packages (seenpmrc-setup.md)GITHUB_READ_PACKAGES_TOKENset in your shell environment
export GITHUB_READ_PACKAGES_TOKEN=<your token>Running Tests
All module tests live in _test/ inside the module directory. Always run commands from _test/ -- never from the module root or the repo root.
Step 1: Install dependencies
Navigate to the module's _test/ directory and run:
npm install # from <module>/_testRun this every time before testing. The module itself is linked via file:../ so source changes are picked up automatically, but published peer dependencies may have been updated and npm install is the only safe way to refresh them. Skipping npm install is never worth the few seconds saved.
During module refactoring only: use
rm -rf node_modules package-lock.json && npm installinstead. Stale lock files from temporaryfile:swaps and version resets to1.0.0can cause silent wrong-version installs. Seepitfalls.mdentry 21.
Step 2: Run tests
npm testThat is all. For modules with Docker dependencies, npm test manages the full container lifecycle automatically via pretest and posttest:
pretest-- tears down any leftover container and starts a fresh one, waiting for the healthcheck to pass before proceedingtest-- runs the Node.js test suiteposttest-- tears down the container and removes its volumes
Never start Docker containers manually before running npm test. pretest runs docker compose down first; a manually started container will conflict on port allocation or be deleted mid-run.
Pre-Publish Checklist
Before bumping the version in package.json and pushing to main, both gates must pass locally. CI always runs lint before tests; skipping lint locally means a broken push, a second fix commit, and wasted pipeline time.
Gate 1: Lint (run from module root)
# From the module root
npm run lintMust exit 0 with no errors and no warnings. Common failures that only show up here and not in npm test:
- Trailing spaces or blank lines with whitespace (
no-trailing-spaces) - Stale
// eslint-disable-linedirectives on lines where ESLint no longer reports the suppressed rule (--report-unused-disable-directives) - Unused variables introduced during a refactor
Fix all lint issues before proceeding.
Gate 2: Tests (run from _test/)
# From the module's _test/ directory
npm install && npm testMust exit 0 with fail 0. See Running Tests above for the full contract.
Then commit
Only after both gates pass:
- Bump
versionin the module rootpackage.json(patch / minor / major as appropriate) git addonly the files belonging to the module being published- Commit and push - CI will detect the version change and publish
Never push a version bump with a lint failure. It wastes pipeline time and pollutes the git log with a follow-up fix commit.
The failure mode that produced this rule is journaled in pitfalls.md → CI/CD Publishing entry 13.
Test Patterns You'll Encounter
Most modules follow the Docker-backed pattern above. A few use other patterns that are equally valid -- knowing which is which prevents wasted debugging time.
Pattern: Real in-process server (Express adapter)
js-server-helper-http-gateway-adapter-express boots a real Express 5 server on a random free port (app.listen(0)) inside each describe block's before hook, exercises it with native fetch, and shuts it down in after. No Docker, no mocked req/res objects.
Why this matters: it caught Express 5 dropping the ? optional-parameter route syntax that would never surface against a mocked Express. When you see server-helper.js with startTestServer / makeRequest in an _test/ directory, this is the pattern.
Pattern: JSON event fixtures (AWS adapter)
js-server-helper-http-gateway-adapter-aws-apigateway runs against 23 real API Gateway v2.0 event JSONs stored in _test/fixtures/. Six are copied verbatim from aws/aws-lambda-go events/testdata (the AWS SDK's own test inputs); 17 are hand-written for scenarios AWS does not publish.
No Docker, no AWS SDK, no SAM, no LocalStack. Each fixture is loaded via fs.readFileSync and piped through the adapter. When you see _test/fixtures/*.json, this is the pattern. Adding a new scenario means adding a new JSON file -- no extra test boilerplate.
Pattern: In-process stub adapter (gateway module itself)
js-server-helper-http-gateway ships with an in-process stub adapter (_test/stub-adapter.js) that satisfies the 3-method adapter contract with fixed outputs. The stub is not a simulation of any real runtime -- it exists only to let the gateway exercise its own logic in isolation. Real-runtime coverage lives in the two adapter packages above.
Test Infrastructure Dependencies
Some modules require direct access to underlying SDK classes for test setup/teardown, even though the main module wraps these dependencies internally. This creates a dependency duplication pattern that must be documented to prevent accidental removal.
When Test Infrastructure Dependencies Are Needed
Pattern: Test needs direct access to SDK classes that the main module doesn't export
Common scenarios:
- Database table management -
CreateTableCommand,DeleteTableCommandfor test lifecycle - Cloud resource setup - Bucket creation, queue setup, or other infrastructure commands
- Service client mocking - Direct client instances for test doubles
Nomenclature Convention
Use descriptive devDependency names that make purpose obvious:
{
"devDependencies": {
"test-infra-aws-sdk": "npm:@aws-sdk/client-dynamodb@^1.2.3",
"test-infra-aws-sdk-s3": "npm:@aws-sdk/client-s3@^1.2.3",
"test-infra-aws-sdk-sqs": "npm:@aws-sdk/client-sqs@^1.2.3"
}
}Why this naming:
test-infra-prefix clearly indicates test infrastructure purpose- Prevents accidental removal during cleanup
- Makes version-matching requirements obvious
Version Matching Discipline
The test infrastructure dependency must stay version-matched with the main module's dependencies:
// In test.js - document the contract
// NOTE: This devDependency must stay version-matched with ../package.json
const { SomeCommand } = require('@aws-sdk/client-dynamodb');When upgrading the main module's SDK version, update the corresponding test infrastructure dependency simultaneously.
Extensible Pattern
The test-infra- prefix and naming convention apply to any SDK or service client, not only AWS. Add new entries using the same shape: test-infra-[provider]-[service].
Healthcheck Philosophy (Mandatory)
A healthcheck must verify the same level of readiness that the test code requires. Probing the process is not enough. Probing a public port is rarely enough. The probe must exercise the application path the test will use.
Each module's _test/docker-compose.yml defines its own healthcheck. docker compose up --wait only returns once the healthcheck passes, which guarantees the service is ready for real traffic before any test code runs.
Principles
Probe the application protocol, not the process.
java -version,process exists, orport openonly confirm the binary started -- not that it can serve requests. Usepg_isready,mongosh rs.status(), an HTTP request that returns a body, or a query against the test schema.Probe with the credentials and database the tests will use. A healthcheck that authenticates as
rootagainst a server that is still creatingtest_useris a false positive. Probe astest_useragainsttest_db. The check then implicitly confirms init has completed.Use TCP (
127.0.0.1), not the local socket (localhost). Tests connect from outside the container over TCP. A healthcheck that succeeds via Unix socket can pass while TCP listening is still being set up. Match the transport the tests use.The healthcheck must work on the slowest CI runner you target. Set
interval,timeout,retries, andstart_periodso the total budget covers a cold-pull, cold-start initialization on a constrained runner -- not just a warm Docker Desktop on the developer's laptop. A healthcheck that passes locally and times out in CI is the same as no healthcheck.--waitplus a real healthcheck is sufficient. Sleeps are not. If you find yourself adding&& sleep 5afterdocker compose up -d --wait, the healthcheck is wrong. Fix the healthcheck.
Why this matters: MySQL's two-phase init
The official mysql:8 image runs init in two phases:
- Start
mysqldin temporary mode -- onlyrootexists. - Apply config, create
test_user, createtest_db. - Stop and restart
mysqldin normal mode.
A healthcheck of mysqladmin ping -u root passes during step 1. --wait returns. Tests start connecting as test_user. Step 3 then drops every live connection with PROTOCOL_CONNECTION_LOST. The fix is to probe with test_user, which is created at the end of step 2 -- the check then succeeds only after init is genuinely complete.
This pattern (an artifact created late in init that the test relies on) generalizes. Every database image has a similar sequence somewhere; design the healthcheck around it.
Test Concurrency (Mandatory for Stateful Modules)
The Node.js built-in test runner runs top-level describe() blocks concurrently by default. Within a single test file, this means setup hooks (before/after) and tests from different describe blocks interleave -- including their first calls into the module under test.
For a module that uses lazy initialization (a connection pool, an AWS SDK client, a singleton adapter), concurrent first-calls race each other. The first parallel before may create a half-initialized resource that the second before then reuses incorrectly. Symptoms range from "test did not finish before its parent and was cancelled" to spurious connection errors.
Rule
For every module test file that exercises a module with any per-instance lazy state (DB pool, persistent client, reused SDK), wrap the entire test in a single outer describe with concurrency: false:
describe('ModuleName', { concurrency: false }, function () {
before(async function () {
// Setup that the lazy-init relies on
});
describe('feature one', function () {
it('...', function () { ... });
});
describe('feature two', function () {
it('...', function () { ... });
});
after(async function () {
// Teardown
});
});Stateless modules (pure functions, no shared resources) do not need this wrapper, but adding it is harmless and makes the file pattern uniform.
Known Failure Modes
Every symptom, root cause, and durable fix this testing setup has ever produced is journaled in pitfalls.md → Local Module Testing.
When you hit a new testing failure: reproduce it, confirm the root cause, then add an entry to pitfalls.md under Local Module Testing (Symptom → Cause → Lesson). Do not add it here. This file is for positive rules only. If the rule is small enough to live in AGENTS.md, recompile the compact one-liner into it (AGENTS.md is a derived artifact, never edited directly).
When something does not match this guide
Doc drift is the slowest bug to find. If you encounter a failure mode that this guide does not cover, do not "just fix it once". Add the entry to pitfalls.md → Local Module Testing in the same change that fixes the underlying problem, then recompile AGENTS.md so it reflects the new state.