Skip to content

Commit 0576768

Browse files
Sort out a solution for testing that works for Spring Boot too
1 parent c7bcbee commit 0576768

File tree

12 files changed

+318
-122
lines changed

12 files changed

+318
-122
lines changed

sdk-spring-boot-starter/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ dependencies {
6767

6868
testAnnotationProcessor(project(":sdk-api-gen"))
6969
testImplementation(project(":sdk-serde-jackson"))
70+
testImplementation(project(":sdk-testing"))
7071

7172
testImplementation("org.springframework.boot:spring-boot-starter-test:$springBootVersion")
7273
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH
2+
//
3+
// This file is part of the Restate Java SDK,
4+
// which is released under the MIT license.
5+
//
6+
// You can find a copy of the license in file LICENSE in the root
7+
// directory of this repository or package, or at
8+
// https://github.com/restatedev/sdk-java/blob/main/LICENSE
9+
package dev.restate.sdk.springboot;
10+
11+
import static org.assertj.core.api.Assertions.assertThat;
12+
13+
import dev.restate.sdk.client.Client;
14+
import dev.restate.sdk.testing.*;
15+
import org.junit.jupiter.api.Test;
16+
import org.junit.jupiter.api.Timeout;
17+
import org.springframework.beans.factory.annotation.Autowired;
18+
import org.springframework.boot.test.context.SpringBootTest;
19+
20+
@SpringBootTest(
21+
classes = Greeter.class,
22+
properties = {"greetingPrefix=Something something "})
23+
@RestateTest(restateContainerImage = "ghcr.io/restatedev/restate:main")
24+
public class SdkTestingIntegrationTest {
25+
26+
@Autowired @BindService private Greeter greeter;
27+
28+
@Test
29+
@Timeout(value = 10)
30+
void greet(@RestateClient Client ingressClient) {
31+
var client = greeterClient.fromClient(ingressClient);
32+
33+
assertThat(client.greet("Francesco")).isEqualTo("Something something Francesco");
34+
}
35+
}

sdk-testing/src/main/java/dev/restate/sdk/testing/BaseRestateRunner.java

Lines changed: 0 additions & 64 deletions
This file was deleted.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH
2+
//
3+
// This file is part of the Restate Java SDK,
4+
// which is released under the MIT license.
5+
//
6+
// You can find a copy of the license in file LICENSE in the root
7+
// directory of this repository or package, or at
8+
// https://github.com/restatedev/sdk-java/blob/main/LICENSE
9+
package dev.restate.sdk.testing;
10+
11+
import java.lang.annotation.ElementType;
12+
import java.lang.annotation.Retention;
13+
import java.lang.annotation.RetentionPolicy;
14+
import java.lang.annotation.Target;
15+
16+
/**
17+
* @see RestateTest
18+
*/
19+
@Target(ElementType.FIELD)
20+
@Retention(RetentionPolicy.RUNTIME)
21+
public @interface BindService {}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH
2+
//
3+
// This file is part of the Restate Java SDK,
4+
// which is released under the MIT license.
5+
//
6+
// You can find a copy of the license in file LICENSE in the root
7+
// directory of this repository or package, or at
8+
// https://github.com/restatedev/sdk-java/blob/main/LICENSE
9+
package dev.restate.sdk.testing;
10+
11+
import java.util.List;
12+
import org.junit.jupiter.api.extension.*;
13+
import org.junit.platform.commons.support.AnnotationSupport;
14+
15+
/**
16+
* @see RestateTest
17+
*/
18+
public class RestateExtension implements BeforeAllCallback, ParameterResolver {
19+
20+
static final ExtensionContext.Namespace NAMESPACE =
21+
ExtensionContext.Namespace.create(RestateExtension.class);
22+
static final String RUNNER = "Runner";
23+
24+
@Override
25+
public void beforeAll(ExtensionContext extensionContext) {
26+
extensionContext
27+
.getStore(NAMESPACE)
28+
.getOrComputeIfAbsent(
29+
RUNNER, ignored -> initializeRestateRunner(extensionContext), RestateRunner.class)
30+
.beforeAll(extensionContext);
31+
}
32+
33+
@Override
34+
public boolean supportsParameter(
35+
ParameterContext parameterContext, ExtensionContext extensionContext)
36+
throws ParameterResolutionException {
37+
return extensionContext
38+
.getStore(NAMESPACE)
39+
.getOrComputeIfAbsent(
40+
RUNNER, ignored -> initializeRestateRunner(extensionContext), RestateRunner.class)
41+
.supportsParameter(parameterContext, extensionContext);
42+
}
43+
44+
@Override
45+
public Object resolveParameter(
46+
ParameterContext parameterContext, ExtensionContext extensionContext)
47+
throws ParameterResolutionException {
48+
return extensionContext
49+
.getStore(NAMESPACE)
50+
.getOrComputeIfAbsent(
51+
RUNNER, ignored -> initializeRestateRunner(extensionContext), RestateRunner.class)
52+
.resolveParameter(parameterContext, extensionContext);
53+
}
54+
55+
private RestateRunner initializeRestateRunner(ExtensionContext extensionContext) {
56+
// Discover services
57+
List<Object> servicesToBind =
58+
AnnotationSupport.findAnnotatedFieldValues(
59+
extensionContext.getRequiredTestInstance(), BindService.class);
60+
if (servicesToBind.isEmpty()) {
61+
throw new IllegalStateException(
62+
"The class "
63+
+ extensionContext.getRequiredTestClass().getName()
64+
+ " is annotated with @RestateTest, but there are no fields annotated with @BindService");
65+
}
66+
67+
RestateTest testAnnotation =
68+
AnnotationSupport.findAnnotation(extensionContext.getRequiredTestClass(), RestateTest.class)
69+
.orElseThrow(
70+
() ->
71+
new IllegalStateException(
72+
"Expecting @RestateTest annotation on the test class"));
73+
74+
// Build runner discovering services to bind
75+
var runnerBuilder = RestateRunnerBuilder.create();
76+
servicesToBind.forEach(runnerBuilder::bind);
77+
runnerBuilder.withRestateContainerImage(testAnnotation.restateContainerImage());
78+
return runnerBuilder.buildRunner();
79+
}
80+
}

sdk-testing/src/main/java/dev/restate/sdk/testing/RestateRunner.java

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88
// https://github.com/restatedev/sdk-java/blob/main/LICENSE
99
package dev.restate.sdk.testing;
1010

11-
import org.junit.jupiter.api.extension.BeforeAllCallback;
12-
import org.junit.jupiter.api.extension.ExtensionContext;
11+
import dev.restate.sdk.client.Client;
12+
import java.net.URI;
13+
import java.net.URISyntaxException;
14+
import java.net.URL;
15+
import org.junit.jupiter.api.extension.*;
1316

1417
/**
1518
* Restate runner for JUnit 5. Example:
@@ -39,8 +42,16 @@
3942
* long response = client.get();
4043
* assertThat(response).isEqualTo(0L);
4144
* }</pre>
45+
*
46+
* @deprecated We now recommend using {@link RestateTest}.
4247
*/
43-
public class RestateRunner extends BaseRestateRunner implements BeforeAllCallback {
48+
@Deprecated
49+
public class RestateRunner implements BeforeAllCallback, ParameterResolver {
50+
51+
static final ExtensionContext.Namespace NAMESPACE =
52+
ExtensionContext.Namespace.create(RestateRunner.class);
53+
static final String DEPLOYER_KEY = "Deployer";
54+
4455
private final ManualRestateRunner deployer;
4556

4657
RestateRunner(ManualRestateRunner deployer) {
@@ -52,4 +63,56 @@ public void beforeAll(ExtensionContext context) {
5263
deployer.start();
5364
context.getStore(NAMESPACE).put(DEPLOYER_KEY, deployer);
5465
}
66+
67+
@Override
68+
public boolean supportsParameter(
69+
ParameterContext parameterContext, ExtensionContext extensionContext)
70+
throws ParameterResolutionException {
71+
return supportsParameter(parameterContext);
72+
}
73+
74+
static boolean supportsParameter(ParameterContext parameterContext) {
75+
return (parameterContext.isAnnotated(RestateAdminClient.class)
76+
&& dev.restate.admin.client.ApiClient.class.isAssignableFrom(
77+
parameterContext.getParameter().getType()))
78+
|| (parameterContext.isAnnotated(RestateClient.class)
79+
&& Client.class.isAssignableFrom(parameterContext.getParameter().getType()))
80+
|| (parameterContext.isAnnotated(RestateURL.class)
81+
&& (String.class.isAssignableFrom(parameterContext.getParameter().getType())
82+
|| URL.class.isAssignableFrom(parameterContext.getParameter().getType())));
83+
}
84+
85+
@Override
86+
public Object resolveParameter(
87+
ParameterContext parameterContext, ExtensionContext extensionContext)
88+
throws ParameterResolutionException {
89+
if (parameterContext.isAnnotated(RestateAdminClient.class)) {
90+
return getDeployer(extensionContext).getAdminClient();
91+
} else if (parameterContext.isAnnotated(RestateClient.class)) {
92+
return resolveClient(extensionContext);
93+
} else if (parameterContext.isAnnotated(RestateURL.class)) {
94+
URL url = getDeployer(extensionContext).getIngressUrl();
95+
if (parameterContext.getParameter().getType().equals(String.class)) {
96+
return url.toString();
97+
}
98+
if (parameterContext.getParameter().getType().equals(URI.class)) {
99+
try {
100+
return url.toURI();
101+
} catch (URISyntaxException e) {
102+
throw new RuntimeException(e);
103+
}
104+
}
105+
return url;
106+
}
107+
throw new ParameterResolutionException("The parameter is not supported");
108+
}
109+
110+
private Client resolveClient(ExtensionContext extensionContext) {
111+
URL url = getDeployer(extensionContext).getIngressUrl();
112+
return Client.connect(url.toString());
113+
}
114+
115+
private ManualRestateRunner getDeployer(ExtensionContext extensionContext) {
116+
return (ManualRestateRunner) extensionContext.getStore(NAMESPACE).get(DEPLOYER_KEY);
117+
}
55118
}

sdk-testing/src/main/java/dev/restate/sdk/testing/RestateRunnerBuilder.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
*/
2121
public class RestateRunnerBuilder {
2222

23-
private static final String DEFAULT_RESTATE_CONTAINER = "docker.io/restatedev/restate";
23+
private static final String DEFAULT_RESTATE_CONTAINER = "docker.io/restatedev/restate:latest";
2424
private final RestateHttpEndpointBuilder endpointBuilder;
2525
private String restateContainerImage = DEFAULT_RESTATE_CONTAINER;
2626
private final Map<String, String> additionalEnv = new HashMap<>();
@@ -90,7 +90,9 @@ public ManualRestateRunner buildManualRunner() {
9090

9191
/**
9292
* @return a {@link RestateRunner} to be used as JUnit 5 Extension.
93+
* @deprecated If you use JUnit 5, use {@link RestateTest}
9394
*/
95+
@Deprecated
9496
public RestateRunner buildRunner() {
9597
return new RestateRunner(this.buildManualRunner());
9698
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH
2+
//
3+
// This file is part of the Restate Java SDK,
4+
// which is released under the MIT license.
5+
//
6+
// You can find a copy of the license in file LICENSE in the root
7+
// directory of this repository or package, or at
8+
// https://github.com/restatedev/sdk-java/blob/main/LICENSE
9+
package dev.restate.sdk.testing;
10+
11+
import java.lang.annotation.*;
12+
import org.junit.jupiter.api.TestInstance;
13+
import org.junit.jupiter.api.extension.ExtendWith;
14+
15+
/**
16+
* Annotation to enable the Restate extension for JUnit 5. The annotation will bootstrap a Restate
17+
* environment using TestContainers, and will automatically register all services field of the class
18+
* annotated with {@link BindService}.
19+
*
20+
* <p>Example:
21+
*
22+
* <pre>
23+
* // Annotate the class as RestateTest to start a Restate environment
24+
* {@code @RestateTest}
25+
* class CounterTest {
26+
*
27+
* // Annotate the service to bind
28+
* {@code @BindService} private final Counter counter = new Counter();
29+
*
30+
* // Inject the client to send requests
31+
* {@code @Test}
32+
* void testGreet({@code @RestateClient} Client ingressClient) {
33+
* var client = CounterClient.fromClient(ingressClient, "my-counter");
34+
*
35+
* long response = client.get();
36+
* assertThat(response).isEqualTo(0L);
37+
* }
38+
* }
39+
* </pre>
40+
*
41+
* <p>The runner will deploy the services locally, execute Restate as container using <a
42+
* href="https://java.testcontainers.org/">Testcontainers</a>, and register the services.
43+
*
44+
* <p>This extension is scoped per test class, meaning that the restate runner will be shared among
45+
* test methods. Because of the aforementioned issue, the extension sets the {@link TestInstance}
46+
* {@link TestInstance.Lifecycle#PER_CLASS} automatically.
47+
*
48+
* <p>Use the annotations {@link RestateClient}, {@link RestateURL} and {@link RestateAdminClient}
49+
* to interact with the deployed environment:
50+
*
51+
* <pre>
52+
* {@code @Test}
53+
* void initialCountIsZero({@code @RestateClient} Client client) {
54+
* var client = CounterClient.fromClient(ingressClient, "my-counter");
55+
*
56+
* // Use client as usual
57+
* long response = client.get();
58+
* assertThat(response).isEqualTo(0L);
59+
* }</pre>
60+
*/
61+
@Target(ElementType.TYPE)
62+
@Retention(RetentionPolicy.RUNTIME)
63+
@Documented
64+
@Inherited
65+
@ExtendWith(RestateExtension.class)
66+
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
67+
public @interface RestateTest {
68+
69+
/** Restate container image to use */
70+
String restateContainerImage() default "docker.io/restatedev/restate:latest";
71+
}

0 commit comments

Comments
 (0)