Skip to content

feat(kotest): typesafe Kotest scenario DSL integration - #688

Open
wilmveel wants to merge 33 commits into
masterfrom
kotest-scenario-dsl
Open

feat(kotest): typesafe Kotest scenario DSL integration#688
wilmveel wants to merge 33 commits into
masterfrom
kotest-scenario-dsl

Conversation

@wilmveel

@wilmveel wilmveel commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

What

New src:integration:kotest module that emits a typesafe Kotest scenario DSL next to the generated Kotlin models, via the IR KotestDslExtension. Endpoints, channels, and types each get a block-style entry point read through the generated name. Every builder entry point returns a Gen<…> — compose it into checkAll, or call()/send()/draw() a single value out of it:

// endpoint — request { } returns Gen<Request>; call() draws one and sends it
val response = CreateProduct.generate.request {
    body { sku = Arb.constant("SKU-001"); price = Arb.constant(29.95) }
}.call()

// channel — message { } returns Gen<Payload>; send() draws one and publishes it
Queue.generate.message { eventType = Arb.constant(CampaignEventType.ENDED) }.send()
Queue.generate.listen { expecting { event -> event.eventType shouldBe CREATED } }

// type — Gen<T> for a standalone record
val gen: Gen<TodoDto> = TodoDto.generate { name = Arb.constant("milk") }

// draw one value to inspect (response builders, or any of the above)
val canned: CreateProduct.Response201 = CreateProduct.generate.response201 { body = Arb.constant(p) }.draw()

Fields are pinned with kotest Gens; un-set fields are drawn by the Wirespec generator. call()/send() draw seeded by the per-test ambient RandomSource (installed by the endpoint/channel extensions), which also resolves the transport.

Shared builders

Every record has one reusable <Type>Builder, referenced by endpoint request bodies, channel payloads, and <Type>.generate alike rather than replicated per operation. Nested record fields expose a <field>Block { … } sub-block that opens the nested type's builder, so overrides compose to any depth.

<Type>.generate is a companion-object extension mirroring the endpoint/channel <X>.generate. Generated records have no companion, so the extension injects an empty companion object into each — the generate logic stays in the kotest package, keeping the model dependency-free.

Spring bridge

Both test extensions are framework-agnostic (no Spring/DI dependency); the module carries no Spring on its classpath:

  • WirespecEndpointExtension — supply an endpoint context eagerly (WirespecEndpointContext, or transportation + serialization), or use the factory form: suspend factories for Wirespec.Transportation + Wirespec.Serialization, resolved per test.
  • WirespecChannelExtension — the same, plus a managed overload for a stateful transport: suspend factories it builds once, resets per test (reset), and closes after the spec. A same-named generic factory keeps reset = { it.clear() } cast-free.

Each has a same-named factory function alongside its constructors, so named arguments (serialization = …, transport(ation) = …) select the factory form unambiguously. A caller wires its framework in those factories — the example resolves the server port, Wirespec.Serialization bean, and Kafka bootstrap servers from the Spring test context via testContextManager(). The managed channel extension builds its transport per spec (keyed by Spec), so a single instance is safe to register suite-wide.

The example registers everything once in a single io.kotest.provided.ProjectConfigSpringExtension plus the two wirespec extensions — so specs carry no extension wiring (no @ApplyExtension, no in-body extension(...), no per-app subclass or @TestConfiguration bean); they just declare @SpringBootTest / @EmbeddedKafka.

Build wiring

  • Register src:integration:kotest in settings.gradle.kts; add kotest-property + kotlinx-coroutines-core to the catalog.
  • Move the generated Wirespec.kt runtime to commonMain so this KMP module can reference the Wirespec.* interfaces from its own commonMain.

Example & testing

  • examples/gradle-kotest — end-to-end Spring + embedded-Kafka app driving the DSL against a live server and a real broker.
  • KotestDslExtensionTest asserts the emitted DSL (Gen<> entry points, builder dedup, companion injection); the generated output was also compiled with kotlinc against the real runtime for a flat and a nested/channel spec.

🤖 Generated with Claude Code

@wilmveel
wilmveel force-pushed the kotest-scenario-dsl branch 15 times, most recently from 64c6934 to 029dff3 Compare July 16, 2026 20:45
val parameterTypes: List<Type>,
val returnType: Type,
val receiver: Type? = null,
val isSuspend: kotlin.Boolean = false,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isAsync

returnType: Type,
receiver: Type? = null,
parameterTypes: List<Type> = emptyList(),
isSuspend: Boolean = false,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isAsync

@jerrevanveluw jerrevanveluw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the example added to the pipeline?

Comment thread examples/gradle-kotest/gradle/wrapper/gradle-wrapper.jar

// Hard keywords that match the identifier pattern and therefore must be backtick-escaped
// when used as a name. Soft/modifier keywords are contextual and need no escaping.
private val hardKeywords = setOf(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use reserved keword from emitter

@wilmveel
wilmveel force-pushed the kotest-scenario-dsl branch 4 times, most recently from 697d82f to d41bc1c Compare July 29, 2026 19:33
wilmveel and others added 5 commits August 12, 2026 09:33
Add a new `src:integration:kotest` module that emits a typesafe Kotest
scenario DSL alongside the generated Kotlin models, driven by the IR
`KotestDslExtension`. Each operation and type gets a block-style entry
point that reads through the generated name:

- endpoint: `PutTodo.generate.request { … }.call()` (plus
  `response200 { … }` for canned responses)
- channel:  `Queue.generate.message { … }.send()` and
  `Queue.generate.listen { expecting { … } }`
- type:     `TodoDto.gen { … }` returning a `Gen<TodoDto>`

Per-field values are pinned with kotest `Gen`s; un-set fields are drawn
by the Wirespec generator so a scenario only spells out what it cares
about. A per-test ambient context (installed by the endpoint/channel
extensions) resolves the transport and a reproducible `RandomSource`.

Builders are shared, not replicated: every record has one reusable
`<Type>Builder`, referenced by endpoint request bodies, channel payloads
and `<Type>.gen`. Nested record fields expose a `<field>Block { … }`
sub-block that opens the nested type's builder, so overrides compose to
any depth. `<Type>.gen` is an extension on the type's companion object;
the extension injects an empty `companion object` into each generated
record so there is a receiver to hang on, keeping the model itself
dependency-free.

Build wiring: register the module, add kotest-property + coroutines, and
move the generated `Wirespec.kt` runtime to commonMain so this KMP module
can reference the `Wirespec.*` interfaces from its own commonMain.

Includes `examples/gradle-kotest`, an end-to-end Spring + embedded-Kafka
example driving the DSL against a live server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `Gen<Response<*>>.mock { req -> … }`, the response-side twin of
`Gen<Request>.call()`: it draws a response and stubs it on a mock server
for every incoming request the typed predicate accepts. Per-endpoint
generated (so `req` is the endpoint's typed Request), backed by a new
framework-neutral `MockServer` interface (mirroring `ChannelTransport`)
resolved from a `WirespecMockContext` installed by `WirespecMockExtension`
— the response-side counterpart to `WirespecEndpointExtension`.

Trim the channel DSL to send-only: drop the `listen`/receive half
(`expecting`/`collecting`/`returning`) from the generator and runtime, and
`receive()` from the `ChannelTransport` interface. Asserting on what the
app published is left to the test's own broker consumer.

Example (gradle-kotest):
- WireMock-backed `MockServer` (`WireMockMockServer`, the mock analogue of
  `KafkaChannelTransport`) and a full end-to-end mock scenario: a second
  contract `inventory.ws` for a downstream service the app calls via the
  generated `GetStock` client, mocked with `.mock { req -> req.path.sku == … }`
  in `ProductAvailabilityMockTest`.
- `KafkaChannelTransport` simplified to producer-only; `CampaignChannelScenarioTest`
  asserts published events with a plain Kafka consumer (standard Kotest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…extension subpackage

- Make `ChannelTransport` a `fun interface`; the example's `KafkaChannelTransport`
  becomes a factory returning a SAM lambda over its producer.
- `WirespecMockExtension` gains a caller-owned form (shared server, per-test reset,
  no per-spec close), so it registers once in `ProjectConfig` like the endpoint/channel
  extensions against a suite-wide server whose base URL is wired via `@DynamicPropertySource`.
- Move `WireMockMockServer` into `ProjectConfig.kt` and back it with the wirespec WireMock
  integration: expose `requestBuilder(method, pathTemplate)` / `responseBuilder(rawResponse)`
  there and reuse them, keeping only the typed-predicate matcher example-side.
- Move the three Kotest extensions into the `...kotest.extension` subpackage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…example transport

Move the WireMock Request -> Wirespec.RawRequest mapping out of the example's
ProjectConfig into the shared wiremock integration module as a public
Request.toRawRequest(), and point the example at the integration-provided
HttpTransportation instead of its own copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… topic

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wilmveel and others added 27 commits August 12, 2026 09:33
Add a Visibility enum to the IR (Ast/Dsl) and render it in KotlinGenerator,
then thread explicit visibility through the kotest scenario DSL emitters.
Make the endpoint request-scope slot vars (path/query/header/body) private so
each slot is settable only via its function form (`path { … }`), dropping the
redundant public `var` assignment path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`KotestDslExtension` emits the scenario DSL into the generated dir wired to
the main source set, so the DSL runtime (`wirespec.integration.kotest`) and
`kotest-property` have to compile against main — they were declared
`testImplementation`, leaving `Gen`/`recordGen`/`endpointCall`/… unresolved.
Promote both to `implementation`, matching the adjacent comment's intent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace raw code fragments with typed IR nodes where they map 1:1 with
byte-identical output: the `generate` getters, the endpoint `request` body
(`val scope = …Scope()` / `scope.block()` / `return scope.buildRequest()`),
and the response `build()` cast. Imperative bodies that need trailing/
multi-statement lambdas or method chains (flush, registration, body copy,
messageGen/send) stay raw — the shared KotlinGenerator can't emit those yet.

Verified byte-identical: regenerated the gradle-kotest example's DSL and
diffed against the pre-change output; unit and scenario tests stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the JDK-HttpClient-backed Wirespec.Transportation out of the
kotest integration into a new dependency-light src:integration:java
module (published as community.flock.wirespec.integration:java-jvm), so
other integrations can share it. The module api-exposes the wirespec
runtime since HttpTransportation implements Wirespec.Transportation.

Repackage to community.flock.wirespec.integration.java.transport and
repoint the gradle-kotest example (imports + catalog + dependency) and
the kotest README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… in HttpTransportation

Path segments were joined into the URI raw while query values were encoded, so
any URI-illegal character in a path param (e.g. a space or `#`) made URI.create
throw before the request was ever sent. Path segments are now percent-encoded
like query values, translating URLEncoder's `+` to `%20` for the path context.

The default `Content-Type: application/json` was appended unconditionally for
any non-null body, on top of headers already copied from the request, producing
a duplicate header when the request declared its own Content-Type. It is now
added only when the request carries no Content-Type of its own.

Adds the module's first test source set: HttpTransportationTest drives the
transport against an in-process JDK HttpServer and asserts on the received
request. Both correctness fixes are regression-proven — the tests fail against
the pre-fix code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ommonTest

The module has a single jvm() target, so its commonMain compiles against the JVM
and can use reflection, ConcurrentHashMap and coroutines directly. The commonMain
vs jvmMain split was therefore organizational, not a portability boundary, so the
whole reflection-driven runtime (ArbReceiver, EndpointReflection, CallExecutor,
PrimitiveArbs, the *CallBuilder/ResponseBuilder terminals, the generator adapter)
and the kotest framework extensions move into commonMain, and the four tests move
into commonTest. jvmMain and jvmTest are removed entirely.

The kotest-engine, kotlinx-coroutines-core and kotlin-reflect dependencies move
from the jvmMain block into commonMain; commonTest drops the two dependencies it
re-declared (kotest.property and :src:integration:wirespec already come through
commonMain via dependsOn).

Also folds in the in-flight reorganization this migration built on: the context
handles move to a context/ subpackage, and the DSL emitter's per-model import
collection is hoisted into a shared EndpointShape.modelImportsFor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up quality pass on the scenario-DSL runtime, no behavior change:

- PrimitiveArbs: replace the exception-driven `supports()` (which built an Arb
  inside runCatching just to test membership) with `forTypeOrNull()`, so callers
  resolve the generator in a single lookup instead of building it twice.
- EndpointReflection: extract `pickEmitterConstructor(excludeParam, label)`; the
  request (`method`) and response-variant (`status`) constructor selection were
  the same algorithm copied twice.
- Extensions: extract a shared `SpecScopedResource<T>` for the per-spec lazy
  build + mutex + afterSpec close that Channel and Mock duplicated, and hoist the
  merge-or-create-ambient idiom (with its RandomSource seeding) into
  mergeEndpoint/mergeChannel/mergeMock so all three extensions share one path.
- CallExecutor: move a stacked KDoc block onto buildResponseWith, the function it
  actually documents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Senior-review pass over src/integration/kotest, collapsing duplication and
tightening nullability without changing behavior:

- EndpointReflection: extract Parameter.isListType/listElementClassOrNull to
  de-duplicate the List<T> element-class reflection; map param names to non-null
  at the source instead of carrying List<String?> and filtering late.
- EndpointShape: unify the NestedObject/NestedList sealed branches via
  nestedTypeName/childFields accessors, collapsing collectNestedTypeNames and
  collectFieldTypeNames.
- KotestWirespecGenerator: extract applyOverride() for the duplicated
  draw-then-wrap override path; inline single-use range locals in generateLeaf.
- CallExecutor: entries.associate -> mapKeys; drop redundant bodyGen!! by
  branching on the gen; split out resolveGeneratedBody.
- ChannelCallBuilder: factor the shared payload build; drop the redundant
  { it() } wrapper in favor of block = it.
- ResponseMock: try/catch -> runCatching { }.getOrDefault(false).
- EndpointDslFile: smart-cast bodyType local over bodyType!!; extract
  requireBodyElementType() for the duplicated error.

Also folded in the surrounding branch work: rename IR Type.Function.isSuspend
to isAsync (Ast/Dsl/KotlinGenerator), doc tidy-ups in the wiremock and java
integrations, and gradle-kotest example updates.
Two architecture fixes on the kotest integration module, driven by a
deep-modules / clear-interfaces review. Behavior-preserving; jvmTest green.

- Kill the Response<NNN> naming-convention leak: EndpointReflection,
  EndpointCallBuilder and EndpointShape each encoded "variant class =
  Response + 3-digit status" independently. Centralize into one internal
  ResponseVariantNaming (className/statusOf); all three defer to it.

- Split the overloaded extension/ package by lifecycle. It fused the
  build-time IR emitter (deps: compiler + ir) with the test-time Kotest
  framework hooks (deps: kotest.core) under one word ("extension" meaning
  both IrExtension and TestCaseExtension). Move the 10 internal codegen
  helpers (*DslFile, *Shape, RecordBuilder, KotlinIdentifier,
  KotlinTypeMapper, ValueSetter) into a new emit/ package; the runtime
  Kotest extensions and the public KotestDslExtension stay in extension/,
  so no public FQN changes. KotestDslExtension now imports the *DslFile
  builders from emit/.
…er, drop dead code

Three cleanups on the kotest integration module (behavior-preserving; jvmTest green):

- Combine each emit/*Shape into its *DslFile: ChannelShape -> ChannelDslFile,
  TypeShape -> TypeDslFile, EndpointShape -> EndpointDslFile. All were single-
  consumer (EndpointShape stays referenced across the package, which is fine —
  file boundaries don't affect same-package access).

- Reuse the IR generator's identifier escaper instead of a local copy: promote
  KotlinGenerator's private String.escapeIdentifier to a public
  String.escapeKotlinIdentifier (:src:compiler:ir) and delete kotest's
  KotlinIdentifier, repointing its call sites. Removes a duplicated Kotlin
  keyword list and guarantees raw-code emission escapes names identically to
  the generator that renders the final file.

- Remove unused code:
  * ChannelCallBuilder: buildMessage/buildMessageFields/send()/send(payload)/
    sendFields + the orphaned private generatePayload (superseded by
    messageGen / send(gen)).
  * EndpointCallBuilder.buildRequest() + CallExecutor.buildRequest()
    (superseded by buildRequestGen).
  * The unreferenced typed-terminal subsystem: expecting()/collecting(),
    statusOf, the customAssertion/expectedStatuses fields,
    CallExecutor.executeEndpoint, and ContractValidator's expected-status check
    incl. the public ContractViolationKind.UnexpectedStatus. The .call() path
    (executeRequest) is untouched.
`<T>` already means `<T : Any?>`, so the explicit bound was noise. The
container variants keep their meaningful `T : Any` non-null bounds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Remove the write-only `type: KType` from KotestFieldEnum/KotestFieldUnion
  (only KotestFieldShape.type is ever read).
- Centralize the base-or-override generator resolution in
  ArbReceiver.generateModel(), collapsing the identical block in recordGen
  and ChannelCallBuilder.buildPayload (RNG draw order preserved).
- Drop a compiler-flagged redundant cast in JvmRefinedWrapper.ctorFor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bind the receiver once via `when (val field = this)` so the array/nullable/
dict branches reference the smart-cast field directly, dropping the explicit
`as Wirespec.GeneratorField*<Any>` casts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stop pre-rendering Kotlin type strings in the kotest emit stage. Instead
carry language-neutral IrType through the DSL shapes via the pipeline's
Reference.convert(), and let KotlinGenerator render them at generation time.

- Add KotlinGenerator.generateType(Type) so a single type can be rendered
  in the generate step (used where a type name is embedded in raw emitted
  code, e.g. channelCall<Payload>).
- Thread IrType through NamedTypedField, ResponseVariantShape.bodyType and
  BodyFieldShape.Primitive; valueSetter/genOf/genNullableOf take IrType.
- mapWithRefinedUnwrap returns IrType (Array/Dict/Nullable/convert nodes).
- Delete the bespoke KotlinTypeMapper.

Output is byte-for-byte identical (validated by KotestDslExtensionTest's
exact emitted-type assertions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename the src/integration/java module (and its Gradle path
:src:integration:jvm) and Kotlin package
community.flock.wirespec.integration.java -> .jvm. Update the
gradle-kotest example's dependency catalog (java-jvm -> jvm-jvm),
imports, and the kotest README reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nvert

Group the Kotest generator core (KotestWirespecGenerator, KotestField,
KotestOverrides, KotestBuilderJvm, KotestWirespecKotlinGenerator) under a
dedicated generator package, and rename the emit package to convert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…im comments

Replace the single WirespecAmbient coroutine-context element (which merged
endpoint/channel/mock contexts plus a shared RandomSource) with three
independent context elements folded into their extension files, plus a shared
WirespecSeed element that preserves one-seed-per-test reproducibility.

Also strip inline comments and collapse verbose KDoc to one-line summaries
across the module's commonMain sources.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…VariantNaming

Delete the dead WirespecRequestScope helper (referenced only by its own
test) and inline the ResponseVariantNaming convention into its two call
sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An all-nullable slot's registrations were wrapped in `<slot>?.let { … }`, so
omitting the block left every param unregistered and CallExecutor fell back to
a random value instead of null. Build the slot builder as nullable and register
each field unconditionally, so an absent block yields `Arb.constant(null)`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The scenario DSL's `generate` entry points were typed `Gen<T>` even though
every one was already backed by an `Arb` at runtime. Type them as `Arb` so
callers get the richer Arb API (next/take/map/…) off `generate`.

Runtime backends: recordGen, buildRequestGen, buildGen, messageGen.
Emitters: <Type>.Companion.generate, endpoint request/responseNNN/build/
buildRequest, channel message.

Input/override slots and consumer receivers (.call/.mock/.send) stay `Gen`
— they accept any generator, and Arb is a Gen, so results still chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Branch on builder-slot presence instead of the drawn value when copying
primitive body fields. Using `?: base` conflated an unset slot with a slot
explicitly configured to draw null (e.g. `endDate(null)` on a nullable
field), silently restoring the base value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Apply the same slot-presence branching to the collection override: a
configured whole-collection slot that draws null now stays null instead of
falling through to the block override or the base value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A param-less request/response emitted as a Kotlin `data object` only exposes
a private synthetic constructor, which made reflective instantiation fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Endpoint, channel and record DSL entries were emitted using the raw
Wirespec identifier, while the declarations themselves are emitted
pascal-cased. Specs with underscored definition names (`Publish_Event`)
produced DSL code referencing names that do not resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Channels sharing a payload type emitted identical Gen<Payload>.send
extensions into one package, failing compilation with conflicting
overloads; only the first channel per payload now carries it.

Endpoints with a raw primitive body (e.g. Bytes) get a whole-value
body slot wired through bodyTransform instead of a per-field builder,
and the runtime falls back to primitive Arbs (incl. ByteArray) where
no reflectable model generator exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wilmveel
wilmveel force-pushed the kotest-scenario-dsl branch from 75168a4 to b27eec1 Compare August 12, 2026 07:36
A bare null for Reference.Any produced uncompilable generator code:
non-null Any fields can't hold null and the GeneratorFieldNullable/
Array/Dict lambdas left T uninferred. Route any leaves through a
GeneratorFieldString descriptor so every target language gets a
concrete, assignable value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants