Skip to Content
Mobile Automation
Sign in
  • Home
  • Start Here
  • About
  • Blog
  • Tags
  • Contact
  • X

BDD and Cucumber with Appium: Plain-English Tests for Your Framework

Write plain-English mobile tests with BDD and Cucumber

Featured Post For Members Framework June 1, 2026
BDD and Cucumber with Appium: Plain-English Tests for Your Framework
On this page
Unlock full content

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 app

Gherkin 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:

  1. Cucumber dependencies — cucumber-java, cucumber-spring, and cucumber-junit-platform-engine wired into the Maven POMs
  2. CucumberSpringConfiguration — a single class that connects Cucumber's lifecycle to Spring Boot's application context
  3. A feature file — a plain-English test specification written in Gherkin
  4. 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, @Then annotations that turn methods into step definitions, and @Before/@After hooks for per-scenario setup and teardown.
  • cucumber-spring — the Spring integration bridge. Without it, @Autowired doesn't work in step definitions because Cucumber has no knowledge of Spring's dependency injection. This artifact hands step definition instances to Spring's TestContextManager so 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 .feature files.
  • junit-platform-suite — provides the @Suite annotation used by RunCucumberTest. Maven Surefire discovers test classes by name pattern; RunCucumberTest is 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 name RunCucumberTest matters. Surefire's default include patterns are **/Test*.java and **/*Test.java — the class must start or end with Test to be discovered automatically. RunCucumberTest ends with Test, 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=pretty

cucumber.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 app

Feature 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:

  1. Appium is running — open a terminal and type appium
  2. Your device is ready — Android emulator running, or a physical device connected
  3. app.path is correct in application.properties
  4. Delete HomeScreenTest — the Cucumber scenario verifies the home screen as its first Given step. 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 test

If everything is wired correctly, you'll see the Cucumber pretty output:

💡 IDE vs Maven: when to use each. You can also right-click RunCucumberTest in 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). Use mvn clean test before 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 test runs but no scenarios execute — Surefire couldn't find RunCucumberTest. Confirm the class is in com.mobileframework (not inside the steps package) and that the filename is exactly RunCucumberTest.java.
  • @CucumberContextConfiguration not found — the class isn't on the glue path. Confirm CucumberSpringConfiguration is in package com.mobileframework.steps and that cucumber.glue=com.mobileframework.steps in junit-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 — the features/ directory doesn't exist at team-tests/src/test/resources/features/, or cucumber.features=classpath:features doesn't match the actual path. Confirm the file is at src/test/resources/features/login.feature.
  • No cucumber.glue property set — junit-platform.properties isn't on the test classpath. Confirm the file is at team-tests/src/test/resources/junit-platform.properties.
  • Scenarios run twice in IntelliJ — one passes, one fails — cucumber.features=classpath:features is set in junit-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: remove cucumber.features from junit-platform.properties — feature location is already handled by @SelectClasspathResource("features") in RunCucumberTest.
  • Driver or connection errors — the same failure points from Article 3. Check Appium is running and application.properties has the correct app.path.

Understanding What You've Built

Here's what happens at runtime when Maven runs your Cucumber scenario:

  1. Maven Surefire finds RunCucumberTest — @Suite triggers the JUnit Platform launcher, @IncludeEngines("cucumber") activates the Cucumber engine
  2. Cucumber reads junit-platform.properties and scans classpath:features for .feature files
  3. Cucumber scans com.mobileframework.steps and finds CucumberSpringConfiguration and LoginScreenSteps
  4. @CucumberContextConfiguration + @SpringBootTest triggers Spring Boot's TestContextManager
  5. Spring creates the application context — DriverManager initializes, @PostConstruct fires, Appium session opens
  6. Spring creates HomeScreen and LoginScreen beans — BasePage constructor runs for each, PageFactory initializes their locators
  7. Spring injects both screen objects into LoginScreenSteps via @Autowired
  8. Cucumber matches each Gherkin line to its @Given, @When, or @Then annotation in LoginScreenSteps and executes them in order
  9. theLoginScreenIsDisplayed() calls homeScreen.tapLogin() then asserts loginScreen.isLoaded() — two Appium calls; theUserEntersTheirUsernameAndPassword() calls loginScreen.login() which enters credentials and taps the button — three Appium calls; theUserIsLoggedIntoTheApp() calls loginScreen.isLoginSuccessful() — one Appium call
  10. Scenario passes
  11. All scenarios complete — Spring context shuts down, @PreDestroy fires, 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, core module, and team-tests module
  • DriverManager, BasePage, and HomeScreen from Article 3 — HomeScreen extended with tapLogin()
  • LoginScreen — new in this article
  • RunCucumberTest, CucumberSpringConfiguration, LoginScreenSteps, login.feature, and junit-platform.properties
  • A ready-to-edit application.properties template
💡 Before running, open application.properties and update app.path and device.udid to match your machine. Then run mvn clean test from 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.

This post is for subscribers only

Become a member now and have access to all posts, enjoy exclusive content, and stay updated with constant updates.

Become a member

Already have an account? Sign in

  • Share on X
  • Share on Facebook
  • Share on LinkedIn
  • Share on Pinterest
  • Email

Written by

Mayvin Ramasawmy

Software engineer with 18 years of experience in development and test automation. From startups to enterprise — I build automation frameworks and share practical mobile testing knowledge at mobile-automation.io.

Montreal, Canada

Related Posts

Driver Setup and Screen Objects: Building the Working Core of Your Mobile Framework
Featured Post For Members

Driver Setup and Screen Objects: Building the Working Core of Your Mobile Framework

Framework May 26, 2026
How to Structure a Mobile Test Automation Framework That Scales Across Teams
Featured Post

How to Structure a Mobile Test Automation Framework That Scales Across Teams

Framework May 19, 2026
Why Most Mobile Test Automation Frameworks Fail — And How to Build One That Doesn't
Featured Post

Why Most Mobile Test Automation Frameworks Fail — And How to Build One That Doesn't

Framework May 6, 2026

Join the Mobile Automation Community

Receive new Appium and Maestro tutorials, framework design tips, and mobile testing best practices.

Please check your inbox and click the confirmation link.
Subscribe 1 Subscribe 2 Subscribe 3 Subscribe 1 Clone Subscribe 2 Clone Subscribe 3 Clone Subscribe 1 Clone
Subscribe 4 Subscribe 5 Subscribe 6 Subscribe 4 Clone Subscribe 5 Clone Subscribe 6 Clone Subscribe 4 Clone

Tags

Appium Best Practices Framework Java
© 2026 Mobile Automation
Mobile Automation
  • Home
  • Start Here
  • About
  • Blog
  • Tags
  • Contact
  • X
Sign in