A BDD testing framework integrating Gherkin-style tests with ZIO’s effect system
This document explains how to wire services into zio-bdd step bodies using
the R type parameter, the three-tier layer model, per-scenario overrides,
resource scopes, flag-matrix layers, and the HasService typeclass for
reusable step libraries.
R type parameterZIOSteps[R, S] has two type parameters:
S — the per-scenario state type (covered in state.md)R — the ZIO environment type whose services are available inside every step
bodyR is declared once per suite and is typically a type intersection of all the
services the suite’s steps need:
type AppEnv = HttpClient & AccountService & DynamoDB
object MySuite extends ZIOSteps[AppEnv, AppState]:
Given("an account exists") {
ZIO.service[AccountService].flatMap(_.create(AccountId.random))
}
Step bodies can access services via ZIO.service[A] when A is part of R.
Services are not injected via constructor parameters into step methods — they
are looked up from the environment inside each step body as needed.
The type R flows through all three tiers of the layer model described below.
Everything you provide via environment, featureLayer, or scenarioLayer
must collectively satisfy R.
The framework builds an environment in three distinct tiers. Override the methods that apply to your use case; leave the rest at their defaults.
globalLayer built once per JVM process
featureLayer rebuilt once per Feature file
scenarioLayer rebuilt once per Scenario
Each tier delegates to the one above it by default, so overriding only
environment (the legacy single-tier entry point) continues to work unchanged.
globalLayerBuilt once when the suite first starts and shared for the entire JVM lifetime. Use it for expensive shared resources — connection pools, embedded databases, in-process gRPC servers — that should start once and be reused across all features and scenarios.
override def globalLayer: ZLayer[Any, Throwable, R] =
sharedConnectionPool
Default: delegates to featureLayer.
featureLayerBuilt once per Feature execution. Suitable for resources that should be
fresh per feature but shared across the scenarios within that feature.
override def featureLayer: ZLayer[Any, Throwable, R] =
sharedConnectionPool >>> freshSchemaPerFeature
Default: delegates to environment (the legacy single-tier entry point).
scenarioLayerBuilt once per Scenario. The ScenarioMetadata argument carries the
scenario name, tags, file, line number, and (if flag expansion is active) the
current flag values. Use this for conditional layer selection based on tags:
override def scenarioLayer(meta: ScenarioMetadata): ZLayer[Any, Throwable, R] =
if (meta.tags.contains("use-mock")) mockHttpLayer
else realHttpLayer
Default: delegates to featureLayer.
environment — the legacy single-tier entry pointSuites that do not need tier differentiation can override environment alone:
override def environment: ZLayer[Any, Throwable, R] =
ZLayer.succeed(Config("Hello")) >>> GreetingService.live
All three tier methods default to delegating through to environment, so
existing suites need no changes.
Rule: Override environment OR the three-tier methods. Do not mix both —
overriding featureLayer without also overriding environment means the two
are decoupled, which causes confusing behaviour where the environment override
is ignored.
Any conditional logic that reads ScenarioMetadata belongs in scenarioLayer.
Common patterns:
override def scenarioLayer(meta: ScenarioMetadata): ZLayer[Any, Throwable, R] =
// Switch between mock and real by tag
if (meta.tags.contains("integration"))
realDependencyLayer
else
mockDependencyLayer
// Or: switch by scenario name fragment
override def scenarioLayer(meta: ScenarioMetadata): ZLayer[Any, Throwable, R] =
if (meta.name.contains("error path"))
brokenDependencyLayer
else
happyPathLayer
ScenarioMetadata fields:
case class ScenarioMetadata(
name: String,
tags: List[String],
file: Option[String],
line: Option[Int],
flagValues: Map[String, String] // populated by @flags expansion
)
Scope: resource lifecycle in stepsThe framework wraps every scenario in a ZIO.scoped block. This means a
Scope is always present in the ZIO environment when step bodies run — even
though Scope is not part of the declared R type.
Step bodies can acquire and release resources that are automatically finalised at the end of the scenario, even if a later step fails:
Given("a temporary DynamoDB table exists") {
ZIO.acquireRelease(
DynamoDB.createTable(testTableName).orDie
)(
_ => DynamoDB.deleteTable(testTableName).orDie
).unit
// Table is deleted when the scenario ends, pass or fail
}
This is the standard ZIO resource pattern: ZIO.acquireRelease registers
both the acquire and the release action. The release runs when the scenario’s
Scope closes, in reverse-acquisition order.
No changes to R or S are required to use scoped resources. The Scope
service is injected by the executor automatically.
flagLayer: injecting @flags values into the environmentThe @flags(key=value) tag syntax expands one scenario into multiple runs,
one per @flags(...) annotation. The framework calls flagLayer for each
expansion, passing the parsed key/value pairs:
override def flagLayer(
meta: ScenarioMetadata,
flags: Map[String, String]
): ZLayer[Any, Throwable, R] =
environment >>> FeatureFlagOverride.layer(flags)
When the @flags tags are:
@flags(rateLimiting=true)
@flags(rateLimiting=false)
Scenario: payment processing under rate limiting
flagLayer is called twice — once with Map("rateLimiting" -> "true") and
once with Map("rateLimiting" -> "false"). The scenario name in the report is
suffixed automatically: payment processing under rate limiting [rateLimiting=true].
flagLayer is only called for scenarios that have at least one @flags(...)
tag. Scenarios without @flags(...) tags go through scenarioLayer as
normal.
The default implementation ignores flags and delegates to scenarioLayer(meta),
which means flag values appear in meta.flagValues but do not affect the
environment unless you override flagLayer.
@flags(rateLimiting) is equivalent to @flags(rateLimiting=true). The
parsed map will contain "rateLimiting" -> "true".
A Scenario Outline combined with @flags produces a full cross-product. Two
Examples rows × two @flags tags = four scenario runs.
HasService[A, R]: reusable step librariesA step module that calls ZIO.service[HttpClient] inside its steps has an
implicit dependency on HttpClient being part of R. Without any annotation
this requirement is invisible. When the step module is reused in a different
suite, the compiler may fail with a confusing environment mismatch rather than
a clear “HttpClient is missing” message.
HasService doesHasService[A, R] is a typeclass that expresses “R contains service A“.
It is derived automatically by the compiler from intersection types:
sealed trait HasService[A, R]
object HasService:
given identity[A]: HasService[A, A] = ...
given leftIntersection[A, B]: HasService[A, A & B] = ...
given intersection[A, B, R](using HasService[A, R]): HasService[A, B & R] = ...
So HasService[HttpClient, HttpClient & DynamoDB] is automatically available
because HttpClient is present in the intersection.
HasService to constrain step modulesDeclare the constraint on the step module’s type parameter:
// This module requires HttpClient to be present in R
trait HttpSteps[R](using HasService[HttpClient, R])
{ self: ZIOSteps[R, ?] =>
Given("GET " / string / " returns " / int) { (url: String, expected: Int) =>
for {
client <- ZIO.service[HttpClient]
status <- client.get(url).map(_.status)
_ <- Assertions.assertEquals(status, expected)
} yield ()
}
}
Mixing this trait into a suite automatically satisfies the constraint when R
includes HttpClient:
object MySuite
extends ZIOSteps[HttpClient & DynamoDB, AppState]
with HttpSteps[HttpClient & DynamoDB] // compiles: HasService[HttpClient, HttpClient & DynamoDB]
with DynamoSteps[HttpClient & DynamoDB]: // compiles: HasService[DynamoDB, HttpClient & DynamoDB]
override def environment = ...
If HttpClient were absent from R, the using HasService[HttpClient, R]
clause would produce a clear compile error at the with HttpSteps[...] line,
naming the missing service.
HasServiceOps.withService[A]A convenience wrapper for accessing a service directly. Equivalent to
ZIO.service[A]:
import zio.bdd.core.step.HasServiceOps
HasServiceOps.withService[HttpClient].flatMap(_.get(url))
This is a documentation aid — it signals that the calling code depends on A.
In practice ZIO.service[A] is shorter and equally correct.
globalLayer: sharing expensive resources across the JVMglobalLayer is the top tier and is built at most once per JVM process. It is
the right home for:
HikariCP, AWS SDK clients)override def globalLayer: ZLayer[Any, Throwable, R] =
ZLayer.make[R](
SharedConnectionPool.layer,
LocalStackContainer.layer,
AccountService.layer,
// ...
)
Note: If globalLayer fails to build, the entire test run fails. Keep it
focused on truly global resources and push per-feature or per-scenario
isolation to the lower tiers.
By default globalLayer delegates to featureLayer. If you override neither,
both tiers use whatever environment returns, which is the same single layer
evaluated once per feature (the pre-3.0 behaviour).
MockControl mock backendThe HTTP-mocking feature is delivered as an ordinary ZLayer[…, …, MockControl], so a mock backend
plugs into the same three-tier model. Add it to whichever tier owns the mock’s lifetime — usually
environment/featureLayer for a shared backend, or scenarioLayer when each scenario gets fresh,
isolated mock spaces (e.g. with @mock(...) fixtures):
// a WireMock backend (in-process, JDK 11+, no Docker) available to every step:
override def environment: ZLayer[Any, Throwable, MockControl] =
Provisioning.live >>> WireMock.correlated()
Swap that one layer to run the same suite on Rift (container or embedded) — see
Mock Adapters. For per-scenario fixtures wired into scenarioLayer, see
Mock Gherkin Integration.