Skip to main content

Layered Tests from One Spec

One business specification can drive layered tests that verify its behaviour at different application layers. This example runs the same cart-pricing scenarios through direct Java calls, a Spring-managed bean, and a Spring Boot browser UI — giving you fast feedback on the business logic and broader confidence in the application from one shared specification.

Source: examples/going-further/example-9 on GitHub.

Why layered tests?​

When a team implements all of its Gherkin scenarios through a browser, “run the BDD tests” comes to mean starting the application, preparing its environment, and waiting for UI interactions. It is easy for that cost to feel unavoidable, but the test implementation determines how much of the application a scenario exercises and how much setup it needs. When browser and network I/O are not part of the behaviour being verified, a scenario can connect directly to the business logic for faster, more focused feedback.

SpecBinder lets you keep several implementations of the same specification side by side. Each concrete test class shares the same scenarios while exercising a different application layer.

Declarative scenarios leave the testing layer open​

Two choices are involved: how you describe the behaviour and where you exercise it.

An imperative scenario describes the procedure: enter a price in a field, select a discount, click Calculate, and read a label. Those instructions commit the scenario to UI interactions.

A declarative scenario states the relevant business facts and expected outcome:

Given a cart holding "1" items priced "9.99"
When a discount of "15" percent is applied
Then the cart total should be "8.49"

The values and expected result are precise, while the interaction details live in the step implementations. A browser implementation can fill the form; a focused implementation can call the pricing service directly. Both verify the behaviour the scenario describes. The scenario remains declarative because it states the business facts and expected result without prescribing how a test must interact with the system.

A declarative scenario can therefore back either a focused test or an end-to-end test. Its wording does not dictate the testing layer. When the behaviour being specified is itself a UI interaction — keyboard focus, for example — the implementation needs to exercise that interface.

Fast feedback and broader coverage from the same spec​

A single implementation ties the specification to one level of testing. Layered tests let you keep several:

  • While changing a business rule, run the direct implementation for quick feedback without starting Spring or a browser.
  • When checking application configuration, run the Spring implementation against the managed bean.
  • When checking the user journey, run the browser implementation to verify that the form, request handling, service, and displayed result work together.

All three inherit the same scenarios and expected outcomes. A change to the specification reaches every implementation, so separately maintained unit and UI specifications cannot drift apart. Each implementation still needs its own step code and maintenance; the shared specification keeps their behavioural expectations aligned.

The focused tests provide narrower assurance, and the browser tests cover more of the stack. Keeping both means you can choose the feedback cycle you need without giving up the other level of verification.

What this demonstrates​

  • Default abstract mode generates one CartPricingScenarios class shared by three concrete tests
  • Layered implementations live in concrete subclasses, each supplying steps for a different application layer
  • All three tests exercise the same pricing logic and expect identical results
  • The UI layer starts a real Spring Boot application with Spring MVC, Thymeleaf, and embedded Tomcat
  • JUnit @Tag annotations allow each layer to run independently
  • @ExtendWith(SpecBinderReporter.class) on the marker enables a separate execution report for each concrete test class

Class hierarchy​

CartPricingFeature.java              (marker class, declares abstract step methods)
└→ CartPricingScenarios.java (generated, abstract, contains the scenarios)
├→ CartPricingUnitTest.java (implements steps using a new Java object)
├→ CartPricingSpringTest.java (implements steps using a Spring-managed bean)
└→ CartPricingUiTest.java (implements steps using Playwright)

JUnit runs all four inherited scenarios for each concrete class. Adding a scenario to CartPricing.specb adds it to every layer; adding a new abstract step requires an implementation in each subclass.

Testing layers​

Test classEntry pointRelative speedCoverage
CartPricingUnitTestnew CartPricingService()FastPricing logic in isolation
CartPricingSpringTestBean from PricingConfigurationMediumPricing logic and Spring bean configuration
CartPricingUiTestChromium interacting with a Spring Boot application over HTTPSlowStatic form, Spring MVC binding, Thymeleaf rendering, Spring wiring, and pricing logic

All three use the same CartPricingService. The UI submits a real HTML form to an embedded Spring Boot server on a random loopback port. The browser test provides the broadest application coverage; the direct test gives the fastest feedback on business rules. Speeds are relative, and this small application has no database or external services.

The marker class​

@Gherkin2JUnit
@Gherkin2JUnitOptions(descriptionAsAnnotation = true)
@ExtendWith(SpecBinderReporter.class)
public abstract class CartPricingFeature {
public abstract void aCartHolding$p1ItemsPriced$p2(Integer quantity, String unitPrice);

public abstract void aDiscountOf$p1PercentIsApplied(Integer percent);

public abstract void theCartTotalShouldBe$p1(String expected);
}

The marker declares the shared step signatures. Each concrete test extends the generated CartPricingScenarios and implements those methods with its own setup, interactions, and assertions.

The spec, CartPricing.specb, sits next to the marker in src/test/java, so a bare @Gherkin2JUnit finds it by convention. The generated CartPricingScenarios lands in the same package, so the three test classes extend it without an import. descriptionAsAnnotation only adds the spec's descriptions to the reports; shouldBeAbstract needs no setting, since it defaults to true.

The same scenario at every layer​

For the discount scenario above, the expected total stays 8.49 at every layer. What changes is how the inputs reach the application and how the result is checked. The excerpts below show how the concrete classes implement the shared steps.

Direct Java calls​

CartPricingUnitTest creates the service directly. Its Given and When steps collect the quantity, price, and discount; its Then step calls the service and compares the result:

@Tag("unit")
public class CartPricingUnitTest extends CartPricingScenarios {
private final CartPricingService pricing = new CartPricingService();

// … fields and Given/When implementations …

@Override
public void theCartTotalShouldBe$p1(String expected) {
assertEquals(new BigDecimal(expected), pricing.total(quantity, unitPrice, discount));
}
}

This checks the calculation with no application context or browser setup.

Spring-managed bean​

CartPricingSpringTest uses the same service calls and assertions, but obtains its service from the application's Spring configuration:

@Tag("spring")
public class CartPricingSpringTest extends CartPricingScenarios {
private AnnotationConfigApplicationContext application;
private CartPricingService pricing;

@BeforeEach
void startSpring() {
application = new AnnotationConfigApplicationContext(PricingConfiguration.class);
pricing = application.getBean(CartPricingService.class);
}

@AfterEach
void stopSpring() {
if (application != null) application.close();
}

// … step implementations …
}

Each scenario creates and closes a Spring context. A passing run also verifies that the application's pricing bean can be created and retrieved through that configuration.

Browser UI​

CartPricingUiTest starts the Spring Boot application on a random loopback port and launches Chromium once for the test class:

@BeforeAll
static void startApplicationAndBrowser() {
application = new SpringApplicationBuilder(PricingWebApplication.class)
.properties("server.address=127.0.0.1", "server.port=0", "spring.main.banner-mode=off")
.run();
baseUrl = "http://127.0.0.1:"
+ application.getEnvironment().getRequiredProperty("local.server.port");

playwright = Playwright.create();
browser = playwright.chromium().launch();
}

Each scenario then gets a fresh browser context. Its step implementations interact with the page:

@Override
public void aCartHolding$p1ItemsPriced$p2(Integer quantity, String unitPrice) {
page.getByLabel("Quantity").fill(quantity.toString());
page.getByLabel("Unit price").fill(unitPrice);
}

@Override
public void aDiscountOf$p1PercentIsApplied(Integer percent) {
page.getByLabel("Discount (%)").fill(percent.toString());
}

@Override
public void theCartTotalShouldBe$p1(String expected) {
page.getByText("Calculate total", new Page.GetByTextOptions().setExact(true)).click();
assertThat(page.getByLabel("Cart total")).hasText(expected);
}

The page follows Spring Boot's conventional web structure:

src/main/
├── java/.../PricingWebApplication.java Spring Boot entry point
├── java/.../PricingController.java POST /total controller
└── resources/
├── static/index.html cart form
├── static/styles.css shared responsive styling
└── templates/total.html Thymeleaf result view

Spring Boot serves the form and stylesheet as static resources. Submitting the form reaches PricingController, where Spring MVC binds the quantity, unit price, and discount to typed method parameters. The controller calls the Spring-managed CartPricingService, adds the calculation details to the model, and returns the total Thymeleaf view.

This layer can detect a broken form, incorrect request binding, a missing template value, or an incorrectly displayed total even when the direct service tests pass.

Run it​

With Java 21 or newer and the local SpecBinder snapshot installed:

cd examples/going-further/example-9
mvn clean test

If Playwright reports a missing browser, install Chromium:

mvn test-compile exec:java \
-Dexec.mainClass=com.microsoft.playwright.CLI \
-Dexec.classpathScope=test \
-Dexec.args="install chromium"

All 12 scenario executions should pass. Failures fail the build. The UI test starts its own application on a random loopback port.

You can also run the Spring Boot application and use the calculator yourself:

mvn spring-boot:run

Then open http://localhost:8080.

Select a single layer using its JUnit tag:

mvn clean test -Dgroups=unit
mvn clean test -Dgroups=spring
mvn clean test -Dgroups=ui

The unit and Spring tests do not require a browser. clean removes reports from previous runs.

Execution reports​

A full run writes three reports:

target/specbinder-reports/dev/specbinder/examples/goingfurther/multipletests/
├── CartPricingUnitTest.json 4 passed
├── CartPricingSpringTest.json 4 passed
└── CartPricingUiTest.json 4 passed

Each report identifies dev/specbinder/examples/goingfurther/multipletests/CartPricing.specb as its source and records its concrete testClass.

See Execution Reporter for reporter setup and Playwright Traces per Step for adding browser traces to step results.