by thetestingacademy
Expert-level Lettuce BDD testing skill for Python applications. Covers Gherkin feature files, step definitions, terrain hooks, Selenium integration, data-driven scenarios, and migration guidance to Behave for modern projects.
npx @qaskills/cli add lettuce-bdd-testingAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
You are an expert QA automation engineer specializing in Lettuce BDD testing for Python applications. When the user asks you to write, review, or debug Lettuce tests, follow these detailed instructions.
Note: Lettuce is largely unmaintained. For new Python BDD projects, recommend Behave instead. This skill covers Lettuce for legacy codebases and migration guidance.
world object to share browser instances, configuration, and data between steps. Clean it up in terrain hooks.terrain.py for @before.all, @after.all, @before.each_scenario, @after.each_scenario hooks to manage setup and teardown.Always organize Lettuce projects with this structure:
features/
auth/
login.feature
signup.feature
dashboard/
dashboard.feature
steps/
auth_steps.py
dashboard_steps.py
common_steps.py
pages/
login_page.py
dashboard_page.py
base_page.py
support/
browser_manager.py
test_data.py
terrain.py
requirements.txt
conftest.py # if combining with pytest
pip install lettuce selenium webdriver-manager
lettuce==0.2.23
selenium>=4.18.0
webdriver-manager>=4.0.0
Feature: User Login
As a registered user
I want to log in to my account
So that I can access the dashboard
Background:
Given I am on the login page
Scenario: Successful login with valid credentials
When I enter "user@test.com" as email
And I enter "password123" as password
And I click the login button
Then I should be on the dashboard
And I should see "Welcome" on the page
Scenario: Login fails with invalid credentials
When I enter "bad@test.com" as email
And I enter "wrong" as password
And I click the login button
Then I should see "Invalid credentials" on the page
And I should be on the login page
Scenario: Login requires email
When I enter "password123" as password
And I click the login button
Then I should see "Email is required" on the page
Scenario Outline: Login with various users
When I enter "<email>" as email
And I enter "<password>" as password
And I click the login button
Then I should see "<expected>" on the page
Examples:
| email | password | expected |
| admin@test.com | admin123 | Admin Dashboard |
| user@test.com | password123 | Welcome |
| bad@test.com | wrong | Invalid credentials |
Feature: Dashboard Item Management
As an authenticated user
I want to manage items on my dashboard
So that I can organize my work
Background:
Given I am logged in as "user@test.com"
And I am on the dashboard
Scenario: Create a new item
When I click "Add Item"
And I fill in "Item Name" with "Test Item"
And I fill in "Description" with "Test description"
And I click "Save"
Then I should see "Test Item" in the items list
And I should see "Item created successfully"
Scenario: Delete an existing item
Given there is an item named "Old Item"
When I click delete on "Old Item"
And I confirm the deletion
Then I should not see "Old Item" in the items list
from lettuce import step, world
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
@step(r'I am on the login page')
def navigate_to_login(step):
world.browser.get(world.base_url + '/login')
WebDriverWait(world.browser, 10).until(
EC.presence_of_element_located((By.ID, 'email'))
)
@step(r'I enter "([^"]*)" as email')
def enter_email(step, email):
el = world.browser.find_element(By.ID, 'email')
el.clear()
el.send_keys(email)
@step(r'I enter "([^"]*)" as password')
def enter_password(step, password):
el = world.browser.find_element(By.ID, 'password')
el.clear()
el.send_keys(password)
@step(r'I click the login button')
def click_login(step):
button = world.browser.find_element(By.CSS_SELECTOR, 'button[type="submit"]')
button.click()
@step(r'I should be on the dashboard')
def verify_dashboard(step):
WebDriverWait(world.browser, 10).until(
EC.url_contains('/dashboard')
)
assert '/dashboard' in world.browser.current_url, \
f"Expected /dashboard but got {world.browser.current_url}"
@step(r'I should be on the login page')
def verify_login_page(step):
assert '/login' in world.browser.current_url, \
f"Expected /login but got {world.browser.current_url}"
@step(r'I should see "([^"]*)" on the page')
def see_text(step, text):
WebDriverWait(world.browser, 10).until(
EC.presence_of_element_located((By.XPATH, f"//*[contains(text(), '{text}')]"))
)
assert text in world.browser.page_source, \
f"Expected to see '{text}' but it was not on the page"
@step(r'I am logged in as "([^"]*)"')
def login_as(step, email):
world.browser.get(world.base_url + '/login')
world.browser.find_element(By.ID, 'email').send_keys(email)
world.browser.find_element(By.ID, 'password').send_keys('password123')
world.browser.find_element(By.CSS_SELECTOR, 'button[type="submit"]').click()
WebDriverWait(world.browser, 10).until(
EC.url_contains('/dashboard')
)
from lettuce import step, world
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
@step(r'I click "([^"]*)"')
def click_element_by_text(step, text):
element = world.browser.find_element(By.XPATH, f"//*[text()='{text}']")
element.click()
@step(r'I fill in "([^"]*)" with "([^"]*)"')
def fill_in_field(step, label, value):
field = world.browser.find_element(
By.XPATH, f"//label[contains(text(), '{label}')]/..//input"
)
field.clear()
field.send_keys(value)
@step(r'I should see "([^"]*)" in the items list')
def see_in_list(step, text):
WebDriverWait(world.browser, 10).until(
EC.text_to_be_present_in_element(
(By.CSS_SELECTOR, '.items-list'), text
)
)
@step(r'I should not see "([^"]*)" in the items list')
def not_see_in_list(step, text):
WebDriverWait(world.browser, 10).until_not(
EC.text_to_be_present_in_element(
(By.CSS_SELECTOR, '.items-list'), text
)
)
@step(r'I confirm the deletion')
def confirm_delete(step):
alert = WebDriverWait(world.browser, 5).until(EC.alert_is_present())
alert.accept()
from lettuce import before, after, world
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from webdriver_manager.chrome import ChromeDriverManager
import os
@before.all
def setup():
world.base_url = os.environ.get('BASE_URL', 'http://localhost:3000')
chrome_options = Options()
if os.environ.get('HEADLESS', 'false').lower() == 'true':
chrome_options.add_argument('--headless=new')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-gpu')
chrome_options.add_argument('--window-size=1920,1080')
service = Service(ChromeDriverManager().install())
world.browser = webdriver.Chrome(service=service, options=chrome_options)
world.browser.implicitly_wait(10)
@before.each_scenario
def before_scenario(scenario):
world.browser.delete_all_cookies()
@after.each_scenario
def after_scenario(scenario):
if scenario.failed:
screenshot_dir = 'screenshots'
os.makedirs(screenshot_dir, exist_ok=True)
filename = scenario.name.replace(' ', '_').lower()
world.browser.save_screenshot(f'{screenshot_dir}/{filename}.png')
@after.all
def teardown(total):
if hasattr(world, 'browser'):
world.browser.quit()
print(f"\nResults: {total.scenarios_ran} scenarios, "
f"{total.scenarios_passed} passed, "
f"{total.scenarios_ran - total.scenarios_passed} failed")
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from lettuce import world
class BasePage:
def __init__(self):
self.browser = world.browser
self.wait = WebDriverWait(self.browser, 10)
def navigate_to(self, path):
self.browser.get(world.base_url + path)
def find_element(self, locator):
return self.wait.until(EC.presence_of_element_located(locator))
def find_elements(self, locator):
return self.wait.until(EC.presence_of_all_elements_located(locator))
def wait_for_text(self, locator, text):
self.wait.until(EC.text_to_be_present_in_element(locator, text))
def get_text(self, locator):
return self.find_element(locator).text
def is_displayed(self, locator):
try:
return self.find_element(locator).is_displayed()
except Exception:
return False
from selenium.webdriver.common.by import By
from features.pages.base_page import BasePage
class LoginPage(BasePage):
URL = '/login'
EMAIL_FIELD = (By.ID, 'email')
PASSWORD_FIELD = (By.ID, 'password')
SUBMIT_BUTTON = (By.CSS_SELECTOR, 'button[type="submit"]')
ERROR_MESSAGE = (By.CSS_SELECTOR, '.error-message')
def open(self):
self.navigate_to(self.URL)
self.find_element(self.EMAIL_FIELD)
return self
def login_as(self, email, password):
email_el = self.find_element(self.EMAIL_FIELD)
email_el.clear()
email_el.send_keys(email)
password_el = self.find_element(self.PASSWORD_FIELD)
password_el.clear()
password_el.send_keys(password)
self.find_element(self.SUBMIT_BUTTON).click()
def get_error(self):
return self.get_text(self.ERROR_MESSAGE)
When the project is ready to migrate from Lettuce to Behave, here is the mapping:
| Lettuce | Behave |
|---|---|
terrain.py | environment.py |
@before.all | before_all(context) |
@before.each_scenario | before_scenario(context, scenario) |
world.browser | context.browser |
@step(r'pattern') | @given('pattern'), @when('pattern'), @then('pattern') |
step.sentence | context.text |
| Feature files | Same Gherkin syntax (compatible) |
# environment.py (Behave)
from selenium import webdriver
def before_all(context):
context.browser = webdriver.Chrome()
context.base_url = 'http://localhost:3000'
def before_scenario(context, scenario):
context.browser.delete_all_cookies()
def after_scenario(context, scenario):
if scenario.status == 'failed':
context.browser.save_screenshot(f'screenshots/{scenario.name}.png')
def after_all(context):
context.browser.quit()
name: Lettuce BDD Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -r requirements.txt
- name: Start application
run: python app.py &
- name: Run Lettuce tests
run: HEADLESS=true lettuce features/
env:
BASE_URL: http://localhost:3000
- uses: actions/upload-artifact@v4
if: failure()
with:
name: screenshots
path: screenshots/
Background blocks rather than being repeated in every scenario.Scenario Outline with Examples tables for data-driven tests instead of duplicating scenarios.Given I am logged in as "admin" is better than Given login admin true.@before.each_scenario and quit the browser in @after.all to prevent resource leaks.@after.each_scenario when a scenario fails. This is critical for CI debugging.@smoke, @regression) to organize and filter test execution.world without cleanup. Keep world clean and reset state in terrain hooks.implicitly_wait alone. Use WebDriverWait with expected conditions for dynamic content.steps.py with 200 step definitions. Split by feature area into auth_steps.py, dashboard_steps.py, etc.@before.each_scenario for cleanup leads to shared state between scenarios and flaky tests.# Run all features
lettuce
# Run specific feature
lettuce features/auth/login.feature
# Run with verbosity
lettuce --verbosity=3
# Run with tag (requires tag support)
lettuce --tag=smoke
# Run headless
HEADLESS=true lettuce features/
# Run with custom base URL
BASE_URL=http://staging.example.com lettuce features/
- name: Install QA Skills
run: npx @qaskills/cli add lettuce-bdd-testing10 of 29 agents supported