Skip to content

Commit 114dcdd

Browse files
committed
⌚ Add deffered shed fixture
1 parent d213e2e commit 114dcdd

7 files changed

Lines changed: 299 additions & 57 deletions

File tree

README.md

Lines changed: 86 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
# KloudKIT TestShed
22

3-
> Meet **KloudKIT TestShed**, a tidy home for your integration-testing power tools.
4-
>
5-
> It snap-fits into `pytest`, auto-provisions Docker, runs Playwright, and cleans up after itself
6-
> so you can focus on building sharp tests.
3+
A pytest plugin for integration testing with Docker and Playwright.
4+
It handles container lifecycle, browser provisioning, and cleanup automatically.
75

86
## Features
97

10-
- **Automated Docker management:** Spin up and control containers from tests.
11-
- **Playwright integration:** Run browser tests in isolated Docker environments.
12-
- **Configurable via markers & CLI:** Tune environments per test or suite.
13-
- **Automatic resource cleanup:** Ensures a clean state after tests.
8+
- **Docker management:** provision and control containers from tests.
9+
- **Playwright integration:** browser testing in isolated Docker environments.
10+
- **Configurable via markers & CLI:** tune environments per test or suite.
11+
- **Automatic cleanup:** containers and volumes are removed after tests.
1412

1513
## Installation
1614

@@ -40,9 +38,44 @@ from kloudkit.testshed.fixtures.playwright import playwright_browser
4038

4139
TestShed provides fixtures to manage containers inside your tests.
4240

43-
#### High-level `shed` fixtures
41+
#### Configure containers with decorators
42+
43+
Configure containers using `pytest` markers:
4444

45-
Use the `shed` fixture for smart container management with configurable defaults:
45+
- **`@shed_config(**kwargs)`:** pass arguments to the container factory (e.g. `publish`, `networks`).
46+
- **`@shed_env(**envs)`:** set environment variables.
47+
- **`@shed_volumes(*mounts)`:** mount volumes as `(source, dest)` tuples or `BaseVolume` instances.
48+
- **`@shed_mutable()`:** force a dedicated container for tests that mutate state (bypasses the shared default).
49+
50+
```python
51+
from kloudkit.testshed.docker import InlineVolume, RemoteVolume
52+
53+
@shed_config(publish=[(8080, 80)])
54+
@shed_env(MY_ENV_VAR="hello")
55+
@shed_volumes(
56+
("/path/to/host/data", "/app/data"),
57+
InlineVolume("/app/config.txt", "any content you want", mode=0o644),
58+
RemoteVolume("/app/remote-config.json", "https://api.example.com/config.json", mode=0o644),
59+
)
60+
def test_configured_docker_app(shed):
61+
# ... test logic ...
62+
```
63+
64+
Use `@shed_mutable()` when your test writes data, installs packages, or otherwise changes the container.
65+
66+
This ensures it gets its own instance instead of reusing the shared default:
67+
68+
```python
69+
@shed_mutable()
70+
def test_install_package(shed):
71+
shed.execute("apt-get install -y curl")
72+
73+
assert "curl" in shed.execute("which curl")
74+
```
75+
76+
#### High-level `shed` fixture
77+
78+
Use the `shed` fixture for container management with configurable defaults:
4679

4780
```python
4881
import pytest
@@ -73,55 +106,55 @@ def test_my_app_with_debug(shed):
73106
assert shed.execute("echo $APP_PORT") == "3000"
74107
```
75108

76-
You can also use the factory directly:
109+
#### Deferred deployment with `shed_deferred`
110+
111+
Use `shed_deferred` when you need to control *when* the container starts, for pre-deployment
112+
setup, runtime parameterization, or spinning up multiple containers in a single test:
77113

78114
```python
79-
def test_custom_setup(shed_factory):
80-
container = shed_factory(envs={"CUSTOM_VAR": "value"})
81-
# ... test logic ...
115+
@shed_env(APP_PORT="3000")
116+
def test_deferred_deployment(shed_deferred):
117+
# Container is NOT running yet — do setup here
118+
# ...
119+
120+
# Deploy with optional call-time overrides
121+
container = shed_deferred(envs={"DEBUG": "true"})
122+
# envs are merged: APP_PORT=3000 + DEBUG=true
123+
124+
assert container.execute("echo $DEBUG") == "true"
125+
assert container.execute("echo $APP_PORT") == "3000"
126+
127+
128+
def test_multiple_containers(shed_deferred):
129+
primary = shed_deferred(envs={"ROLE": "primary"})
130+
replica = shed_deferred(envs={"ROLE": "replica"})
131+
# Each call spins up a new container
82132
```
83133

134+
Call-time parameters merge with decorator config:
135+
136+
- **`envs`:** dict merge *(call-time values override decorator values)*.
137+
- **`volumes`:** concatenated *(call-time volumes added after decorator volumes)*.
138+
- **`**kwargs`:** passed as config args *(override decorator `@shed_config` values)*.
139+
84140
#### Basic Docker container
85141

86-
For a lower-level API, use the `docker_sidecar` fixture to create containers:
142+
For a lower-level API, use the `docker_sidecar` fixture:
87143

88144
```python
89-
import pytest
90-
91145
def test_my_docker_app(docker_sidecar):
92-
# Launch a simple Nginx container
146+
# Launch a container
93147
nginx = docker_sidecar("nginx:latest", publish=[(8080, 80)])
94148

95149
# Execute a command inside the container
96-
assert "nginx version" in nginx.execute(["nginx", "-v"])
150+
hostname = nginx.execute("cat /etc/hostname")
151+
assert len(hostname) > 0
97152

98153
# Access the container's IP
99-
print(f"Nginx container IP: {nginx.ip()}")
154+
print(f"Container IP: {nginx.ip()}")
100155

101156
# Interact with the file system
102-
assert "/usr/share/nginx/html" in nginx.fs.ls("/usr/share/nginx")
103-
```
104-
105-
#### Configure containers with decorators
106-
107-
Configure containers using `pytest` markers/decorators:
108-
109-
- **`@shed_config(**kwargs)`:** Generic container args.
110-
- **`@shed_env(**envs)`:** Environment variables.
111-
- **`@shed_volumes(*mounts)`:** Volume mounts as `(source, dest)` or `BaseVolume`.
112-
- **`@shed_mutable()`:** Force non-default shed for tests that perform mutable operations.
113-
114-
```python
115-
from kloudkit.testshed.docker import InlineVolume, RemoteVolume
116-
117-
@shed_env(MY_ENV_VAR="hello")
118-
@shed_volumes(
119-
("/path/to/host/data", "/app/data"),
120-
InlineVolume("/app/config.txt", "any content you want", mode=0o644),
121-
RemoteVolume("/app/remote-config.json", "https://api.example.com/config.json", mode=0o644),
122-
)
123-
def test_configured_docker_app(shed):
124-
# ... test logic ...
157+
assert "html" in nginx.fs.ls("/usr/share/nginx")
125158
```
126159

127160
### Playwright browser testing
@@ -140,13 +173,13 @@ def test_example_website(playwright_browser):
140173

141174
TestShed extends `pytest` with options to control the Docker environment:
142175

143-
- **`--shed`:** Enable TestShed for the current test suite *(default: disabled)*.
144-
- **`--shed-image IMAGE`:** Base image *(e.g., `ghcr.io/acme/app`)*.
145-
- **`--shed-tag TAG|SHA`:** Image tag or digest *(default: `tests`)*.
176+
- **`--shed`:** enable TestShed for the current test suite *(default: disabled)*.
177+
- **`--shed-image IMAGE`:** base image *(e.g., `ghcr.io/acme/app`)*.
178+
- **`--shed-tag TAG|SHA`:** image tag or digest *(default: `tests`)*.
146179
- **`--shed-build-context DIR`:** Docker build context *(default: `pytest.ini` directory)*.
147-
- **`--shed-image-policy POLICY`:** Image acquisition policy for building or pulling *(default: `pull`)*.
148-
- **`--shed-skip-bootstrap`:** Skip Docker bootstrapping *(useful for unit tests)*.
149-
- **`--shed-container-logs`:** Print container logs on failure *(default: disabled)*.
180+
- **`--shed-image-policy POLICY`:** image acquisition policy *(default: `pull`)*.
181+
- **`--shed-skip-bootstrap`:** skip Docker bootstrapping *(useful for unit tests)*.
182+
- **`--shed-container-logs`:** print container logs on failure *(default: disabled)*.
150183

151184
> [!NOTE]
152185
> When TestShed is installed globally, you must explicitly enable it per suite with
@@ -157,10 +190,10 @@ TestShed extends `pytest` with options to control the Docker environment:
157190

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

160-
- **`pull`** *(default)***:** Pull image if not found locally, build as fallback.
161-
- **`build`:** Build only if image doesn't exist locally.
162-
- **`require`:** Require existing local image *(fails if not found)*.
163-
- **`rebuild`:** Always rebuild the image.
193+
- **`pull`** *(default)*: pull image if not found locally, build as fallback.
194+
- **`build`:** build only if image doesn't exist locally.
195+
- **`require`:** require existing local image *(fails if not found)*.
196+
- **`rebuild`:** always rebuild the image.
164197

165198
#### Examples
166199

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ classifiers = [
1818
"Typing :: Typed",
1919
]
2020
dependencies = [
21-
"playwright>=1.57.0,<2.0.0",
21+
"playwright>=1.58.0,<2.0.0",
2222
"pytest>=9.0.2",
2323
"python-on-whales>=0.80.0,<1.0.0",
2424
"PyYAML>=6.0.3,<7.0.0",
@@ -34,8 +34,8 @@ dynamic = ["readme", "version"]
3434
[project.optional-dependencies]
3535
dev = [
3636
"pre-commit>=4.5.1",
37-
"ruff>=0.14.11",
38-
"coverage>=7.13.1",
37+
"ruff>=0.15.5",
38+
"coverage>=7.13.4",
3939
"pytest-cov>=7.0.0",
4040
]
4141

src/kloudkit/testshed/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.0.9"
1+
__version__ = "0.0.10"

src/kloudkit/testshed/docker/container_config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,22 @@ def to_dict(self) -> dict:
2727
**self.args,
2828
)
2929

30+
def merge(
31+
self,
32+
*,
33+
envs: dict | None = None,
34+
volumes: tuple | None = None,
35+
args: dict | None = None,
36+
) -> Self:
37+
"""Return a new config with call-time overrides merged in."""
38+
39+
return type(self)(
40+
envs={**self.envs, **(envs or {})},
41+
volumes=self.volumes + tuple(volumes or ()),
42+
args={**self.args, **(args or {})},
43+
test_name=self.test_name,
44+
)
45+
3046
@classmethod
3147
def create(cls, request: pytest.FixtureRequest) -> Self:
3248
"""Create configs from a pytest request of the current node."""

src/kloudkit/testshed/fixtures/shed.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,21 @@ def _wrapper(**kwargs):
6262
return _wrapper
6363

6464

65+
@pytest.fixture
66+
def shed_deferred(request: pytest.FixtureRequest):
67+
"""Callable that deploys a container when invoked."""
68+
69+
config = ContainerConfig.create(request)
70+
shed_factory = request.getfixturevalue("shed_factory")
71+
72+
def _deploy(*, envs=None, volumes=None, **kwargs):
73+
return shed_factory(
74+
**config.merge(envs=envs, volumes=volumes, args=kwargs).to_dict()
75+
)
76+
77+
return _deploy
78+
79+
6580
@pytest.fixture
6681
def shed(request: pytest.FixtureRequest):
6782
"""Reuses default or creates new based on markers."""

tests/unit/docker/test_container_config.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,56 @@ def test_create_with_all_markers():
8383
assert config.volumes == ("/vol1",)
8484
assert config.args == {"image": "test:latest"}
8585
assert config.test_name == "test_module::test_function"
86+
87+
88+
def test_merge_with_no_overrides():
89+
config = ContainerConfig(
90+
envs={"A": "1"}, volumes=("/v1",), args={"x": "y"}, test_name="test_fn"
91+
)
92+
93+
merged = config.merge()
94+
95+
assert merged.envs == {"A": "1"}
96+
assert merged.volumes == ("/v1",)
97+
assert merged.args == {"x": "y"}
98+
assert merged.test_name == "test_fn"
99+
100+
101+
def test_merge_envs_override_and_add():
102+
config = ContainerConfig(
103+
envs={"A": "1", "B": "2"}, volumes=(), args={}, test_name="test_fn"
104+
)
105+
106+
merged = config.merge(envs={"B": "overridden", "C": "3"})
107+
108+
assert merged.envs == {"A": "1", "B": "overridden", "C": "3"}
109+
110+
111+
def test_merge_volumes_concatenate():
112+
config = ContainerConfig(
113+
envs={}, volumes=("/v1",), args={}, test_name="test_fn"
114+
)
115+
116+
merged = config.merge(volumes=("/v2", "/v3"))
117+
118+
assert merged.volumes == ("/v1", "/v2", "/v3")
119+
120+
121+
def test_merge_args_override():
122+
config = ContainerConfig(
123+
envs={}, volumes=(), args={"a": "1", "b": "2"}, test_name="test_fn"
124+
)
125+
126+
merged = config.merge(args={"b": "overridden", "c": "3"})
127+
128+
assert merged.args == {"a": "1", "b": "overridden", "c": "3"}
129+
130+
131+
def test_merge_preserves_test_name():
132+
config = ContainerConfig(
133+
envs={}, volumes=(), args={}, test_name="original::test"
134+
)
135+
136+
merged = config.merge(envs={"X": "1"})
137+
138+
assert merged.test_name == "original::test"

0 commit comments

Comments
 (0)