|
| 1 | +# How to Test With JUnit Guide |
| 2 | + |
| 3 | +This guide covers the JUnit 5 testing utilities for writing instrumentation and unit tests. |
| 4 | + |
| 5 | +## Config injection with `@WithConfig` |
| 6 | + |
| 7 | +`@WithConfig` declares configuration overrides for tests. It injects system properties (`dd.` prefix) or environment variables (`DD_` prefix) and rebuilds the `Config` singleton before each test. |
| 8 | + |
| 9 | +### Class-level config |
| 10 | + |
| 11 | +Applies to all tests in the class: |
| 12 | + |
| 13 | +```java |
| 14 | +@WithConfig(key = "service", value = "my-service") |
| 15 | +@WithConfig(key = "trace.analytics.enabled", value = "true") |
| 16 | +class MyTest extends DDJavaSpecification { |
| 17 | + @Test |
| 18 | + void test() { |
| 19 | + // dd.service=my-service and dd.trace.analytics.enabled=true are set |
| 20 | + } |
| 21 | +} |
| 22 | +``` |
| 23 | + |
| 24 | +### Method-level config |
| 25 | + |
| 26 | +Applies to a single test method, in addition to class-level config: |
| 27 | + |
| 28 | +```java |
| 29 | +@WithConfig(key = "service", value = "my-service") |
| 30 | +class MyTest extends DDJavaSpecification { |
| 31 | + @Test |
| 32 | + @WithConfig(key = "trace.resolver.enabled", value = "false") |
| 33 | + void testWithResolverDisabled() { |
| 34 | + // dd.service=my-service AND dd.trace.resolver.enabled=false |
| 35 | + } |
| 36 | + |
| 37 | + @Test |
| 38 | + void testWithDefaults() { |
| 39 | + // only dd.service=my-service |
| 40 | + } |
| 41 | +} |
| 42 | +``` |
| 43 | + |
| 44 | +### Environment variables |
| 45 | + |
| 46 | +Use `env = true` to set an environment variable instead of a system property: |
| 47 | + |
| 48 | +```java |
| 49 | +@WithConfig(key = "AGENT_HOST", value = "localhost", env = true) |
| 50 | +``` |
| 51 | + |
| 52 | +### Raw keys (no auto-prefix) |
| 53 | + |
| 54 | +Use `addPrefix = false` to skip the automatic `dd.`/`DD_` prefix: |
| 55 | + |
| 56 | +```java |
| 57 | +@WithConfig(key = "OTEL_SERVICE_NAME", value = "test", env = true, addPrefix = false) |
| 58 | +``` |
| 59 | + |
| 60 | +### Config with constant references |
| 61 | + |
| 62 | +Annotation values accept compile-time constants: |
| 63 | + |
| 64 | +```java |
| 65 | +@WithConfig(key = TracerConfig.TRACE_RESOLVER_ENABLED, value = "false") |
| 66 | +``` |
| 67 | + |
| 68 | +### Inheritance |
| 69 | + |
| 70 | +`@WithConfig` on a superclass applies to all subclasses. When a subclass adds its own `@WithConfig`, both the parent's and the subclass's configs are applied (parent first, subclass second). This allows base classes to set shared config while subclasses add specifics: |
| 71 | + |
| 72 | +```java |
| 73 | +@WithConfig(key = "integration.opentelemetry.experimental.enabled", value = "true") |
| 74 | +abstract class AbstractOtelTest extends AbstractInstrumentationTest { } |
| 75 | + |
| 76 | +@WithConfig(key = "trace.propagation.style", value = "b3multi") |
| 77 | +class B3MultiTest extends AbstractOtelTest { |
| 78 | + // Both configs are active |
| 79 | +} |
| 80 | +``` |
| 81 | + |
| 82 | +### Composed annotations |
| 83 | + |
| 84 | +Bundle multiple configs into a reusable annotation: |
| 85 | + |
| 86 | +```java |
| 87 | +@Retention(RetentionPolicy.RUNTIME) |
| 88 | +@Target({ElementType.TYPE, ElementType.METHOD}) |
| 89 | +@WithConfig(key = "iast.enabled", value = "true") |
| 90 | +@WithConfig(key = "iast.detection.mode", value = "FULL") |
| 91 | +@WithConfig(key = "iast.redaction.enabled", value = "false") |
| 92 | +public @interface IastFullDetection {} |
| 93 | +``` |
| 94 | + |
| 95 | +Then reuse across test classes: |
| 96 | + |
| 97 | +```java |
| 98 | +@IastFullDetection |
| 99 | +class IastTagTest extends DDJavaSpecification { } |
| 100 | + |
| 101 | +@IastFullDetection |
| 102 | +class IastReporterTest extends DDJavaSpecification { } |
| 103 | +``` |
| 104 | + |
| 105 | +### Imperative config injection |
| 106 | + |
| 107 | +For dynamic values that can't be expressed in annotations, use the static methods directly: |
| 108 | + |
| 109 | +```java |
| 110 | +@Test |
| 111 | +void testDynamic() { |
| 112 | + String port = startServer(); |
| 113 | + WithConfigExtension.injectSysConfig("trace.agent.port", port); |
| 114 | + // ... |
| 115 | +} |
| 116 | +``` |
| 117 | + |
| 118 | +### Lifecycle |
| 119 | + |
| 120 | +Config is rebuilt from a clean slate before each test: |
| 121 | + |
| 122 | +1. **`beforeAll`**: class-level `@WithConfig` applied + config rebuilt (available for `@BeforeAll` methods) |
| 123 | +2. **`beforeEach`**: properties restored, class + method `@WithConfig` applied, config rebuilt |
| 124 | +3. **`afterEach`**: env vars cleared, properties restored, config rebuilt |
| 125 | + |
| 126 | +This means each test starts with a clean config, and method-level `@WithConfig` doesn't leak between tests. |
| 127 | + |
| 128 | +## Instrumentation tests with `AbstractInstrumentationTest` |
| 129 | + |
| 130 | +`AbstractInstrumentationTest` is the JUnit 5 base class for instrumentation tests. It installs the agent once per test class, creates a shared tracer and writer, and provides trace assertion helpers. |
| 131 | + |
| 132 | +### Lifecycle |
| 133 | + |
| 134 | +| Phase | Scope | What happens | |
| 135 | +|---|---|---| |
| 136 | +| `@BeforeAll initAll()` | Once per class | Creates tracer + writer, installs ByteBuddy agent | |
| 137 | +| `@BeforeEach init()` | Per test | Flushes tracer, resets writer | |
| 138 | +| `@AfterEach tearDown()` | Per test | Flushes tracer | |
| 139 | +| `@AfterAll tearDownAll()` | Once per class | Closes tracer, removes agent transformer | |
| 140 | + |
| 141 | +### Available fields |
| 142 | + |
| 143 | +- `tracer` — the DD `TracerAPI` instance (shared across tests in the class) |
| 144 | +- `writer` — the `ListWriter` that captures traces written by the tracer |
| 145 | + |
| 146 | +### Configuring the tracer |
| 147 | + |
| 148 | +The tracer can be configured at class level using the `testConfig` builder. Call it from a static initializer (runs before `@BeforeAll`): |
| 149 | + |
| 150 | +```java |
| 151 | +class MyTest extends AbstractInstrumentationTest { |
| 152 | + static { |
| 153 | + testConfig.idGenerationStrategy("RANDOM").strictTraceWrites(false); |
| 154 | + } |
| 155 | +} |
| 156 | +``` |
| 157 | + |
| 158 | +Available settings: |
| 159 | + |
| 160 | +| Method | Default | Description | |
| 161 | +|---|---|---| |
| 162 | +| `idGenerationStrategy(String)` | `"SEQUENTIAL"` | Span ID generation strategy | |
| 163 | +| `strictTraceWrites(boolean)` | `true` | Enable strict trace write validation | |
| 164 | + |
| 165 | +### Basic test |
| 166 | + |
| 167 | +```java |
| 168 | +class HttpInstrumentationTest extends AbstractInstrumentationTest { |
| 169 | + @Test |
| 170 | + void testHttpRequest() { |
| 171 | + // exercise the instrumented code |
| 172 | + makeHttpRequest("http://example.com/api"); |
| 173 | + |
| 174 | + // assert the trace structure |
| 175 | + assertTraces( |
| 176 | + trace( |
| 177 | + span().root().operationName("http.request").resourceName("GET /api"))); |
| 178 | + } |
| 179 | +} |
| 180 | +``` |
| 181 | + |
| 182 | +## Trace assertion API |
| 183 | + |
| 184 | +The assertion API verifies trace structure using a fluent builder pattern. Import the static factories: |
| 185 | + |
| 186 | +```java |
| 187 | +import static datadog.trace.agent.test.assertions.TraceMatcher.trace; |
| 188 | +import static datadog.trace.agent.test.assertions.SpanMatcher.span; |
| 189 | +import static datadog.trace.agent.test.assertions.TagsMatcher.*; |
| 190 | +import static datadog.trace.agent.test.assertions.Matchers.*; |
| 191 | +``` |
| 192 | + |
| 193 | +### Asserting traces |
| 194 | + |
| 195 | +`assertTraces` waits for traces to arrive (20s timeout), then verifies the structure: |
| 196 | + |
| 197 | +```java |
| 198 | +// Single trace with 2 spans |
| 199 | +assertTraces( |
| 200 | + trace( |
| 201 | + span().root().operationName("parent"), |
| 202 | + span().childOfPrevious().operationName("child"))); |
| 203 | + |
| 204 | +// Multiple traces |
| 205 | +assertTraces( |
| 206 | + trace(span().root().operationName("trace-1")), |
| 207 | + trace(span().root().operationName("trace-2"))); |
| 208 | +``` |
| 209 | + |
| 210 | +### Trace options |
| 211 | + |
| 212 | +```java |
| 213 | +import static datadog.trace.agent.test.assertions.TraceAssertions.*; |
| 214 | + |
| 215 | +// Ignore extra traces beyond the expected ones |
| 216 | +assertTraces(IGNORE_ADDITIONAL_TRACES, |
| 217 | + trace(span().root().operationName("expected"))); |
| 218 | + |
| 219 | +// Sort traces by start time before assertion |
| 220 | +assertTraces(SORT_BY_START_TIME, |
| 221 | + trace(span().root().operationName("first")), |
| 222 | + trace(span().root().operationName("second"))); |
| 223 | +``` |
| 224 | + |
| 225 | +### Span matching |
| 226 | + |
| 227 | +```java |
| 228 | +span() |
| 229 | + // Identity |
| 230 | + .root() // root span (parent ID = 0) |
| 231 | + .childOfPrevious() // child of previous span in trace |
| 232 | + .childOf(parentSpanId) // child of specific span |
| 233 | + |
| 234 | + // Properties |
| 235 | + .operationName("http.request") // exact match |
| 236 | + .operationName(Pattern.compile("http.*"))// regex match |
| 237 | + .resourceName("GET /api") // exact match |
| 238 | + .serviceName("my-service") // exact match |
| 239 | + .type("web") // span type |
| 240 | + |
| 241 | + // Error |
| 242 | + .error() // expects error = true |
| 243 | + .error(false) // expects error = false |
| 244 | + |
| 245 | + // Duration |
| 246 | + .durationShorterThan(Duration.ofMillis(100)) |
| 247 | + .durationLongerThan(Duration.ofMillis(1)) |
| 248 | + |
| 249 | + // Tags |
| 250 | + .tags( |
| 251 | + defaultTags(), // all standard DD tags |
| 252 | + tag("http.method", is("GET")), // exact tag value |
| 253 | + tag("db.type", is("postgres"))) |
| 254 | + |
| 255 | + // Span links |
| 256 | + .links( |
| 257 | + SpanLinkMatcher.to(otherSpan), |
| 258 | + SpanLinkMatcher.any()) |
| 259 | +``` |
| 260 | + |
| 261 | +### Tag matching |
| 262 | + |
| 263 | +```java |
| 264 | +// Default DD tags (thread name, runtime ID, sampling, etc.) |
| 265 | +defaultTags() |
| 266 | + |
| 267 | +// Exact value |
| 268 | +tag("http.status", is(200)) |
| 269 | + |
| 270 | +// Custom validation |
| 271 | +tag("response.body", validates(v -> ((String) v).contains("success"))) |
| 272 | + |
| 273 | +// Any value (just check presence) |
| 274 | +tag("custom.tag", any()) |
| 275 | + |
| 276 | +// Error tags from exception |
| 277 | +error(IOException.class) |
| 278 | +error(IOException.class, "Connection refused") |
| 279 | +error(new IOException("Connection refused")) |
| 280 | + |
| 281 | +// Check tag presence without value check |
| 282 | +includes("tag1", "tag2") |
| 283 | +``` |
| 284 | + |
| 285 | +### Value matchers |
| 286 | + |
| 287 | +```java |
| 288 | +is("expected") // equality |
| 289 | +isNull() // null check |
| 290 | +isNonNull() // non-null check |
| 291 | +isTrue() // boolean true |
| 292 | +isFalse() // boolean false |
| 293 | +matches("regex.*") // regex match |
| 294 | +matches(Pattern.compile("...")) |
| 295 | +validates(v -> ...) // custom predicate |
| 296 | +any() // accept anything |
| 297 | +``` |
| 298 | + |
| 299 | +### Span link matching |
| 300 | + |
| 301 | +```java |
| 302 | +// Link to a specific span |
| 303 | +SpanLinkMatcher.to(parentSpan) |
| 304 | + |
| 305 | +// Link with trace/span IDs |
| 306 | +SpanLinkMatcher.to(traceId, spanId) |
| 307 | + |
| 308 | +// Link with additional properties |
| 309 | +SpanLinkMatcher.to(span) |
| 310 | + .traceFlags((byte) 0x01) |
| 311 | + .traceState("vendor=value") |
| 312 | + |
| 313 | +// Accept any link |
| 314 | +SpanLinkMatcher.any() |
| 315 | +``` |
| 316 | + |
| 317 | +### Sorting spans within a trace |
| 318 | + |
| 319 | +```java |
| 320 | +import static datadog.trace.agent.test.assertions.TraceMatcher.SORT_BY_START_TIME; |
| 321 | + |
| 322 | +assertTraces( |
| 323 | + trace( |
| 324 | + SORT_BY_START_TIME, |
| 325 | + span().root().operationName("parent"), |
| 326 | + span().childOfPrevious().operationName("child"))); |
| 327 | +``` |
| 328 | + |
| 329 | +### Waiting for traces |
| 330 | + |
| 331 | +```java |
| 332 | +// Wait until a condition is met (20s timeout) |
| 333 | +blockUntilTracesMatch(traces -> traces.size() >= 2); |
| 334 | + |
| 335 | +// Wait for child spans to finish |
| 336 | +blockUntilChildSpansFinished(3); |
| 337 | +``` |
| 338 | + |
| 339 | +### Accessing traces directly |
| 340 | + |
| 341 | +For assertions not covered by the fluent API, access the writer directly: |
| 342 | + |
| 343 | +```java |
| 344 | +writer.waitForTraces(1); |
| 345 | +List<DDSpan> trace = writer.firstTrace(); |
| 346 | +DDSpan span = trace.get(0); |
| 347 | + |
| 348 | +assertEquals("expected-op", span.getOperationName().toString()); |
| 349 | +assertEquals(42L, span.getTag("custom.metric")); |
| 350 | +``` |
0 commit comments