Skip to content

Commit f0bcaec

Browse files
feat: implement tag-based selective container startup for behave tests
1 parent bcbf3be commit f0bcaec

7 files changed

Lines changed: 125 additions & 10 deletions

features/atomic_transactions.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
@needs-postgres
12
Feature: SQLAlchemy Atomic Transactions
23

34
Background:

features/elastic_adapter.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# features/elasticsearch.feature
2+
@needs-elasticsearch
23
Feature: Elasticsearch Operations Testing
34
As a developer
45
I want to test Elasticsearch operations using the adapter pattern

features/environment.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import logging
1010
import uuid
1111

12-
from behave.model import Scenario
12+
from behave.model import Scenario, Feature
1313
from behave.runner import Context
1414
from features.scenario_context_pool_manager import ScenarioContextPoolManager
1515
from pydantic_settings import SettingsConfigDict
@@ -63,9 +63,45 @@ def before_all(context: Context):
6363
# Create the scenario context pool manager
6464
context.scenario_context_pool = ScenarioContextPoolManager()
6565

66-
# Initialize and start all test containers
66+
# Initialize container manager
6767
context.test_containers = ContainerManager
68-
context.test_containers.start_all()
68+
69+
# Collect feature-level tags from all features being executed
70+
all_tags: set[str] = set()
71+
if hasattr(context, "features") and context.features:
72+
for feature in context.features:
73+
# Get feature-level tags - convert Tag objects to strings
74+
if hasattr(feature, "tags") and feature.tags:
75+
feature_tags = [str(tag) for tag in feature.tags]
76+
all_tags.update(feature_tags)
77+
context.logger.debug(f"Feature '{feature.name}' has tags: {feature_tags}")
78+
79+
# Extract required containers from tags
80+
required_containers = ContainerManager.extract_containers_from_tags(list(all_tags))
81+
82+
if required_containers:
83+
context.logger.info(f"Detected required containers from tags: {sorted(required_containers)}")
84+
ContainerManager.start_containers(list(required_containers))
85+
else:
86+
context.logger.info("No container tags detected, no containers will be started")
87+
88+
89+
def before_feature(context: Context, feature: Feature):
90+
"""Setup performed before each feature runs.
91+
92+
This is a fallback to ensure containers are started if they weren't started in before_all().
93+
"""
94+
# Extract feature-level tags - convert Tag objects to strings
95+
if hasattr(feature, "tags") and feature.tags:
96+
feature_tags = [str(tag) for tag in feature.tags]
97+
98+
if feature_tags:
99+
# Extract required containers from tags
100+
required_containers = ContainerManager.extract_containers_from_tags(feature_tags)
101+
102+
if required_containers:
103+
# Start containers if not already started (start_containers handles this)
104+
ContainerManager.start_containers(list(required_containers))
69105

70106

71107
def before_scenario(context: Context, scenario: Scenario):

features/kafka_adapters.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# features/kafka_operations.feature
2+
@needs-kafka
23
Feature: Kafka Adapter Operations Testing
34
As a developer
45
I want to test Kafka adapter operations

features/keycloak_adapter.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# features/keycloak_auth.feature
2+
@needs-keycloak
23
Feature: Keycloak Authentication Testing
34
As a developer
45
I want to test Keycloak authentication operations

features/minio_adapter.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# features/minio_operations.feature
2+
@needs-minio
23
Feature: MinIO Operations Testing
34
As a developer
45
I want to test MinIO storage operations

features/test_containers.py

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,24 @@
1717

1818
logger = logging.getLogger(__name__)
1919

20+
# Mapping of feature tags to container names
21+
TAG_CONTAINER_MAP: dict[str, str] = {
22+
"needs-postgres": "postgres",
23+
"needs-kafka": "kafka",
24+
"needs-elasticsearch": "elasticsearch",
25+
"needs-minio": "minio",
26+
"needs-keycloak": "keycloak",
27+
"needs-redis": "redis",
28+
}
29+
2030

2131
class ContainerManager:
2232
"""Registry for managing all test containers."""
2333

2434
_containers = {}
2535
_container_instances = {}
2636
_started = False
37+
_started_containers: set[str] = set()
2738

2839
@classmethod
2940
def register(cls, name: str):
@@ -36,18 +47,29 @@ def decorator(container_class):
3647

3748
@classmethod
3849
def get_container(cls, name: str, **kwargs):
39-
"""Get a container instance by name."""
50+
"""Get a container instance by name.
51+
52+
If the container is not started, it will be started lazily.
53+
"""
4054
if name not in cls._containers:
4155
raise KeyError(f"Container '{name}' not found. Available: {list(cls._containers.keys())}")
4256

4357
# Return stored instance if available (Singleton pattern ensures same instance)
4458
if name in cls._container_instances:
45-
return cls._container_instances[name]
59+
instance = cls._container_instances[name]
60+
# Start container if not already running (lazy startup)
61+
if name not in cls._started_containers:
62+
instance.start()
63+
cls._started_containers.add(name)
64+
return instance
4665

4766
# Create new instance if not stored yet
4867
container_class = cls._containers[name]
4968
instance = container_class(**kwargs)
5069
cls._container_instances[name] = instance
70+
# Start container lazily
71+
instance.start()
72+
cls._started_containers.add(name)
5173
return instance
5274

5375
@classmethod
@@ -61,21 +83,72 @@ def start_all(cls):
6183
container = container_class()
6284
cls._container_instances[name] = container
6385
container.start()
86+
cls._started_containers.add(name)
6487

6588
cls._started = True
6689
logger.info("All test containers started")
6790

91+
@classmethod
92+
def start_containers(cls, container_names: list[str]):
93+
"""Start specific containers by name.
94+
95+
Args:
96+
container_names: List of container names to start
97+
"""
98+
for name in container_names:
99+
if name not in cls._containers:
100+
logger.warning(f"Container '{name}' not found. Available: {list(cls._containers.keys())}")
101+
continue
102+
103+
if name in cls._started_containers:
104+
logger.debug(f"Container '{name}' already started, skipping")
105+
continue
106+
107+
logger.info(f"Starting {name} container...")
108+
# get_container will start the container and add it to _started_containers
109+
cls.get_container(name)
110+
111+
logger.info(f"Started containers: {sorted(cls._started_containers)}")
112+
113+
@classmethod
114+
def extract_containers_from_tags(cls, tags: list[str]) -> set[str]:
115+
"""Extract container names from feature/scenario tags.
116+
117+
Args:
118+
tags: List of tag strings (e.g., ["needs-postgres", "needs-kafka"])
119+
120+
Returns:
121+
Set of container names that should be started
122+
"""
123+
containers: set[str] = set()
124+
for tag in tags:
125+
# Remove @ prefix if present
126+
tag_name = tag.lstrip("@")
127+
if tag_name in TAG_CONTAINER_MAP:
128+
container_name = TAG_CONTAINER_MAP[tag_name]
129+
containers.add(container_name)
130+
logger.debug(f"Tag '{tag}' maps to container '{container_name}'")
131+
else:
132+
# Only log warning for tags that look like container tags but aren't mapped
133+
if tag_name.startswith("needs-"):
134+
logger.warning(f"Unknown container tag '{tag}'. Available tags: {list(TAG_CONTAINER_MAP.keys())}")
135+
136+
return containers
137+
68138
@classmethod
69139
def stop_all(cls):
70-
"""Stop all registered containers."""
71-
if not cls._started:
140+
"""Stop all started containers."""
141+
if not cls._started_containers:
72142
return
73143

74-
for name, instance in cls._container_instances.items():
75-
logger.info(f"Stopping {name} container...")
76-
instance.stop()
144+
for name in list(cls._started_containers):
145+
if name in cls._container_instances:
146+
logger.info(f"Stopping {name} container...")
147+
instance = cls._container_instances[name]
148+
instance.stop()
77149

78150
cls._container_instances.clear()
151+
cls._started_containers.clear()
79152
cls._started = False
80153
logger.info("All test containers stopped")
81154

@@ -85,6 +158,7 @@ def reset(cls):
85158
cls.stop_all()
86159
cls._containers.clear()
87160
cls._container_instances.clear()
161+
cls._started_containers.clear()
88162
cls._started = False
89163

90164
@classmethod

0 commit comments

Comments
 (0)