Calabash with Gherkin: Writing BDD Mobile Tests
Calabash uses Cucumber as its test runner, which means your tests are written in Gherkin — the same plain-language format used by web-focused BDD tools like Capybara. A Gherkin scenario describes behavior in terms a non-engineer can read. The Ruby step definitions underneath translate those sentences into Calabash API calls.
This post focuses on how to structure feature files, write maintainable step definitions, organize tests with tags, and build a shared step library for cross-platform test suites.
Feature File Anatomy
A feature file is a .feature text file in the features/ directory. It has three main constructs:
Feature: Product search
As a shopper
I want to search for products by name
So that I can find what I need quickly
Background:
Given I am logged in as a standard user
And I am on the home screen
Scenario: Search returns matching results
When I search for "running shoes"
Then I should see at least 1 result
And each result should contain the text "running"
Scenario: Search with no results shows empty state
When I search for "xyzzy_no_results_12345"
Then I should see the empty search screen
And I should see the text "No results found"
Scenario Outline: Search filters work correctly
When I search for "shoes"
And I apply the "<filter>" filter
Then I should see results filtered by "<filter>"
Examples:
| filter |
| Men |
| Women |
| Sale |Background steps run before each scenario in the file. Scenario Outline with Examples runs the same scenario once per row with the placeholders substituted.
Step Definitions
Step definitions connect Gherkin sentences to Ruby code. Cucumber matches each step to a definition using regular expressions or Cucumber Expressions.
Cucumber Expressions (preferred — more readable):
# features/step_definitions/search_steps.rb
Given('I am logged in as a standard user') do
# Assumes auth state is pre-loaded via environment setup
wait_for_element_exists("* marked:'home_tab'", timeout: 15)
end
Given('I am on the home screen') do
tap("* marked:'home_tab'")
wait_for_element_exists("* marked:'search_bar'", timeout: 10)
end
When('I search for {string}') do |query|
tap("* marked:'search_bar'")
keyboard_enter_text(query)
tap_keyboard_action_key
end
Then('I should see at least {int} result') do |min_count|
wait_for_element_exists("* marked:'search_result_item'", timeout: 10)
results = query("* marked:'search_result_item'")
expect(results.length).to be >= min_count
end
Then('each result should contain the text {string}') do |text|
results = query("* marked:'result_title'", :text)
results.each do |title|
expect(title.downcase).to include(text.downcase),
"Result '#{title}' does not contain '#{text}'"
end
end
Then('I should see the empty search screen') do
wait_for_element_exists("* marked:'empty_search_view'", timeout: 10)
end
When('I apply the {string} filter') do |filter|
tap("* marked:'filter_button'")
wait_for_element_exists("* marked:'filter_sheet'", timeout: 5)
tap("* text:'#{filter}'")
tap("* marked:'apply_filters_button'")
endRegex syntax (useful for more complex matching):
When(/I swipe (left|right) on the "(.*?)" carousel/) do |direction, carousel_name|
swipe(direction.to_sym, query: "* marked:'#{carousel_name}'")
endParameterized Steps
Cucumber Expressions support several built-in parameter types:
| Expression | Matches | Ruby type |
|---|---|---|
{string} |
Quoted string | String |
{int} |
Integer | Integer |
{float} |
Decimal number | Float |
{word} |
Single word, no spaces | String |
You can define custom parameter types for your domain:
# features/support/parameter_types.rb
ParameterType(
name: 'payment_method',
regexp: /credit card|debit card|PayPal|Apple Pay/,
transformer: ->(s) { s.downcase.gsub(' ', '_').to_sym }
)Then use it in steps:
When('I pay with {payment_method}') do |method|
tap("* marked:'payment_#{method}'")
endTags
Tags (@tag_name) appear above Feature, Scenario, or Scenario Outline blocks. They control which scenarios run and can carry metadata.
@smoke @critical
Feature: User authentication
@happy_path
Scenario: Login with valid credentials
...
@wip
Scenario: Login with biometrics
...
@android_only
Scenario: Login with Google account
...
@slow
Scenario Outline: Login with all OAuth providers
...Run only tagged scenarios:
# Smoke suite
bundle exec cucumber --tags @smoke
# Exclude WIP
bundle exec cucumber --tags 'not @wip'
# Multiple tags (AND logic)
bundle exec cucumber --tags '@smoke and @critical'
# Multiple tags (OR logic)
bundle exec cucumber --tags '@smoke or @regression'Skip platform-incompatible scenarios by checking for tags in hooks:
# features/support/hooks.rb
Before('@ios_only') do
skip_this_scenario unless ENV['PLATFORM'] == 'ios'
end
Before('@android_only') do
skip_this_scenario unless ENV['PLATFORM'] == 'android'
endSet PLATFORM=ios or PLATFORM=android when running tests to enforce the right filter.
Shared Step Libraries
When you maintain tests for both iOS and Android from a single codebase, you want most steps to work on both platforms. The marked: selector helps — it maps to accessibilityLabel on iOS and contentDescription on Android — but some interactions differ enough to need platform branching.
Define a platform helper:
# features/support/platform.rb
def android?
ENV['PLATFORM'] == 'android'
end
def ios?
ENV['PLATFORM'] == 'ios' || !android?
endUse it inside step definitions that need conditional behavior:
When('I dismiss the keyboard') do
if android?
hide_soft_keyboard
else
tap_keyboard_action_key
end
end
When('I go back') do
if android?
press_back_button
else
tap("* marked:'Back'")
end
endKeep shared steps in features/step_definitions/shared/ and platform-specific steps in features/step_definitions/ios/ or features/step_definitions/android/:
features/step_definitions/
├── shared/
│ ├── auth_steps.rb
│ ├── navigation_steps.rb
│ └── form_steps.rb
├── ios/
│ └── camera_steps.rb
└── android/
└── notification_steps.rbCucumber loads all .rb files under features/ recursively, so the directory structure is purely organizational.
Page Object Pattern
For larger test suites, wrapping screen interactions in page objects reduces duplication and makes step definitions easier to read.
# features/support/screens/login_screen.rb
class LoginScreen
def open
wait_for_element_exists("* marked:'login_screen'", timeout: 15)
self
end
def enter_email(email)
tap("* marked:'email_field'")
keyboard_enter_text(email)
self
end
def enter_password(password)
tap("* marked:'password_field'")
keyboard_enter_text(password)
self
end
def submit
tap("* marked:'login_button'")
self
end
def error_message
query("* marked:'error_label'", :text).first
end
def visible?
element_exists?("* marked:'login_screen'")
end
endStep definitions become thin wrappers:
# features/step_definitions/auth_steps.rb
Given('I am on the login screen') do
@login = LoginScreen.new.open
end
When('I sign in as {string} with password {string}') do |email, password|
@login.enter_email(email).enter_password(password).submit
end
Then('I should see the login error {string}') do |message|
expect(@login.error_message).to eq(message)
endRequire all screen classes from features/support/env.rb:
Dir[File.expand_path('../screens/**/*.rb', __FILE__)].each { |f| require f }Data Tables
Cucumber data tables let you pass structured data to a step:
Scenario: Add multiple items to cart
When I add the following items to my cart:
| Product | Quantity |
| Running Shoes Pro | 1 |
| Sports Socks | 3 |
| Water Bottle | 2 |
Then the cart total should reflect all itemsWhen('I add the following items to my cart:') do |table|
table.hashes.each do |row|
search_and_add_to_cart(row['Product'], row['Quantity'].to_i)
end
end
def search_and_add_to_cart(product_name, quantity)
tap("* marked:'search_bar'")
keyboard_enter_text(product_name)
tap_keyboard_action_key
wait_for_element_exists("* marked:'search_result_item'", timeout: 10)
tap("* marked:'add_to_cart_button' index:0")
quantity_adjust(quantity) if quantity > 1
endHooks
Hooks run at specific points in the test lifecycle. They are defined in features/support/:
# features/support/hooks.rb
# Runs once before the entire suite
Before(:all) do
FileUtils.mkdir_p('reports/screenshots')
end
# Runs before each scenario — receive the scenario object for tag inspection
Before do |scenario|
puts "Starting: #{scenario.name}"
start_test_server_in_background
end
# Runs after each scenario
After do |scenario|
if scenario.failed?
path = "reports/screenshots/failure_#{Time.now.to_i}.png"
screenshot(path: path)
embed(path, 'image/png', "Screenshot at failure")
end
calabash_exit
end
# Tagged hook — only runs for @slow scenarios
Before('@slow') do
puts "Warning: this scenario may take over 60 seconds"
endDocstrings
For steps that take multi-line input (like verifying displayed text blocks), Cucumber docstrings let you pass a heredoc-style string:
Then the terms and conditions text should include:
"""
By using this app, you agree to our Privacy Policy
and Terms of Service. You must be 13 or older.
"""Then('the terms and conditions text should include:') do |expected_text|
actual_text = query("* marked:'terms_text_view'", :text).first
expect(actual_text).to include(expected_text.strip)
endGenerating Reports
Cucumber supports several output formatters. For CI, JSON is easiest to process:
bundle exec cucumber \
--format json \
--out reports/cucumber.json \
--format prettyGenerate an HTML report from the JSON using cucumber-html-formatter or third-party gems like cluecumber-report. A minimal CI pipeline step:
# .github/workflows/mobile-tests.yml (excerpt)
- name: Run Calabash tests
run: |
bundle exec cucumber \
--format json --out reports/results.json \
--format progress \
--tags 'not @wip'
- name: Upload test results
uses: actions/upload-artifact@v3
with:
name: calabash-results
path: reports/