| Level | Purpose | Mocking |
|---|---|---|
| E2E | Happy paths via real examples | None |
| Integration | Edge cases, error handling | External boundaries only |
| Unit | Pure logic, branches | Everything |
| Language | E2E | Integration | Unit | Location |
|---|---|---|---|---|
| TypeScript | *.e2e.test.ts |
*.integration.test.ts |
*.unit.test.ts |
__tests__/ |
| Python | test_*_e2e.py |
test_*_integration.py |
test_*.py |
tests/ |
| Go | *_e2e_test.go |
*_integration_test.go |
*_test.go |
same package |
- Spec first: Write a
.featurefile inspecs/. Use tags:@e2e,@integration,@unit. - Challenge: LLM/reviewer challenges missing edge cases before implementation.
- Examples drive E2E: Working examples in
examples/are wrapped by e2e tests. - Implement: Outside-in test driven (TDD). Red → Green → Refactor.
Is this a happy path demonstrating SDK usage?
→ E2E (wrap an example)
Does it test orchestration between internal modules or external API behavior?
→ Integration (mock external boundaries)
Is it pure logic or a single class in isolation?
→ Unit (mock collaborators)
Is it a regression from production?
→ Add test at the LOWEST sufficient level (unit > integration > e2e)
Each scenario should test one invariant. When deciding whether to extend an existing scenario or create a new one:
- Extend (add
And/But): The new assertion is a natural consequence of the same behavior - New scenario: The assertion tests a distinct invariant that could fail independently
Example: "Cache returns stale data" and "Cache key includes version" are orthogonal invariants — separate scenarios. If one fails, you immediately know which contract broke.
- Type definitions
- Simple pass-throughs with no logic
- Third-party library internals
- Constants/config (unless dynamic)
Edge cases not covered upfront are handled via regression tests. When a bug is found:
- Reproduce with a failing test
- Add test at the lowest sufficient level
- Fix and verify green
This keeps the suite lean while ensuring real failures never recur.
The voice @e2e demos under python/tests/voice/test_*_e2e.py are auto-marked
integration by python/tests/voice/conftest.py:pytest_collection_modifyitems
and the default python-ci job deselects them with -m "not integration".
They only run via the on-demand voice-integration.yml dispatch.
Several multi-turn demos additionally carry the voice_multiturn marker.
They MUST run one pytest process each: collecting them together in a single
process wedges the run (issue #491). The marker lets voice-integration.yml
deselect them from the bulk run (-m "not voice_multiturn") and execute each in
a fresh process — replacing the earlier @pytest.mark.skip markers that left
the @e2e contract asserted-but-never-run.
scenario.run() offloads each scenario to a worker thread with a private event
loop and, in that thread's finally, calls event_bus.drain() synchronously
(python/scenario/_events/event_bus.py). drain() does an unbounded
self._event_queue.join() that only returns once the event-bus worker thread
has POSTed every scenario event to the LangWatch telemetry endpoint. Each POST
has a 30s httpx timeout (event_reporter.py) and the worker drains events
serially, so teardown cost scales with event_count × up-to-30s whenever the
endpoint is reachable-but-slow. Multi-turn voice demos emit the most events (one
per turn, plus base64 audio snapshots), so their teardown is the most exposed;
running several in one process compounds it past the 60s per-test timeout and the
process appears wedged.
Confirmed creds-free: point LANGWATCH_ENDPOINT at a socket that accepts but
never responds and run any scenario.run() — the worker blocks in
socket.recv_into (awaiting the HTTP response) while the calling thread blocks in
event_bus.drain() → queue.join(). Locally the default endpoint fast-refuses,
so the drain returns immediately — which is why the wedge is invisible in
isolation and on a developer box but bites in the integration workflow, where
LANGWATCH_API_KEY is set and telemetry actually posts.
Other contributors that per-process isolation also neutralises: background
asyncio.create_tasks spawned by adapters (Gemini Live _session_lifetime,
Twilio webhook server, Cloudflare tunnel subprocess) that aren't fully reaped
between function-scoped event loops, and the ffmpeg playback subprocess in
python/scenario/voice/playback.py not always exiting cleanly on .stop().
A narrower SDK-level fix — bounding event_bus.drain() so telemetry can never
block test teardown indefinitely — is recommended as a follow-up (#791). It changes
the shared event-bus delivery guarantee for every scenario.run() caller, so it
is tracked separately from this process-isolation resolution.
You do not need a LangWatch account. Point the endpoint at a socket that accepts but never responds; any dummy key works, because nothing ever answers.
# blackhole.py — accepts TCP, swallows the request, never writes a response.
import socket, threading, time
s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 9099)); s.listen(128)
def handle(c):
c.recv(65536)
while True: time.sleep(3600) # hold it open, never respond
while True:
c, _ = s.accept(); threading.Thread(target=handle, args=(c,), daemon=True).start()python3 blackhole.py &
export LANGWATCH_ENDPOINT=http://127.0.0.1:9099
export LANGWATCH_API_KEY=sk-lw-blackhole-not-a-real-key
pytest -p no:cacheprovider -x python/tests/voice/test_multi_intent_e2e.py --timeout=540Measured: that demo takes 27.6s with telemetry off and 181s against the
black hole — +153s of pure teardown. A stack dump mid-teardown parks at
event_bus.drain() → _event_queue.join() → all_tasks_done.wait(), and the POST
ReadTimeouts land exactly 30s apart. So: teardown ≈ events × 30s, serial,
unbounded. That is the number #791 needs.
Always pass an explicit --timeout. pytest.ini sets timeout = 60 and it
wins over pyproject.toml (pytest says so: configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!)). Worse, timeout_method = thread kills
the entire pytest process, not just the slow test — so one demo over the cap
takes the whole suite down with a thread dump, which looks exactly like a hang. Three
of these demos exceed 60s even with telemetry off, and the drain above adds far more.
# Supported: one process per demo (what voice-integration.yml does).
pytest -p no:cacheprovider -x python/tests/voice/test_long_hold_e2e.py --timeout=540
# …or discover the whole set by marker and loop, one process each:
for f in $(pytest python/tests/voice -m voice_multiturn --collect-only -q \
| grep '::' | cut -d: -f1 | sort -u); do
timeout 600 pytest -p no:cacheprovider -x "$f" --timeout=540
done
# Collecting them together in ONE process is not supported — but be precise about
# why: it is not intrinsic. With the cap lifted and telemetry off they complete
# together in ~424s. What kills a shared run is the drain pushing a demo past the
# 60s cap, at which point the thread method destroys the whole process. Per-process
# isolation is defence-in-depth (it caps a pathological drain at one demo instead of
# the suite) — the load-bearing fix is the timeout override.
pytest -m voice_multiturn python/tests/voice/Committed WAV fixtures (javascript/examples/vitest/outputs/recordings/, python/recordings/)
are small fixed inputs regenerated by running the voice demo tests with a real API key.
They are committed as binary blobs for offline CI.
Decision (captured 2026-05-29, issue #582 item 2): when the total recording footprint crosses 50 MB, migrate to one of:
- Git LFS — add a
.gitattributesentry:*.wav filter=lfs diff=lfs merge=lfs -text --update-recordingsflag — regenerate on demand and.gitignoreWAV outputs
Until that threshold is reached, the current approach (small blobs committed directly)
is acceptable. Track footprint via: du -sh javascript/examples/vitest/outputs/recordings/ python/recordings/.