diff --git a/sdk-tests/pom.xml b/sdk-tests/pom.xml index 29d0511358..f22e42a6c8 100644 --- a/sdk-tests/pom.xml +++ b/sdk-tests/pom.xml @@ -143,6 +143,11 @@ org.testcontainers junit-jupiter + + io.dapr + testcontainers-dapr + test + org.springframework.data spring-data-keyvalue diff --git a/sdk-tests/src/test/java/io/dapr/it/testcontainers/actors/DaprActorsIT.java b/sdk-tests/src/test/java/io/dapr/it/testcontainers/actors/DaprActorsIT.java index ba8cec619c..fe90b452c1 100644 --- a/sdk-tests/src/test/java/io/dapr/it/testcontainers/actors/DaprActorsIT.java +++ b/sdk-tests/src/test/java/io/dapr/it/testcontainers/actors/DaprActorsIT.java @@ -20,64 +20,32 @@ import io.dapr.testcontainers.Component; import io.dapr.testcontainers.DaprContainer; import io.dapr.testcontainers.DaprLogLevel; +import io.dapr.testcontainers.spring.DaprContainerFactory; +import io.dapr.testcontainers.spring.DaprSidecarContainer; +import io.dapr.testcontainers.spring.DaprSpringBootTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.testcontainers.containers.Network; import org.testcontainers.containers.wait.strategy.Wait; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; import java.util.Map; -import java.util.Random; import java.util.UUID; -import static io.dapr.it.testcontainers.ContainerConstants.DAPR_RUNTIME_IMAGE_TAG; import static org.junit.jupiter.api.Assertions.assertEquals; -@SpringBootTest( - webEnvironment = WebEnvironment.RANDOM_PORT, - classes = { - TestActorsApplication.class, - TestDaprActorsConfiguration.class - } -) -@Testcontainers +@DaprSpringBootTest(classes = {TestActorsApplication.class, TestDaprActorsConfiguration.class}) @Tag("testcontainers") public class DaprActorsIT { - private static final Network DAPR_NETWORK = Network.newNetwork(); - private static final Random RANDOM = new Random(); - private static final int PORT = RANDOM.nextInt(1000) + 8000; private static final String ACTORS_MESSAGE_PATTERN = ".*Actor runtime started.*"; - @Container - private static final DaprContainer DAPR_CONTAINER = new DaprContainer(DAPR_RUNTIME_IMAGE_TAG) - .withAppName("actor-dapr-app") - .withNetwork(DAPR_NETWORK) - .withComponent(new Component("kvstore", "state.in-memory", "v1", - Map.of("actorStateStore", "true"))) - .withDaprLogLevel(DaprLogLevel.DEBUG) - .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) - .withAppChannelAddress("host.testcontainers.internal") - .withAppPort(PORT); - - /** - * Expose the Dapr ports to the host. - * - * @param registry the dynamic property registry - */ - @DynamicPropertySource - static void daprProperties(DynamicPropertyRegistry registry) { - registry.add("dapr.http.endpoint", DAPR_CONTAINER::getHttpEndpoint); - registry.add("dapr.grpc.endpoint", DAPR_CONTAINER::getGrpcEndpoint); - registry.add("server.port", () -> PORT); - } + @DaprSidecarContainer + private static final DaprContainer DAPR_CONTAINER = DaprContainerFactory.createForSpringBootTest("actor-dapr-app") + .withComponent(new Component("kvstore", "state.in-memory", "v1", + Map.of("actorStateStore", "true"))) + .withDaprLogLevel(DaprLogLevel.DEBUG) + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())); @Autowired private ActorClient daprActorClient; @@ -86,8 +54,8 @@ static void daprProperties(DynamicPropertyRegistry registry) { private ActorRuntime daprActorRuntime; @BeforeEach - public void setUp(){ - org.testcontainers.Testcontainers.exposeHostPorts(PORT); + public void setUp() { + org.testcontainers.Testcontainers.exposeHostPorts(DAPR_CONTAINER.getAppPort()); daprActorRuntime.registerActor(TestActorImpl.class); // Wait for actor runtime to start. diff --git a/sdk-tests/src/test/java/io/dapr/testcontainers/spring/DaprContainerFactory.java b/sdk-tests/src/test/java/io/dapr/testcontainers/spring/DaprContainerFactory.java new file mode 100644 index 0000000000..fdf9815f46 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/testcontainers/spring/DaprContainerFactory.java @@ -0,0 +1,64 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.testcontainers.spring; + +import io.dapr.testcontainers.DaprContainer; + +import java.io.IOException; +import java.net.ServerSocket; + +import static io.dapr.testcontainers.DaprContainerConstants.DAPR_RUNTIME_IMAGE_TAG; + +/** + * Factory for creating DaprContainer instances configured for Spring Boot integration tests. + * + *

This class handles the common setup required for bidirectional communication + * between Spring Boot applications and the Dapr sidecar in test scenarios.

+ */ +public final class DaprContainerFactory { + + private DaprContainerFactory() { + // Utility class + } + + /** + * Creates a DaprContainer pre-configured for Spring Boot integration tests. + * This factory method handles the common setup required for bidirectional + * communication between Spring Boot and the Dapr sidecar: + * + * + * @param appName the Dapr application name + * @return a pre-configured DaprContainer for Spring Boot tests + */ + public static DaprContainer createForSpringBootTest(String appName) { + int port = allocateFreePort(); + + return new DaprContainer(DAPR_RUNTIME_IMAGE_TAG) + .withAppName(appName) + .withAppPort(port) + .withAppChannelAddress("host.testcontainers.internal"); + } + + private static int allocateFreePort() { + try (ServerSocket socket = new ServerSocket(0)) { + socket.setReuseAddress(true); + return socket.getLocalPort(); + } catch (IOException e) { + throw new IllegalStateException("Failed to allocate free port", e); + } + } +} diff --git a/sdk-tests/src/test/java/io/dapr/testcontainers/spring/DaprSidecarContainer.java b/sdk-tests/src/test/java/io/dapr/testcontainers/spring/DaprSidecarContainer.java new file mode 100644 index 0000000000..2fd2ee47f1 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/testcontainers/spring/DaprSidecarContainer.java @@ -0,0 +1,67 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.testcontainers.spring; + +import org.testcontainers.junit.jupiter.Container; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a static field containing a {@link io.dapr.testcontainers.DaprContainer} + * for automatic integration with Spring Boot tests. + * + *

This annotation combines the Testcontainers {@link Container} annotation + * with Dapr-specific configuration. When used with {@link DaprSpringBootTest}, + * it automatically:

+ * + * + *

Important: For tests that require Dapr-to-app communication (like actor tests), + * you must call {@code Testcontainers.exposeHostPorts(container.getAppPort())} + * in your {@code @BeforeEach} method before registering actors or making Dapr calls.

+ * + *

Example usage:

+ *
{@code
+ * @DaprSpringBootTest(classes = MyApplication.class)
+ * class MyDaprIT {
+ *
+ *     @DaprSidecarContainer
+ *     private static final DaprContainer DAPR = DaprContainer.createForSpringBootTest("my-app")
+ *         .withComponent(new Component("statestore", "state.in-memory", "v1", Map.of()));
+ *
+ *     @BeforeEach
+ *     void setUp() {
+ *         Testcontainers.exposeHostPorts(DAPR.getAppPort());
+ *     }
+ *
+ *     @Test
+ *     void testSomething() {
+ *         // Your test code here
+ *     }
+ * }
+ * }
+ * + * @see DaprSpringBootTest + * @see io.dapr.testcontainers.DaprContainer#createForSpringBootTest(String) + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@Container +public @interface DaprSidecarContainer { +} diff --git a/sdk-tests/src/test/java/io/dapr/testcontainers/spring/DaprSpringBootContextInitializer.java b/sdk-tests/src/test/java/io/dapr/testcontainers/spring/DaprSpringBootContextInitializer.java new file mode 100644 index 0000000000..1fa0ffb453 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/testcontainers/spring/DaprSpringBootContextInitializer.java @@ -0,0 +1,104 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.testcontainers.spring; + +import io.dapr.testcontainers.DaprContainer; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.env.MapPropertySource; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Supplier; + +/** + * Spring {@link ApplicationContextInitializer} that configures Dapr-related properties + * based on the {@link DaprContainer} registered by {@link DaprSpringBootExtension}. + * + *

This initializer sets the following properties:

+ * + * + *

This initializer is automatically registered when using {@link DaprSpringBootTest}.

+ */ +public class DaprSpringBootContextInitializer + implements ApplicationContextInitializer { + + private static final String PROPERTY_SOURCE_NAME = "daprTestcontainersProperties"; + + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + DaprContainer container = findContainer(); + + if (container == null) { + throw new IllegalStateException( + "No DaprContainer found in registry. Ensure you are using @DaprSpringBootTest " + + "with a @DaprSidecarContainer annotated field." + ); + } + + // Create a property source with lazy resolution for endpoints + // server.port can be resolved immediately since it's set at container creation time + // Dapr endpoints are resolved lazily since the container may not be started yet + applicationContext.getEnvironment().getPropertySources() + .addFirst(new DaprLazyPropertySource(PROPERTY_SOURCE_NAME, container)); + } + + private DaprContainer findContainer() { + // Return the first container in the registry + // In a test scenario, there should only be one test class running at a time + return DaprSpringBootExtension.CONTAINER_REGISTRY.values().stream() + .findFirst() + .orElse(null); + } + + /** + * Custom PropertySource that lazily resolves Dapr container endpoints. + * This allows the endpoints to be resolved after the container has started. + */ + private static class DaprLazyPropertySource extends MapPropertySource { + private final Map> lazyProperties; + + DaprLazyPropertySource(String name, DaprContainer container) { + super(name, new HashMap<>()); + + this.lazyProperties = new HashMap<>(); + lazyProperties.put("server.port", container::getAppPort); + lazyProperties.put("dapr.http.endpoint", container::getHttpEndpoint); + lazyProperties.put("dapr.grpc.endpoint", container::getGrpcEndpoint); + } + + @Override + public Object getProperty(String name) { + Supplier supplier = lazyProperties.get(name); + if (supplier != null) { + return supplier.get(); + } + return null; + } + + @Override + public boolean containsProperty(String name) { + return lazyProperties.containsKey(name); + } + + @Override + public String[] getPropertyNames() { + return lazyProperties.keySet().toArray(new String[0]); + } + } +} diff --git a/sdk-tests/src/test/java/io/dapr/testcontainers/spring/DaprSpringBootExtension.java b/sdk-tests/src/test/java/io/dapr/testcontainers/spring/DaprSpringBootExtension.java new file mode 100644 index 0000000000..6dcafa4918 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/testcontainers/spring/DaprSpringBootExtension.java @@ -0,0 +1,88 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.testcontainers.spring; + +import io.dapr.testcontainers.DaprContainer; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.platform.commons.support.AnnotationSupport; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * JUnit 5 extension that handles Dapr container setup for Spring Boot tests. + * + *

This extension:

+ *
    + *
  • Discovers fields annotated with {@link DaprSidecarContainer}
  • + *
  • Registers the container for property injection by {@link DaprSpringBootContextInitializer}
  • + *
+ * + *

This extension is automatically registered when using {@link DaprSpringBootTest}.

+ */ +public class DaprSpringBootExtension implements BeforeAllCallback { + + /** + * Registry of DaprContainers by test class. Used by {@link DaprSpringBootContextInitializer} + * to configure Spring properties. + */ + static final Map, DaprContainer> CONTAINER_REGISTRY = new ConcurrentHashMap<>(); + + @Override + public void beforeAll(ExtensionContext context) throws Exception { + Class testClass = context.getRequiredTestClass(); + + // Find fields annotated with @DaprSidecarContainer + List containerFields = AnnotationSupport.findAnnotatedFields( + testClass, + DaprSidecarContainer.class, + field -> DaprContainer.class.isAssignableFrom(field.getType()) + ); + + if (containerFields.isEmpty()) { + throw new IllegalStateException( + "No @DaprSidecarContainer annotated field of type DaprContainer found in " + testClass.getName() + + ". Add a static field like: @DaprSidecarContainer private static final DaprContainer DAPR = " + + "DaprContainer.createForSpringBootTest(\"my-app\");" + ); + } + + if (containerFields.size() > 1) { + throw new IllegalStateException( + "Multiple @DaprSidecarContainer annotated fields found in " + testClass.getName() + + ". Only one DaprContainer per test class is supported." + ); + } + + Field containerField = containerFields.get(0); + containerField.setAccessible(true); + + DaprContainer container = (DaprContainer) containerField.get(null); + + if (container == null) { + throw new IllegalStateException( + "@DaprSidecarContainer field '" + containerField.getName() + "' is null in " + testClass.getName() + ); + } + + // Register container for the context initializer + CONTAINER_REGISTRY.put(testClass, container); + + // Note: Testcontainers.exposeHostPorts() is NOT called here because of timing requirements. + // It must be called in @BeforeEach, after the container starts to ensure proper Dapr-to-app communication. + } +} diff --git a/sdk-tests/src/test/java/io/dapr/testcontainers/spring/DaprSpringBootTest.java b/sdk-tests/src/test/java/io/dapr/testcontainers/spring/DaprSpringBootTest.java new file mode 100644 index 0000000000..a76552645a --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/testcontainers/spring/DaprSpringBootTest.java @@ -0,0 +1,79 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.testcontainers.spring; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.core.annotation.AliasFor; +import org.springframework.test.context.ContextConfiguration; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Composed annotation that combines {@link SpringBootTest}, {@link Testcontainers}, + * and the necessary extensions for Dapr integration testing. + * + *

This annotation simplifies the setup of Spring Boot integration tests with Dapr + * by handling port allocation, property configuration, and container lifecycle automatically.

+ * + *

Example usage:

+ *
{@code
+ * @DaprSpringBootTest(classes = MyApplication.class)
+ * class MyDaprIT {
+ *
+ *     @DaprSidecarContainer
+ *     private static final DaprContainer DAPR = DaprContainer.createForSpringBootTest("my-app")
+ *         .withComponent(new Component("statestore", "state.in-memory", "v1", Map.of()));
+ *
+ *     @Test
+ *     void testSomething() {
+ *         // Your test code here
+ *     }
+ * }
+ * }
+ * + * @see DaprSidecarContainer + * @see io.dapr.testcontainers.DaprContainer#createForSpringBootTest(String) + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(DaprSpringBootExtension.class) // Must be first to register container before Spring starts +@Testcontainers // Starts containers via @Container/@DaprSidecarContainer +@ContextConfiguration(initializers = DaprSpringBootContextInitializer.class) +@SpringBootTest(webEnvironment = WebEnvironment.DEFINED_PORT) // Starts Spring context last +public @interface DaprSpringBootTest { + + /** + * The application classes to use for the test. + * Alias for {@link SpringBootTest#classes()}. + * + * @return the application classes + */ + @AliasFor(annotation = SpringBootTest.class, attribute = "classes") + Class[] classes() default {}; + + /** + * Additional properties to configure the test. + * Alias for {@link SpringBootTest#properties()}. + * + * @return additional properties + */ + @AliasFor(annotation = SpringBootTest.class, attribute = "properties") + String[] properties() default {}; +} diff --git a/testcontainers-dapr/pom.xml b/testcontainers-dapr/pom.xml index 786ec56a96..ebc6887b9a 100644 --- a/testcontainers-dapr/pom.xml +++ b/testcontainers-dapr/pom.xml @@ -15,6 +15,17 @@ jar + + + org.yaml + snakeyaml + + + org.testcontainers + testcontainers + + + org.junit.jupiter junit-jupiter @@ -25,14 +36,6 @@ mockito-core test - - org.yaml - snakeyaml - - - org.testcontainers - testcontainers -