diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/AbstractInstrumentationTest.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/AbstractInstrumentationTest.java
index cfcc93ed006..42d543931ff 100644
--- a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/AbstractInstrumentationTest.java
+++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/AbstractInstrumentationTest.java
@@ -14,7 +14,7 @@
import datadog.trace.api.Config;
import datadog.trace.api.IdGenerationStrategy;
import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
-import datadog.trace.bootstrap.instrumentation.api.AgentTracer;
+import datadog.trace.bootstrap.instrumentation.api.AgentTracer.TracerAPI;
import datadog.trace.common.writer.ListWriter;
import datadog.trace.core.CoreTracer;
import datadog.trace.core.DDSpan;
@@ -30,16 +30,26 @@
import java.util.function.Function;
import java.util.function.Predicate;
import net.bytebuddy.agent.ByteBuddyAgent;
+import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.opentest4j.AssertionFailedError;
/**
- * This class is an experimental base to run instrumentation tests using JUnit Jupiter. It is still
- * early development, and the overall API is expected to change to leverage its extension model. The
- * current implementation is inspired and kept close to it Groovy / Spock counterpart, the {@code
- * InstrumentationSpecification}.
+ * Base class for instrumentation tests using JUnit Jupiter.
+ *
+ *
It is still early development, and the overall API might change to leverage its extension
+ * model. The current implementation is inspired and kept close to its Groovy / Spock counterpart,
+ * the {@code InstrumentationSpecification}.
+ *
+ *
+ * - {@code @BeforeAll}: Installs the agent and creates a shared tracer
+ *
- {@code @BeforeEach}: Flushes and resets the writer
+ *
- {@code @AfterEach}: Flushes the tracer
+ *
- {@code @AfterAll}: Closes the tracer and removes the agent transformer
+ *
*/
@ExtendWith({TestClassShadowingExtension.class, AllowContextTestingExtension.class})
public abstract class AbstractInstrumentationTest {
@@ -47,31 +57,29 @@ public abstract class AbstractInstrumentationTest {
static final long TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(20);
- protected AgentTracer.TracerAPI tracer;
+ protected static final InstrumentationTestConfig testConfig = new InstrumentationTestConfig();
- protected ListWriter writer;
+ protected static TracerAPI tracer;
+ protected static ListWriter writer;
+ private static ClassFileTransformer activeTransformer;
+ private static ClassFileTransformerListener transformerListener;
- protected ClassFileTransformer activeTransformer;
- protected ClassFileTransformerListener transformerLister;
-
- @BeforeEach
- public void init() {
+ @BeforeAll
+ static void initAll() {
// If this fails, it's likely the result of another test loading Config before it can be
// injected into the bootstrap classpath.
- // If one test extends AgentTestRunner in a module, all tests must extend
assertNull(Config.class.getClassLoader(), "Config must load on the bootstrap classpath.");
- // Initialize test tracer
- this.writer = new ListWriter();
- // Initialize test tracer
- CoreTracer tracer =
+ // Create shared test writer and tracer
+ writer = new ListWriter();
+ CoreTracer coreTracer =
CoreTracer.builder()
- .writer(this.writer)
- .idGenerationStrategy(IdGenerationStrategy.fromName(idGenerationStrategyName()))
- .strictTraceWrites(useStrictTraceWrites())
+ .writer(writer)
+ .idGenerationStrategy(IdGenerationStrategy.fromName(testConfig.idGenerationStrategy))
+ .strictTraceWrites(testConfig.strictTraceWrites)
.build();
- TracerInstaller.forceInstallGlobalTracer(tracer);
- this.tracer = tracer;
+ TracerInstaller.forceInstallGlobalTracer(coreTracer);
+ tracer = coreTracer;
ClassInjector.enableClassInjection(INSTRUMENTATION);
@@ -85,33 +93,43 @@ public void init() {
.iterator()
.hasNext(),
"No instrumentation found");
- this.transformerLister = new ClassFileTransformerListener();
- this.activeTransformer =
+ transformerListener = new ClassFileTransformerListener();
+ activeTransformer =
AgentInstaller.installBytebuddyAgent(
- INSTRUMENTATION, true, AgentInstaller.getEnabledSystems(), this.transformerLister);
- }
-
- protected String idGenerationStrategyName() {
- return "SEQUENTIAL";
+ INSTRUMENTATION, true, AgentInstaller.getEnabledSystems(), transformerListener);
}
- private boolean useStrictTraceWrites() {
- return true;
+ @BeforeEach
+ public void init() {
+ tracer.flush();
+ writer.start();
}
@AfterEach
public void tearDown() {
- this.tracer.close();
- this.writer.close();
- if (this.activeTransformer != null) {
- INSTRUMENTATION.removeTransformer(this.activeTransformer);
- this.activeTransformer = null;
- }
+ tracer.flush();
+ }
- // All cleanups should happen before these assertions.
+ @AfterAll
+ static void tearDownAll() {
+ if (tracer != null) {
+ tracer.close();
+ tracer = null;
+ }
+ if (writer != null) {
+ writer.close();
+ writer = null;
+ }
+ if (activeTransformer != null) {
+ INSTRUMENTATION.removeTransformer(activeTransformer);
+ activeTransformer = null;
+ }
+ // All cleanups should happen before this verify call.
// If not, a failing assertion may prevent cleanup
- this.transformerLister.verify();
- this.transformerLister = null;
+ if (transformerListener != null) {
+ transformerListener.verify();
+ transformerListener = null;
+ }
}
/**
@@ -134,11 +152,11 @@ protected void assertTraces(
TraceMatcher... matchers) {
int expectedTraceCount = matchers.length;
try {
- this.writer.waitForTraces(expectedTraceCount);
+ writer.waitForTraces(expectedTraceCount);
} catch (InterruptedException | TimeoutException e) {
throw new AssertionFailedError("Timeout while waiting for traces", e);
}
- TraceAssertions.assertTraces(this.writer, options, matchers);
+ TraceAssertions.assertTraces(writer, options, matchers);
}
/**
@@ -149,7 +167,7 @@ protected void assertTraces(
*/
protected void blockUntilTracesMatch(Predicate>> predicate) {
long deadline = System.currentTimeMillis() + TIMEOUT_MILLIS;
- while (!predicate.test(this.writer)) {
+ while (!predicate.test(writer)) {
if (System.currentTimeMillis() > deadline) {
throw new RuntimeException(new TimeoutException("Timed out waiting for traces/spans."));
}
@@ -161,8 +179,8 @@ protected void blockUntilTracesMatch(Predicate>> predicate) {
}
}
- protected void blockUntilChildSpansFinished(final int numberOfSpans) {
- blockUntilChildSpansFinished(this.tracer.activeSpan(), numberOfSpans);
+ protected void blockUntilChildSpansFinished(int numberOfSpans) {
+ blockUntilChildSpansFinished(tracer.activeSpan(), numberOfSpans);
}
static void blockUntilChildSpansFinished(AgentSpan span, int numberOfSpans) {
@@ -190,4 +208,20 @@ static void blockUntilChildSpansFinished(AgentSpan span, int numberOfSpans) {
}
}
}
+
+ /** Configuration for {@link AbstractInstrumentationTest}. */
+ protected static class InstrumentationTestConfig {
+ private String idGenerationStrategy = "SEQUENTIAL";
+ private boolean strictTraceWrites = true;
+
+ public InstrumentationTestConfig idGenerationStrategy(String strategy) {
+ this.idGenerationStrategy = strategy;
+ return this;
+ }
+
+ public InstrumentationTestConfig strictTraceWrites(boolean strict) {
+ this.strictTraceWrites = strict;
+ return this;
+ }
+ }
}
diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Matchers.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Matchers.java
index cf25a5fa581..973338bd1f1 100644
--- a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Matchers.java
+++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Matchers.java
@@ -1,9 +1,10 @@
package datadog.trace.agent.test.assertions;
+import static org.junit.jupiter.api.AssertionFailureBuilder.assertionFailure;
+
import java.util.Optional;
import java.util.function.Predicate;
import java.util.regex.Pattern;
-import org.opentest4j.AssertionFailedError;
/** This class is a utility class to create generic matchers. */
public final class Matchers {
@@ -103,12 +104,11 @@ public static Matcher any() {
static void assertValue(Matcher matcher, T value, String message) {
if (matcher != null && !matcher.test(value)) {
Optional expected = matcher.expected();
- if (expected.isPresent()) {
- throw new AssertionFailedError(
- message + ". " + matcher.failureReason(), expected.get(), value);
- } else {
- throw new AssertionFailedError(message + ": " + value + ". " + matcher.failureReason());
- }
+ assertionFailure()
+ .message(message + ". " + matcher.failureReason())
+ .expected(expected.orElse(null))
+ .actual(value)
+ .buildAndThrow();
}
}
}
diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/SpanMatcher.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/SpanMatcher.java
index 4ceb959c797..ce41e2126c8 100644
--- a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/SpanMatcher.java
+++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/SpanMatcher.java
@@ -10,6 +10,7 @@
import static datadog.trace.agent.test.assertions.Matchers.validates;
import static datadog.trace.core.DDSpanAccessor.spanLinks;
import static java.time.Duration.ofNanos;
+import static org.junit.jupiter.api.AssertionFailureBuilder.assertionFailure;
import datadog.trace.api.TagMap;
import datadog.trace.bootstrap.instrumentation.api.AgentSpanLink;
@@ -322,7 +323,7 @@ private void assertSpanTags(TagMap tags) {
if (matcher == null) {
uncheckedTagNames.add(key);
} else {
- assertValue(matcher, value, "Unexpected " + key + " tag value.");
+ assertValue(matcher, value, "Unexpected " + key + " tag value");
}
});
// Remove matchers that accept missing tags
@@ -344,10 +345,18 @@ private void assertSpanTags(TagMap tags) {
* It might evolve into partial link collection testing, matching links using TID/SIP.
*/
private void assertSpanLinks(List links) {
+ // Check if links should be asserted at all
+ if (this.linkMatchers == null) {
+ return;
+ }
int linkCount = links == null ? 0 : links.size();
- int expectedLinkCount = this.linkMatchers == null ? 0 : this.linkMatchers.length;
+ int expectedLinkCount = this.linkMatchers.length;
if (linkCount != expectedLinkCount) {
- throw new AssertionFailedError("Unexpected span link count", expectedLinkCount, linkCount);
+ assertionFailure()
+ .message("Unexpected span link count")
+ .expected(expectedLinkCount)
+ .actual(linkCount)
+ .buildAndThrow();
}
for (int i = 0; i < expectedLinkCount; i++) {
SpanLinkMatcher linkMatcher = this.linkMatchers[expectedLinkCount];
diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/TagsMatcher.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/TagsMatcher.java
index 21dae20ebaa..b9d439d0259 100644
--- a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/TagsMatcher.java
+++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/TagsMatcher.java
@@ -3,18 +3,28 @@
import static datadog.trace.agent.test.assertions.Matchers.any;
import static datadog.trace.agent.test.assertions.Matchers.is;
import static datadog.trace.agent.test.assertions.Matchers.isNonNull;
+import static datadog.trace.api.DDTags.BASE_SERVICE;
+import static datadog.trace.api.DDTags.DD_INTEGRATION;
+import static datadog.trace.api.DDTags.DJM_ENABLED;
+import static datadog.trace.api.DDTags.DSM_ENABLED;
import static datadog.trace.api.DDTags.ERROR_MSG;
import static datadog.trace.api.DDTags.ERROR_STACK;
import static datadog.trace.api.DDTags.ERROR_TYPE;
import static datadog.trace.api.DDTags.LANGUAGE_TAG_KEY;
+import static datadog.trace.api.DDTags.PARENT_ID;
+import static datadog.trace.api.DDTags.PID_TAG;
+import static datadog.trace.api.DDTags.PROFILING_CONTEXT_ENGINE;
+import static datadog.trace.api.DDTags.PROFILING_ENABLED;
import static datadog.trace.api.DDTags.REQUIRED_CODE_ORIGIN_TAGS;
import static datadog.trace.api.DDTags.RUNTIME_ID_TAG;
+import static datadog.trace.api.DDTags.SCHEMA_VERSION_TAG_KEY;
+import static datadog.trace.api.DDTags.SPAN_LINKS;
import static datadog.trace.api.DDTags.THREAD_ID;
import static datadog.trace.api.DDTags.THREAD_NAME;
+import static datadog.trace.api.DDTags.TRACER_HOST;
import static datadog.trace.common.sampling.RateByServiceTraceSampler.SAMPLING_AGENT_RATE;
import static datadog.trace.common.writer.ddagent.TraceMapper.SAMPLING_PRIORITY_KEY;
-import datadog.trace.api.DDTags;
import java.util.HashMap;
import java.util.Map;
@@ -34,15 +44,17 @@ public static TagsMatcher defaultTags() {
tagMatchers.put(SAMPLING_AGENT_RATE, any());
tagMatchers.put(SAMPLING_PRIORITY_KEY.toString(), any());
tagMatchers.put("_sample_rate", any());
- tagMatchers.put(DDTags.PID_TAG, any());
- tagMatchers.put(DDTags.SCHEMA_VERSION_TAG_KEY, any());
- tagMatchers.put(DDTags.PROFILING_ENABLED, any());
- tagMatchers.put(DDTags.PROFILING_CONTEXT_ENGINE, any());
- tagMatchers.put(DDTags.BASE_SERVICE, any());
- tagMatchers.put(DDTags.DSM_ENABLED, any());
- tagMatchers.put(DDTags.DJM_ENABLED, any());
- tagMatchers.put(DDTags.PARENT_ID, any());
- tagMatchers.put(DDTags.SPAN_LINKS, any()); // this is checked by LinksAsserter
+ tagMatchers.put(PID_TAG, any());
+ tagMatchers.put(SCHEMA_VERSION_TAG_KEY, any());
+ tagMatchers.put(PROFILING_ENABLED, any());
+ tagMatchers.put(PROFILING_CONTEXT_ENGINE, any());
+ tagMatchers.put(BASE_SERVICE, any());
+ tagMatchers.put(DSM_ENABLED, any());
+ tagMatchers.put(DJM_ENABLED, any());
+ tagMatchers.put(PARENT_ID, any());
+ tagMatchers.put(SPAN_LINKS, any()); // this is checked by LinksAsserter
+ tagMatchers.put(DD_INTEGRATION, any());
+ tagMatchers.put(TRACER_HOST, any());
for (String tagName : REQUIRED_CODE_ORIGIN_TAGS) {
tagMatchers.put(tagName, any());
diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/TraceAssertions.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/TraceAssertions.java
index 58726369ba3..c2bb123103f 100644
--- a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/TraceAssertions.java
+++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/TraceAssertions.java
@@ -1,12 +1,12 @@
package datadog.trace.agent.test.assertions;
import static java.util.function.Function.identity;
+import static org.junit.jupiter.api.AssertionFailureBuilder.assertionFailure;
import datadog.trace.core.DDSpan;
import java.util.Comparator;
import java.util.List;
import java.util.function.Function;
-import org.opentest4j.AssertionFailedError;
/**
* This class is a helper class to verify traces structure.
@@ -87,11 +87,19 @@ public static void assertTraces(
int traceCount = traces.size();
if (opts.ignoredAdditionalTraces) {
if (traceCount < expectedTraceCount) {
- throw new AssertionFailedError("Not enough of traces", expectedTraceCount, traceCount);
+ assertionFailure()
+ .message("Not enough of traces")
+ .expected(expectedTraceCount)
+ .actual(traceCount)
+ .buildAndThrow();
}
} else {
if (traceCount != expectedTraceCount) {
- throw new AssertionFailedError("Invalid number of traces", expectedTraceCount, traceCount);
+ assertionFailure()
+ .message("Invalid number of traces")
+ .expected(expectedTraceCount)
+ .actual(traceCount)
+ .buildAndThrow();
}
}
if (opts.sorter != null) {
diff --git a/docs/how_to_test_with_junit.md b/docs/how_to_test_with_junit.md
new file mode 100644
index 00000000000..3966b3249c0
--- /dev/null
+++ b/docs/how_to_test_with_junit.md
@@ -0,0 +1,350 @@
+# How to Test With JUnit Guide
+
+This guide covers the JUnit 5 testing utilities for writing instrumentation and unit tests.
+
+## Config injection with `@WithConfig`
+
+`@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.
+
+### Class-level config
+
+Applies to all tests in the class:
+
+```java
+@WithConfig(key = "service", value = "my-service")
+@WithConfig(key = "trace.analytics.enabled", value = "true")
+class MyTest extends DDJavaSpecification {
+ @Test
+ void test() {
+ // dd.service=my-service and dd.trace.analytics.enabled=true are set
+ }
+}
+```
+
+### Method-level config
+
+Applies to a single test method, in addition to class-level config:
+
+```java
+@WithConfig(key = "service", value = "my-service")
+class MyTest extends DDJavaSpecification {
+ @Test
+ @WithConfig(key = "trace.resolver.enabled", value = "false")
+ void testWithResolverDisabled() {
+ // dd.service=my-service AND dd.trace.resolver.enabled=false
+ }
+
+ @Test
+ void testWithDefaults() {
+ // only dd.service=my-service
+ }
+}
+```
+
+### Environment variables
+
+Use `env = true` to set an environment variable instead of a system property:
+
+```java
+@WithConfig(key = "AGENT_HOST", value = "localhost", env = true)
+```
+
+### Raw keys (no auto-prefix)
+
+Use `addPrefix = false` to skip the automatic `dd.`/`DD_` prefix:
+
+```java
+@WithConfig(key = "OTEL_SERVICE_NAME", value = "test", env = true, addPrefix = false)
+```
+
+### Config with constant references
+
+Annotation values accept compile-time constants:
+
+```java
+@WithConfig(key = TracerConfig.TRACE_RESOLVER_ENABLED, value = "false")
+```
+
+### Inheritance
+
+`@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:
+
+```java
+@WithConfig(key = "integration.opentelemetry.experimental.enabled", value = "true")
+abstract class AbstractOtelTest extends AbstractInstrumentationTest { }
+
+@WithConfig(key = "trace.propagation.style", value = "b3multi")
+class B3MultiTest extends AbstractOtelTest {
+ // Both configs are active
+}
+```
+
+### Composed annotations
+
+Bundle multiple configs into a reusable annotation:
+
+```java
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE, ElementType.METHOD})
+@WithConfig(key = "iast.enabled", value = "true")
+@WithConfig(key = "iast.detection.mode", value = "FULL")
+@WithConfig(key = "iast.redaction.enabled", value = "false")
+public @interface IastFullDetection {}
+```
+
+Then reuse across test classes:
+
+```java
+@IastFullDetection
+class IastTagTest extends DDJavaSpecification { }
+
+@IastFullDetection
+class IastReporterTest extends DDJavaSpecification { }
+```
+
+### Imperative config injection
+
+For dynamic values that can't be expressed in annotations, use the static methods directly:
+
+```java
+@Test
+void testDynamic() {
+ String port = startServer();
+ WithConfigExtension.injectSysConfig("trace.agent.port", port);
+ // ...
+}
+```
+
+### Lifecycle
+
+Config is rebuilt from a clean slate before each test:
+
+1. **`beforeAll`**: class-level `@WithConfig` applied + config rebuilt (available for `@BeforeAll` methods)
+2. **`beforeEach`**: properties restored, class + method `@WithConfig` applied, config rebuilt
+3. **`afterEach`**: env vars cleared, properties restored, config rebuilt
+
+This means each test starts with a clean config, and method-level `@WithConfig` doesn't leak between tests.
+
+## Instrumentation tests with `AbstractInstrumentationTest`
+
+`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.
+
+### Lifecycle
+
+| Phase | Scope | What happens |
+|---|---|---|
+| `@BeforeAll initAll()` | Once per class | Creates tracer + writer, installs ByteBuddy agent |
+| `@BeforeEach init()` | Per test | Flushes tracer, resets writer |
+| `@AfterEach tearDown()` | Per test | Flushes tracer |
+| `@AfterAll tearDownAll()` | Once per class | Closes tracer, removes agent transformer |
+
+### Available fields
+
+- `tracer` — the DD `TracerAPI` instance (shared across tests in the class)
+- `writer` — the `ListWriter` that captures traces written by the tracer
+
+### Configuring the tracer
+
+The tracer can be configured at class level using the `testConfig` builder. Call it from a static initializer (runs before `@BeforeAll`):
+
+```java
+class MyTest extends AbstractInstrumentationTest {
+ static {
+ testConfig.idGenerationStrategy("RANDOM").strictTraceWrites(false);
+ }
+}
+```
+
+Available settings:
+
+| Method | Default | Description |
+|---|---|---|
+| `idGenerationStrategy(String)` | `"SEQUENTIAL"` | Span ID generation strategy |
+| `strictTraceWrites(boolean)` | `true` | Enable strict trace write validation |
+
+### Basic test
+
+```java
+class HttpInstrumentationTest extends AbstractInstrumentationTest {
+ @Test
+ void testHttpRequest() {
+ // exercise the instrumented code
+ makeHttpRequest("http://example.com/api");
+
+ // assert the trace structure
+ assertTraces(
+ trace(
+ span().root().operationName("http.request").resourceName("GET /api")));
+ }
+}
+```
+
+## Trace assertion API
+
+The assertion API verifies trace structure using a fluent builder pattern. Import the static factories:
+
+```java
+import static datadog.trace.agent.test.assertions.TraceMatcher.trace;
+import static datadog.trace.agent.test.assertions.SpanMatcher.span;
+import static datadog.trace.agent.test.assertions.TagsMatcher.*;
+import static datadog.trace.agent.test.assertions.Matchers.*;
+```
+
+### Asserting traces
+
+`assertTraces` waits for traces to arrive (20s timeout), then verifies the structure:
+
+```java
+// Single trace with 2 spans
+assertTraces(
+ trace(
+ span().root().operationName("parent"),
+ span().childOfPrevious().operationName("child")));
+
+// Multiple traces
+assertTraces(
+ trace(span().root().operationName("trace-1")),
+ trace(span().root().operationName("trace-2")));
+```
+
+### Trace options
+
+```java
+import static datadog.trace.agent.test.assertions.TraceAssertions.*;
+
+// Ignore extra traces beyond the expected ones
+assertTraces(IGNORE_ADDITIONAL_TRACES,
+ trace(span().root().operationName("expected")));
+
+// Sort traces by start time before assertion
+assertTraces(SORT_BY_START_TIME,
+ trace(span().root().operationName("first")),
+ trace(span().root().operationName("second")));
+```
+
+### Span matching
+
+```java
+span()
+ // Identity
+ .root() // root span (parent ID = 0)
+ .childOfPrevious() // child of previous span in trace
+ .childOf(parentSpanId) // child of specific span
+
+ // Properties
+ .operationName("http.request") // exact match
+ .operationName(Pattern.compile("http.*"))// regex match
+ .resourceName("GET /api") // exact match
+ .serviceName("my-service") // exact match
+ .type("web") // span type
+
+ // Error
+ .error() // expects error = true
+ .error(false) // expects error = false
+
+ // Duration
+ .durationShorterThan(Duration.ofMillis(100))
+ .durationLongerThan(Duration.ofMillis(1))
+
+ // Tags
+ .tags(
+ defaultTags(), // all standard DD tags
+ tag("http.method", is("GET")), // exact tag value
+ tag("db.type", is("postgres")))
+
+ // Span links
+ .links(
+ SpanLinkMatcher.to(otherSpan),
+ SpanLinkMatcher.any())
+```
+
+### Tag matching
+
+```java
+// Default DD tags (thread name, runtime ID, sampling, etc.)
+defaultTags()
+
+// Exact value
+tag("http.status", is(200))
+
+// Custom validation
+tag("response.body", validates(v -> ((String) v).contains("success")))
+
+// Any value (just check presence)
+tag("custom.tag", any())
+
+// Error tags from exception
+error(IOException.class)
+error(IOException.class, "Connection refused")
+error(new IOException("Connection refused"))
+
+// Check tag presence without value check
+includes("tag1", "tag2")
+```
+
+### Value matchers
+
+```java
+is("expected") // equality
+isNull() // null check
+isNonNull() // non-null check
+isTrue() // boolean true
+isFalse() // boolean false
+matches("regex.*") // regex match
+matches(Pattern.compile("..."))
+validates(v -> ...) // custom predicate
+any() // accept anything
+```
+
+### Span link matching
+
+```java
+// Link to a specific span
+SpanLinkMatcher.to(parentSpan)
+
+// Link with trace/span IDs
+SpanLinkMatcher.to(traceId, spanId)
+
+// Link with additional properties
+SpanLinkMatcher.to(span)
+ .traceFlags((byte) 0x01)
+ .traceState("vendor=value")
+
+// Accept any link
+SpanLinkMatcher.any()
+```
+
+### Sorting spans within a trace
+
+```java
+import static datadog.trace.agent.test.assertions.TraceMatcher.SORT_BY_START_TIME;
+
+assertTraces(
+ trace(
+ SORT_BY_START_TIME,
+ span().root().operationName("parent"),
+ span().childOfPrevious().operationName("child")));
+```
+
+### Waiting for traces
+
+```java
+// Wait until a condition is met (20s timeout)
+blockUntilTracesMatch(traces -> traces.size() >= 2);
+
+// Wait for child spans to finish
+blockUntilChildSpansFinished(3);
+```
+
+### Accessing traces directly
+
+For assertions not covered by the fluent API, access the writer directly:
+
+```java
+writer.waitForTraces(1);
+List trace = writer.firstTrace();
+DDSpan span = trace.get(0);
+
+assertEquals("expected-op", span.getOperationName().toString());
+assertEquals(42L, span.getTag("custom.metric"));
+```
diff --git a/utils/junit-utils/src/main/java/datadog/trace/junit/utils/config/WithConfig.java b/utils/junit-utils/src/main/java/datadog/trace/junit/utils/config/WithConfig.java
index 172c24f03ff..a77045210ca 100644
--- a/utils/junit-utils/src/main/java/datadog/trace/junit/utils/config/WithConfig.java
+++ b/utils/junit-utils/src/main/java/datadog/trace/junit/utils/config/WithConfig.java
@@ -1,9 +1,11 @@
package datadog.trace.junit.utils.config;
-import java.lang.annotation.ElementType;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -27,8 +29,8 @@
* }
* }
*/
-@Retention(RetentionPolicy.RUNTIME)
-@Target({ElementType.TYPE, ElementType.METHOD})
+@Retention(RUNTIME)
+@Target({TYPE, METHOD})
@Repeatable(WithConfigs.class)
@ExtendWith(WithConfigExtension.class)
public @interface WithConfig {
diff --git a/utils/junit-utils/src/main/java/datadog/trace/junit/utils/config/WithConfigExtension.java b/utils/junit-utils/src/main/java/datadog/trace/junit/utils/config/WithConfigExtension.java
index 5041d8dc669..28d175ee0f1 100644
--- a/utils/junit-utils/src/main/java/datadog/trace/junit/utils/config/WithConfigExtension.java
+++ b/utils/junit-utils/src/main/java/datadog/trace/junit/utils/config/WithConfigExtension.java
@@ -19,6 +19,7 @@
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
+import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -62,6 +63,7 @@ public class WithConfigExtension
private static Field configInstanceField;
private static Constructor> configConstructor;
+ private static volatile boolean configTransformerInstalled = false;
private static volatile boolean isConfigInstanceModifiable = false;
private static volatile boolean configModificationFailed = false;
@@ -73,22 +75,33 @@ public class WithConfigExtension
@Override
public void beforeAll(ExtensionContext context) {
- installConfigTransformer();
+ if (!configTransformerInstalled) {
+ installConfigTransformer();
+ configTransformerInstalled = true;
+ }
makeConfigInstanceModifiable();
assertFalse(configModificationFailed, "Config class modification failed");
+ if (isConfigInstanceModifiable) {
+ checkConfigTransformation();
+ }
if (originalSystemProperties == null) {
saveProperties();
}
+ // Apply class-level @WithConfig so config is available before @BeforeAll methods
+ applyClassLevelConfig(context);
+ if (isConfigInstanceModifiable) {
+ rebuildConfig();
+ }
}
@Override
public void beforeEach(ExtensionContext context) {
restoreProperties();
environmentVariables.clear();
+ applyDeclaredConfig(context);
if (isConfigInstanceModifiable) {
rebuildConfig();
}
- applyDeclaredConfig(context);
}
@Override
@@ -108,14 +121,29 @@ public void afterAll(ExtensionContext context) {
}
}
- private void applyDeclaredConfig(ExtensionContext context) {
- // Class-level @WithConfig annotations (supports composed/meta-annotations)
- List classConfigs =
- AnnotationSupport.findRepeatableAnnotations(
- context.getRequiredTestClass(), WithConfig.class);
- for (WithConfig cfg : classConfigs) {
- applyConfig(cfg);
+ private static void applyDeclaredConfig(ExtensionContext context) {
+ applyClassLevelConfig(context);
+ applyMethodLevelConfig(context);
+ }
+
+ private static void applyClassLevelConfig(ExtensionContext context) {
+ // Walk the entire class hierarchy so annotations on superclasses are applied
+ // (topmost first, then subclass overrides)
+ Class> testClass = context.getRequiredTestClass();
+ List> hierarchy = new ArrayList<>();
+ for (Class> cls = testClass; cls != null; cls = cls.getSuperclass()) {
+ hierarchy.add(cls);
}
+ for (int i = hierarchy.size() - 1; i >= 0; i--) {
+ List classConfigs =
+ AnnotationSupport.findRepeatableAnnotations(hierarchy.get(i), WithConfig.class);
+ for (WithConfig cfg : classConfigs) {
+ applyConfig(cfg);
+ }
+ }
+ }
+
+ private static void applyMethodLevelConfig(ExtensionContext context) {
// Method-level @WithConfig annotations (supports composed/meta-annotations)
context
.getTestMethod()
@@ -131,12 +159,22 @@ private void applyDeclaredConfig(ExtensionContext context) {
private static void applyConfig(WithConfig cfg) {
if (cfg.env()) {
- injectEnvConfig(cfg.key(), cfg.value(), cfg.addPrefix());
+ setEnvVariable(cfg.key(), cfg.value(), cfg.addPrefix());
} else {
- injectSysConfig(cfg.key(), cfg.value(), cfg.addPrefix());
+ setSysProperty(cfg.key(), cfg.value(), cfg.addPrefix());
}
}
+ private static void setSysProperty(String name, String value, boolean addPrefix) {
+ String prefixedName = name.startsWith("dd.") || !addPrefix ? name : "dd." + name;
+ System.setProperty(prefixedName, value);
+ }
+
+ private static void setEnvVariable(String name, String value, boolean addPrefix) {
+ String prefixedName = name.startsWith("DD_") || !addPrefix ? name : "DD_" + name;
+ environmentVariables.set(prefixedName, value);
+ }
+
// endregion
// region Public static API for imperative config injection
@@ -146,9 +184,7 @@ public static void injectSysConfig(String name, String value) {
}
public static void injectSysConfig(String name, String value, boolean addPrefix) {
- checkConfigTransformation();
- String prefixedName = name.startsWith("dd.") || !addPrefix ? name : "dd." + name;
- System.setProperty(prefixedName, value);
+ setSysProperty(name, value, addPrefix);
rebuildConfig();
}
@@ -157,7 +193,6 @@ public static void removeSysConfig(String name) {
}
public static void removeSysConfig(String name, boolean addPrefix) {
- checkConfigTransformation();
String prefixedName = name.startsWith("dd.") || !addPrefix ? name : "dd." + name;
System.clearProperty(prefixedName);
rebuildConfig();
@@ -168,9 +203,7 @@ public static void injectEnvConfig(String name, String value) {
}
public static void injectEnvConfig(String name, String value, boolean addPrefix) {
- checkConfigTransformation();
- String prefixedName = name.startsWith("DD_") || !addPrefix ? name : "DD_" + name;
- environmentVariables.set(prefixedName, value);
+ setEnvVariable(name, value, addPrefix);
rebuildConfig();
}
@@ -179,7 +212,6 @@ public static void removeEnvConfig(String name) {
}
public static void removeEnvConfig(String name, boolean addPrefix) {
- checkConfigTransformation();
String prefixedName = name.startsWith("DD_") || !addPrefix ? name : "DD_" + name;
environmentVariables.removePrefixed(prefixedName);
rebuildConfig();
@@ -245,7 +277,6 @@ static void makeConfigInstanceModifiable() {
private static void rebuildConfig() {
synchronized (WithConfigExtension.class) {
- checkConfigTransformation();
try {
Object newInstConfig = instConfigConstructor.newInstance();
instConfigInstanceField.set(null, newInstConfig);
diff --git a/utils/junit-utils/src/main/java/datadog/trace/junit/utils/config/WithConfigs.java b/utils/junit-utils/src/main/java/datadog/trace/junit/utils/config/WithConfigs.java
index 64a21fe5845..e0343437823 100644
--- a/utils/junit-utils/src/main/java/datadog/trace/junit/utils/config/WithConfigs.java
+++ b/utils/junit-utils/src/main/java/datadog/trace/junit/utils/config/WithConfigs.java
@@ -1,14 +1,16 @@
package datadog.trace.junit.utils.config;
-import java.lang.annotation.ElementType;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.junit.jupiter.api.extension.ExtendWith;
/** Container annotation for repeatable {@link WithConfig}. */
-@Retention(RetentionPolicy.RUNTIME)
-@Target({ElementType.TYPE, ElementType.METHOD})
+@Retention(RUNTIME)
+@Target({TYPE, METHOD})
@ExtendWith(WithConfigExtension.class)
public @interface WithConfigs {
WithConfig[] value();