---
title: Exec Script Testing Patterns
read_when:
  - "testing exec CLI commands"
  - "writing integration tests for scripts"
  - "debugging 'Context not initialized' errors in tests"
  - "debugging flaky tests in parallel execution"
tripwires:
  - action: "using monkeypatch.chdir() in exec script tests"
    warning: "Use obj=ErkContext.for_test(cwd=tmp_path) instead. monkeypatch.chdir() doesn't inject context, causing 'Context not initialized' errors."
  - action: "testing code that reads from Path.home() or ~/.claude/ or ~/.erk/"
    warning: "Tests that run in parallel must use monkeypatch to isolate from real filesystem state. Functions like extract_slugs_from_session() cause flakiness when they read from the user's home directory."
  - action: "using Path.home() directly in production code"
    warning: "Use gateway abstractions instead. For ~/.claude/ paths use ClaudeInstallation, for ~/.erk/ paths use ErkInstallation. Direct Path.home() access bypasses testability (fakes) and creates parallel test flakiness."
---

# Exec Script Testing Patterns

Testing patterns for CLI commands in `src/erk/cli/commands/exec/scripts/`.

## Required Pattern: Context Injection

All exec script tests MUST use `ErkContext.for_test()` for dependency injection:

```python
from click.testing import CliRunner
from erk_shared.context import ErkContext
from erk.cli.commands.exec.scripts.my_command import my_command

def test_my_command(tmp_path: Path) -> None:
    """Test using context injection."""
    runner = CliRunner()
    result = runner.invoke(
        my_command,
        ["--json"],
        obj=ErkContext.for_test(cwd=tmp_path),  # REQUIRED
    )
    assert result.exit_code == 0
```

## Anti-Pattern: monkeypatch.chdir()

Do NOT use `monkeypatch.chdir()` for exec script tests:

```python
# WRONG - Causes "Context not initialized" error
def test_my_command_wrong(tmp_path: Path, monkeypatch) -> None:
    monkeypatch.chdir(tmp_path)  # This doesn't inject context!

    runner = CliRunner()
    result = runner.invoke(my_command, ["--json"])
    # FAILS: "Error: Context not initialized"
```

## Why Context Injection?

Exec scripts use `require_cwd(ctx)` and similar helpers that read from `ctx.obj`:

```python
@click.command()
@click.pass_context
def my_command(ctx: click.Context) -> None:
    cwd = require_cwd(ctx)  # Reads from ctx.obj.cwd
```

When you use `monkeypatch.chdir()`:

- The process working directory changes
- But `ctx.obj` remains `None`
- `require_cwd(ctx)` fails with "Context not initialized"

When you use `obj=ErkContext.for_test(cwd=...)`:

- The context is properly injected
- `ctx.obj` contains the `ErkContext` instance
- `require_cwd(ctx)` returns the test path

## Sharing Context Across Multiple Invocations

When testing multiple commands in sequence, reuse the same context:

```python
def test_multiple_commands(tmp_path: Path) -> None:
    runner = CliRunner()
    ctx = ErkContext.for_test(cwd=tmp_path)  # Create once

    # First command
    result1 = runner.invoke(command1, ["arg"], obj=ctx)
    assert result1.exit_code == 0

    # Second command (same context)
    result2 = runner.invoke(command2, ["arg"], obj=ctx)
    assert result2.exit_code == 0
```

## Available Context Helpers

From `erk_shared.context.helpers`:

| Helper                   | Returns        | Usage                     |
| ------------------------ | -------------- | ------------------------- |
| `require_cwd(ctx)`       | `Path`         | Current working directory |
| `require_repo_root(ctx)` | `Path`         | Repository root path      |
| `require_git(ctx)`       | `Git`          | Git operations            |
| `require_github(ctx)`    | `GitHub`       | GitHub operations         |
| `require_issues(ctx)`    | `GitHubIssues` | GitHub issues operations  |

## Test File Locations

- **Integration tests**: `tests/integration/cli/commands/exec/scripts/`
- **Unit tests**: `tests/unit/cli/commands/exec/scripts/`

## Pattern: Parameterizing Path.home() for Testability

When functions need home directory paths (for `~/.claude/`, `~/.ssh/`, etc.), use this pattern to maintain testability.

### The Problem

```python
# BAD - Not testable, triggers tripwire
def build_docker_run_args(worktree_path: Path) -> list[str]:
    claude_dir = Path.home() / ".claude"  # Hardcoded!
    ...
```

### The Solution

Make `home_dir` a **required parameter** with no default/fallback:

```python
# GOOD - Testable function
def build_docker_run_args(
    *,
    worktree_path: Path,
    image_name: str,
    interactive: bool,
    home_dir: Path,  # Required, no fallback
) -> list[str]:
    claude_dir = home_dir / ".claude"
    ssh_dir = home_dir / ".ssh"
    ...
```

### CLI Boundary

Call `Path.home()` only in CLI-layer functions that can't be unit tested anyway (e.g., functions using `os.execvp` or subprocess):

```python
# CLI-layer function - acceptable to use Path.home() here
def execute_docker_interactive(...) -> None:
    docker_args = build_docker_run_args(
        worktree_path=worktree_path,
        image_name=image_name,
        interactive=True,
        home_dir=Path.home(),  # At CLI boundary
    )
    os.execvp("docker", docker_args)  # Can't unit test anyway
```

### Testing

Tests pass `tmp_path` for the home directory:

```python
def test_build_docker_run_args_mounts_claude_dir(tmp_path: Path) -> None:
    # Create fake home structure
    claude_dir = tmp_path / ".claude"
    claude_dir.mkdir()

    args = build_docker_run_args(
        worktree_path=Path("/path/to/worktree"),
        image_name="erk-local:latest",
        interactive=True,
        home_dir=tmp_path,  # Test with tmp_path
    )

    # Verify mount includes fake home path
    assert any(str(claude_dir) in arg for arg in args)
```

### Why No Default?

Using `home_dir: Path | None = None` with fallback `Path.home()` still triggers the tripwire because the fallback executes in the function body. Making the parameter required:

1. Forces CLI code to explicitly provide `Path.home()`
2. Makes the testability boundary clear
3. Prevents accidental use of real home in tests

**Source example**: `src/erk/cli/commands/docker_executor.py:build_docker_run_args()`

## Testing Branch Operations

When testing exec scripts that create and checkout branches, use FakeBranchManager to verify the operation sequence.

### Pattern: Verify create_branch + checkout_branch Sequence

```python
from erk_shared.context.testing import context_for_test
from erk_shared.branch_manager.fake import FakeBranchManager

def test_setup_creates_and_checks_out_branch(tmp_path: Path) -> None:
    """Verify branch creation is followed by checkout."""
    fake_branch_manager = FakeBranchManager(
        current_branch="main",
        tracked_branches={"main": None},
    )
    ctx = context_for_test(
        cwd=tmp_path,
        branch_manager=fake_branch_manager,
    )

    # Run the command
    result = runner.invoke(setup_impl_from_issue, ["123"], obj=ctx)

    # Verify branch was created AND checked out
    assert "P123-" in str(fake_branch_manager.created_branches)
    assert fake_branch_manager.current_branch.startswith("P123-")
```

### Why This Matters

GraphiteBranchManager.create_branch() restores the original branch after Graphite tracking. Tests must verify that commands explicitly call checkout_branch() afterward, or they'll silently end up on the wrong branch.

## Testing Idempotent Commands

Commands that support `--session-id` for deduplication need tests verifying idempotency behavior.

### Pattern: Two Sequential Invocations

```python
def test_command_is_idempotent_within_session(tmp_path: Path) -> None:
    """Verify second invocation returns skipped_duplicate."""
    runner = CliRunner()
    ctx = ErkContext.for_test(cwd=tmp_path)
    session_id = "test-session-123"

    # First invocation - executes normally
    result1 = runner.invoke(
        my_command,
        ["--session-id", session_id, "--format", "json"],
        obj=ctx,
    )
    assert result1.exit_code == 0
    data1 = json.loads(result1.output)
    assert data1.get("skipped_duplicate") is not True  # First run executes

    # Second invocation - should be deduplicated
    result2 = runner.invoke(
        my_command,
        ["--session-id", session_id, "--format", "json"],
        obj=ctx,
    )
    assert result2.exit_code == 0
    data2 = json.loads(result2.output)
    assert data2.get("skipped_duplicate") is True  # Second run skipped
```

### Assertions to Verify

1. **First invocation**: `skipped_duplicate` is `False` or absent
2. **Second invocation**: `skipped_duplicate` is `True`
3. **Exit code**: Both invocations succeed (exit code 0)
4. **Side effects**: Only the first invocation creates resources

### Format-Specific Testing

Some commands have different output formats. Test idempotency in both:

```python
@pytest.mark.parametrize("format_flag", ["--format=json", "--format=text"])
def test_idempotency_all_formats(format_flag: str, tmp_path: Path) -> None:
    # Test with each output format
    ...
```

## Migrating Tests for Discriminated Union Returns

When a gateway method changes from exception-based to discriminated union (e.g., `T | ErrorType`), update exec script tests to check return types instead of catching exceptions.

### Before: Exception-Based Pattern

```python
def test_issue_not_found(fake_github: FakeGitHub) -> None:
    """Test error handling when issue doesn't exist."""
    fake_github.issue_not_found_error = True  # Fake raises exception

    result = runner.invoke(cli, ["exec", "get-issue", "123"])

    assert result.exit_code == 1
    assert "Issue not found" in result.output
```

### After: Discriminated Union Pattern

```python
def test_issue_not_found(fake_github: FakeGitHub) -> None:
    """Test error handling when issue doesn't exist."""
    # Fake returns IssueNotFound sentinel instead of raising
    fake_github.set_issue_result(123, IssueNotFound(issue_number=123, message="Issue not found"))

    result = runner.invoke(cli, ["exec", "get-issue", "123"])

    assert result.exit_code == 1
    assert "Issue not found" in result.output
```

### Key Differences

| Aspect              | Exception-Based                     | Discriminated Union                     |
| ------------------- | ----------------------------------- | --------------------------------------- |
| Fake setup          | Set error flag → raises exception   | Set return value → returns error type   |
| Gateway behavior    | `raise IssueNotFoundError()`        | `return IssueNotFound(...)`             |
| Exec script pattern | `try/except` block                  | `if isinstance(result, IssueNotFound):` |
| Test assertion      | Verify exception caught & formatted | Verify error type returned & formatted  |

### Migration Checklist

When migrating exec script tests for discriminated unions:

1. [ ] Update fake setup to return error types instead of raising
2. [ ] Update exec script to use `isinstance()` checks instead of `try/except`
3. [ ] Verify test still checks exit code and error message
4. [ ] Verify test covers both success and error paths
5. [ ] Update test names if needed (e.g., `test_issue_not_found_exception` → `test_issue_not_found`)

### Example: Actual Migration

From PR #6304 (hypothetical `get_issue` conversion):

**Before:**

```python
# Fake gateway
def get_issue(self, issue_number: int) -> IssueDetails:
    if self.issue_not_found_error:
        raise IssueNotFoundError(f"Issue {issue_number} not found")
    return self.issues[issue_number]

# Test
def test_issue_not_found():
    fake.issue_not_found_error = True
    result = runner.invoke(cli, ["exec", "get-issue", "123"])
    assert result.exit_code == 1
```

**After:**

```python
# Fake gateway
def get_issue(self, issue_number: int) -> IssueInfo | IssueNotFound:
    if issue_number in self.issue_results:
        return self.issue_results[issue_number]
    return IssueNotFound(issue_number=issue_number, message=f"Issue {issue_number} not found")

# Test
def test_issue_not_found():
    fake.set_issue_result(123, IssueNotFound(issue_number=123, message="Issue not found"))
    result = runner.invoke(cli, ["exec", "get-issue", "123"])
    assert result.exit_code == 1
```

## Testing Interactive/NoReturn Gateway Methods

Some gateway methods are `NoReturn` - they call `os.exec*()` to replace the current process (e.g., `exec_ssh_interactive()`). These methods never return control to the caller.

### Problem

Methods with `NoReturn` type annotation never return. Testing them directly would cause the test process to be replaced.

### Solution Pattern

**DO NOT call the method directly.** Instead:

1. Verify the fake gateway recorded the call
2. Check the call parameters (interactive=True, correct command, etc.)
3. No assertions after the call - they won't execute

### Example: Testing exec_ssh_interactive

**From:** `tests/unit/cli/commands/codespace/run/objective/test_next_plan_cmd.py`

```python
def test_run_next_plan_starts_codespace_and_runs_command() -> None:
    """run objective next-plan starts the codespace and runs the command."""
    runner = CliRunner()

    cs = _make_codespace("mybox")
    fake_codespace = FakeCodespace()
    codespace_registry = FakeCodespaceRegistry(codespaces=[cs], default_codespace="mybox")
    ctx = context_for_test(codespace=fake_codespace, codespace_registry=codespace_registry)

    # Invoke the command (which calls exec_ssh_interactive internally)
    result = runner.invoke(
        cli,
        ["codespace", "run", "objective", "next-plan", "42"],
        obj=ctx,
        catch_exceptions=False,
    )

    # Verify the call was made via the fake
    assert fake_codespace.exec_called is True
    assert len(fake_codespace.ssh_calls) == 1
    call = fake_codespace.ssh_calls[0]
    assert call.gh_name == "user-mybox-abc123"
    assert "erk objective next-plan 42" in call.remote_command
    assert call.interactive is True
```

**Key points:**

- `exec_called` attribute tracks whether `exec_ssh_interactive()` was invoked
- `ssh_calls` list stores parameters passed to the call
- `interactive` field distinguishes `exec_ssh_interactive()` from `run_ssh_command()`

### Why This Works

**FakeCodespace behavior:**

- `exec_ssh_interactive()` records the call and returns normally (doesn't actually exec)
- Real gateway would replace process, test never reaches assertions
- Fake gateway lets test continue and verify the call parameters

### Gotcha: No Code After exec_ssh_interactive

In real code, nothing after `exec_ssh_interactive()` executes:

```python
# Command implementation
def next_plan(ctx: ErkContext, issue_ref: str) -> None:
    codespace.exec_ssh_interactive(gh_name, remote_command)
    # THIS LINE NEVER RUNS - process was replaced
    print("Done")  # Dead code
```

**In tests:** The fake lets execution continue, but this doesn't match production behavior. Don't write tests that depend on post-exec code running.

### Verification Checklist

When testing NoReturn gateway methods:

- ✅ Verify `exec_called is True`
- ✅ Check parameters in the call record (`ssh_calls`, `gh_name`, `remote_command`, `interactive`)
- ✅ Verify command arguments are correctly formatted
- ❌ Don't assert on return values (there are none)
- ❌ Don't write code that depends on execution continuing after the call

### Related Gateway Methods

Other `NoReturn` methods that follow this pattern:

- `exec_ssh_interactive()` - Replace process with SSH command
- `os.execvp()` - Replace process with any command

See [SSH Command Execution](../architecture/ssh-command-execution.md) for when to use `exec_ssh_interactive()` vs `run_ssh_command()`.

## Related Documentation

- [CLI Testing Patterns](cli-testing.md) - General CLI testing patterns
- [Progress Schema Reference](../planning/progress-schema.md) - For testing progress commands
- [SSH Command Execution](../architecture/ssh-command-execution.md) - Decision framework for SSH methods
- Source: `src/erk/cli/commands/exec/scripts/AGENTS.md` - Exec script standards
