OpenTelemetry Testing Guide: Instrumenting and Verifying Traces
Tracing tells you what happened — testing tells you whether it should have happened. OpenTelemetry brings them together: instrument once, verify always.
What OpenTelemetry Adds to Testing
Classic unit and integration tests check return values. They miss latency, span counts, attribute correctness, and propagation across services. OpenTelemetry testing fills that gap by asserting on the telemetry your application actually emits.
Scenarios where this matters:
- Span completeness — did every critical code path create a span?
- Attribute correctness — is
http.status_codeset correctly on error paths? - Propagation — does
traceparentsurvive a message queue hop? - Sampling decisions — are high-value traces never dropped?
Setting Up OpenTelemetry for Testing
Install the SDK
# Node.js
npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node
<span class="hljs-comment"># Python
pip install opentelemetry-sdk opentelemetry-api opentelemetry-exporter-otlpUse an In-Memory Exporter in Tests
Avoid sending real traces to Jaeger or Honeycomb during tests. Use the in-memory span exporter instead.
// test-setup.js
const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
const { SimpleSpanProcessor } = require('@opentelemetry/sdk-trace-base');
const { InMemorySpanExporter } = require('@opentelemetry/sdk-trace-base');
const memoryExporter = new InMemorySpanExporter();
const provider = new NodeTracerProvider();
provider.addSpanProcessor(new SimpleSpanProcessor(memoryExporter));
provider.register();
module.exports = { memoryExporter };Now spans are captured in memory and available for assertions.
Writing Span Assertions
const { memoryExporter } = require('./test-setup');
const { checkout } = require('../src/checkout');
afterEach(() => memoryExporter.reset());
test('checkout creates a root span with order attributes', async () => {
await checkout({ userId: 'u1', items: [{ sku: 'A', qty: 2 }] });
const spans = memoryExporter.getFinishedSpans();
const checkoutSpan = spans.find(s => s.name === 'checkout');
expect(checkoutSpan).toBeDefined();
expect(checkoutSpan.status.code).toBe(SpanStatusCode.OK);
expect(checkoutSpan.attributes['order.item_count']).toBe(2);
expect(checkoutSpan.attributes['user.id']).toBe('u1');
});
test('checkout span includes db child spans', async () => {
await checkout({ userId: 'u1', items: [{ sku: 'A', qty: 1 }] });
const spans = memoryExporter.getFinishedSpans();
const dbSpans = spans.filter(s => s.name.startsWith('db.'));
expect(dbSpans.length).toBeGreaterThan(0);
expect(dbSpans[0].attributes['db.system']).toBe('postgresql');
});Python Example with pytest
# conftest.py
import pytest
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
@pytest.fixture
def span_exporter():
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
yield exporter
exporter.clear()
# test_checkout.py
def test_checkout_span_attributes(span_exporter, tracer):
result = checkout(user_id="u1", items=[{"sku": "A", "qty": 2}])
spans = span_exporter.get_finished_spans()
root = next(s for s in spans if s.name == "checkout")
assert root.status.status_code.name == "OK"
assert root.attributes["order.item_count"] == 2Testing Error Paths
test('failed payment marks span as error', async () => {
mockPaymentGateway.rejectNext('insufficient_funds');
await expect(checkout({ userId: 'u1', items: [{ sku: 'A', qty: 1 }] }))
.rejects.toThrow();
const spans = memoryExporter.getFinishedSpans();
const paymentSpan = spans.find(s => s.name === 'payment.charge');
expect(paymentSpan.status.code).toBe(SpanStatusCode.ERROR);
expect(paymentSpan.status.message).toContain('insufficient_funds');
expect(paymentSpan.events.some(e => e.name === 'exception')).toBe(true);
});Error events in spans follow the OTel exception semantic conventions. Your test should verify the event is recorded — not just the status code.
Testing Context Propagation
When a service emits a span and another service picks it up, the traceId must match.
test('trace context propagates through message queue', async () => {
const { traceId } = context.with(trace.setSpan(context.active(), rootSpan), () => {
publishOrderEvent({ orderId: '42' });
return rootSpan.spanContext();
});
// simulate consumer processing
await processOrderEvent();
const spans = memoryExporter.getFinishedSpans();
const consumerSpan = spans.find(s => s.name === 'order.process');
expect(consumerSpan.spanContext().traceId).toBe(traceId);
});Running in CI
Add an environment variable to keep tests isolated:
# .github/workflows/test.yml
env:
OTEL_SDK_DISABLED: false
OTEL_EXPORTER_OTLP_ENDPOINT: "" # no remote export in CIAnd in your app bootstrap:
if (process.env.NODE_ENV === 'test') {
// register in-memory exporter only
provider.addSpanProcessor(new SimpleSpanProcessor(testExporter));
} else {
// register OTLP exporter for production
provider.addSpanProcessor(new BatchSpanProcessor(otlpExporter));
}What to Assert (Checklist)
| Assertion | Why |
|---|---|
| Span name matches convention | Prevents dashboard breakage |
| Status code (OK / ERROR) | Detects silent failures |
| Required attributes present | Validates semantic conventions |
| Child span count | Detects missing instrumentation |
| Span duration reasonable | Catches performance regressions |
| Exception events on errors | Ensures error detail is captured |
| Trace ID propagated | Validates distributed tracing |
Integrating with HelpMeTest
Once your traces are exported to a backend (Jaeger, Honeycomb, Grafana Tempo), HelpMeTest can run E2E scenarios and then assert on the resulting traces — validating that a user flow both succeeded and was correctly observed.
This closes the loop: your behavioral tests and your observability tests run together, giving you confidence from user action to backend span.
Summary
OpenTelemetry testing is straightforward once you swap the OTLP exporter for an in-memory one in test mode:
- Register
InMemorySpanExporterin test setup - Execute the code under test
- Assert on span names, attributes, status, and child relationships
- Reset the exporter between tests
This makes your observability layer a first-class citizen of your test suite — not an afterthought you check manually in production.