Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DM-45281: Switch to testcontainers from tox-docker #277

Merged
merged 1 commit into from
Jul 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ jobs:
with:
python-version: ${{ matrix.python }}
tox-envs: "py,typing"
tox-plugins: "tox-docker tox-uv"
tox-plugins: "tox-uv"

docs:

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/periodic-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
with:
python-version: ${{ matrix.python }}
tox-envs: "lint,typing,py"
tox-plugins: "tox-docker tox-uv"
tox-plugins: "tox-uv"
use-cache: false

- name: Report status
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ clean:
.PHONY: init
init:
pip install --upgrade uv
uv pip install --upgrade pre-commit tox tox-docker tox-uv
uv pip install --upgrade pre-commit tox tox-uv
uv pip install --upgrade -e ".[arq,db,dev,gcs,kubernetes]"
pre-commit install
rm -rf .tox
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ dev = [
"respx",
"scriv",
"sqlalchemy[mypy]",
"testcontainers[postgres,redis]",
"uvicorn",
# documentation
"documenteer[guide]>=1",
Expand Down
45 changes: 30 additions & 15 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,33 @@

from __future__ import annotations

import os
from collections.abc import AsyncIterator, Iterator
from datetime import timedelta

import pytest
import pytest_asyncio
import redis.asyncio as redis
import respx
from redis.asyncio import Redis
from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer

from safir.testing.gcs import MockStorageClient, patch_google_storage
from safir.testing.kubernetes import MockKubernetesApi, patch_kubernetes
from safir.testing.slack import MockSlackWebhook, mock_slack_webhook


@pytest.fixture
def database_url() -> str:
"""Dynamically construct the test database URL from tox-docker envvars."""
host = os.environ["POSTGRES_HOST"]
port = os.environ["POSTGRES_5432_TCP_PORT"]
return f"postgresql://safir@{host}:{port}/safir"
@pytest.fixture(scope="session")
def database_password() -> str:
return "INSECURE@%PASSWORD/"


@pytest.fixture(scope="session")
def database_url(database_password: str) -> Iterator[str]:
"""Start a PostgreSQL database and return a URL for it."""
with PostgresContainer(
driver="asyncpg", username="safir", password=database_password
) as postgres:
yield postgres.get_connection_url()


@pytest.fixture
Expand All @@ -41,15 +48,23 @@ def mock_slack(respx_mock: respx.Router) -> MockSlackWebhook:
return mock_slack_webhook("https://example.com/slack", respx_mock)


@pytest.fixture(scope="session")
def redis() -> Iterator[RedisContainer]:
"""Start a Redis container."""
with RedisContainer() as redis:
yield redis


@pytest_asyncio.fixture
async def redis_client() -> AsyncIterator[redis.Redis]:
"""Redis client for testing.
async def redis_client(redis: RedisContainer) -> AsyncIterator[Redis]:
"""Create a Redis client for testing.

This fixture connects to the Redis server that runs via tox-docker.
This must be done separately for each test since it's tied to the per-test
event loop, and therefore must be separated from the session-shared Redis
server container.
"""
host = os.environ["REDIS_HOST"]
port = os.environ["REDIS_6379_TCP_PORT"]
client = redis.Redis(host=host, port=int(port), db=0)
host = redis.get_container_host_ip()
port = redis.get_exposed_port(6379)
client = Redis(host=host, port=port, db=0)
yield client

await client.aclose()
23 changes: 13 additions & 10 deletions tests/database_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import os
from datetime import UTC, datetime, timedelta, timezone
from urllib.parse import unquote, urlparse

Expand All @@ -25,8 +24,6 @@
)
from safir.database._connection import _build_database_url

TEST_DATABASE_PASSWORD = os.environ["TEST_DATABASE_PASSWORD"]

Base = declarative_base()


Expand All @@ -39,9 +36,11 @@ class User(Base):


@pytest.mark.asyncio
async def test_database_init(database_url: str) -> None:
async def test_database_init(
database_url: str, database_password: str
) -> None:
logger = structlog.get_logger(__name__)
engine = create_database_engine(database_url, TEST_DATABASE_PASSWORD)
engine = create_database_engine(database_url, database_password)
await initialize_database(engine, logger, schema=Base.metadata, reset=True)
session = await create_async_session(engine, logger)
async with session.begin():
Expand All @@ -58,7 +57,7 @@ async def test_database_init(database_url: str) -> None:

# Reinitializing the database with reset should delete the data. Try
# passing in the password as a SecretStr.
password = SecretStr(TEST_DATABASE_PASSWORD)
password = SecretStr(database_password)
engine = create_database_engine(database_url, password)
await initialize_database(engine, logger, schema=Base.metadata, reset=True)
session = await create_async_session(engine, logger)
Expand Down Expand Up @@ -111,9 +110,11 @@ def test_build_database_url(database_url: str) -> None:


@pytest.mark.asyncio
async def test_create_async_session(database_url: str) -> None:
async def test_create_async_session(
database_url: str, database_password: str
) -> None:
logger = structlog.get_logger(__name__)
engine = create_database_engine(database_url, TEST_DATABASE_PASSWORD)
engine = create_database_engine(database_url, database_password)
await initialize_database(engine, logger, schema=Base.metadata, reset=True)

session = await create_async_session(
Expand Down Expand Up @@ -165,9 +166,11 @@ class Test(BaseModel):


@pytest.mark.asyncio
async def test_retry_async_transaction(database_url: str) -> None:
async def test_retry_async_transaction(
database_url: str, database_password: str
) -> None:
logger = structlog.get_logger(__name__)
engine = create_database_engine(database_url, TEST_DATABASE_PASSWORD)
engine = create_database_engine(database_url, database_password)
await initialize_database(engine, logger, schema=Base.metadata, reset=True)
session = await create_async_session(engine, logger)
async with session.begin():
Expand Down
11 changes: 3 additions & 8 deletions tests/dependencies/db_session_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import os
from typing import Annotated

import pytest
Expand All @@ -21,8 +20,6 @@
)
from safir.dependencies.db_session import db_session_dependency

TEST_DATABASE_PASSWORD = os.getenv("TEST_DATABASE_PASSWORD")

Base = declarative_base()


Expand All @@ -35,14 +32,12 @@ class User(Base):


@pytest.mark.asyncio
async def test_session(database_url: str) -> None:
async def test_session(database_url: str, database_password: str) -> None:
logger = structlog.get_logger(__name__)
engine = create_database_engine(database_url, TEST_DATABASE_PASSWORD)
engine = create_database_engine(database_url, database_password)
await initialize_database(engine, logger, schema=Base.metadata, reset=True)
session = await create_async_session(engine, logger)
await db_session_dependency.initialize(
database_url, TEST_DATABASE_PASSWORD
)
await db_session_dependency.initialize(database_url, database_password)

app = FastAPI()

Expand Down
31 changes: 0 additions & 31 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,6 @@
envlist = py,coverage-report,typing,lint,docs
isolated_build = True

[docker:postgres]
image = postgres:latest
environment =
POSTGRES_PASSWORD=INSECURE@%PASSWORD/
POSTGRES_USER=safir
POSTGRES_DB=safir
# The healthcheck ensures that tox-docker won't run tests until the
# container is up and the command finishes with exit code 0 (success)
healthcheck_cmd = PGPASSWORD=$POSTGRES_PASSWORD psql \
--user=$POSTGRES_USER --dbname=$POSTGRES_DB \
--host=127.0.0.1 --quiet --no-align --tuples-only \
-1 --command="SELECT 1"
healthcheck_timeout = 1
healthcheck_retries = 30
healthcheck_interval = 1
healthcheck_start_period = 1

[docker:redis]
image = redis:latest
healthcheck_cmd =
redis-cli ping
healthcheck_timeout = 1
healthcheck_retries = 30
healthcheck_interval = 1
healthcheck_start_period = 1

[testenv]
description = Run pytest against {envname}.
extras =
Expand All @@ -40,13 +14,8 @@ extras =

[testenv:py]
description = Run pytest with PostgreSQL via Docker.
docker =
postgres
redis
commands =
coverage run -m pytest {posargs}
setenv =
TEST_DATABASE_PASSWORD = INSECURE@%PASSWORD/

[testenv:coverage-report]
description = Compile coverage from each test run.
Expand Down