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}. + * + *

*/ @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();