added integration tests
Code Quality Pipeline / code-quality (pull_request) Failing after 43s
Test Python Package / test (pull_request) Failing after 11s

This commit is contained in:
Brian Bjarke Jensen
2025-09-14 19:52:47 +02:00
parent 524bd0f06b
commit 0948799653
4 changed files with 590 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
"""Integration tests configuration."""
import os
from typing import Generator
import pytest
import redis
import structlog
import logging
from testcontainers.redis import RedisContainer
@pytest.fixture(scope="session", autouse=True)
def configure_logging() -> None:
"""Configure logging for the test session."""
# Configure structlog
structlog.configure(
processors=[
structlog.processors.JSONRenderer(),
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
# Set up basic logging configuration
logging.basicConfig(level=logging.ERROR)
@pytest.fixture(scope="session")
def redis_container() -> Generator[str, None, None]:
"""Set up a Redis container for testing and yield the Redis URI."""
# Start container
container = RedisContainer(
image="redis/redis-stack:7.2.0-v0",
port=6379,
).with_bind_ports(
container=6379,
host=6379,
)
container.start()
# Set environment variable for Redis URI
redis_host = container.get_container_host_ip()
redis_port = container.get_exposed_port(6379)
redis_uri = f"redis://{redis_host}:{redis_port}"
yield redis_uri
# Stop container
container.stop()
@pytest.fixture(scope="session", autouse=True)
def set_environment_variables(
redis_container: str,
) -> Generator[dict[str, str], None, None]:
"""Set environment variables needed for tests."""
# Build environment variables dictionary
env_vars = {"REDIS_URI": redis_container}
# Set environment variables
for key, value in env_vars.items():
os.environ[key] = value
yield env_vars
# Cleanup
for key in env_vars:
_ = os.environ.pop(key, default=None)
@pytest.fixture(scope="session")
def raw_redis_client(redis_container: str) -> Generator[redis.Redis, None, None]:
"""Provide a raw Redis client connected to the test Redis container."""
# Connect client
client = redis.Redis.from_url(
url=redis_container,
socket_connect_timeout=10,
)
yield client
# Cleanup
client.flushall()
client.close()
@@ -0,0 +1,105 @@
"""Integration tests for ConnectionAwareInterface."""
import pytest
from python_repositories.interfaces.connection_aware_interface import ConnectionAwareInterface
def test_instantiation_fails_when_connect_not_implemented() -> None:
"""Test that instantiation fails if connect is not implemented."""
class Incomplete(ConnectionAwareInterface):
"""A class that does not implement connect."""
def disconnect(self) -> None:
pass
@property
def is_connected(self) -> bool:
return False
with pytest.raises(TypeError):
_ = Incomplete()
def test_instantiation_fails_when_disconnect_not_implemented() -> None:
"""Test that instantiation fails if disconnect is not implemented."""
class Incomplete(ConnectionAwareInterface):
"""A class that does not implement disconnect."""
def connect(self) -> None:
pass
@property
def is_connected(self) -> bool:
return False
with pytest.raises(TypeError):
_ = Incomplete()
def test_instantiation_fails_when_is_connected_not_implemented() -> None:
"""Test that instantiation fails if is_connected is not implemented."""
class Incomplete(ConnectionAwareInterface):
"""A class that does not implement is_connected."""
def connect(self) -> None:
pass
def disconnect(self) -> None:
pass
with pytest.raises(TypeError):
_ = Incomplete()
def test_connect_raises_not_implemented_if_not_overwritten() -> None:
"""Test that connect raises NotImplementedError if not implemented."""
class Incomplete(ConnectionAwareInterface):
"""A class that does not implement connect."""
def connect(self) -> None:
super().connect()
def disconnect(self) -> None:
pass
@property
def is_connected(self) -> bool:
return False
instance = Incomplete()
with pytest.raises(NotImplementedError):
instance.connect()
def test_disconnect_raises_not_implemented_if_not_overwritten() -> None:
"""Test that disconnect raises NotImplementedError if not implemented."""
class Incomplete(ConnectionAwareInterface):
"""A class that does not implement disconnect."""
def connect(self) -> None:
pass
def disconnect(self) -> None:
super().disconnect()
@property
def is_connected(self) -> bool:
return False
instance = Incomplete()
with pytest.raises(NotImplementedError):
instance.disconnect()
def test_is_connected_raises_not_implemented_if_not_overwritten() -> None:
"""Test that is_connected raises NotImplementedError if not implemented."""
class Incomplete(ConnectionAwareInterface):
"""A class that does not implement is_connected."""
def connect(self) -> None:
pass
def disconnect(self) -> None:
pass
@property
def is_connected(self) -> bool:
return super().is_connected
instance = Incomplete()
with pytest.raises(NotImplementedError):
_ = instance.is_connected
@@ -0,0 +1,62 @@
"""Integration tests for ContextAwareInterface."""
import pytest
from python_repositories.interfaces.context_aware_interface import ContextAwareInterface
def test_instantiation_fails_when_enter_not_implemented() -> None:
"""Test that instantiation fails if __enter__ is not implemented."""
class Incomplete(ContextAwareInterface):
"""A class that does not implement __enter__."""
def __exit__(
self, exc_type: type | None, exc_val: object | None, exc_tb: object | None
) -> None:
pass
with pytest.raises(TypeError):
_ = Incomplete()
def test_instantiation_fails_when_exit_not_implemented() -> None:
"""Test that instantiation fails if __exit__ is not implemented."""
class Incomplete(ContextAwareInterface):
"""A class that does not implement __exit__."""
def __enter__(self) -> ContextAwareInterface:
return self
with pytest.raises(TypeError):
_ = Incomplete()
def test_enter_raises_not_implemented_if_not_overwritten() -> None:
"""Test that __enter__ raises NotImplementedError if not implemented."""
class Incomplete(ContextAwareInterface):
"""A class that does not implement __enter__."""
def __enter__(self) -> ContextAwareInterface:
super().__enter__()
def __exit__(
self, exc_type: type | None, exc_val: object | None, exc_tb: object | None
) -> None:
pass
instance = Incomplete()
with pytest.raises(NotImplementedError):
instance.__enter__()
def test_exit_raises_not_implemented_if_not_overwritten() -> None:
"""Test that __exit__ raises NotImplementedError if not implemented."""
class Incomplete(ContextAwareInterface):
"""A class that does not implement __exit__."""
def __enter__(self) -> ContextAwareInterface:
return self
def __exit__(
self, exc_type: type | None, exc_val: object | None, exc_tb: object | None
) -> None:
super().__exit__(exc_type, exc_val, exc_tb)
instance = Incomplete()
with pytest.raises(NotImplementedError):
instance.__exit__(None, None, None)
+340
View File
@@ -0,0 +1,340 @@
# pylint: disable=protected-access
# The above line disables pylint's protected member access warnings for this file,
# allowing tests to access RedisAdapter's internal methods as needed for integration testing.
"""Integration tests for the RedisAdapter."""
from typing import Generator
import pytest
import redis
from redis.commands.json.path import Path as RedisPath
from python_repositories.adapters.redis_adapter import RedisAdapter
@pytest.fixture(scope="session")
def data() -> Generator[dict[str, str], None, None]:
"""Provide a sample data dictionary for tests."""
yield {"foo": "bar"}
@pytest.fixture(scope="function")
def data_in_redis(
raw_redis_client: redis.Redis,
data: dict[str, str],
) -> Generator[tuple[str, dict[str, str]], None, None]:
"""Fixture to set up a known value in Redis before each test."""
key = "test_key"
path = RedisPath.root_path()
raw_redis_client.json().set(key, path, data)
yield key, data
# Cleanup
raw_redis_client.delete(key)
@pytest.fixture(scope="module")
def redis_adapter(redis_container: str) -> Generator[RedisAdapter, None, None]:
"""Fixture to provide a connected RedisAdapter instance."""
adapter = RedisAdapter()
adapter.connect()
yield adapter
adapter.disconnect()
@pytest.fixture(scope="function", autouse=True)
def clear_redis(raw_redis_client: redis.Redis) -> None:
"""Fixture to clear all Redis keys before each test."""
# Clear all keys before each test
raw_redis_client.flushall()
def test_should_adhere_to_interface(
redis_container: str
) -> None:
"""Test that the RedisAdapter adheres to the expected interface."""
# Instantiation fails if interface not adhered to
_ = RedisAdapter()
def test_should_have_logger_when_instantiated(
redis_container: str
) -> None:
"""Test that the RedisAdapter has a logger when instantiated."""
adapter = RedisAdapter()
assert hasattr(adapter, "logger")
assert adapter.logger is not None
def test_should_not_be_connected_when_instantiated(
redis_container: str
) -> None:
"""Test that the RedisAdapter is not connected when instantiated."""
adapter = RedisAdapter()
assert adapter._client is None
assert not adapter.is_connected
def test_should_raise_connection_error_when_unable_to_connect(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test that the RedisAdapter raises ConnectionError when unable to connect."""
# Arrange
monkeypatch.setenv("REDIS_URI", "redis://invalid:6379")
adapter = RedisAdapter()
# Act & Assert
with pytest.raises(ConnectionError):
adapter.connect()
assert adapter._client is None
assert not adapter.is_connected
def test_connect_raises_connection_error_when_unable_to_ping(
monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test that the RedisAdapter raises ConnectionError when ping fails."""
# Set an invalid URI
monkeypatch.setenv("REDIS_URI", "redis://invalid:6379")
# Monkeypatch redis.Redis.from_url to return a mock client
class MockRedis:
"""A mock Redis client that simulates a failed ping."""
def ping(self) -> bool:
"""Simulate a failed ping."""
return False # Simulate failed ping
monkeypatch.setattr("redis.Redis.from_url", lambda *a, **kw: MockRedis())
adapter = RedisAdapter()
with pytest.raises(ConnectionError, match="Could not connect to Redis"):
adapter.connect()
def test_should_connect_and_disconnect(
redis_container: str,
) -> None:
"""Test that the RedisAdapter can connect and disconnect."""
# Connect
adapter = RedisAdapter()
adapter.connect()
# Assert connected
assert adapter._client is not None
assert adapter.is_connected
# Disconnect
adapter.disconnect()
# Assert disconnected
assert adapter._client is None
assert not adapter.is_connected
def test_should_log_error_on_exception_during_exit(
redis_container: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that the RedisAdapter logs an error when an exception occurs during context exit."""
try:
with RedisAdapter() as adapter:
assert adapter.is_connected
raise ValueError("Simulated error")
except ValueError:
pass # Expected
# Assert error was logged
assert "Error while exiting context" in caplog.text
def test_should_have_context_manager(
redis_container: str
) -> None:
"""Test that the RedisAdapter can be used as a context manager."""
with RedisAdapter() as adapter:
assert adapter._client is not None
assert adapter._client is None
def test_should_get_value(
data_in_redis: tuple[str, dict[str, str]],
redis_adapter: RedisAdapter,
) -> None:
"""Test that the RedisAdapter can get a value."""
# Arrange
key, data = data_in_redis
# Act
value = redis_adapter._get(key)
# Assert
assert value == data
def test_should_get_none_for_missing_key(
redis_adapter: RedisAdapter,
) -> None:
"""Test that getting a non-existent key returns None."""
# Act
value = redis_adapter._get("nonexistent_key")
# Assert
assert value is None
def test_should_raise_value_error_on_invalid_get_key(
redis_adapter: RedisAdapter,
) -> None:
"""Test that the RedisAdapter raises ValueError when getting with an invalid key."""
# Arrange
invalid_keys = ["", 123, None]
# Act & Assert
for key in invalid_keys:
with pytest.raises(ValueError):
redis_adapter._get(key)
def test_should_raise_connection_error_on_get_when_not_connected(
redis_adapter: RedisAdapter,
) -> None:
"""Test that the RedisAdapter raises ConnectionError when getting while not connected."""
# Arrange
adapter = RedisAdapter()
# Act & Assert
with pytest.raises(ConnectionError):
adapter._get("some_key")
def test_should_set_value(
data: dict[str, str],
redis_adapter: RedisAdapter,
) -> None:
"""Test that the RedisAdapter can set a value."""
# Arrange
key = "test_key"
# Act
redis_adapter._set(key, data)
# Assert
assert redis_adapter._get(key) == data
def test_should_update_value(
data_in_redis: tuple[str, dict[str, str]],
redis_adapter: RedisAdapter,
) -> None:
"""Test that the RedisAdapter can update an existing value."""
# Arrange
key, _ = data_in_redis
new_data = {"new_key": "new_value"}
# Act
redis_adapter._set(key, new_data)
# Assert
assert redis_adapter._get(key) == new_data
def test_should_raise_value_error_on_invalid_set_key(
redis_adapter: RedisAdapter,
data: dict[str, str],
) -> None:
"""Test that the RedisAdapter raises ValueError when setting with an invalid key."""
# Arrange
invalid_keys = ["", 123, None]
# Act & Assert
for key in invalid_keys:
with pytest.raises(ValueError):
redis_adapter._set(key, data)
def test_should_raise_value_error_on_invalid_set_data(
redis_adapter: RedisAdapter,
) -> None:
"""Test that the RedisAdapter raises ValueError when setting with invalid data."""
# Arrange
key = "test_key"
invalid_data = ["", 123, None, [], {}]
# Act & Assert
for data in invalid_data:
with pytest.raises(ValueError):
redis_adapter._set(key, data)
def test_should_raise_connection_error_on_set_when_not_connected(
redis_adapter: RedisAdapter,
data: dict[str, str],
) -> None:
"""Test that the RedisAdapter raises ConnectionError when setting while not connected."""
# Arrange
adapter = RedisAdapter()
key = "test_key"
# Act & Assert
with pytest.raises(ConnectionError):
adapter._set(key, data)
def test_should_delete_key(
data_in_redis: tuple[str, dict[str, str]],
redis_adapter: RedisAdapter,
) -> None:
"""Test that deleting a key removes it from Redis."""
# Arrange
key, _ = data_in_redis
# Act
redis_adapter._delete(key)
# Assert
assert redis_adapter._get(key) is None
def test_should_raise_value_error_on_invalid_delete_key(
redis_adapter: RedisAdapter,
) -> None:
"""Test that the RedisAdapter raises ValueError when deleting with an invalid key."""
# Arrange
invalid_keys = ["", 123, None]
# Act & Assert
for key in invalid_keys:
with pytest.raises(ValueError):
redis_adapter._delete(key)
def test_should_raise_connection_error_on_delete_when_not_connected(
redis_adapter: RedisAdapter,
) -> None:
"""Test that the RedisAdapter raises ConnectionError when deleting while not connected."""
# Arrange
adapter = RedisAdapter()
# Act & Assert
with pytest.raises(ConnectionError):
adapter._delete("some_key")
def test_should_list_keys(
redis_adapter: RedisAdapter,
) -> None:
"""Test listing keys matching a pattern returns correct keys."""
# Arrange
redis_adapter._set("key1", {"a": 1})
redis_adapter._set("key2", {"b": 2})
# Act
keys = redis_adapter._list_keys("key*")
# Assert
assert set(keys) == {"key1", "key2"}
def test_should_raise_value_error_on_invalid_list_keys_pattern(
redis_adapter: RedisAdapter,
) -> None:
"""Test that the RedisAdapter raises ValueError when listing keys with an invalid pattern."""
# Arrange
invalid_patterns = ["", 123, None]
# Act & Assert
for pattern in invalid_patterns:
with pytest.raises(ValueError):
redis_adapter._list_keys(pattern)
def test_should_raise_connection_error_on_list_keys_when_not_connected(
redis_adapter: RedisAdapter,
) -> None:
"""Test that the RedisAdapter raises ConnectionError when listing keys while not connected."""
# Arrange
adapter = RedisAdapter()
# Act & Assert
with pytest.raises(ConnectionError):
adapter._list_keys("some_pattern")
# allows local debugging by running file as script
if __name__ == "__main__":
pytest.main(["-s", "-v", __file__])