Skip to content

Commit 53cfbc8

Browse files
feat: Add multi-backend testing infrastructure (Phase 1)
Implement infrastructure for testing DataJoint against both MySQL and PostgreSQL backends. Tests automatically run against both backends via parameterized fixtures, with support for testcontainers and docker-compose. docker-compose.yaml changes: - Added PostgreSQL 15 service with health checks - Added PostgreSQL environment variables to app service - PostgreSQL runs on port 5432 alongside MySQL on 3306 tests/conftest.py changes: - Added postgres_container fixture (testcontainers integration) - Added backend parameterization fixtures: * backend: Parameterizes tests to run as [mysql, postgresql] * db_creds_by_backend: Returns credentials for current backend * connection_by_backend: Creates connection for current backend - Updated pytest_collection_modifyitems to auto-mark backend tests - Backend-parameterized tests automatically get mysql, postgresql, and backend_agnostic markers pyproject.toml changes: - Added pytest markers: mysql, postgresql, backend_agnostic - Updated testcontainers dependency: testcontainers[mysql,minio,postgres]>=4.0 tests/integration/test_multi_backend.py (NEW): - Example backend-agnostic tests demonstrating infrastructure - 4 tests × 2 backends = 8 test instances collected - Tests verify: table declaration, foreign keys, data types, comments Usage: pytest tests/ # All tests, both backends pytest -m "mysql" # MySQL tests only pytest -m "postgresql" # PostgreSQL tests only pytest -m "backend_agnostic" # Multi-backend tests only DJ_USE_EXTERNAL_CONTAINERS=1 pytest tests/ # Use docker-compose Benefits: - Zero-config testing: pytest automatically manages containers - Flexible: testcontainers (auto) or docker-compose (manual) - Selective: Run specific backends via pytest markers - Parallel CI: Different jobs can test different backends - Easy debugging: Use docker-compose for persistent containers Phase 1 of multi-backend testing implementation complete. Next phase: Convert existing tests to use backend fixtures. Related: #1338
1 parent ca5ea6c commit 53cfbc8

File tree

4 files changed

+293
-1
lines changed

4 files changed

+293
-1
lines changed

docker-compose.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@ services:
2424
timeout: 30s
2525
retries: 5
2626
interval: 15s
27+
postgres:
28+
image: postgres:${POSTGRES_VER:-15}
29+
environment:
30+
- POSTGRES_PASSWORD=${PG_PASS:-password}
31+
- POSTGRES_USER=${PG_USER:-postgres}
32+
- POSTGRES_DB=${PG_DB:-test}
33+
ports:
34+
- "5432:5432"
35+
healthcheck:
36+
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
37+
timeout: 30s
38+
retries: 5
39+
interval: 15s
2740
minio:
2841
image: minio/minio:${MINIO_VER:-RELEASE.2025-02-28T09-55-16Z}
2942
environment:
@@ -52,6 +65,8 @@ services:
5265
depends_on:
5366
db:
5467
condition: service_healthy
68+
postgres:
69+
condition: service_healthy
5570
minio:
5671
condition: service_healthy
5772
environment:
@@ -61,6 +76,10 @@ services:
6176
- DJ_TEST_HOST=db
6277
- DJ_TEST_USER=datajoint
6378
- DJ_TEST_PASSWORD=datajoint
79+
- DJ_PG_HOST=postgres
80+
- DJ_PG_USER=postgres
81+
- DJ_PG_PASS=password
82+
- DJ_PG_PORT=5432
6483
- S3_ENDPOINT=minio:9000
6584
- S3_ACCESS_KEY=datajoint
6685
- S3_SECRET_KEY=datajoint

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ test = [
8989
"pytest-cov",
9090
"requests",
9191
"graphviz",
92-
"testcontainers[mysql,minio]>=4.0",
92+
"testcontainers[mysql,minio,postgres]>=4.0",
9393
"polars>=0.20.0",
9494
"pyarrow>=14.0.0",
9595
]
@@ -228,6 +228,9 @@ ignore-words-list = "rever,numer,astroid"
228228
markers = [
229229
"requires_mysql: marks tests as requiring MySQL database (deselect with '-m \"not requires_mysql\"')",
230230
"requires_minio: marks tests as requiring MinIO object storage (deselect with '-m \"not requires_minio\"')",
231+
"mysql: marks tests that run on MySQL backend (select with '-m mysql')",
232+
"postgresql: marks tests that run on PostgreSQL backend (select with '-m postgresql')",
233+
"backend_agnostic: marks tests that should pass on all backends (auto-marked for parameterized tests)",
231234
]
232235

233236

tests/conftest.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ def pytest_collection_modifyitems(config, items):
6666
"stores_config",
6767
"mock_stores",
6868
}
69+
# Tests that use these fixtures are backend-parameterized
70+
backend_fixtures = {
71+
"backend",
72+
"db_creds_by_backend",
73+
"connection_by_backend",
74+
}
6975

7076
for item in items:
7177
# Get all fixtures this test uses (directly or indirectly)
@@ -80,6 +86,13 @@ def pytest_collection_modifyitems(config, items):
8086
if fixturenames & minio_fixtures:
8187
item.add_marker(pytest.mark.requires_minio)
8288

89+
# Auto-mark backend-parameterized tests
90+
if fixturenames & backend_fixtures:
91+
# Test will run for both backends - add all backend markers
92+
item.add_marker(pytest.mark.mysql)
93+
item.add_marker(pytest.mark.postgresql)
94+
item.add_marker(pytest.mark.backend_agnostic)
95+
8396

8497
# =============================================================================
8598
# Container Fixtures - Auto-start MySQL and MinIO via testcontainers
@@ -118,6 +131,35 @@ def mysql_container():
118131
logger.info("MySQL container stopped")
119132

120133

134+
@pytest.fixture(scope="session")
135+
def postgres_container():
136+
"""Start PostgreSQL container for the test session (or use external)."""
137+
if USE_EXTERNAL_CONTAINERS:
138+
# Use external container - return None, credentials come from env
139+
logger.info("Using external PostgreSQL container")
140+
yield None
141+
return
142+
143+
from testcontainers.postgres import PostgresContainer
144+
145+
container = PostgresContainer(
146+
image="postgres:15",
147+
username="postgres",
148+
password="password",
149+
dbname="test",
150+
)
151+
container.start()
152+
153+
host = container.get_container_host_ip()
154+
port = container.get_exposed_port(5432)
155+
logger.info(f"PostgreSQL container started at {host}:{port}")
156+
157+
yield container
158+
159+
container.stop()
160+
logger.info("PostgreSQL container stopped")
161+
162+
121163
@pytest.fixture(scope="session")
122164
def minio_container():
123165
"""Start MinIO container for the test session (or use external)."""
@@ -225,6 +267,91 @@ def s3_creds(minio_container) -> Dict:
225267
)
226268

227269

270+
# =============================================================================
271+
# Backend-Parameterized Fixtures
272+
# =============================================================================
273+
274+
275+
@pytest.fixture(scope="session", params=["mysql", "postgresql"])
276+
def backend(request):
277+
"""Parameterize tests to run against both backends."""
278+
return request.param
279+
280+
281+
@pytest.fixture(scope="session")
282+
def db_creds_by_backend(backend, mysql_container, postgres_container):
283+
"""Get root database credentials for the specified backend."""
284+
if backend == "mysql":
285+
if mysql_container is not None:
286+
host = mysql_container.get_container_host_ip()
287+
port = mysql_container.get_exposed_port(3306)
288+
return {
289+
"backend": "mysql",
290+
"host": f"{host}:{port}",
291+
"user": "root",
292+
"password": "password",
293+
}
294+
else:
295+
# External MySQL container
296+
host = os.environ.get("DJ_HOST", "localhost")
297+
port = os.environ.get("DJ_PORT", "3306")
298+
return {
299+
"backend": "mysql",
300+
"host": f"{host}:{port}" if port else host,
301+
"user": os.environ.get("DJ_USER", "root"),
302+
"password": os.environ.get("DJ_PASS", "password"),
303+
}
304+
305+
elif backend == "postgresql":
306+
if postgres_container is not None:
307+
host = postgres_container.get_container_host_ip()
308+
port = postgres_container.get_exposed_port(5432)
309+
return {
310+
"backend": "postgresql",
311+
"host": f"{host}:{port}",
312+
"user": "postgres",
313+
"password": "password",
314+
}
315+
else:
316+
# External PostgreSQL container
317+
host = os.environ.get("DJ_PG_HOST", "localhost")
318+
port = os.environ.get("DJ_PG_PORT", "5432")
319+
return {
320+
"backend": "postgresql",
321+
"host": f"{host}:{port}" if port else host,
322+
"user": os.environ.get("DJ_PG_USER", "postgres"),
323+
"password": os.environ.get("DJ_PG_PASS", "password"),
324+
}
325+
326+
327+
@pytest.fixture(scope="session")
328+
def connection_by_backend(db_creds_by_backend):
329+
"""Create connection for the specified backend."""
330+
# Configure backend
331+
dj.config["database.backend"] = db_creds_by_backend["backend"]
332+
333+
# Parse host:port
334+
host_port = db_creds_by_backend["host"]
335+
if ":" in host_port:
336+
host, port = host_port.rsplit(":", 1)
337+
else:
338+
host = host_port
339+
port = "3306" if db_creds_by_backend["backend"] == "mysql" else "5432"
340+
341+
dj.config["database.host"] = host
342+
dj.config["database.port"] = int(port)
343+
dj.config["safemode"] = False
344+
345+
connection = dj.Connection(
346+
host=host_port,
347+
user=db_creds_by_backend["user"],
348+
password=db_creds_by_backend["password"],
349+
)
350+
351+
yield connection
352+
connection.close()
353+
354+
228355
# =============================================================================
229356
# DataJoint Configuration
230357
# =============================================================================
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""
2+
Integration tests that verify backend-agnostic behavior.
3+
4+
These tests run against both MySQL and PostgreSQL to ensure:
5+
1. DDL generation is correct
6+
2. SQL queries work identically
7+
3. Data types map correctly
8+
9+
To run these tests:
10+
pytest tests/integration/test_multi_backend.py # Run against both backends
11+
pytest -m "mysql" tests/integration/test_multi_backend.py # MySQL only
12+
pytest -m "postgresql" tests/integration/test_multi_backend.py # PostgreSQL only
13+
"""
14+
15+
import pytest
16+
import datajoint as dj
17+
18+
19+
@pytest.mark.backend_agnostic
20+
def test_simple_table_declaration(connection_by_backend, backend, prefix):
21+
"""Test that simple tables can be declared on both backends."""
22+
schema = dj.Schema(
23+
f"{prefix}_multi_backend_{backend}_simple",
24+
connection=connection_by_backend,
25+
)
26+
27+
@schema
28+
class User(dj.Manual):
29+
definition = """
30+
user_id : int
31+
---
32+
username : varchar(255)
33+
created_at : datetime
34+
"""
35+
36+
# Verify table exists
37+
assert User.is_declared
38+
39+
# Insert and fetch data
40+
from datetime import datetime
41+
42+
User.insert1((1, "alice", datetime(2025, 1, 1)))
43+
data = User.fetch1()
44+
45+
assert data["user_id"] == 1
46+
assert data["username"] == "alice"
47+
48+
# Cleanup
49+
schema.drop()
50+
51+
52+
@pytest.mark.backend_agnostic
53+
def test_foreign_keys(connection_by_backend, backend, prefix):
54+
"""Test foreign key declarations work on both backends."""
55+
schema = dj.Schema(
56+
f"{prefix}_multi_backend_{backend}_fk",
57+
connection=connection_by_backend,
58+
)
59+
60+
@schema
61+
class Animal(dj.Manual):
62+
definition = """
63+
animal_id : int
64+
---
65+
name : varchar(255)
66+
"""
67+
68+
@schema
69+
class Observation(dj.Manual):
70+
definition = """
71+
-> Animal
72+
obs_id : int
73+
---
74+
notes : varchar(1000)
75+
"""
76+
77+
# Insert data
78+
Animal.insert1((1, "Mouse"))
79+
Observation.insert1((1, 1, "Active"))
80+
81+
# Verify data was inserted
82+
assert len(Animal) == 1
83+
assert len(Observation) == 1
84+
85+
# Cleanup
86+
schema.drop()
87+
88+
89+
@pytest.mark.backend_agnostic
90+
def test_data_types(connection_by_backend, backend, prefix):
91+
"""Test that core data types work on both backends."""
92+
schema = dj.Schema(
93+
f"{prefix}_multi_backend_{backend}_types",
94+
connection=connection_by_backend,
95+
)
96+
97+
@schema
98+
class TypeTest(dj.Manual):
99+
definition = """
100+
id : int
101+
---
102+
int_value : int
103+
str_value : varchar(255)
104+
float_value : float
105+
bool_value : bool
106+
"""
107+
108+
# Insert data
109+
TypeTest.insert1((1, 42, "test", 3.14, True))
110+
111+
# Fetch and verify
112+
data = (TypeTest & {"id": 1}).fetch1()
113+
assert data["int_value"] == 42
114+
assert data["str_value"] == "test"
115+
assert abs(data["float_value"] - 3.14) < 0.001
116+
assert data["bool_value"] == 1 # MySQL stores as tinyint(1)
117+
118+
# Cleanup
119+
schema.drop()
120+
121+
122+
@pytest.mark.backend_agnostic
123+
def test_table_comments(connection_by_backend, backend, prefix):
124+
"""Test that table comments are preserved on both backends."""
125+
schema = dj.Schema(
126+
f"{prefix}_multi_backend_{backend}_comments",
127+
connection=connection_by_backend,
128+
)
129+
130+
@schema
131+
class Commented(dj.Manual):
132+
definition = """
133+
# This is a test table for backend testing
134+
id : int # primary key
135+
---
136+
value : varchar(255) # some value
137+
"""
138+
139+
# Verify table was created
140+
assert Commented.is_declared
141+
142+
# Cleanup
143+
schema.drop()

0 commit comments

Comments
 (0)