Metadata-Version: 2.4
Name: testshed
Version: 0.0.18
Summary: 🛠️ PyTest's tool shed: Docker up, Playwright on, cleanup done
Author-email: Dov Benyomin Sohacheski <b@kloud.email>
License-Expression: MIT
Project-URL: Homepage, https://github.com/kloudkit/testshed
Project-URL: Bug Tracker, https://github.com/kloudkit/testshed/issues
Project-URL: Source Code, https://github.com/kloudkit/testshed
Keywords: pytest,docker,fixtures,playwright,testing
Classifier: Framework :: Pytest
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: playwright<2.0.0,>=1.58.0
Requires-Dist: pytest>=9.0.2
Requires-Dist: python-on-whales<1.0.0,>=0.80.0
Requires-Dist: PyYAML<7.0.0,>=6.0.3
Requires-Dist: requests<3.0.0,>=2.32.5
Dynamic: license-file

# KloudKIT TestShed

Integration tests need real services (a database, an API, a browser), not mocks.
Standing those up by hand means boilerplate container code in `conftest.py`,
flaky startup races, and leftover containers when a test crashes.

TestShed is a pytest plugin that hands you a running Docker container (or a
Playwright browser) as a fixture. You ask for `shed`, you get a live container;
the plugin builds or pulls the image, waits for readiness, and tears everything
down when the test ends.

## Features

- **Containers as fixtures:** request `shed` (or `docker_sidecar`) and run
  commands, read files, and inspect processes inside a real container.
- **Playwright browsers:** drive a browser running in a Playwright sidecar
  container.
- **Per-test configuration:** set env vars, volumes, and readiness probes with
  markers, or defaults for the whole suite via CLI.
- **Lifecycle handled:** images are resolved up front; containers and volumes are
  removed when the test finishes, even on failure.

## Requirements

- A running Docker daemon (local, or reachable via `DOCKER_HOST`).
- Python ≥ 3.11.
- pytest ≥ 9.

## Installation

```sh
pip install testshed
# or
uv add testshed --group dev
```

## Quick start

Enable TestShed via your pytest config:

```toml
# pyproject.toml
[tool.pytest.ini_options]
addopts = "--shed --shed-image=ghcr.io/acme/app --shed-tag=tests"
```

Write a test against the auto-registered `shed` fixture:

```python
# tests/test_smoke.py
def test_container_is_alive(shed):
  assert shed.execute(["echo", "hello"]) == "hello"
```

Run it:

```sh
pytest
```

The fixtures (`shed`, `docker_sidecar`, `playwright_browser`, ...) are auto-registered
whenever `--shed` is set, with no `conftest.py` import required.

## Usage

### Docker container testing

The `shed` fixture is the main entry point. By default it returns a **shared
container** (`shed_default`) reused across tests that don't override anything —
fast, because the container starts once. Apply a marker (env, volume, config, or
`@shed_mutable()`) and `shed` instead gives that test its own dedicated
container.

#### Configure containers with decorators

- **`@shed_config(**kwargs)`:** pass arguments to the container factory (e.g. `publish`, `networks`).
- **`@shed_env(**envs)`:** set environment variables.
- **`@shed_volumes(*mounts)`:** mount volumes as `(source, dest)` tuples or `BaseVolume` instances.
- **`@shed_mutable()`:** force a dedicated container for tests that mutate state (bypasses the shared default).

```python
from kloudkit.testshed.docker import InlineVolume, RemoteVolume

@shed_config(publish=[(8080, 80)])
@shed_env(MY_ENV_VAR="hello")
@shed_volumes(
  ("/path/to/host/data", "/app/data"),
  InlineVolume("/app/config.txt", "any content you want", mode=0o644),
  RemoteVolume("/app/remote-config.json", "https://api.example.com/config.json", mode=0o644),
)
def test_configured_docker_app(shed):
  # ... test logic ...
```

Use `@shed_mutable()` when your test writes data, installs packages, or otherwise changes the container.

This ensures it gets its own instance instead of reusing the shared default:

```python
@shed_mutable()
def test_install_package(shed):
  shed.execute("apt-get install -y curl")

  assert "curl" in shed.execute("which curl")
```

#### High-level `shed` fixture

Use the `shed` fixture for container management with configurable defaults:

```python
import pytest

from kloudkit.testshed.docker import Container, HttpProbe

class MyAppContainer(Container):
  DEFAULT_USER = "app"

@pytest.fixture(scope="session")
def shed_container_defaults():
  """Override this fixture to set project-specific defaults."""

  return {
    "container_class": MyAppContainer,
    "envs": {"APP_PORT": 3000},
    "probe": HttpProbe(port=3000, endpoint="/health"),
  }

def test_my_app(shed):
  # Picks up your configured defaults.
  assert shed.execute("whoami") == "app"

@shed_env(DEBUG="true")
def test_my_app_with_debug(shed):
  # New container with override, merged with defaults
  assert shed.execute("echo $DEBUG") == "true"
  assert shed.execute("echo $APP_PORT") == "3000"
```

#### Deferred deployment with `shed_deferred`

Use `shed_deferred` when you need to control *when* the container starts, for pre-deployment
setup, runtime parameterization, or spinning up multiple containers in a single test:

```python
@shed_env(APP_PORT="3000")
def test_deferred_deployment(shed_deferred):
  # Container is NOT running yet; do setup here
  # ...

  # Deploy with optional call-time overrides
  container = shed_deferred(envs={"DEBUG": "true"})
  # envs are merged: APP_PORT=3000 + DEBUG=true

  assert container.execute("echo $DEBUG") == "true"
  assert container.execute("echo $APP_PORT") == "3000"


def test_multiple_containers(shed_deferred):
  primary = shed_deferred(envs={"ROLE": "primary"})
  replica = shed_deferred(envs={"ROLE": "replica"})
  # Each call spins up a new container
```

Call-time parameters merge with decorator config:

- **`envs`:** dict merge *(call-time values override decorator values)*.
- **`volumes`:** concatenated *(call-time volumes added after decorator volumes)*.
- **`**kwargs`:** passed as config args *(override decorator `@shed_config` values)*.

#### Basic Docker container

For a lower-level API, use the `docker_sidecar` fixture, which launches a
container directly from any image:

```python
def test_my_docker_app(docker_sidecar):
  nginx = docker_sidecar("nginx:latest", publish=[(8080, 80)])

  # The container's internal IP and current user.
  print(f"Container IP: {nginx.ip()}  user: {nginx.whoami()}")
```

#### Interacting with a container

Every container TestShed returns (`shed`, `docker_sidecar`, and friends) exposes
the same three helpers for poking at its runtime: `execute`, `fs`, and `proc`.

**`execute`: run commands.** Call it directly to use the container's default
shell, or pick one explicitly. Pass `raises=True` to fail the test on a non-zero
exit, and `user=` to run as another user:

```python
def test_commands(shed):
  # Default shell; output is returned stripped.
  assert shed.execute("echo hello") == "hello"

  # Specific shells.
  shed.execute.bash("set -o pipefail; cat /etc/os-release | head -1")
  shed.execute.sh(["echo", "portable"])
  shed.execute.zsh("print -r -- $ZSH_VERSION")

  # Raise on failure instead of returning, and run as a given user.
  shed.execute("test -f /etc/hostname", raises=True)
  assert shed.execute("whoami", user="nobody") == "nobody"
```

**`fs`: read the file system.** Check existence, list directories, and read
files as text, bytes, JSON, or YAML:

```python
def test_files(shed):
  assert shed.fs.exists("/etc/hostname")

  # List a directory (pass hidden=True to include dotfiles).
  assert "hostname" in shed.fs.ls("/etc")

  # Read contents. Calling fs directly is shorthand for fs.text(...).
  assert "root" in shed.fs("/etc/passwd")
  raw = shed.fs.bytes("/etc/hostname")
  config = shed.fs.json("/app/config.json")
  manifest = shed.fs.yaml("/app/manifest.yaml")

  # File mode (octal string), and ownership (user / group names).
  assert shed.fs.mode("/etc/hostname") == "644"
  assert shed.fs.owner("/etc/hostname") == "root"
  assert shed.fs.group("/etc/hostname") == "root"
```

**`proc`: inspect processes.** Check what's running by command name, resolve
PIDs, and read a process's command line or environment:

```python
def test_processes(shed):
  assert shed.proc.running("nginx")
  assert shed.proc.pids("nginx")  # tuple of matching PIDs

  # Command line of the first match.
  assert "nginx" in shed.proc.cmdline("nginx")

  # Environment of a process (PID 1 by default).
  assert shed.proc.environ()["PATH"]
```

#### Readiness probes

A `probe` blocks until the container is ready, so tests don't race the startup
sequence. TestShed ships three:

```python
from kloudkit.testshed.docker import HttpProbe, LogProbe, ShellProbe

# Wait for an HTTP endpoint to respond.
HttpProbe(port=3000, endpoint="/health", timeout=30.0)

# Wait for a regex match in stdout/stderr.
LogProbe(pattern=r"ready to accept connections", timeout=30.0)

# Wait for a shell command to exit 0.
ShellProbe(command="pg_isready -U postgres", timeout=30.0)
```

Pass a probe wherever a container is created:

```python
@shed_config(probe=HttpProbe(port=8080, endpoint="/healthz"))
def test_app(shed):
  ...

def test_db(docker_sidecar):
  docker_sidecar(
    "postgres:16",
    envs={"POSTGRES_PASSWORD": "x"},
    probe=ShellProbe(command="pg_isready -U postgres"),
  )
```

You can also set a default for every `shed` container via the
`shed_container_defaults` fixture (`probe=...`).

To wait longer without restating the probe, pass `timeout=`; it overlays
onto the resolved probe, keeping the default's type and other fields:

```python
@shed_config(timeout=90.0)
def test_slow_boot(shed):
  ...
```

### Playwright browser testing

Get a Playwright browser instance running in Docker via `playwright_browser`:

```python
def test_example_website(playwright_browser):
  page = playwright_browser.new_page()
  page.goto("http://example.com")
  assert "Example Domain" in page.title()
  # ... more Playwright test logic ...
```

### Fixture reference

In addition to the headline fixtures (`shed`, `shed_deferred`, `docker_sidecar`,
`playwright_browser`), TestShed registers:

| Fixture                   | Scope    | Purpose                                                                                                                        |
| ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `docker_module_sidecar`   | module   | Same factory as `docker_sidecar`, shared per test module.                                                                      |
| `docker_session_sidecar`  | session  | Same factory, shared across the whole session.                                                                                 |
| `shed_factory`            | function | Callable that builds a container with `shed_container_defaults` merged in; what `shed` and `shed_deferred` use under the hood. |
| `shed_container_defaults` | session  | Override in your own `conftest.py` to set image defaults (`container_class`, `envs`, `probe`, ...).                            |
| `shed_state`              | session  | Read-only access to the resolved `ShedState` (image, tag, network, paths).                                                     |
| `shed_tag`                | session  | Fully qualified `image:tag` (or `image@sha256:...`) used for the session.                                                      |
| `shed_default`            | session  | The shared default container that `shed` returns when no marker overrides apply.                                               |
| `downloader`              | function | `downloader(url, "filename") -> Path`: fetch a URL into a `tmp_path` file.                                                     |
| `test_root`               | session  | Absolute path to the tests root.                                                                                               |
| `project_root`            | session  | Absolute path to the project source root.                                                                                      |

### Command-line options

| Flag                         | Default                                | Description                                                                                                       |
| ---------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `--shed`                     | off                                    | Enable TestShed for the current test suite.                                                                       |
| `--shed-image IMAGE`         | —                                      | Base image repository (e.g. `ghcr.io/acme/app`). Must **not** contain a tag or digest; use `--shed-tag` for that. |
| `--shed-tag TAG\|SHA`        | `tests`                                | Image tag or digest (use a `sha256:...` for immutable builds).                                                    |
| `--shed-build-context DIR`   | project root (pytest config directory) | Docker build context for the `build` / `rebuild` policies.                                                        |
| `--shed-image-policy POLICY` | `pull`                                 | One of `pull`, `build`, `require`, `rebuild` (see below).                                                         |
| `--shed-src-dir DIR`         | `src`                                  | Project source directory, relative to the pytest config.                                                          |
| `--shed-stubs-dir DIR`       | `tests/stubs`                          | Directory of stub files (resolved by relative `@shed_volumes` sources).                                           |
| `--shed-tests-dir DIR`       | `tests`                                | Tests root directory.                                                                                             |
| `--shed-skip-bootstrap`      | off                                    | Skip Docker bootstrap (useful when running just the unit subset).                                                 |
| `--shed-container-logs`      | off                                    | Dump container logs on failure.                                                                                   |

> [!NOTE]
> When TestShed is installed globally, you must explicitly enable it per suite with
> `--shed`.
> This prevents it from configuring Docker in projects that don't use it.

#### Image policies

The `--shed-image-policy` option controls how TestShed acquires Docker images:

- **`pull`:** pull image if not found locally, build as fallback *(default)*.
- **`build`:** build only if image doesn't exist locally.
- **`require`:** require existing local image *(fails if not found)*.
- **`rebuild`:** always rebuild the image.

#### Examples

```sh
# Rebuild the image, then run the suite against it.
pytest --shed --shed-image my-test-image --shed-image-policy rebuild

# Omit --shed and the plugin stays dormant: a plain pytest run.
pytest
```

### Parallel execution

`pytest -n auto` (pytest-xdist) works without extra setup. The Docker network
and container labels are namespaced by `PYTEST_XDIST_WORKER`, so workers don't
collide and each worker's containers are cleaned up independently.
