The framework you built in Article 3 works — it opens Appium sessions, screen objects locate elements, and tests make assertions. But those tests are Java code. A QA manager or product owner who asks "what does the test suite cover?" gets a list of method names like homeScreenShouldLoad — which tells them almost nothing.
Cucumber solves this by separating what a test does from how it does it. You describe test behaviour in plain English — Then the home screen should be visible — inside .feature files that anyone on the team can read. A step definitions layer maps each English line to the screen object calls that already power your framework. The DriverManager, BasePage, and HomeScreen from Article 3 don't change — Cucumber connects to them from above.
💡 This is Article 4 of the Framework Series. It picks up directly from Driver Setup and Screen Objects: Your Mobile Framework's Core.
What Is BDD?
Behaviour-Driven Development (BDD) is an approach to testing where behaviour is described in structured plain-English specifications before any code is written. Specifications are written in Gherkin — a simple Given-When-Then syntax:
Scenario: User logs into the app
Given the app is open
When the user enters their username and password
Then the user is logged into the appGherkin lives in .feature files. At test time, Cucumber reads those files, matches each step to a Java method in your step definitions, and runs them in order. The result is a test report written in the same plain English as the scenario itself — pass or fail, in language any stakeholder can read without needing to understand Java.
💡 JBehave is the other widely-used Java BDD framework — it actually predates Cucumber and introduced the Given-When-Then format. I used JBehave at a previous company back in 2015, and if you've come across it before, the concepts here will be immediately familiar. JBehave uses .story files instead of .feature files, but the step annotation model (@Given, @When, @Then) is essentially the same. This article uses Cucumber because it has broader adoption in the Java ecosystem, tighter JUnit Platform integration, and a more active community.
What Cucumber is not: a replacement for good framework design. It adds a readable specification layer on top of your existing code — sessions, locators, and assertions still live in the framework, exactly where they belong. Cucumber's job is to connect those layers through language, not to own them.
What You're Building
This article adds four things to the framework:
- Cucumber dependencies —
cucumber-java,cucumber-spring, andcucumber-junit-platform-enginewired into the Maven POMs CucumberSpringConfiguration— a single class that connects Cucumber's lifecycle to Spring Boot's application context- A feature file — a plain-English test specification written in Gherkin
- Step definitions — Java methods that translate Gherkin steps into screen object calls
Step 1: Add Cucumber Dependencies
Cucumber ships several artifacts that work together. The cleanest way to manage them is to import the Cucumber BOM in the root POM, then declare the individual dependencies in team-tests without repeating version numbers.
Root POM
Add a cucumber.version property and import the Cucumber BOM in <dependencyManagement>:
<properties>
...
<cucumber.version>7.34.3</cucumber.version>
</properties>💡 7.34.3 is the latest stable release at the time of writing. Check Maven Central — cucumber-bom for a newer version before publishing your project.
Then add the BOM import in <dependencyManagement>, alongside the existing Spring Boot BOM:
<dependencyManagement>
<dependencies>
<!-- existing Spring Boot BOM import -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring.boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Cucumber BOM — manages versions for all cucumber-* artifacts -->
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-bom</artifactId>
<version>${cucumber.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- rest of existing dependencyManagement -->
</dependencies>
</dependencyManagement>This follows the same BOM import pattern already used for Spring Boot. Cucumber ships several artifacts that must all be on the same version — cucumber-java, cucumber-spring, and cucumber-junit-platform-engine. Without the BOM, you'd need to repeat 7.34.3 on each individual dependency and update all three whenever you upgrade. Miss one and you get a version mismatch at runtime — the kind that produces subtle, hard-to-diagnose failures. The BOM declares the version once; every cucumber-* dependency in team-tests inherits it automatically.
team-tests/pom.xml
Add the four dependencies:
<!-- Cucumber step definitions and hooks in Java -->
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Boot integration — connects Cucumber to Spring's TestContextManager -->
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-spring</artifactId>
<scope>test</scope>
</dependency>
<!-- JUnit Platform engine — lets Maven Surefire discover and run Cucumber scenarios -->
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-junit-platform-engine</artifactId>
<scope>test</scope>
</dependency>
<!-- JUnit Platform Suite — needed for the RunCucumberTest entry point -->
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-suite</artifactId>
<scope>test</scope>
</dependency>Why four artifacts?
Each one has a distinct job — and you need all four:
cucumber-java— the core Cucumber library for Java. Provides the@Given,@When,@Thenannotations that turn methods into step definitions, and@Before/@Afterhooks for per-scenario setup and teardown.cucumber-spring— the Spring integration bridge. Without it,@Autowireddoesn't work in step definitions because Cucumber has no knowledge of Spring's dependency injection. This artifact hands step definition instances to Spring'sTestContextManagerso the context can wire them up.cucumber-junit-platform-engine— registers Cucumber as a JUnit Platform engine. Without it, Maven Surefire doesn't know Cucumber exists and won't pick up your.featurefiles.junit-platform-suite— provides the@Suiteannotation used byRunCucumberTest. Maven Surefire discovers test classes by name pattern;RunCucumberTestis the class it finds, which in turn triggers the Cucumber engine.
💡 This is OCP from the framework blueprint: you're adding a new capability — BDD test execution — without modifying any existing code. Cucumber plugs into the JUnit Platform layer; every class below it is unchanged.
Step 2: Create CucumberSpringConfiguration
Create team-tests/src/test/java/com/mobileframework/steps/CucumberSpringConfiguration.java:
package com.mobileframework.steps;
import com.mobileframework.TestApplication;
import io.cucumber.spring.CucumberContextConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
@CucumberContextConfiguration
@SpringBootTest(classes = TestApplication.class)
public class CucumberSpringConfiguration {
}This is the most important class in the Cucumber integration — and it has no methods. That's not an oversight; both annotations are declarative instructions to frameworks, not hooks that require a code body.
@CucumberContextConfiguration is a marker annotation. It tells cucumber-spring that this class designates the Spring context for the test run. There must be exactly one class with this annotation on the glue path — that's the entire job. No methods to implement, no interface to satisfy. Without it, Cucumber has no idea how to start a Spring context and @Autowired in every step definition class will fail.
@SpringBootTest(classes = TestApplication.class) tells Spring Boot's test infrastructure which configuration class to load. Spring reads the annotation, bootstraps the full context — DriverManager, HomeScreen, and all other Spring-managed beans — and handles everything from there. The Appium session starts via @PostConstruct before the first scenario runs, and @PreDestroy shuts it down when all scenarios are done. Again, no code needed: Spring is entirely driven by the annotation metadata.
Think of it this way: all the actual configuration — the beans, the driver setup, the properties — lives in TestApplication and application.properties. Those files define what the framework does. CucumberSpringConfiguration just tells Cucumber where to find that configuration. It's a pointer, not a definition. Remove either annotation and @Autowired stops working in every step definition in the project — not because something in this class broke, but because Cucumber lost its reference to the Spring context entirely.
💡 This is SRP from the framework blueprint: CucumberSpringConfiguration has one reason to change — the way Cucumber connects to the Spring context. Nothing else belongs here.Step 3: Create RunCucumberTest
Create team-tests/src/test/java/com/mobileframework/RunCucumberTest.java:
💡 The nameRunCucumberTestmatters. Surefire's default include patterns are**/Test*.javaand**/*Test.java— the class must start or end withTestto be discovered automatically.RunCucumberTestends withTest, so no Surefire configuration changes are needed.
package com.mobileframework;
import org.junit.platform.suite.api.IncludeEngines;
import org.junit.platform.suite.api.SelectClasspathResource;
import org.junit.platform.suite.api.Suite;
@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("features")
public class RunCucumberTest {
}This class has no test methods. Surefire discovers tests by class name pattern (Test*.java, *Test.java, *Tests.java, *TestCase.java) — RunCucumberTest ends with Test, so it matches. Without a class it can match, Surefire exits without invoking the JUnit Platform at all, so cucumber-junit-platform-engine never runs and your .feature files are never executed.
@Suite marks this as a JUnit Platform suite — a container that aggregates test engines rather than containing test methods itself.
@IncludeEngines("cucumber") tells the JUnit Platform launcher to activate specifically the Cucumber engine.
@SelectClasspathResource("features") points the engine at the features/ directory on the test classpath — the single source of truth for feature location.
💡 This is SRP from the framework blueprint: RunCucumberTest has one responsibility — acting as the entry point for Surefire. Everything else about how Cucumber runs is configured elsewhere.Step 4: Configure junit-platform.properties
Create team-tests/src/test/resources/junit-platform.properties:
# Cucumber configuration
cucumber.junit-platform.naming-strategy=long
cucumber.glue=com.mobileframework.steps
cucumber.plugin=prettycucumber.glue is the package Cucumber scans for step definitions, hooks, and CucumberSpringConfiguration. Every class in com.mobileframework.steps is picked up automatically.
cucumber.junit-platform.naming-strategy=long — long is a literal config value that tells Cucumber to use the full-name format in Surefire's test reports: feature name and scenario name together. When a test fails, you see Login > User logs in with valid credentials rather than the scenario name alone. (surefire is an alternative value that exists only as a workaround for Surefire versions older than 3.5.3; this project uses 3.5.5, so long is the right choice.)
cucumber.plugin=pretty formats scenario output in the terminal with step-by-step pass/fail indicators. Additional reporters — html:target/cucumber-reports/cucumber.html for an HTML report, json:target/cucumber.json for CI integration — can be added to this comma-separated list later without touching any other configuration.
💡 cucumber.features is intentionally absent. Feature location is already declared by @SelectClasspathResource("features") in RunCucumberTest — including it here as well would cause IntelliJ to run your scenarios twice: once via the @Suite (correct) and once via the standalone Cucumber engine (which reads this file independently). The standalone run fails because the Appium session from the first run was already closed by @PreDestroy.
Step 5: Write Your First Feature File
Create team-tests/src/test/resources/features/login.feature:
Feature: Login
Scenario: User logs in with valid credentials
Given the login screen is displayed
When the user enters their username and password
Then the user is logged into the appFeature groups related scenarios around a single piece of functionality. Keep all login-related scenarios — successful login, validation errors, forgot password — in login.feature. Don't mix unrelated functionality into the same file just because the scenarios live on the same screen.
Scenario is a single test case with a name and one or more steps.
Given establishes the starting state — the app is open and the login screen is in front of the user. The step definition handles the navigation from the home screen; the feature file just states where things should be.
When describes the action at the right level of detail. When the user enters their username and password names the inputs without describing how they're entered — no sendKeys, no element IDs, no tap coordinates. A QA manager reading this knows exactly what the test covers without needing to understand the implementation. The mechanics — finding the fields, typing the values, tapping the button — live in the step definition and the screen object where they belong.
Then asserts the outcome. Pass or fail, the report will show exactly this line — which is the clearest possible description of what was being verified.
Step 6: Write the Screen Objects and Step Definitions
The login scenario crosses two screens: the home screen (where the app opens) and the login form (where credentials are entered). That means two screen objects and one step definitions class.
Update HomeScreen
HomeScreen from Article 3 can check whether the screen is loaded — but now it also needs to navigate to the login form. Add one method:
@AndroidFindBy(accessibility = "Login")
private WebElement loginMenuItem;
public void tapLogin() {
loginMenuItem.click();
}Create LoginScreen
package com.mobileframework.screens;
import com.mobileframework.driver.DriverManager;
import com.mobileframework.pages.BasePage;
import io.appium.java_client.pagefactory.AndroidFindBy;
import org.openqa.selenium.WebElement;
import org.springframework.stereotype.Component;
@Component
public class LoginScreen extends BasePage {
@AndroidFindBy(accessibility = "input-email")
private WebElement emailField;
@AndroidFindBy(accessibility = "input-password")
private WebElement passwordField;
@AndroidFindBy(accessibility = "button-LOGIN")
private WebElement loginButton;
@AndroidFindBy(id = "android:id/message")
private WebElement alertMessage;
public LoginScreen(DriverManager driverManager) {
super(driverManager);
}
public boolean isLoaded() {
return emailField.isDisplayed();
}
public void login(String email, String password) {
emailField.sendKeys(email);
passwordField.sendKeys(password);
loginButton.click();
}
public boolean isLoginSuccessful() {
return alertMessage.isDisplayed();
}
}LoginScreen has no knowledge of Cucumber, Spring, or the driver — it only knows the login form's elements and what you can do with them. The login() method groups the three actions that always happen together; there's no value in exposing them as separate public methods when the step definitions always call them in sequence.
Create LoginScreenSteps
Create team-tests/src/test/java/com/mobileframework/steps/LoginScreenSteps.java:
package com.mobileframework.steps;
import com.mobileframework.screens.HomeScreen;
import com.mobileframework.screens.LoginScreen;
import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;
import org.springframework.beans.factory.annotation.Autowired;
import static org.assertj.core.api.Assertions.assertThat;
public class LoginScreenSteps {
@Autowired
private HomeScreen homeScreen;
@Autowired
private LoginScreen loginScreen;
@Given("the login screen is displayed")
public void theLoginScreenIsDisplayed() {
homeScreen.tapLogin();
assertThat(loginScreen.isLoaded())
.as("Login screen should be displayed")
.isTrue();
}
@When("the user enters their username and password")
public void theUserEntersTheirUsernameAndPassword() {
loginScreen.login("alice@example.com", "10203040");
}
@Then("the user is logged into the app")
public void theUserIsLoggedIntoTheApp() {
assertThat(loginScreen.isLoginSuccessful())
.as("Success message should appear after valid login")
.isTrue();
}
}Three things to call out:
No @Component. cucumber-spring registers step definition classes with Spring automatically — @Autowired works without it.
Both screen objects inject via @Autowired — they're Spring beans, initialized via BasePage, backed by the same DriverManager. The step definition layer is just a new caller.
The step text must match exactly. Annotations are case-sensitive and space-sensitive. If they don't match, Cucumber reports an undefined step and the scenario fails before any code runs.
💡 This is DIP from the framework blueprint: step definitions depend on screen objects' public interfaces (tapLogin(),login(),isLoginSuccessful()), not on Appium directly. The driver, the PageFactory proxy, the locators — none of that is visible to the step definition layer.
💡 Cucumber hooks (io.cucumber.java.Before and io.cucumber.java.After) are available for scenario-level setup and teardown — useful for resetting app state between scenarios. These are distinct from Spring's @PostConstruct/@PreDestroy, which run once for the entire application context. You don't need them for this article, but they're available in any class on the glue path when you do.
Step 7: Run the Tests
Before running, make sure:
- Appium is running — open a terminal and type
appium - Your device is ready — Android emulator running, or a physical device connected
app.pathis correct inapplication.properties- Delete
HomeScreenTest— the Cucumber scenario verifies the home screen as its firstGivenstep. Keeping both means the home screen is tested twice through different layers, which muddies the purpose of BDD.
Then run from the project root:
mvn clean testIf everything is wired correctly, you'll see the Cucumber pretty output:

💡 IDE vs Maven: when to use each. You can also right-clickRunCucumberTestin your IDE and choose Run — this skips the Maven compile cycle and starts in seconds, making it ideal for the fast feedback loop while writing code (and supports breakpoints for debugging). Usemvn clean testbefore committing — it's a full clean build that replicates exactly what CI runs and catches dependency issues the IDE can hide.
🚨 Common failure points at this step:
mvn clean testruns but no scenarios execute — Surefire couldn't findRunCucumberTest. Confirm the class is incom.mobileframework(not inside thestepspackage) and that the filename is exactlyRunCucumberTest.java.@CucumberContextConfigurationnot found — the class isn't on the glue path. ConfirmCucumberSpringConfigurationis in packagecom.mobileframework.stepsand thatcucumber.glue=com.mobileframework.stepsinjunit-platform.properties.- Undefined step — the step text in the feature file doesn't exactly match the annotation in the step definition. Check for capitalisation differences, extra spaces, or punctuation mismatches.
No features found— thefeatures/directory doesn't exist atteam-tests/src/test/resources/features/, orcucumber.features=classpath:featuresdoesn't match the actual path. Confirm the file is atsrc/test/resources/features/login.feature.No cucumber.glue property set—junit-platform.propertiesisn't on the test classpath. Confirm the file is atteam-tests/src/test/resources/junit-platform.properties.- Scenarios run twice in IntelliJ — one passes, one fails —
cucumber.features=classpath:featuresis set injunit-platform.properties. IntelliJ activates both the@Suite(correct) and the standalone Cucumber engine (which reads that property and runs the same features again). The standalone run fails because the Appium session from the first run was already closed. Fix: removecucumber.featuresfromjunit-platform.properties— feature location is already handled by@SelectClasspathResource("features")inRunCucumberTest. - Driver or connection errors — the same failure points from Article 3. Check Appium is running and
application.propertieshas the correctapp.path.
Understanding What You've Built
Here's what happens at runtime when Maven runs your Cucumber scenario:
- Maven Surefire finds
RunCucumberTest—@Suitetriggers the JUnit Platform launcher,@IncludeEngines("cucumber")activates the Cucumber engine - Cucumber reads
junit-platform.propertiesand scansclasspath:featuresfor.featurefiles - Cucumber scans
com.mobileframework.stepsand findsCucumberSpringConfigurationandLoginScreenSteps @CucumberContextConfiguration+@SpringBootTesttriggers Spring Boot'sTestContextManager- Spring creates the application context —
DriverManagerinitializes,@PostConstructfires, Appium session opens - Spring creates
HomeScreenandLoginScreenbeans —BasePageconstructor runs for each, PageFactory initializes their locators - Spring injects both screen objects into
LoginScreenStepsvia@Autowired - Cucumber matches each Gherkin line to its
@Given,@When, or@Thenannotation inLoginScreenStepsand executes them in order theLoginScreenIsDisplayed()callshomeScreen.tapLogin()then assertsloginScreen.isLoaded()— two Appium calls;theUserEntersTheirUsernameAndPassword()callsloginScreen.login()which enters credentials and taps the button — three Appium calls;theUserIsLoggedIntoTheApp()callsloginScreen.isLoginSuccessful()— one Appium call- Scenario passes
- All scenarios complete — Spring context shuts down,
@PreDestroyfires, driver quits cleanly
| Component | Location | Responsibility |
|---|---|---|
RunCucumberTest |
team-tests/.../ |
Entry point for Maven Surefire — triggers the JUnit Platform launcher and activates the Cucumber engine |
CucumberSpringConfiguration |
team-tests/.../steps/ |
Connects Cucumber's lifecycle to the Spring Boot context — the integration bridge |
login.feature |
team-tests/.../resources/features/ |
Plain-English login scenario — readable by any stakeholder, credentials visible at a glance |
LoginScreenSteps |
team-tests/.../steps/ |
Translates Gherkin steps into screen object calls — the only layer that knows both languages |
HomeScreen |
team-tests/.../screens/ |
Verifies the home screen loaded; navigates to the login form — extended from Article 3 |
LoginScreen |
team-tests/.../screens/ |
Enters credentials, taps login, and checks the success message — new in this article |
DriverManager |
core/.../driver/ |
Opens and closes the Appium session — unchanged from Article 3 |
junit-platform.properties |
team-tests/src/test/resources/ |
Tells Cucumber where to find glue code and feature files, and how to name scenarios |
What's Next?
The next article — One Test, Two Platforms: Cross-Platform Mobile Testing with POM — extends the screen objects and Cucumber scenarios you've built here to run on both Android and iOS. The feature file doesn't change; only the screen object implementations diverge per platform, behind a shared interface that the step definitions never need to know about.
Each article picks up exactly where the last one left off. If you ran mvn clean test and saw your Cucumber scenario pass, the integration is complete.
Download the Code
You don't have to wire this up from scratch. The complete, runnable workspace for this article is available as a free download — everything built across Articles 2, 3, and 4, wired together and ready to open in your IDE.
What's inside:
- The full Maven multi-module project — root POM with Cucumber BOM,
coremodule, andteam-testsmodule DriverManager,BasePage, andHomeScreenfrom Article 3 —HomeScreenextended withtapLogin()LoginScreen— new in this articleRunCucumberTest,CucumberSpringConfiguration,LoginScreenSteps,login.feature, andjunit-platform.properties- A ready-to-edit
application.propertiestemplate
💡 Before running, openapplication.propertiesand updateapp.pathanddevice.udidto match your machine. Then runmvn clean testfrom the project root — Step 6 above covers the full prerequisite checklist.
✅ Subscribe below to download the workspace .zip — free, just your email, and you'll get every new Framework Series article as it's published.