Skip to content

Commit

Permalink
fix: flaky garbage collection resulting in testing errors (#423)
Browse files Browse the repository at this point in the history
# change

Fixes #399. Applied a bit of defensive coding and attempted to create
some tests for it, however reproducing it with a local dev machine is
not easy. I did my best to reproduce the issue with garbage collection
in the new test.

---------

Co-authored-by: Balint Bartha <[email protected]>
  • Loading branch information
totallyzen and totallyzen authored Feb 13, 2024
1 parent 3271357 commit b535ea2
Show file tree
Hide file tree
Showing 4 changed files with 35 additions and 15 deletions.
20 changes: 11 additions & 9 deletions core/testcontainers/core/container.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os
from typing import Iterable, Optional, Tuple
from typing import Optional, Tuple

from docker.models.containers import Container

Expand All @@ -23,6 +23,7 @@ class DockerContainer:
>>> with DockerContainer("hello-world") as container:
... delay = wait_for_logs(container, "Hello from Docker!")
"""

def __init__(self, image: str, docker_client_kw: Optional[dict] = None, **kwargs) -> None:
self.env = {}
self.ports = {}
Expand All @@ -42,7 +43,7 @@ def with_bind_ports(self, container: int, host: int = None) -> 'DockerContainer'
self.ports[container] = host
return self

def with_exposed_ports(self, *ports: Iterable[int]) -> 'DockerContainer':
def with_exposed_ports(self, *ports: int) -> 'DockerContainer':
for port in ports:
self.ports[port] = None
return self
Expand All @@ -67,7 +68,7 @@ def start(self) -> 'DockerContainer':
return self

def stop(self, force=True, delete_volume=True) -> None:
self.get_wrapped_container().remove(force=force, v=delete_volume)
self._container.remove(force=force, v=delete_volume)

def __enter__(self) -> 'DockerContainer':
return self.start()
Expand All @@ -77,13 +78,14 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> None:

def __del__(self) -> None:
"""
Try to remove the container in all circumstances
__del__ runs when Python attempts to garbage collect the object.
In case of leaky test design, we still attempt to clean up the container.
"""
if self._container is not None:
try:
try:
if self._container is not None:
self.stop()
except: # noqa: E722
pass
finally:
pass

def get_container_host_ip(self) -> str:
# infer from docker host
Expand Down Expand Up @@ -143,4 +145,4 @@ def get_logs(self) -> Tuple[str, str]:
def exec(self, command) -> Tuple[int, str]:
if not self._container:
raise ContainerStartException("Container should be started before executing a command")
return self.get_wrapped_container().exec_run(command)
return self._container.exec_run(command)
16 changes: 12 additions & 4 deletions core/testcontainers/core/docker_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,22 @@ class DockerClient:
"""
Thin wrapper around :class:`docker.DockerClient` for a more functional interface.
"""

def __init__(self, **kwargs) -> None:
self.client = docker.from_env(**kwargs)

@ft.wraps(ContainerCollection.run)
def run(self, image: str, command: Union[str, List[str]] = None,
environment: Optional[dict] = None, ports: Optional[dict] = None,
detach: bool = False, stdout: bool = True, stderr: bool = False, remove: bool = False,
**kwargs) -> Container:
def run(
self, image: str,
command: Union[str, List[str]] = None,
environment: Optional[dict] = None,
ports: Optional[dict] = None,
detach: bool = False,
stdout: bool = True,
stderr: bool = False,
remove: bool = False,
**kwargs
) -> Container:
container = self.client.containers.run(
image, command=command, stdout=stdout, stderr=stderr, remove=remove, detach=detach,
environment=environment, ports=ports, **kwargs
Expand Down
12 changes: 11 additions & 1 deletion core/tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,22 @@
from testcontainers.core.waiting_utils import wait_for_logs


def test_raise_timeout():
def test_timeout_is_raised_when_waiting_for_logs():
with pytest.raises(TimeoutError):
with DockerContainer("alpine").with_command("sleep 2") as container:
wait_for_logs(container, "Hello from Docker!", timeout=1e-3)


def test_garbage_collection_is_defensive():
# For more info, see https://github.com/testcontainers/testcontainers-python/issues/399
# we simulate garbage collection: start, stop, then call `del`
container = DockerContainer("postgres:latest")
container.start()
container.stop(force=True, delete_volume=True)
delattr(container, "_container")
del container


def test_wait_for_hello():
with DockerContainer("hello-world") as container:
wait_for_logs(container, "Hello from Docker!")
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ priority = "primary"
line-length = 120

[tool.pytest.ini_options]
addopts = "--cov-report=term --tb=short --strict-markers"
addopts = "--cov-report=term --cov-report=html --tb=short --strict-markers"
log_cli = true
log_cli_level = "INFO"

Expand Down

0 comments on commit b535ea2

Please sign in to comment.