Skip to content

Commit 92c6b7e

Browse files
committed
Add initial experimental multi database support
1 parent 59d0bf3 commit 92c6b7e

17 files changed

+266
-33
lines changed

docs/database.rst

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,21 +64,25 @@ select using an argument to the ``django_db`` mark::
6464
Tests requiring multiple databases
6565
----------------------------------
6666

67+
.. caution::
68+
69+
This support is **experimental** and is subject to change without
70+
deprecation. We are still figuring out the best way to expose this
71+
functionality. If you are using this successfully or unsuccessfully,
72+
`let us know <https://github.com/pytest-dev/pytest-django/issues/924>`_!
73+
74+
``pytest-django`` has experimental support for multi-database configurations.
6775
Currently ``pytest-django`` does not specifically support Django's
68-
multi-database support.
76+
multi-database support, using the ``databases`` argument to the
77+
:py:func:`django_db <pytest.mark.django_db>` mark::
6978

70-
You can however use normal :class:`~django.test.TestCase` instances to use its
71-
:ref:`django:topics-testing-advanced-multidb` support.
72-
In particular, if your database is configured for replication, be sure to read
73-
about :ref:`django:topics-testing-primaryreplica`.
79+
@pytest.mark.django_db(databases=['default', 'other'])
80+
def test_spam():
81+
assert MyModel.objects.using('other').count() == 0
7482

75-
If you have any ideas about the best API to support multiple databases
76-
directly in ``pytest-django`` please get in touch, we are interested
77-
in eventually supporting this but unsure about simply following
78-
Django's approach.
83+
For details see :py:attr:`django.test.TransactionTestCase.databases` and
84+
:py:attr:`django.test.TestCase.databases`.
7985

80-
See `pull request 431 <https://github.com/pytest-dev/pytest-django/pull/431>`_
81-
for an idea/discussion to approach this.
8286

8387
``--reuse-db`` - reuse the testing database between test runs
8488
--------------------------------------------------------------

docs/helpers.rst

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Markers
2424
``pytest.mark.django_db`` - request database access
2525
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2626

27-
.. py:function:: pytest.mark.django_db([transaction=False, reset_sequences=False])
27+
.. py:function:: pytest.mark.django_db([transaction=False, reset_sequences=False, databases=None])
2828
2929
This is used to mark a test function as requiring the database. It
3030
will ensure the database is set up correctly for the test. Each test
@@ -54,6 +54,23 @@ Markers
5454
effect. Please be aware that not all databases support this feature.
5555
For details see :py:attr:`django.test.TransactionTestCase.reset_sequences`.
5656

57+
58+
:type databases: Union[Iterable[str], str, None]
59+
:param databases:
60+
.. caution::
61+
62+
This argument is **experimental** and is subject to change without
63+
deprecation. We are still figuring out the best way to expose this
64+
functionality. If you are using this successfully or unsuccessfully,
65+
`let us know <https://github.com/pytest-dev/pytest-django/issues/924>`_!
66+
67+
The ``databases`` argument defines which databases in a multi-database
68+
configuration will be set up and may be used by the test. Defaults to
69+
only the ``default`` database. The special value ``"__all__"`` may be use
70+
to specify all configured databases.
71+
For details see :py:attr:`django.test.TransactionTestCase.databases` and
72+
:py:attr:`django.test.TestCase.databases`.
73+
5774
.. note::
5875

5976
If you want access to the Django database inside a *fixture*, this marker may

pytest_django/fixtures.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""All pytest-django fixtures"""
2-
from typing import Any, Generator, List
2+
from typing import Any, Generator, Iterable, List, Optional, Tuple, Union
33
import os
44
from contextlib import contextmanager
55
from functools import partial
@@ -12,8 +12,13 @@
1212

1313
TYPE_CHECKING = False
1414
if TYPE_CHECKING:
15+
from typing import Literal
16+
1517
import django
1618

19+
_DjangoDbDatabases = Optional[Union["Literal['__all__']", Iterable[str]]]
20+
_DjangoDb = Tuple[bool, bool, _DjangoDbDatabases]
21+
1722

1823
__all__ = [
1924
"django_db_setup",
@@ -142,6 +147,10 @@ def _django_db_fixture_helper(
142147
# Do nothing, we get called with transactional=True, too.
143148
return
144149

150+
_databases = getattr(
151+
request.node, "_pytest_django_databases", None,
152+
) # type: Optional[_DjangoDbDatabases]
153+
145154
django_db_blocker.unblock()
146155
request.addfinalizer(django_db_blocker.restore)
147156

@@ -158,6 +167,8 @@ def _django_db_fixture_helper(
158167
class PytestDjangoTestCase(test_case_class): # type: ignore[misc,valid-type]
159168
if transactional and _reset_sequences:
160169
reset_sequences = True
170+
if _databases is not None:
171+
databases = _databases
161172

162173
PytestDjangoTestCase.setUpClass()
163174
request.addfinalizer(PytestDjangoTestCase.tearDownClass)

pytest_django/plugin.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848

4949
import django
5050

51+
from .fixtures import _DjangoDb, _DjangoDbDatabases
52+
5153

5254
SETTINGS_MODULE_ENV = "DJANGO_SETTINGS_MODULE"
5355
CONFIGURATION_ENV = "DJANGO_CONFIGURATION"
@@ -262,12 +264,14 @@ def pytest_load_initial_conftests(
262264
# Register the marks
263265
early_config.addinivalue_line(
264266
"markers",
265-
"django_db(transaction=False, reset_sequences=False): "
267+
"django_db(transaction=False, reset_sequences=False, databases=None): "
266268
"Mark the test as using the Django test database. "
267269
"The *transaction* argument allows you to use real transactions "
268270
"in the test like Django's TransactionTestCase. "
269271
"The *reset_sequences* argument resets database sequences before "
270-
"the test.",
272+
"the test. "
273+
"The *databases* argument sets which database aliases the test "
274+
"uses (by default, only 'default'). Use '__all__' for all databases.",
271275
)
272276
early_config.addinivalue_line(
273277
"markers",
@@ -452,7 +456,11 @@ def _django_db_marker(request) -> None:
452456
"""
453457
marker = request.node.get_closest_marker("django_db")
454458
if marker:
455-
transaction, reset_sequences = validate_django_db(marker)
459+
transaction, reset_sequences, databases = validate_django_db(marker)
460+
461+
# TODO: Use pytest Store (item.store) once that's stable.
462+
request.node._pytest_django_databases = databases
463+
456464
if reset_sequences:
457465
request.getfixturevalue("django_db_reset_sequences")
458466
elif transaction:
@@ -727,21 +735,22 @@ def restore(self) -> None:
727735
_blocking_manager = _DatabaseBlocker()
728736

729737

730-
def validate_django_db(marker) -> Tuple[bool, bool]:
738+
def validate_django_db(marker) -> "_DjangoDb":
731739
"""Validate the django_db marker.
732740
733-
It checks the signature and creates the ``transaction`` and
734-
``reset_sequences`` attributes on the marker which will have the
735-
correct values.
741+
It checks the signature and creates the ``transaction``,
742+
``reset_sequences`` and ``databases`` attributes on the marker
743+
which will have the correct values.
736744
737745
A sequence reset is only allowed when combined with a transaction.
738746
"""
739747

740748
def apifun(
741749
transaction: bool = False,
742750
reset_sequences: bool = False,
743-
) -> Tuple[bool, bool]:
744-
return transaction, reset_sequences
751+
databases: "_DjangoDbDatabases" = None,
752+
) -> "_DjangoDb":
753+
return transaction, reset_sequences, databases
745754

746755
return apifun(*marker.args, **marker.kwargs)
747756

pytest_django_test/app/migrations/0001_initial.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,20 @@ class Migration(migrations.Migration):
2424
),
2525
("name", models.CharField(max_length=100)),
2626
],
27-
)
27+
),
28+
migrations.CreateModel(
29+
name="SecondItem",
30+
fields=[
31+
(
32+
"id",
33+
models.AutoField(
34+
auto_created=True,
35+
primary_key=True,
36+
serialize=False,
37+
verbose_name="ID",
38+
),
39+
),
40+
("name", models.CharField(max_length=100)),
41+
],
42+
),
2843
]

pytest_django_test/app/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
from django.db import models
22

33

4+
# Routed to database "main".
45
class Item(models.Model):
56
name = models.CharField(max_length=100) # type: str
7+
8+
9+
# Routed to database "second".
10+
class SecondItem(models.Model):
11+
name = models.CharField(max_length=100) # type: str

pytest_django_test/db_helpers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
# An explicit test db name was given, is that as the base name
2727
TEST_DB_NAME = "{}_inner".format(TEST_DB_NAME)
2828

29+
SECOND_DB_NAME = DB_NAME + '_second' if DB_NAME is not None else None
30+
SECOND_TEST_DB_NAME = TEST_DB_NAME + '_second' if DB_NAME is not None else None
31+
2932

3033
def get_db_engine():
3134
return _settings["ENGINE"].split(".")[-1]

pytest_django_test/db_router.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
class DbRouter:
2+
def db_for_read(self, model, **hints):
3+
if model._meta.app_label == 'app' and model._meta.model_name == 'seconditem':
4+
return 'second'
5+
return None
6+
7+
def db_for_write(self, model, **hints):
8+
if model._meta.app_label == 'app' and model._meta.model_name == 'seconditem':
9+
return 'second'
10+
return None
11+
12+
def allow_migrate(self, db, app_label, model_name=None, **hints):
13+
if app_label == 'app' and model_name == 'seconditem':
14+
return db == 'second'

pytest_django_test/settings_base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,5 @@
2727
"OPTIONS": {},
2828
}
2929
]
30+
31+
DATABASE_ROUTERS = ['pytest_django_test.db_router.DbRouter']

pytest_django_test/settings_mysql_innodb.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,38 @@
66
DATABASES = {
77
"default": {
88
"ENGINE": "django.db.backends.mysql",
9-
"NAME": "pytest_django_should_never_get_accessed",
9+
"NAME": "pytest_django_tests_default",
10+
"USER": environ.get("TEST_DB_USER", "root"),
11+
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
12+
"HOST": environ.get("TEST_DB_HOST", "localhost"),
13+
"OPTIONS": {
14+
"init_command": "SET default_storage_engine=InnoDB",
15+
"charset": "utf8mb4",
16+
},
17+
"TEST": {
18+
"CHARSET": "utf8mb4",
19+
"COLLATION": "utf8mb4_unicode_ci",
20+
},
21+
},
22+
"replica": {
23+
"ENGINE": "django.db.backends.mysql",
24+
"NAME": "pytest_django_tests_replica",
25+
"USER": environ.get("TEST_DB_USER", "root"),
26+
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
27+
"HOST": environ.get("TEST_DB_HOST", "localhost"),
28+
"OPTIONS": {
29+
"init_command": "SET default_storage_engine=InnoDB",
30+
"charset": "utf8mb4",
31+
},
32+
"TEST": {
33+
"MIRROR": "default",
34+
"CHARSET": "utf8mb4",
35+
"COLLATION": "utf8mb4_unicode_ci",
36+
},
37+
},
38+
"second": {
39+
"ENGINE": "django.db.backends.mysql",
40+
"NAME": "pytest_django_tests_second",
1041
"USER": environ.get("TEST_DB_USER", "root"),
1142
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
1243
"HOST": environ.get("TEST_DB_HOST", "localhost"),

pytest_django_test/settings_mysql_myisam.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,38 @@
66
DATABASES = {
77
"default": {
88
"ENGINE": "django.db.backends.mysql",
9-
"NAME": "pytest_django_should_never_get_accessed",
9+
"NAME": "pytest_django_tests_default",
10+
"USER": environ.get("TEST_DB_USER", "root"),
11+
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
12+
"HOST": environ.get("TEST_DB_HOST", "localhost"),
13+
"OPTIONS": {
14+
"init_command": "SET default_storage_engine=MyISAM",
15+
"charset": "utf8mb4",
16+
},
17+
"TEST": {
18+
"CHARSET": "utf8mb4",
19+
"COLLATION": "utf8mb4_unicode_ci",
20+
},
21+
},
22+
"replica": {
23+
"ENGINE": "django.db.backends.mysql",
24+
"NAME": "pytest_django_tests_replica",
25+
"USER": environ.get("TEST_DB_USER", "root"),
26+
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
27+
"HOST": environ.get("TEST_DB_HOST", "localhost"),
28+
"OPTIONS": {
29+
"init_command": "SET default_storage_engine=MyISAM",
30+
"charset": "utf8mb4",
31+
},
32+
"TEST": {
33+
"MIRROR": "default",
34+
"CHARSET": "utf8mb4",
35+
"COLLATION": "utf8mb4_unicode_ci",
36+
},
37+
},
38+
"second": {
39+
"ENGINE": "django.db.backends.mysql",
40+
"NAME": "pytest_django_tests_second",
1041
"USER": environ.get("TEST_DB_USER", "root"),
1142
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
1243
"HOST": environ.get("TEST_DB_HOST", "localhost"),

pytest_django_test/settings_postgres.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,24 @@
1414
DATABASES = {
1515
"default": {
1616
"ENGINE": "django.db.backends.postgresql",
17-
"NAME": "pytest_django_should_never_get_accessed",
17+
"NAME": "pytest_django_tests_default",
18+
"USER": environ.get("TEST_DB_USER", ""),
19+
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
20+
"HOST": environ.get("TEST_DB_HOST", ""),
21+
},
22+
"replica": {
23+
"ENGINE": "django.db.backends.postgresql",
24+
"NAME": "pytest_django_tests_replica",
25+
"USER": environ.get("TEST_DB_USER", ""),
26+
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
27+
"HOST": environ.get("TEST_DB_HOST", ""),
28+
"TEST": {
29+
"MIRROR": "default",
30+
},
31+
},
32+
"second": {
33+
"ENGINE": "django.db.backends.postgresql",
34+
"NAME": "pytest_django_tests_second",
1835
"USER": environ.get("TEST_DB_USER", ""),
1936
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
2037
"HOST": environ.get("TEST_DB_HOST", ""),

pytest_django_test/settings_sqlite.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
from .settings_base import * # noqa: F401 F403
22

3+
34
DATABASES = {
45
"default": {
56
"ENGINE": "django.db.backends.sqlite3",
67
"NAME": "/should_not_be_accessed",
7-
}
8+
},
9+
"replica": {
10+
"ENGINE": "django.db.backends.sqlite3",
11+
"NAME": "/should_not_be_accessed",
12+
"TEST": {
13+
"MIRROR": "default",
14+
},
15+
},
16+
"second": {
17+
"ENGINE": "django.db.backends.sqlite3",
18+
"NAME": "/should_not_be_accessed",
19+
},
820
}

0 commit comments

Comments
 (0)