Skip to content

couchbaselabs/eventing-testkit

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

16 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

eventing-testkit

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 vm sandbox 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.

Why it exists

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 .json definition, 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 .eventing artifact (handler JS merged into the function definition JSON at Gradle build time). Tests poll affected collections reactively and assert with StepVerifier, with timeouts set well above the function's batching window.

Repository layout

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.

Prerequisites

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.

Running the tests

Unit tests

cd unit
npm install
npm test

Run a single file:

cd unit
npx vitest run test/<filename>.test.js

E2E tests

Full suite:

cd e2e
./gradlew test

Single 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.4

Regenerating the e2e eventing artifact

e2e/src/test/resources/changelog/V2.0__deploy_pnr_buffer.eventing is generated and gitignored. Regenerate it manually with:

cd e2e
./gradlew generateEventingArtifact

This runs automatically as part of ./gradlew test.

How the unit harness works

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.

Generic usage sketch

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 calls

How the e2e harness works

CouchbaseEventingTestBase 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:

  1. A .n1ql migration creates the scopes and collections the function(s) need.
  2. A .eventing migration (generated by the Gradle generateEventingArtifact task) imports and deploys the function — Couchmove sets deployment_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).

Adding a new Eventing function under test

1. Write the function source

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.

2. Write unit tests

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.

3. Write e2e tests

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 generateEventingArtifact Gradle task and the V1.0__create_collections.n1ql changelog are presently wired specifically for pnr_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.


Functions under test

pnr_buffer

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

Unit scenarios (unit/test/pnr_buffer.test.js, unit/test/timerReliability.test.js)

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

E2E scenarios (e2e/src/test/java/.../PnrBufferE2ETest.java, TimerPhaseReliabilityTest.java)

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

About

Unit and end-to-end testing for Couchbase Eventing functions — run handler JS in a mocked sandbox, and verify timer-driven behavior on a real Enterprise cluster.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors