원클릭으로
add-sdk-conformance-test
// Add a new test to the SDK conformance test suite. Use when the user wants to add a new sdk test, conformance test, or test a new SDK feature across all SDK implementations.
// Add a new test to the SDK conformance test suite. Use when the user wants to add a new sdk test, conformance test, or test a new SDK feature across all SDK implementations.
| name | add-sdk-conformance-test |
| description | Add a new test to the SDK conformance test suite. Use when the user wants to add a new sdk test, conformance test, or test a new SDK feature across all SDK implementations. |
| user-invocable | true |
The sdk-tests module is a conformance tool — it defines contracts that SDK implementations must satisfy and test runners that verify them. It contains NO implementation code.
sdk-tests/src/main/kotlin/dev/restate/sdktesting/contracts/) — Kotlin interfaces (@Service, @VirtualObject, @Workflow) that each SDK implements. These define the wire API (service name, handler names, JSON field names).sdk-tests/src/main/kotlin/dev/restate/sdktesting/tests/) — JUnit 5 test classes that drive contracts through the Restate ingress client.Never add implementation code to sdk-tests. Only interfaces in contracts, only test logic in tests.
Edit the relevant contract interface only if strictly needed. The main ones:
VirtualObjectCommandInterpreter — interpreter for combinator/signal/awakeable tests; the workhorse for most feature testsTestUtilsService — utility handlers (cancel, signal resolve/reject, etc.)Contract rules:
@Serializable; sealed hierarchies → @SerialName("camelCase") discriminator@Serializable data class@Handler for exclusive handlers, @Shared for shared handlersAwaitableCommand (sub-operations that can be composed):
CreateAwakeable(awakeableKey), CreateSignal(signalName), Sleep(timeoutMillis), RunReturns(value), RunThrowTerminalException(reason)Command (top-level interpreter steps):
AwaitOne(command) — await a single sub-operationAwaitAny(commands) — first to complete (race); throws if winner failedAwaitAnySuccessful(commands) — first successful or all failed (legacy)AwaitFirstCompleted(commands) — first to complete (race)AwaitFirstSucceededOrAllFailed(commands) — first success, or throws if all failAwaitAllSucceededOrFirstFailed(commands) — all succeed → pipe-joined "v0|v1"; throws on first failAwaitAllCompleted(commands) — all settle → pipe-joined "ok:v0|err:reason|ok:v2"TestUtilsService:
resolveSignal(ResolveSignalRequest(invocationId, signalName, value))rejectSignal(RejectSignalRequest(invocationId, signalName, reason))class MyFeature {
companion object {
@RegisterExtension
val deployerExt: RestateDeployerExtension = RestateDeployerExtension {
withServiceSpec(
ServiceSpec.defaultBuilder()
.withServices(VirtualObjectCommandInterpreter::class, TestUtilsService::class))
}
}
@Test
@DisplayName("Human-readable description")
@Execution(ExecutionMode.CONCURRENT)
fun myTest(@InjectClient ingressClient: Client) = runTest {
// ...
}
}
// Build clients
val voClient = ingressClient.toVirtualObject<VirtualObjectCommandInterpreter>(UUID.randomUUID().toString())
val svcClient = ingressClient.toService<TestUtilsService>()
// Call and get result immediately
val result = voClient.request { myHandler(req) }.options(idempotentCallOptions).call().response
// Send (non-blocking) + attach later — REQUIRED for signals
val sendResponse = voClient.request { myHandler(req) }.options(idempotentCallOptions).send()
val invocationId = sendResponse.invocationId()
// ... resolve/reject signals ...
val result = sendResponse.attachSuspend().response
// Expect a terminal error
assertThat(runCatching { sendResponse.attachSuspend().response }.exceptionOrNull())
.message().contains("expected substring")
// Poll until condition (awakeables only — not needed for signals)
await withAlias "description" untilAsserted {
assertThat(voClient.request { hasAwakeable("key") }.call().response).isTrue()
}
Awakeables — identified by a unique runtime ID stored in VirtualObject state:
interpretCommands call and poll hasAwakeable(key) before resolvinginterpreterClient.request { resolveAwakeable(ResolveAwakeable(key, value)) }Signals — identified by invocation ID + name; no pre-registration needed:
.send() → get invocationId() → resolve/reject via TestUtilsService.resolveSignal/rejectSignal → attachSuspend()./gradlew :sdk-tests:compileKotlin
Build the SDK Docker image (example for TypeScript SDK):
# From the sdk-typescript repo root
podman build -t e2e-ts:local -f packages/tests/restate-e2e-services/Dockerfile .
Run just the new test class:
./gradlew :sdk-tests:run --args='run --sequential --image-pull-policy=CACHED --test-suite=default --test-name=MyFeature --service-container-image=localhost/e2e-ts:local'
After adding a new contract or command type, you must update each SDK's test service implementation. The TypeScript SDK (the reference implementation) lives in sdk-typescript:
packages/tests/restate-e2e-services/src/virtual_object_command_interpreter.ts and test_utils.tspackages/libs/restate-sdk-gen/test-services/src/vo-command-interpreter.ts and test-utils.tsUse the update-sdk-test-contracts skill in the sdk-typescript repo for guidance on the implementation patterns.