Skip to content

Commit 60c7663

Browse files
Feat add testcontainers test (langgenius#23269)
1 parent 8041808 commit 60c7663

17 files changed

Lines changed: 1007 additions & 0 deletions

File tree

.github/workflows/api-tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,6 @@ jobs:
9999

100100
- name: Run Tool
101101
run: uv run --project api bash dev/pytest/pytest_tools.sh
102+
103+
- name: Run TestContainers
104+
run: uv run --project api bash dev/pytest/pytest_testcontainers.sh

api/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ dev = [
114114
"pytest-cov~=4.1.0",
115115
"pytest-env~=1.1.3",
116116
"pytest-mock~=3.14.0",
117+
"testcontainers~=4.10.0",
117118
"types-aiofiles~=24.1.0",
118119
"types-beautifulsoup4~=4.12.0",
119120
"types-cachetools~=5.5.0",

api/tests/test_containers_integration_tests/__init__.py

Whitespace-only changes.
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
"""
2+
TestContainers-based integration test configuration for Dify API.
3+
4+
This module provides containerized test infrastructure using TestContainers library
5+
to spin up real database and service instances for integration testing. This approach
6+
ensures tests run against actual service implementations rather than mocks, providing
7+
more reliable and realistic test scenarios.
8+
"""
9+
10+
import logging
11+
import os
12+
from collections.abc import Generator
13+
from typing import Optional
14+
15+
import pytest
16+
from flask import Flask
17+
from flask.testing import FlaskClient
18+
from sqlalchemy.orm import Session
19+
from testcontainers.core.container import DockerContainer
20+
from testcontainers.core.waiting_utils import wait_for_logs
21+
from testcontainers.postgres import PostgresContainer
22+
from testcontainers.redis import RedisContainer
23+
24+
from app_factory import create_app
25+
from models import db
26+
27+
# Configure logging for test containers
28+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
29+
logger = logging.getLogger(__name__)
30+
31+
32+
class DifyTestContainers:
33+
"""
34+
Manages all test containers required for Dify integration tests.
35+
36+
This class provides a centralized way to manage multiple containers
37+
needed for comprehensive integration testing, including databases,
38+
caches, and search engines.
39+
"""
40+
41+
def __init__(self):
42+
"""Initialize container management with default configurations."""
43+
self.postgres: Optional[PostgresContainer] = None
44+
self.redis: Optional[RedisContainer] = None
45+
self.dify_sandbox: Optional[DockerContainer] = None
46+
self._containers_started = False
47+
logger.info("DifyTestContainers initialized - ready to manage test containers")
48+
49+
def start_containers_with_env(self) -> None:
50+
"""
51+
Start all required containers for integration testing.
52+
53+
This method initializes and starts PostgreSQL, Redis
54+
containers with appropriate configurations for Dify testing. Containers
55+
are started in dependency order to ensure proper initialization.
56+
"""
57+
if self._containers_started:
58+
logger.info("Containers already started - skipping container startup")
59+
return
60+
61+
logger.info("Starting test containers for Dify integration tests...")
62+
63+
# Start PostgreSQL container for main application database
64+
# PostgreSQL is used for storing user data, workflows, and application state
65+
logger.info("Initializing PostgreSQL container...")
66+
self.postgres = PostgresContainer(
67+
image="postgres:16-alpine",
68+
)
69+
self.postgres.start()
70+
db_host = self.postgres.get_container_host_ip()
71+
db_port = self.postgres.get_exposed_port(5432)
72+
os.environ["DB_HOST"] = db_host
73+
os.environ["DB_PORT"] = str(db_port)
74+
os.environ["DB_USERNAME"] = self.postgres.username
75+
os.environ["DB_PASSWORD"] = self.postgres.password
76+
os.environ["DB_DATABASE"] = self.postgres.dbname
77+
logger.info(
78+
"PostgreSQL container started successfully - Host: %s, Port: %s User: %s, Database: %s",
79+
db_host,
80+
db_port,
81+
self.postgres.username,
82+
self.postgres.dbname,
83+
)
84+
85+
# Wait for PostgreSQL to be ready
86+
logger.info("Waiting for PostgreSQL to be ready to accept connections...")
87+
wait_for_logs(self.postgres, "is ready to accept connections", timeout=30)
88+
logger.info("PostgreSQL container is ready and accepting connections")
89+
90+
# Install uuid-ossp extension for UUID generation
91+
logger.info("Installing uuid-ossp extension...")
92+
try:
93+
import psycopg2
94+
95+
conn = psycopg2.connect(
96+
host=db_host,
97+
port=db_port,
98+
user=self.postgres.username,
99+
password=self.postgres.password,
100+
database=self.postgres.dbname,
101+
)
102+
conn.autocommit = True
103+
cursor = conn.cursor()
104+
cursor.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";')
105+
cursor.close()
106+
conn.close()
107+
logger.info("uuid-ossp extension installed successfully")
108+
except Exception as e:
109+
logger.warning("Failed to install uuid-ossp extension: %s", e)
110+
111+
# Set up storage environment variables
112+
os.environ["STORAGE_TYPE"] = "opendal"
113+
os.environ["OPENDAL_SCHEME"] = "fs"
114+
os.environ["OPENDAL_FS_ROOT"] = "storage"
115+
116+
# Start Redis container for caching and session management
117+
# Redis is used for storing session data, cache entries, and temporary data
118+
logger.info("Initializing Redis container...")
119+
self.redis = RedisContainer(image="redis:latest", port=6379)
120+
self.redis.start()
121+
redis_host = self.redis.get_container_host_ip()
122+
redis_port = self.redis.get_exposed_port(6379)
123+
os.environ["REDIS_HOST"] = redis_host
124+
os.environ["REDIS_PORT"] = str(redis_port)
125+
logger.info("Redis container started successfully - Host: %s, Port: %s", redis_host, redis_port)
126+
127+
# Wait for Redis to be ready
128+
logger.info("Waiting for Redis to be ready to accept connections...")
129+
wait_for_logs(self.redis, "Ready to accept connections", timeout=30)
130+
logger.info("Redis container is ready and accepting connections")
131+
132+
# Start Dify Sandbox container for code execution environment
133+
# Dify Sandbox provides a secure environment for executing user code
134+
logger.info("Initializing Dify Sandbox container...")
135+
self.dify_sandbox = DockerContainer(image="langgenius/dify-sandbox:latest")
136+
self.dify_sandbox.with_exposed_ports(8194)
137+
self.dify_sandbox.env = {
138+
"API_KEY": "test_api_key",
139+
}
140+
self.dify_sandbox.start()
141+
sandbox_host = self.dify_sandbox.get_container_host_ip()
142+
sandbox_port = self.dify_sandbox.get_exposed_port(8194)
143+
os.environ["CODE_EXECUTION_ENDPOINT"] = f"http://{sandbox_host}:{sandbox_port}"
144+
os.environ["CODE_EXECUTION_API_KEY"] = "test_api_key"
145+
logger.info("Dify Sandbox container started successfully - Host: %s, Port: %s", sandbox_host, sandbox_port)
146+
147+
# Wait for Dify Sandbox to be ready
148+
logger.info("Waiting for Dify Sandbox to be ready to accept connections...")
149+
wait_for_logs(self.dify_sandbox, "config init success", timeout=60)
150+
logger.info("Dify Sandbox container is ready and accepting connections")
151+
152+
self._containers_started = True
153+
logger.info("All test containers started successfully")
154+
155+
def stop_containers(self) -> None:
156+
"""
157+
Stop and clean up all test containers.
158+
159+
This method ensures proper cleanup of all containers to prevent
160+
resource leaks and conflicts between test runs.
161+
"""
162+
if not self._containers_started:
163+
logger.info("No containers to stop - containers were not started")
164+
return
165+
166+
logger.info("Stopping and cleaning up test containers...")
167+
containers = [self.redis, self.postgres, self.dify_sandbox]
168+
for container in containers:
169+
if container:
170+
try:
171+
container_name = container.image
172+
logger.info("Stopping container: %s", container_name)
173+
container.stop()
174+
logger.info("Successfully stopped container: %s", container_name)
175+
except Exception as e:
176+
# Log error but don't fail the test cleanup
177+
logger.warning("Failed to stop container %s: %s", container, e)
178+
179+
self._containers_started = False
180+
logger.info("All test containers stopped and cleaned up successfully")
181+
182+
183+
# Global container manager instance
184+
_container_manager = DifyTestContainers()
185+
186+
187+
def _create_app_with_containers() -> Flask:
188+
"""
189+
Create Flask application configured to use test containers.
190+
191+
This function creates a Flask application instance that is configured
192+
to connect to the test containers instead of the default development
193+
or production databases.
194+
195+
Returns:
196+
Flask: Configured Flask application for containerized testing
197+
"""
198+
logger.info("Creating Flask application with test container configuration...")
199+
200+
# Re-create the config after environment variables have been set
201+
from configs import dify_config
202+
203+
# Force re-creation of config with new environment variables
204+
dify_config.__dict__.clear()
205+
dify_config.__init__()
206+
207+
# Create and configure the Flask application
208+
logger.info("Initializing Flask application...")
209+
app = create_app()
210+
logger.info("Flask application created successfully")
211+
212+
# Initialize database schema
213+
logger.info("Creating database schema...")
214+
with app.app_context():
215+
db.create_all()
216+
logger.info("Database schema created successfully")
217+
218+
logger.info("Flask application configured and ready for testing")
219+
return app
220+
221+
222+
@pytest.fixture(scope="session")
223+
def set_up_containers_and_env() -> Generator[DifyTestContainers, None, None]:
224+
"""
225+
Session-scoped fixture to manage test containers.
226+
227+
This fixture ensures containers are started once per test session
228+
and properly cleaned up when all tests are complete. This approach
229+
improves test performance by reusing containers across multiple tests.
230+
231+
Yields:
232+
DifyTestContainers: Container manager instance
233+
"""
234+
logger.info("=== Starting test session container management ===")
235+
_container_manager.start_containers_with_env()
236+
logger.info("Test containers ready for session")
237+
yield _container_manager
238+
logger.info("=== Cleaning up test session containers ===")
239+
_container_manager.stop_containers()
240+
logger.info("Test session container cleanup completed")
241+
242+
243+
@pytest.fixture(scope="session")
244+
def flask_app_with_containers(set_up_containers_and_env) -> Flask:
245+
"""
246+
Session-scoped Flask application fixture using test containers.
247+
248+
This fixture provides a Flask application instance that is configured
249+
to use the test containers for all database and service connections.
250+
251+
Args:
252+
containers: Container manager fixture
253+
254+
Returns:
255+
Flask: Configured Flask application
256+
"""
257+
logger.info("=== Creating session-scoped Flask application ===")
258+
app = _create_app_with_containers()
259+
logger.info("Session-scoped Flask application created successfully")
260+
return app
261+
262+
263+
@pytest.fixture
264+
def flask_req_ctx_with_containers(flask_app_with_containers) -> Generator[None, None, None]:
265+
"""
266+
Request context fixture for containerized Flask application.
267+
268+
This fixture provides a Flask request context for tests that need
269+
to interact with the Flask application within a request scope.
270+
271+
Args:
272+
flask_app_with_containers: Flask application fixture
273+
274+
Yields:
275+
None: Request context is active during yield
276+
"""
277+
logger.debug("Creating Flask request context...")
278+
with flask_app_with_containers.test_request_context():
279+
logger.debug("Flask request context active")
280+
yield
281+
logger.debug("Flask request context closed")
282+
283+
284+
@pytest.fixture
285+
def test_client_with_containers(flask_app_with_containers) -> Generator[FlaskClient, None, None]:
286+
"""
287+
Test client fixture for containerized Flask application.
288+
289+
This fixture provides a Flask test client that can be used to make
290+
HTTP requests to the containerized application for integration testing.
291+
292+
Args:
293+
flask_app_with_containers: Flask application fixture
294+
295+
Yields:
296+
FlaskClient: Test client instance
297+
"""
298+
logger.debug("Creating Flask test client...")
299+
with flask_app_with_containers.test_client() as client:
300+
logger.debug("Flask test client ready")
301+
yield client
302+
logger.debug("Flask test client closed")
303+
304+
305+
@pytest.fixture
306+
def db_session_with_containers(flask_app_with_containers) -> Generator[Session, None, None]:
307+
"""
308+
Database session fixture for containerized testing.
309+
310+
This fixture provides a SQLAlchemy database session that is connected
311+
to the test PostgreSQL container, allowing tests to interact with
312+
the database directly.
313+
314+
Args:
315+
flask_app_with_containers: Flask application fixture
316+
317+
Yields:
318+
Session: Database session instance
319+
"""
320+
logger.debug("Creating database session...")
321+
with flask_app_with_containers.app_context():
322+
session = db.session()
323+
logger.debug("Database session created and ready")
324+
try:
325+
yield session
326+
finally:
327+
session.close()
328+
logger.debug("Database session closed")

api/tests/test_containers_integration_tests/factories/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)