BDD Testing Guide

The definitive guide to testing email workflows with Cucumber, Behave, and Playwright using OneShotMail.

This is the flagship integration guide. OneShotMail was built specifically for this use case: verifying email delivery as part of automated test suites.

The problem

Your application sends emails — verification links, password resets, invoices, notifications. Your test suite needs to verify these emails actually arrive with the correct content. Today, teams solve this by:

  • Shared Gmail inboxes — flaky, race conditions between tests, not scriptable.
  • Mailinator — public inboxes are insecure, expensive at scale, unreliable.
  • SES + S3 rigs — complex to set up and maintain, tied to AWS infrastructure.
  • Mocking the email service — does not test actual delivery, misses real bugs.

The solution

OneShotMail gives you a two-line solution in any language:

# 1. Create a disposable address
address = oneshot.create(ttl_seconds=300, label="test-signup")

# 2. Wait for the email (polls with exponential backoff)
email = oneshot.wait_for_email(address.id, timeout=30)

Each address is isolated, one-shot, and auto-deletes. No shared state. No race conditions. No cleanup headaches.


Cucumber (Ruby) — complete example

This is the canonical BDD framework. Here is a complete, runnable project.

Project structure

email-test-suite/
  Gemfile
  features/
    signup.feature
    password_reset.feature
    steps/
      signup_steps.rb
      password_reset_steps.rb
      shared_steps.rb
    support/
      env.rb
      oneshot_helper.rb

Gemfile

source "https://rubygems.org"

gem "cucumber", "~> 9.0"
gem "oneshot-mail", "~> 0.1"
gem "rspec-expectations", "~> 3.12"
gem "faraday", "~> 2.7"  # for your app's API calls

features/support/env.rb

require "oneshot"
require "securerandom"
require_relative "oneshot_helper"

# Shared OneShotMail client for the entire test run
ONESHOT = OneShot::Client.new(api_key: ENV.fetch("ONESHOT_API_KEY"))
RUN_ID = "cuke-#{SecureRandom.hex(4)}"

# Clean up all test addresses when the suite finishes
at_exit do
  ONESHOT.delete_by_label(RUN_ID) rescue nil
end

features/support/oneshot_helper.rb

module OneShotHelper
  # Create a fresh email address for this test scenario
  def create_temp_email(label_suffix = "")
    label = [RUN_ID, label_suffix].reject(&:empty?).join("-")
    ONESHOT.create(ttl: 300, label: label)
  end

  # Wait for an email to arrive
  def wait_for_email(address_id, timeout: 30)
    ONESHOT.wait_for_email(address_id, timeout: timeout)
  end

  # Extract a URL matching a pattern from the email body
  def extract_link(email, pattern)
    body = email["html_body"] || email["text_body"]
    body[pattern]
  end
end

World(OneShotHelper)

features/signup.feature

Feature: User signup
  Users can sign up and receive a verification email.

  Scenario: Successful signup sends verification email
    Given I have a temporary email address
    When I sign up with email "<email>" and name "Alice"
    Then I should receive an email within 30 seconds
    And the email subject should contain "Verify your account"
    And the email body should contain "Alice"
    And the email should contain a link matching "/verify"

  Scenario: Signup with HTML email renders correctly
    Given I have a temporary email address
    When I sign up with email "<email>" and name "Bob"
    Then I should receive an email within 30 seconds
    And the email should have an HTML body
    And the HTML body should contain a button with text "Verify"

features/steps/shared_steps.rb

Given("I have a temporary email address") do
  @addr = create_temp_email("signup")
  @email_addr = @addr["address"]
end

Then("I should receive an email within {int} seconds") do |timeout|
  @email = wait_for_email(@addr["id"], timeout: timeout)
  expect(@email).not_to be_nil
end

Then("the email subject should contain {string}") do |text|
  expect(@email["subject"]).to include(text)
end

Then("the email body should contain {string}") do |text|
  body = @email["text_body"] || @email["html_body"] || ""
  expect(body).to include(text)
end

Then("the email should contain a link matching {string}") do |pattern|
  body = @email["html_body"] || @email["text_body"] || ""
  expect(body).to match(/https?:\/\/[^\s"]*#{Regexp.escape(pattern)}/)
end

Then("the email should have an HTML body") do
  expect(@email["html_body"]).not_to be_nil
  expect(@email["html_body"]).not_to be_empty
end

Then("the HTML body should contain a button with text {string}") do |text|
  expect(@email["html_body"]).to include(text)
end

features/steps/signup_steps.rb

When("I sign up with email {string} and name {string}") do |_email_placeholder, name|
  # Replace with your app's API call
  response = Faraday.post("#{ENV['APP_BASE_URL']}/api/signup") do |req|
    req.headers["Content-Type"] = "application/json"
    req.body = { email: @email_addr, name: name, password: "SecureP@ss1" }.to_json
  end
  expect(response.status).to eq(201)
end

Running

bundle install
export ONESHOT_API_KEY="osm_live_your_key"
export APP_BASE_URL="http://localhost:3000"
bundle exec cucumber

Behave (Python) — complete example

Project structure

email-test-suite/
  requirements.txt
  features/
    signup.feature
    environment.py
    steps/
      signup_steps.py
      shared_steps.py

requirements.txt

behave>=1.2.6
oneshot-mail>=0.1.0
requests>=2.31.0

features/environment.py

import os
import uuid
import oneshot


def before_all(context):
    oneshot.configure(api_key=os.environ["ONESHOT_API_KEY"])
    context.run_id = f"behave-{uuid.uuid4().hex[:8]}"
    context.app_url = os.environ.get("APP_BASE_URL", "http://localhost:3000")


def after_scenario(context, scenario):
    if hasattr(context, "addr"):
        try:
            oneshot.delete(context.addr.id)
        except oneshot.NotFoundError:
            pass


def after_all(context):
    oneshot.delete_by_label(context.run_id)

features/signup.feature

Feature: User signup
  Scenario: Successful signup sends verification email
    Given I have a temporary email address labelled "signup"
    When I sign up with name "Alice"
    Then I should receive an email within 30 seconds
    And the email subject should contain "Verify your account"
    And the email body should contain "Alice"
    And the email should contain a verification link

features/steps/shared_steps.py

import re
from behave import given, then
import oneshot


@given('I have a temporary email address labelled "{label}"')
def step_create_address(context, label):
    context.addr = oneshot.create(
        ttl_seconds=300,
        label=f"{context.run_id}-{label}",
    )


@then("I should receive an email within {timeout:d} seconds")
def step_wait_for_email(context, timeout):
    context.email = oneshot.wait_for_email(context.addr.id, timeout=timeout)
    assert context.email is not None, "No email received"


@then('the email subject should contain "{text}"')
def step_check_subject(context, text):
    assert text in context.email.subject, (
        f"Expected '{text}' in subject '{context.email.subject}'"
    )


@then('the email body should contain "{text}"')
def step_check_body(context, text):
    body = context.email.text_body or context.email.html_body or ""
    assert text in body, f"Expected '{text}' in body"


@then("the email should contain a verification link")
def step_check_link(context):
    body = context.email.text_body or context.email.html_body or ""
    match = re.search(r"https?://\S+/verify\S*", body)
    assert match, "No verification link found"
    context.verification_link = match.group(0)

features/steps/signup_steps.py

import requests
from behave import when


@when('I sign up with name "{name}"')
def step_signup(context, name):
    response = requests.post(
        f"{context.app_url}/api/signup",
        json={
            "email": context.addr.address,
            "name": name,
            "password": "SecureP@ss1",
        },
    )
    assert response.status_code == 201, f"Signup failed: {response.text}"

Running

pip install -r requirements.txt
export ONESHOT_API_KEY="osm_live_your_key"
behave

Playwright (JavaScript) — complete example

Project structure

email-test-suite/
  package.json
  playwright.config.ts
  tests/
    signup.spec.ts
    helpers/
      oneshot.ts

package.json

{
  "devDependencies": {
    "@playwright/test": "^1.40.0",
    "oneshot-mail": "^0.1.0"
  },
  "scripts": {
    "test": "playwright test",
    "test:headed": "playwright test --headed"
  }
}

tests/helpers/oneshot.ts

import { OneShotClient } from "oneshot-mail";

export const oneshot = new OneShotClient(process.env.ONESHOT_API_KEY);

// Helper to extract a link from an email body
export function extractLink(email: { html_body: string; text_body: string }, pattern: RegExp): string | null {
  const body = email.html_body || email.text_body;
  const match = body.match(pattern);
  return match ? match[1] || match[0] : null;
}

tests/signup.spec.ts

import { test, expect } from "@playwright/test";
import { oneshot, extractLink } from "./helpers/oneshot";

test.describe("Signup flow", () => {
  let addressId: string;

  test.afterEach(async () => {
    if (addressId) {
      await oneshot.delete(addressId).catch(() => {});
    }
  });

  test("complete signup with email verification", async ({ page }) => {
    // 1. Create a disposable email
    const addr = await oneshot.create({ ttl: 300, label: "pw-signup" });
    addressId = addr.id;

    // 2. Fill out the signup form
    await page.goto("/signup");
    await page.fill('[name="email"]', addr.address);
    await page.fill('[name="password"]', "SecureP@ss1");
    await page.fill('[name="name"]', "Test User");
    await page.click('button[type="submit"]');
    await expect(page.locator(".success")).toContainText("Check your email");

    // 3. Wait for the verification email
    const email = await oneshot.waitForEmail(addr.id, { timeout: 30000 });
    expect(email.subject).toContain("Verify");

    // 4. Extract and visit the verification link
    const link = extractLink(email, /href="(https?:\/\/[^"]*verify[^"]*)"/);
    expect(link).toBeTruthy();

    await page.goto(link!);
    await expect(page.locator("h1")).toContainText("Verified");

    // 5. Log in with the verified account
    await page.goto("/login");
    await page.fill('[name="email"]', addr.address);
    await page.fill('[name="password"]', "SecureP@ss1");
    await page.click('button[type="submit"]');
    await expect(page.locator(".dashboard")).toBeVisible();
  });
});

Running

npm install
npx playwright install
ONESHOT_API_KEY=osm_live_your_key npm test

Parallel test execution

OneShotMail addresses are completely isolated. Every address is unique and receives only its own email. This makes parallel execution safe and straightforward.

Cucumber (Ruby)

# Use cucumber-parallel for parallel scenarios
bundle exec parallel_cucumber features/ -n 4

Behave (Python)

# Use behave-parallel or run features in parallel with xargs
find features -name "*.feature" | xargs -P 4 -I {} behave {}

Playwright (JavaScript)

Playwright runs tests in parallel by default:

npx playwright test --workers=4

Key pattern: use labels for cleanup

The label is your coordination mechanism. Tag all addresses in a test run with the same label, then bulk-delete at the end:

# In your test setup
run_id = os.environ.get("CI_RUN_ID", str(uuid.uuid4())[:8])

# Every address in this run shares the label
addr = oneshot.create(label=f"ci-{run_id}")

# One-line cleanup at the end
oneshot.delete_by_label(f"ci-{run_id}")

CI/CD integration

See the dedicated CI/CD guide for complete GitHub Actions, GitLab CI, and Jenkins examples.

The key pattern: store your API key as a CI secret and pass it as an environment variable:

# GitHub Actions
env:
  ONESHOT_API_KEY: ${{ secrets.ONESHOT_API_KEY }}

steps:
  - run: bundle exec cucumber

Tips

  1. Set TTL based on test timeout. If your test has a 60-second timeout, set TTL to 300 seconds (5 minutes) for safety margin.

  2. Use specific labels. Include the CI run ID, test class name, or scenario name in the label. This makes debugging easy.

  3. Always clean up. Use delete_by_label() in your after_all / afterAll / at_exit hook. The TTL will eventually clean up, but explicit cleanup is faster and keeps your quota healthy.

  4. Tune poll intervals for your email pipeline. If your app sends emails instantly (e.g., via background job that runs immediately), use a short poll interval. If email delivery takes a few seconds, use the defaults.

  5. Do not assert on exact email bodies. Email content can change. Assert on key phrases, link patterns, and header values instead of exact string matches.

  6. Use get_email_raw() for DKIM/SPF debugging. If you need to verify email authentication headers, use the raw email endpoint rather than the parsed one.