Calabash with Gherkin: Writing BDD Mobile Tests

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'")
end

Regex 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}'")
end

Parameterized 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}'")
end

Tags

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'
end

Set 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?
end

Use 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
end

Keep 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.rb

Cucumber 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
end

Step 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)
end

Require 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 items
When('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
end

Hooks

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"
end

Docstrings

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)
end

Generating Reports

Cucumber supports several output formatters. For CI, JSON is easiest to process:

bundle exec cucumber \
  --format json \
  --out reports/cucumber.json \
  --format pretty

Generate 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/

Read more

Start now free