Skip to content

Commit b12ae13

Browse files
fix(core): wait for ryuk more reliably, improve tests: long_running, filter logs (#984)
This was originally only to fix #983 but I took the time to fix some other stuff that made reading and running the tests less optimal. - Fix test_ryuk flakiness: replace fixed sleep with polling helper (_wait_for_container_removed) that waits for Ryuk to finish reaping - Suppress expected error logs in test_compose_volumes and test_wait_strategies to reduce test noise - Add __test__ = False to TestcontainersConfiguration to prevent pytest from trying to collect it as a test class - Add `long_running` pytest marker to pyproject.toml - Mark DinD/DooD tests as long_running since they build Docker images and take x4 than all the other tests combined - Add `quick-core-tests` Makefile target to run core tests excluding long_running tests for faster feedback loops --------- Co-authored-by: David Ankin <daveankin@gmail.com>
1 parent 5c67efb commit b12ae13

File tree

8 files changed

+60
-21
lines changed

8 files changed

+60
-21
lines changed

Makefile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ build: ## Build the python package
2020

2121
tests: ${TESTS} ## Run tests for each package
2222
${TESTS}: %/tests:
23-
uv run coverage run --parallel -m pytest -v $*/tests
23+
uv run coverage run --parallel -m pytest -v $*/tests
24+
25+
quick-core-tests: ## Run core tests excluding long_running
26+
uv run coverage run --parallel -m pytest -v -m "not long_running" core/tests
2427

2528
coverage: ## Target to combine and report coverage.
2629
uv run coverage combine
@@ -61,7 +64,7 @@ clean-all: clean ## Remove all generated files and reset the local virtual envir
6164
rm -rf .venv
6265

6366
# Targets that do not generate file-level artifacts.
64-
.PHONY: clean docs doctests image tests ${TESTS}
67+
.PHONY: clean docs doctests image tests quick-core-tests ${TESTS}
6568

6669

6770
# Implements this pattern for autodocumenting Makefiles:

core/testcontainers/core/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ def read_tc_properties() -> dict[str, str]:
8888

8989
@dataclass
9090
class TestcontainersConfiguration:
91+
__test__ = False
92+
9193
def _render_bool(self, env_name: str, prop_name: str) -> bool:
9294
env_val = environ.get(env_name, None)
9395
if env_val is not None:

core/tests/test_compose.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
import subprocess
23
from pathlib import Path
34
from re import split
@@ -150,7 +151,7 @@ def test_compose_logs():
150151
assert not line or container.Service in next(iter(line.split("|")))
151152

152153

153-
def test_compose_volumes():
154+
def test_compose_volumes(caplog):
154155
_file_in_volume = "/var/lib/example/data/hello"
155156
volumes = DockerCompose(context=FIXTURES / "basic_volume", keep_volumes=True)
156157
with volumes:
@@ -167,8 +168,11 @@ def test_compose_volumes():
167168
assert "hello" in stdout
168169

169170
# third time we expect the file to be missing
170-
with volumes, pytest.raises(subprocess.CalledProcessError):
171-
volumes.exec_in_container(["cat", _file_in_volume], "alpine")
171+
with caplog.at_level(
172+
logging.CRITICAL, logger="testcontainers.compose.compose"
173+
): # suppress expected error logs about missing volume
174+
with volumes, pytest.raises(subprocess.CalledProcessError):
175+
volumes.exec_in_container(["cat", _file_in_volume], "alpine")
172176

173177

174178
# noinspection HttpUrlsUsage

core/tests/test_docker_in_docker.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ def test_find_host_network_in_dood() -> None:
179179
assert DockerClient().find_host_network() == os.environ[EXPECTED_NETWORK_VAR]
180180

181181

182+
@pytest.mark.long_running
182183
@pytest.mark.skipif(
183184
is_mac(),
184185
reason="Docker socket mounting and container networking do not work reliably on Docker Desktop for macOS",
@@ -219,6 +220,7 @@ def test_dood(python_testcontainer_image: str) -> None:
219220
assert status["StatusCode"] == 0
220221

221222

223+
@pytest.mark.long_running
222224
@pytest.mark.skipif(
223225
is_mac(),
224226
reason="Docker socket mounting and container networking do not work reliably on Docker Desktop for macOS",

core/tests/test_ryuk.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from time import sleep
1+
from time import sleep, perf_counter
22
import pytest
33
from pytest import MonkeyPatch
44

@@ -12,6 +12,27 @@
1212
from testcontainers.core.waiting_utils import wait_for_logs
1313

1414

15+
def _wait_for_container_removed(client: DockerClient, container_id: str, timeout: float = 30) -> None:
16+
"""Poll until a container is fully removed (raises NotFound)."""
17+
start = perf_counter()
18+
while perf_counter() - start < timeout:
19+
try:
20+
client.containers.get(container_id)
21+
except NotFound:
22+
return
23+
sleep(0.5)
24+
25+
try:
26+
c = client.containers.get(container_id)
27+
name = c.name
28+
status = c.status
29+
started_at = c.attrs.get("State", {}).get("StartedAt", "unknown")
30+
detail = f"name={name}, status={status}, started_at={started_at}"
31+
except NotFound:
32+
detail = "container disappeared just after timeout"
33+
raise TimeoutError(f"Container {container_id} was not removed within {timeout}s ({detail})")
34+
35+
1536
@pytest.mark.skipif(
1637
is_mac(),
1738
reason="Ryuk container reaping is unreliable on Docker Desktop for macOS due to VM-based container lifecycle handling",
@@ -39,8 +60,11 @@ def test_wait_for_reaper(monkeypatch: MonkeyPatch):
3960
assert rs
4061
rs.close()
4162

42-
sleep(0.6) # Sleep until Ryuk reaps all dangling containers. 0.5 extra seconds for good measure.
63+
# Ryuk will reap containers then auto-remove itself.
64+
# Wait for the reaper container to disappear and once it's gone, all labeled containers are guaranteed reaped.
65+
_wait_for_container_removed(docker_client, reaper_id)
4366

67+
# Verify both containers were reaped
4468
with pytest.raises(NotFound):
4569
docker_client.containers.get(container_id)
4670
with pytest.raises(NotFound):

core/tests/test_wait_strategies.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
import re
23
import time
34
from datetime import timedelta
@@ -528,7 +529,7 @@ def test_file_exists_wait_strategy_initialization(self, file_path):
528529
@patch("pathlib.Path.is_file")
529530
@patch("time.time")
530531
@patch("time.sleep")
531-
def test_wait_until_ready(self, mock_sleep, mock_time, mock_is_file, file_exists, expected_behavior):
532+
def test_wait_until_ready(self, mock_sleep, mock_time, mock_is_file, file_exists, expected_behavior, caplog):
532533
strategy = FileExistsWaitStrategy("/tmp/test.txt").with_startup_timeout(1)
533534
mock_container = Mock()
534535

@@ -547,7 +548,8 @@ def test_wait_until_ready(self, mock_sleep, mock_time, mock_is_file, file_exists
547548
mock_is_file.assert_called()
548549
else:
549550
with pytest.raises(TimeoutError, match="File.*did not exist within.*seconds"):
550-
strategy.wait_until_ready(mock_container)
551+
with caplog.at_level(logging.CRITICAL, logger="testcontainers.core.wait_strategies"):
552+
strategy.wait_until_ready(mock_container)
551553

552554

553555
class TestCompositeWaitStrategy:
@@ -615,7 +617,7 @@ def test_wait_until_ready_all_strategies_succeed(self):
615617
strategy2.wait_until_ready.assert_called_once_with(mock_container)
616618
strategy3.wait_until_ready.assert_called_once_with(mock_container)
617619

618-
def test_wait_until_ready_first_strategy_fails(self):
620+
def test_wait_until_ready_first_strategy_fails(self, caplog):
619621
"""Test that execution stops when first strategy fails."""
620622
strategy1 = Mock()
621623
strategy2 = Mock()
@@ -628,7 +630,8 @@ def test_wait_until_ready_first_strategy_fails(self):
628630
strategy1.wait_until_ready.side_effect = TimeoutError("First strategy failed")
629631

630632
with pytest.raises(TimeoutError, match="First strategy failed"):
631-
composite.wait_until_ready(mock_container)
633+
with caplog.at_level(logging.CRITICAL, logger="testcontainers.core.wait_strategies"):
634+
composite.wait_until_ready(mock_container)
632635

633636
# Only first strategy should be called
634637
strategy1.wait_until_ready.assert_called_once_with(mock_container)

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ rabbitmq = ["pika>=1"]
9898
redis = ["redis>=7"]
9999
registry = ["bcrypt>=5"]
100100
selenium = ["selenium>=4"]
101-
scylla = ["cassandra-driver>=3"]
101+
scylla = ["cassandra-driver>=3; python_version < '3.14'"]
102102
sftp = ["cryptography"]
103103
vault = []
104104
weaviate = ["weaviate-client>=4"]
@@ -120,7 +120,7 @@ test = [
120120
"psycopg2-binary==2.9.11",
121121
"pg8000==1.31.5",
122122
"psycopg>=3",
123-
"cassandra-driver>=3",
123+
"cassandra-driver>=3; python_version < '3.14'",
124124
"kafka-python-ng>=2",
125125
"hvac>=2; python_version < '4.0'",
126126
"pymilvus>=2",
@@ -277,6 +277,7 @@ log_cli = true
277277
log_cli_level = "INFO"
278278
markers = [
279279
"inside_docker_check: mark test to be used to validate DinD/DooD is working as expected",
280+
"long_running: mark test as very long running",
280281
]
281282
filterwarnings = [
282283
"ignore:The @wait_container_is_ready decorator is deprecated.*:DeprecationWarning",

uv.lock

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)