Design: E2E BDD Testing for Lola CLI
Created: 2026-06-05 Status: Draft
Goal
Structure E2E tests using BDD (Behavior-Driven Development) for the Lola Python CLI. The Gherkin feature files must be reusable when the project is rewritten in Go.
Framework Choice
| Language | Framework | Rationale |
|---|---|---|
| Python | behave | Closest structural analog to godog — features/, steps/, environment.py map 1:1 to godog's layout. |
| Go | godog | Standard Go BDD framework, consumes the same .feature files unchanged. |
Both use Gherkin feature files as the single source of truth. Feature files are 100% portable; only step definitions and helpers get rewritten.
Directory Layout
e2e/
├── features/ # Gherkin files, Python steps, and support
│ ├── behave.ini # behave configuration
│ ├── environment.py # behave hooks (before_scenario, after_scenario)
│ ├── steps/ # Step definitions (behave discovers these)
│ │ ├── cli_steps.py # Given/When/Then for CLI invocations
│ │ ├── filesystem_steps.py # Given/Then for files and directories
│ │ └── marketplace_steps.py # Given for HTTP-served marketplaces
│ ├── support/ # Shared helpers
│ │ ├── cli.py # subprocess wrapper for `lola`
│ │ ├── fixtures.py # Module/marketplace builders
│ │ └── http_server.py # Local HTTP server for marketplace URLs
│ ├── mod/
│ │ ├── add.feature
│ │ ├── rm.feature
│ │ ├── ls.feature
│ │ ├── info.feature
│ │ ├── init.feature
│ │ ├── update.feature
│ │ └── search.feature
│ ├── install/
│ │ ├── install.feature
│ │ ├── uninstall.feature
│ │ ├── update.feature
│ │ ├── list.feature
│ │ └── scope.feature
│ ├── market/
│ │ ├── add.feature
│ │ ├── ls.feature
│ │ ├── rm.feature
│ │ ├── update.feature
│ │ └── set.feature
│ ├── sync.feature
│ ├── completions.feature
│ └── cli.feature
│
└── go/ # Go step definitions (godog) — future
├── e2e_test.go # godog test runner + hooks
├── cli_steps.go
├── filesystem_steps.go
├── marketplace_steps.go
└── support/
├── cli.go
├── fixtures.go
└── http_server.go
Steps, support modules, and environment.py live alongside the feature files under e2e/features/. Behave natively adds this directory to sys.path, so from support.cli import ... works without path manipulation. When Go is added, e2e/go/ has its own step definitions and godog.Options{Paths: []string{"../features"}} points at the same .feature files.
Core Design Principles
1. Black-box CLI testing via subprocess
Never import lola internals. Invoke the lola binary the way a user would. This makes tests valid regardless of implementation language.
# python/support/cli.py
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
@dataclass
class LolaResult:
exit_code: int
stdout: str
stderr: str
class LolaCLI:
def __init__(self, lola_home: Path, work_dir: Path):
self.env = {**os.environ, "LOLA_HOME": str(lola_home)}
self.work_dir = work_dir
def run(self, *args: str) -> LolaResult:
result = subprocess.run(
["lola", *args],
capture_output=True, text=True,
cwd=self.work_dir, env=self.env,
)
return LolaResult(result.returncode, result.stdout, result.stderr)
// go/support/cli.go
type LolaResult struct {
ExitCode int
Stdout string
Stderr string
}
type LolaCLI struct {
LolaHome string
WorkDir string
}
func (c *LolaCLI) Run(args ...string) LolaResult {
cmd := exec.Command("lola", args...)
cmd.Dir = c.WorkDir
cmd.Env = append(os.Environ(), "LOLA_HOME="+c.LolaHome)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
exitCode := 0
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
}
}
return LolaResult{exitCode, stdout.String(), stderr.String()}
}
2. Scenario isolation
Every scenario gets a fresh temp directory with its own LOLA_HOME and project directory. No scenario can leak state to another.
# python/environment.py
import shutil
import tempfile
from pathlib import Path
from support.cli import LolaCLI
def before_scenario(context, scenario):
context.tmp_dir = Path(tempfile.mkdtemp(prefix="lola-e2e-"))
context.lola_home = context.tmp_dir / ".lola"
context.project_dir = context.tmp_dir / "project"
context.project_dir.mkdir(parents=True)
context.cli = LolaCLI(context.lola_home, context.project_dir)
context.modules = {}
context.http_servers = []
context.last_result = None
def after_scenario(context, scenario):
for server in getattr(context, "http_servers", []):
server.stop()
shutil.rmtree(context.tmp_dir, ignore_errors=True)
3. Thin step definitions, fat helpers
Steps delegate to helper functions immediately. This keeps steps readable and helpers testable/reusable. Steps should never contain more than 3-4 lines of glue logic.
4. Consistent step vocabulary
A small, reusable set of step patterns covers most scenarios. New features should compose from these before inventing new steps.
Step Pattern Catalog
These patterns form the shared vocabulary across all features.
CLI Execution
Exit Code & Output Assertions
Then the exit code should be {code:d}
Then the output should contain "{text}"
Then the output should not contain "{text}"
Then the output should match /{pattern}/
Then the error output should contain "{text}"
Filesystem Preconditions (Given)
Given a module "{name}" with a skill "{skill_name}"
Given a module "{name}" with skills, commands, and agents
Given a module "{name}" at "{path}"
Given assistant directories for "{assistant}"
Given a project directory
Given the module "{name}" is registered
Given the module "{name}" is installed to "{assistant}"
Given the module "{name}" is installed to "{assistant}" with scope "{scope}"
Filesystem Assertions (Then)
Then the file "{path}" should exist
Then the file "{path}" should not exist
Then the directory "{path}" should exist
Then the directory "{path}" should not exist
Then the file "{path}" should contain "{text}"
Then the file "{path}" should have frontmatter key "{key}" with value "{value}"
Marketplace Preconditions
Given a marketplace "{name}" serving modules:
| module | version | repository |
| git-module | 1.0.0 | https://github.com/user/repo.git |
Given the marketplace "{name}" is disabled
Given a local HTTP server serving "{file}" at "{url_path}"
Path Interpolation
Feature files use placeholders like {lola_home}, {project}, {module_path}. Step definitions resolve these from the scenario context:
def resolve_path(context, text):
replacements = {
"{lola_home}": str(context.lola_home),
"{project}": str(context.project_dir),
"{home}": str(Path.home()),
"{tmp}": str(context.tmp_dir),
}
for name, path in context.modules.items():
replacements[f"{{{name}_path}}"] = str(path)
replacements["{module_path}"] = str(path)
for k, v in replacements.items():
text = text.replace(k, v)
return text
Example Feature Files
features/mod/add.feature
Feature: Module registration
As a user, I want to register modules from various sources
so that I can install them to my AI assistants.
Background:
Given a project directory
Scenario: Add a module from a local folder
Given a module "my-module" with skills, commands, and agents
When I run lola "mod add {module_path}"
Then the exit code should be 0
And the output should contain "my-module"
And the directory "{lola_home}/modules/my-module" should exist
Scenario: Add a module that already exists
Given a module "my-module" with a skill "example"
And the module "my-module" is registered
When I run lola "mod add {module_path}"
Then the exit code should be 1
And the output should contain "already"
Scenario: Remove a registered module
Given the module "my-module" is registered
When I run lola "mod rm my-module"
Then the exit code should be 0
And the directory "{lola_home}/modules/my-module" should not exist
Scenario: List registered modules
Given the module "my-module" is registered
When I run lola "mod ls"
Then the exit code should be 0
And the output should contain "my-module"
features/install/install.feature
Feature: Module installation
As a user, I want to install modules to AI assistants
so that skills, commands, and agents are available in my workflow.
Background:
Given a project directory
And the module "git-module" is registered
And assistant directories for "claude-code"
Scenario: Install a module to Claude Code
When I run lola "install git-module -a claude-code"
Then the exit code should be 0
And the directory "{project}/.claude/skills" should exist
And the output should contain "Installed"
Scenario: Install with user scope
When I run lola "install git-module -a claude-code --scope user"
Then the exit code should be 0
And the directory "{home}/.claude/skills" should exist
Scenario: Uninstall a previously installed module
Given the module "git-module" is installed to "claude-code"
When I run lola "uninstall git-module -a claude-code"
Then the exit code should be 0
And the directory "{project}/.claude/skills/git-cheatsheet" should not exist
Scenario: Install a module not in registry but available in marketplace
Given a marketplace "community" serving modules:
| module | version | repository |
| new-module | 1.0.0 | https://github.com/user/repo.git |
When I run lola "install new-module -a claude-code"
Then the exit code should be 0
And the output should contain "new-module"
features/market/add.feature
Feature: Marketplace management
As a user, I want to register marketplaces
so I can discover and install modules from curated catalogs.
Scenario: Add a marketplace
Given a local HTTP server serving a marketplace catalog at "/catalog.yml"
When I run lola "market add community {server_url}/catalog.yml"
Then the exit code should be 0
And the file "{lola_home}/market/community.yml" should exist
And the file "{lola_home}/market/cache/community.yml" should exist
Scenario: List marketplaces
Given a marketplace "community" serving modules:
| module | version |
| my-module | 1.0.0 |
When I run lola "market ls"
Then the exit code should be 0
And the output should contain "community"
Scenario: Disable a marketplace excludes it from search
Given a marketplace "community" serving modules:
| module | version |
| my-module | 1.0.0 |
When I run lola "market set community --disable"
Then the exit code should be 0
When I run lola "mod search my-module"
Then the output should not contain "my-module"
Marketplace HTTP Testing
For features that need to download marketplace catalogs, use a local HTTP server:
# python/support/http_server.py
import threading
from functools import partial
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
class LocalHTTPServer:
def __init__(self, directory: Path):
self.directory = directory
handler = partial(SimpleHTTPRequestHandler, directory=str(directory))
self.server = HTTPServer(("127.0.0.1", 0), handler)
self.port = self.server.server_address[1]
self.url = f"http://127.0.0.1:{self.port}"
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
def start(self):
self.thread.start()
def stop(self):
self.server.shutdown()
// go equivalent uses net/http/httptest
server := httptest.NewServer(http.FileServer(http.Dir(directory)))
defer server.Close()
Tagging Strategy
@slow # Tests that take >5s (HTTP, git clone)
@marketplace # Needs HTTP server
@wip # Work in progress — excluded from CI
@smoke # Fast subset for pre-commit
@claude-code # Assistant-specific scenarios
@cursor
@gemini
@opencode
Run subsets:
behave --tags="@smoke" # Pre-commit fast check
behave --tags="~@slow" # Skip slow tests
behave --tags="@marketplace" # Only marketplace tests
behave --tags="~@wip" # CI default
Portability Matrix
| Artifact | Portable? | Go Migration |
|---|---|---|
features/**/*.feature |
Yes | Move unchanged |
| Step patterns (cucumber expressions) | Yes | Same expressions in godog |
| Step definition bodies | No | Rewrite in Go, same logic |
LolaCLI helper |
No | Rewrite using os/exec, same interface |
| Filesystem helpers | No | Rewrite using os/filepath, same interface |
environment.py hooks |
No | Map to ScenarioContext.Before/After |
LocalHTTPServer |
No | Replace with net/http/httptest |
behave.ini |
No | Replace with godog.Options{} |
The Go rewrite touches only step definitions and support code. Feature files and step patterns carry over unchanged.
Relationship to Existing Tests
The existing pytest unit tests in tests/ remain as-is. They test internal functions, data models, parsers, and target generators at the code level. The BDD E2E tests complement them by testing the CLI from the outside:
| Concern | pytest (unit/integration) | BDD E2E |
|---|---|---|
| Scope | Internal functions, classes | CLI binary as subprocess |
| Isolation | Mocked paths, patched imports | Real filesystem, env vars |
| Speed | Fast (no subprocess overhead) | Slower (process spawn per step) |
| Portability | Python-only | Feature files portable to Go |
| Purpose | Correctness of internals | User-visible behavior |
There is intentional overlap at the integration boundary — both test lola install, for example — but from different angles. The BDD tests are the ones that survive the Go rewrite.
Makefile Integration
# mk/dev.mk additions
e2e: ## Run E2E BDD tests
cd e2e/features && uv run behave --format progress --no-capture
e2e-wip: ## Run only @wip tagged E2E scenarios
cd e2e/features && uv run behave --tags="@wip" --format progress
e2e-smoke: ## Run smoke E2E tests (pre-commit)
cd e2e/features && uv run behave --tags="@smoke" --format progress
Cross-OS Containerized Execution
Problem
Lola must work on Linux, macOS, and Windows. E2E tests should verify behavior on all three operating systems, run without root privileges, and use the same feature files and step definitions regardless of the host or implementation language (Python or Go).
Container Runtime: Podman (Rootless)
Podman runs rootless by default — no daemon, no root, no sudo. It is the primary runtime for containerized E2E execution. Docker-compatible Containerfiles work unchanged.
podman build -t lola-e2e-python -f e2e/containers/python.Containerfile .
podman run --rm lola-e2e-python
Multi-OS Strategy
OCI containers are Linux-only. True macOS and Windows testing requires native execution on those platforms. The strategy splits into two layers:
| Layer | Linux | macOS | Windows |
|---|---|---|---|
| Container | Rootless Podman — primary path | Podman Machine (Linux VM) — validates container workflow | WSL2 + Podman — validates container workflow |
| Native | CI runner (also covered by container) | CI runner (macOS host) | CI runner (Windows host) |
- Linux variants (Fedora, Debian, Alpine): fully covered by containers with different base images.
- macOS/Windows native behavior (path separators, case sensitivity, home directory layout): covered by CI runners on real hosts.
- Container workflow validation on macOS/Windows: developers on those platforms use Podman Machine / WSL2 to run the Linux container tests locally.
Directory Layout (Additions)
e2e/
├── features/ # (unchanged — shared Gherkin files)
├── python/ # (unchanged — behave steps)
├── go/ # (unchanged — godog steps, future)
│
└── containers/
├── python.Containerfile # Python + behave image
├── go.Containerfile # Go + godog image (future)
├── base/
│ ├── fedora.Containerfile # Fedora base (default)
│ ├── debian.Containerfile # Debian base
│ └── alpine.Containerfile # Alpine base
└── scripts/
├── run-tests.sh # Entrypoint: run behave or godog
└── install-lola.sh # Build & install lola from source
.github/
└── workflows/
└── e2e.yml # GitHub Actions CI matrix
Containerfiles
Base Image (Fedora)
# e2e/containers/base/fedora.Containerfile
FROM registry.fedoraproject.org/fedora:42
RUN dnf install -y --setopt=install_weak_deps=False \
git curl ca-certificates \
&& dnf clean all
RUN useradd --create-home --shell /bin/bash tester
USER tester
WORKDIR /home/tester
Python Test Image
# e2e/containers/python.Containerfile
ARG BASE=fedora
FROM localhost/lola-e2e-base-${BASE}:latest AS base
USER root
RUN dnf install -y --setopt=install_weak_deps=False \
python3 python3-pip python3-devel \
&& dnf clean all
USER tester
COPY --chown=tester:tester . /home/tester/lola
WORKDIR /home/tester/lola
RUN python3 -m venv .venv \
&& .venv/bin/pip install --no-cache-dir . \
&& .venv/bin/pip install --no-cache-dir behave pyyaml
ENV PATH="/home/tester/lola/.venv/bin:${PATH}"
ENTRYPOINT ["e2e/containers/scripts/run-tests.sh"]
CMD ["python"]
Note: The RUN package-install commands are Fedora-specific. The Debian and Alpine Containerfiles use apt-get and apk respectively but produce the same final environment. The BASE build arg selects which base image to layer on.
Go Test Image (Future)
# e2e/containers/go.Containerfile
ARG BASE=fedora
FROM localhost/lola-e2e-base-${BASE}:latest AS base
USER root
RUN dnf install -y --setopt=install_weak_deps=False golang \
&& dnf clean all
USER tester
ENV PATH="/home/tester/go/bin:${PATH}"
COPY --chown=tester:tester . /home/tester/lola
WORKDIR /home/tester/lola
RUN cd e2e/go && go mod download
ENTRYPOINT ["e2e/containers/scripts/run-tests.sh"]
CMD ["go"]
Test Entrypoint
#!/usr/bin/env bash
# e2e/containers/scripts/run-tests.sh
set -euo pipefail
TEST_LANG="${1:-python}"
TAGS="${E2E_TAGS:-~@wip}"
case "$TEST_LANG" in
python)
cd e2e
exec behave --tags="$TAGS" --no-capture --format progress
;;
go)
cd e2e/go
exec go test -v -run TestFeatures -tags="$TAGS" ./...
;;
*)
echo "Unknown language: $TEST_LANG" >&2
exit 1
;;
esac
Build & Run Commands
# Build base image (once)
podman build -t lola-e2e-base-fedora -f e2e/containers/base/fedora.Containerfile .
# Build and run Python E2E tests
podman build -t lola-e2e-python \
--build-arg BASE=fedora \
-f e2e/containers/python.Containerfile .
podman run --rm lola-e2e-python
# Run with tag filter
podman run --rm -e E2E_TAGS="@smoke" lola-e2e-python
# Run on Debian base
podman build -t lola-e2e-base-debian -f e2e/containers/base/debian.Containerfile .
podman build -t lola-e2e-python-debian \
--build-arg BASE=debian \
-f e2e/containers/python.Containerfile .
podman run --rm lola-e2e-python-debian
Cross-OS Considerations in Feature Files
Feature files must remain OS-agnostic. Step definitions handle platform differences internally.
Path Separators
Feature files always use forward slashes. Step definitions normalize to the host OS:
# python/support/cli.py
def resolve_path(context, text):
resolved = _interpolate_placeholders(context, text)
return str(Path(resolved)) # normalizes separators per OS
// go/support/cli.go
func resolvePath(ctx *scenarioContext, text string) string {
resolved := interpolatePlaceholders(ctx, text)
return filepath.FromSlash(resolved)
}
Home Directory
{home} resolves differently per OS (/home/tester in containers, /Users/x on macOS, C:\Users\x on Windows). Step definitions handle this via the environment:
Filesystem Case Sensitivity
macOS (HFS+/APFS default) and Windows (NTFS) are case-insensitive. Linux (ext4) is case-sensitive. Feature files should use consistent casing and avoid relying on case-insensitive lookups. Step assertions compare paths case-sensitively everywhere — if a test passes on Linux, it will pass on macOS/Windows. The reverse is what catches bugs.
Newlines
Lola writes text files. Step definitions that assert file content should normalize \r\n to \n before comparison so Windows output doesn't break assertions written for Unix:
CI Pipeline (GitHub Actions)
The CI matrix covers three dimensions: OS, implementation language, and Linux distribution (for containers).
# .github/workflows/e2e.yml
name: E2E BDD Tests
on:
pull_request:
push:
branches: [main]
jobs:
e2e-container:
name: "E2E / ${{ matrix.lang }} / ${{ matrix.distro }}"
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
lang: [python] # add 'go' when ready
distro: [fedora, debian, alpine]
steps:
- uses: actions/checkout@v4
- name: Build base image
run: |
podman build -t lola-e2e-base-${{ matrix.distro }} \
-f e2e/containers/base/${{ matrix.distro }}.Containerfile .
- name: Build test image
run: |
podman build -t lola-e2e-${{ matrix.lang }}-${{ matrix.distro }} \
--build-arg BASE=${{ matrix.distro }} \
-f e2e/containers/${{ matrix.lang }}.Containerfile .
- name: Run E2E tests
run: |
podman run --rm \
lola-e2e-${{ matrix.lang }}-${{ matrix.distro }} \
${{ matrix.lang }}
e2e-native:
name: "E2E / ${{ matrix.lang }} / ${{ matrix.os }}"
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
lang: [python] # add 'go' when ready
os: [macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- name: Set up Python
if: matrix.lang == 'python'
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install lola and test dependencies
shell: bash
run: |
python -m venv .venv
source .venv/bin/activate || . .venv/Scripts/activate
pip install . behave pyyaml
- name: Run E2E tests
shell: bash
run: |
source .venv/bin/activate || . .venv/Scripts/activate
cd e2e
behave --tags="~@wip" --no-capture --format progress
CI Matrix Summary
| Job | Runner | Method | What It Validates |
|---|---|---|---|
e2e-container / fedora |
ubuntu-latest |
Rootless Podman | Fedora baseline (default) |
e2e-container / debian |
ubuntu-latest |
Rootless Podman | Debian/dpkg-based compatibility |
e2e-container / alpine |
ubuntu-latest |
Rootless Podman | Musl libc, minimal environment |
e2e-native / macOS |
macos-latest |
Direct execution | Case-insensitive FS, macOS paths |
e2e-native / Windows |
windows-latest |
Direct execution | NTFS, backslash paths, %USERPROFILE% |
When Go is added, each row doubles (lang: [python, go]), but the feature files stay identical — only the runner language changes.
Local Developer Workflow
# Run locally without containers (fastest feedback loop)
cd e2e/features && uv run behave
# Run in a rootless container (reproduces CI)
make e2e-container
# Run on a specific distro
make e2e-container DISTRO=debian
# Run only smoke tests in container
make e2e-container E2E_TAGS=@smoke
Makefile Integration (Updated)
# mk/dev.mk additions
DISTRO ?= fedora
E2E_LANG ?= python
E2E_TAGS ?= ~@wip
e2e: ## Run E2E BDD tests locally
cd e2e/features && uv run behave --no-capture
e2e-wip: ## Run only @wip tagged E2E scenarios
cd e2e/features && uv run behave --tags="@wip" --no-capture
e2e-smoke: ## Run smoke E2E tests (pre-commit)
cd e2e/features && uv run behave --tags="@smoke" --no-capture
e2e-container-build: ## Build E2E container images
podman build -t lola-e2e-base-$(DISTRO) -f e2e/containers/base/$(DISTRO).Containerfile .
podman build -t lola-e2e-$(E2E_LANG)-$(DISTRO) --build-arg BASE=$(DISTRO) \
-f e2e/containers/$(E2E_LANG).Containerfile .
e2e-container: e2e-container-build ## Run E2E tests in a rootless container
podman run --rm -e E2E_TAGS="$(E2E_TAGS)" lola-e2e-$(E2E_LANG)-$(DISTRO) $(E2E_LANG)
Dependencies
behave>=1.2.6 is included in the dev dependency group in pyproject.toml, installed via uv sync --group dev. No separate requirements file is needed.
The E2E tests use only stdlib (subprocess, tempfile, pathlib, http.server, threading, re) plus behave and PyYAML (already a project dependency) for marketplace fixture building.