A reusable base for unit- and end-to-end testing of Couchbase Eventing functions. The project provides two complementary test layers:
- Unit harness — a fast, deterministic layer that runs the real handler JavaScript inside a Node
vmsandbox with every Eventing built-in shimmed (bucket bindings, timers,couchbase.*, controllable clock). - E2E harness — deploys the real function to a throwaway Couchbase Enterprise container (via Testcontainers) and asserts timer-driven, cross-collection behavior reactively using Project Reactor's
StepVerifier.
Both layers treat the function definition JSON as the single source of truth for bindings, keyspaces, settings, and constants.
Couchbase Eventing has no first-party testing story. Handler JavaScript cannot be imported as a module (it expects Eventing globals in scope), so standard unit-test frameworks cannot load it directly. Timer-driven behavior is equally hard to test end-to-end — there is no change-stream API on the Java SDK to await a specific document state, and spinning up Eventing requires the Enterprise edition. This project solves both problems:
- Unit layer — the harness loads any handler
.js+ its.jsondefinition, injects the Eventing globals as shims, and lets tests control time precisely and fire timers on demand. No Couchbase installation, no Docker, sub-second runs. - E2E layer — one Enterprise container per JVM, created once and shared across all test classes. A Couchmove migration changelog creates the required collections and deploys the function from a build-time-generated
.eventingartifact (handler JS merged into the function definition JSON at Gradle build time). Tests poll affected collections reactively and assert withStepVerifier, with timeouts set well above the function's batching window.
functions/
<name>/
<name>.js # Handler source — the only place handler code lives
<name>.json # Function definition: bindings, keyspaces, settings, constants
# appcode is empty; the e2e build injects the JS at test time
unit/ # Node 20 + Vitest — fast, deterministic, no Docker required
harness/ # loadHandler + binding/timer/clock/couchbase shims
test/ # Per-function test files
e2e/ # Gradle + JUnit5 + Testcontainers + Couchmove + Reactor
build.gradle # generateEventingArtifact task; Docker/OrbStack system properties
src/test/
java/com/couchbase/eventing/
CouchbaseEventingTestBase.java # One container per JVM; Couchmove migration
ReactiveAwait.java # Reactive polling helper
*.java # Per-function test classes
For contributor and agent rules, see AGENTS.md.
| Concern | Requirement |
|---|---|
| Unit tests | Node 20+ |
| E2E tests | JDK 17+, Docker runtime (Docker Desktop / OrbStack / colima) |
| E2E Couchbase image | Enterprise edition — Community has no Eventing service |
The e2e suite pulls a multi-GB Couchbase Enterprise image on first run and is slow. Subsequent runs reuse the cached layer.
cd unit
npm install
npm testRun a single file:
cd unit
npx vitest run test/<filename>.test.jsFull suite:
cd e2e
./gradlew testSingle test class:
cd e2e
./gradlew test --tests '*PnrBufferE2ETest'OrbStack / docker-java compatibility: The test task sets -Dapi.version=1.41 automatically so docker-java negotiates a Docker Engine API version OrbStack accepts. Override with -Dapi.version=<version> if your Docker runtime requires a different value.
Override the Couchbase image:
cd e2e
./gradlew test -Dcouchbase.image=couchbase:enterprise-7.6.4e2e/src/test/resources/changelog/V2.0__deploy_pnr_buffer.eventing is generated and gitignored. Regenerate it manually with:
cd e2e
./gradlew generateEventingArtifactThis runs automatically as part of ./gradlew test.
loadHandler(jsPath, defJson, opts) (from unit/harness/loader.js) reads the handler .js and its .json definition, extracts constants from depcfg.constants, and creates a node:vm sandbox populated with every Eventing global:
| Global | Shim |
|---|---|
Bucket bindings (e.g. buffer, target) |
bindingProxy — a Proxy-backed Map; records every get/set/delete op |
createTimer / cancelTimer |
timers registry — stores scheduled timers; exposes fireTimer(ref) and fireDueTimers(nowMs) |
couchbase.mutateIn / MutateInSpec |
couchbaseShim — applies sub-doc ops directly to the binding's backing store |
log |
Captures all log calls into logs[] |
Date / Date.now() |
HarnessDate — delegates to a controllable clock; new Date(ms) keeps real behavior |
The binding aliases (e.g. buffer, target) come from the function definition's depcfg.buckets[].alias entries. A different function may use different alias names; loadHandler creates them generically from the definition.
Constants are injected into the sandbox by name (e.g. BATCH_WINDOW_SECONDS), so tests never hardcode them — they read from the definition file.
import { loadHandler } from '../harness/loader.js';
import { readFileSync } from 'node:fs';
const JS = '/path/to/functions/my_fn/my_fn.js';
const DEF = JSON.parse(readFileSync('/path/to/functions/my_fn/my_fn.json', 'utf8'));
// Optional: seed binding state and set the clock
const h = loadHandler(JS, DEF, {
bufferInit: { 'doc1': { someField: true } },
nowMs: 7000,
});
// Drive the handler
h.OnUpdate({ someField: true }, { id: 'doc1' });
// Inspect recorded ops
console.log(h.buffer.ops); // [{ type:'get', key:'doc1' }, ...]
console.log(h.timers.scheduled().size); // 1
// Advance time and fire a timer
h.clock.advance(5000);
h.timers.fireTimer('doc1');
// Assert final state
console.log(h.target.store.get('doc1')); // propagated doc
console.log(h.logs); // captured log callsCouchbaseEventingTestBase starts a single Couchbase Enterprise container (Eventing requires Enterprise — Community edition does not include it) with CouchbaseService.EVENTING enabled. The container is a JVM-level singleton; it is created once and shared across all test classes in the same Gradle test run.
At startup, a Couchmove migration runs the changelog/ classpath folder:
- A
.n1qlmigration creates the scopes and collections the function(s) need. - A
.eventingmigration (generated by the GradlegenerateEventingArtifacttask) imports and deploys the function — Couchmove setsdeployment_status: true.
The generateEventingArtifact Gradle task merges the handler .js into the function definition .json (populating appcode) and transforms the constants block to the format the Couchbase SDK's AsyncEventingFunctionManager.decodeFunction expects. Handler code therefore lives in exactly one file; the artifact is regenerated on every build and is gitignored.
ReactiveAwait.documentMatching(col, id, predicate) polls a collection every 250 ms and emits the first document matching predicate. Tests wire this into StepVerifier with a 30-second timeout (comfortably above the function's batch window).
Place the handler and its definition under functions/<name>/:
functions/
<name>/
<name>.js # Handler JavaScript (Eventing globals available as normal)
<name>.json # Function definition exported from the Couchbase UI
# Set appcode to ""; the build will inject it
The .json must include depcfg.buckets (the binding aliases your handler uses), depcfg.constants (any named constants), and keyspace/settings fields. It is the single source of truth shared by both test layers.
Create unit/test/<name>.test.js. Load the handler with:
const h = loadHandler('/abs/path/to/<name>.js', DEF, { nowMs: ..., bufferInit: ... });Drive h.OnUpdate / h.OnDelete, advance h.clock, fire timers via h.timers.fireTimer(ref) or h.timers.fireDueTimers(nowMs), and assert on h.buffer.store, h.target.store, h.buffer.ops, and h.logs.
Never use real setTimeout or sleep in unit tests — the harness clock and manual timer firing keep the suite deterministic and milliseconds-fast.
a. Collections — add a Couchmove changelog migration (.n1ql) under e2e/src/test/resources/changelog/ that creates the scopes and collections your function reads and writes.
b. Artifact generation — add a generateEventingArtifact-style Gradle task (or extend the existing one) that merges <name>.js into <name>.json and emits a V<n>__deploy_<name>.eventing changelog file.
c. Test class — extend CouchbaseEventingTestBase. Add Collection fields for the collections your function touches (retrieved from cluster.bucket(...) after Couchmove runs). Write tests that upsert/delete documents, then assert the effect with ReactiveAwait.documentMatching(...) wrapped in StepVerifier with a timeout above your function's batch window.
Current limitation: the
generateEventingArtifactGradle task and theV1.0__create_collections.n1qlchangelog are presently wired specifically forpnr_buffer. Generalizing them to generate and deploy multiple functions is a straightforward extension (separate tasks per function, separate collection migrations), but it has not been done yet.
Source: functions/pnr_buffer/pnr_buffer.js and functions/pnr_buffer/pnr_buffer.json
Purpose: Stages PNR (Passenger Name Record) updates in demo.apps.pnr_buffer. When a document arrives with refreshEndpoints === true (strict boolean), the handler schedules a timer that fires at the next rounded 5-second boundary (minimum 1 second out). When the timer fires (flushToTarget), it copies the staged document to demo.apps.pnr (the target collection) and uses a sub-document mutateIn to reset refreshEndpoints to false in the buffer, preventing self-triggering on the next DCP event.
Bindings: buffer (demo.apps.pnr_buffer, rw), target (demo.apps.pnr, rw)
Constants: BATCH_WINDOW_SECONDS = 5, MIN_TIMER_DELAY_MS = 1000
| Scenario | What it checks |
|---|---|
Boolean gate — false, missing, null |
No timer scheduled |
Boolean gate — strict === true |
String "true" rejected; only boolean true schedules |
_sync* doc ids |
Silently ignored; no timer |
Schedule with refreshEndpoints === true |
Timer keyed by meta.id; context.docId set; fireDate correct |
| Flush happy-path | Timer fires; doc copied to target; refreshEndpoints reset to false via mutateIn |
| Missing staged doc | Timer fires with no buffer doc present; logs "Cannot flush document"; target untouched |
mutateIn failure |
Copy still written to target; logs "Failed to update refreshEndpoints=false" |
OnDelete |
Cancels pending timer; deletes doc from target |
| Timer rounding — boundary edge cases | On a 5s boundary or within MIN_TIMER_DELAY_MS of one, falls back to now + MIN_TIMER_DELAY_MS |
| Reliability invariant — deterministic sweep | fireAt > now and (fireAt - now) >= MIN_TIMER_DELAY_MS for every ms in two windows |
| Reliability invariant — 5000 pseudo-random instants | Same invariant holds over large arbitrary timestamps |
| Scenario | What it checks |
|---|---|
| Source happy path | Doc with refreshEndpoints === true propagates to target within 30 s; buffer's flag resets to false |
| No-propagate | Doc with refreshEndpoints === false never appears in target (15-second quiet assertion) |
| Coalescing | Five rapid upserts to the same id; target receives a single flush with the latest content |
| Delete removes target | Buffer doc deleted after propagation; target document is subsequently removed by OnDelete |
| No-recursion storm | After flush, refreshEndpoints stays false over two observation windows; target CAS does not change |
| Timer-phase reliability | Writes at adversarial offsets across the 5 s window (4900 ms, 100 ms, 1200 ms, 2500 ms, 3700 ms, 4999 ms); flush always reaches target within 30 s |