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
-
Set TTL based on test timeout. If your test has a 60-second timeout, set TTL to 300 seconds (5 minutes) for safety margin.
-
Use specific labels. Include the CI run ID, test class name, or scenario name in the label. This makes debugging easy.
-
Always clean up. Use
delete_by_label()in yourafter_all/afterAll/at_exithook. The TTL will eventually clean up, but explicit cleanup is faster and keeps your quota healthy. -
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.
-
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.
-
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.