Repository: pytest-dev/pytest-django (python)

Issue:
Correctly support DB access in parallel Tox testing
Hi!

Recently, I have migrated some Django projects to run their tests using [Tox](https://tox.readthedocs.io/en/latest/index.html). Because of the number of tests and Tox environments, it was configured to run tests in parallel. This required a similar approach as what's currently handled by `pytest-django` when running tests with `pytest-xdist`: **Database renaming to avoid collisions**.

A simple fixture like `django_db_modify_db_settings_xdist_suffix`, to add a suffix to database names, was enough to fix database collisions, so I wonder if `pytest-django` should also handle Tox testing in these scenarios. And, now more than ever, with Tox implementing real support for [parallel execution](https://github.com/tox-dev/tox/pull/1102).

I have the time to work on this, and provide what I've implemented so far, if this request is accepted!


Discussion:
Yes, it makes sense to use something like `django_db_modify_db_settings_tox_suffix` for this.
But maybe there could be a single `*_suffix` then?
It should also handle xdist in parallel tox then.
I think it depends on how Pytest's fixture resolution order works. If it's deterministic (by design, and this contract won't change), I think that separate fixtures make sense. If not, a single `django_db_modify_db_settings_parallel_suffix` could be enough to avoid different DB names with each run.

Patch:
diff --git a/pytest_django/fixtures.py b/pytest_django/fixtures.py
--- a/pytest_django/fixtures.py
+++ b/pytest_django/fixtures.py
@@ -33,35 +33,35 @@
 
 
 @pytest.fixture(scope="session")
-def django_db_modify_db_settings_xdist_suffix(request):
+def django_db_modify_db_settings_tox_suffix(request):
     skip_if_no_django()
 
-    from django.conf import settings
-
-    for db_settings in settings.DATABASES.values():
-
-        try:
-            test_name = db_settings["TEST"]["NAME"]
-        except KeyError:
-            test_name = None
+    tox_environment = os.getenv("TOX_PARALLEL_ENV")
+    if tox_environment:
+        # Put a suffix like _py27-django21 on tox workers
+        _set_suffix_to_test_databases(suffix=tox_environment)
 
-        if not test_name:
-            if db_settings["ENGINE"] == "django.db.backends.sqlite3":
-                continue
 
-            test_name = "test_{}".format(db_settings["NAME"])
+@pytest.fixture(scope="session")
+def django_db_modify_db_settings_xdist_suffix(request):
+    skip_if_no_django()
 
+    xdist_suffix = getattr(request.config, "slaveinput", {}).get("slaveid")
+    if xdist_suffix:
         # Put a suffix like _gw0, _gw1 etc on xdist processes
-        xdist_suffix = getattr(request.config, "slaveinput", {}).get("slaveid")
-        if test_name != ":memory:" and xdist_suffix is not None:
-            test_name = "{}_{}".format(test_name, xdist_suffix)
+        _set_suffix_to_test_databases(suffix=xdist_suffix)
 
-        db_settings.setdefault("TEST", {})
-        db_settings["TEST"]["NAME"] = test_name
+
+@pytest.fixture(scope="session")
+def django_db_modify_db_settings_parallel_suffix(
+    django_db_modify_db_settings_tox_suffix,
+    django_db_modify_db_settings_xdist_suffix,
+):
+    skip_if_no_django()
 
 
 @pytest.fixture(scope="session")
-def django_db_modify_db_settings(django_db_modify_db_settings_xdist_suffix):
+def django_db_modify_db_settings(django_db_modify_db_settings_parallel_suffix):
     skip_if_no_django()
 
 
@@ -169,6 +169,24 @@ def handle(self, *args, **kwargs):
     migrate.Command = MigrateSilentCommand
 
 
+def _set_suffix_to_test_databases(suffix):
+    from django.conf import settings
+
+    for db_settings in settings.DATABASES.values():
+        test_name = db_settings.get("TEST", {}).get("NAME")
+
+        if not test_name:
+            if db_settings["ENGINE"] == "django.db.backends.sqlite3":
+                continue
+            test_name = "test_{}".format(db_settings["NAME"])
+
+        if test_name == ":memory:":
+            continue
+
+        db_settings.setdefault("TEST", {})
+        db_settings["TEST"]["NAME"] = "{}_{}".format(test_name, suffix)
+
+
 # ############### User visible fixtures ################
 
 
diff --git a/pytest_django/plugin.py b/pytest_django/plugin.py
--- a/pytest_django/plugin.py
+++ b/pytest_django/plugin.py
@@ -22,6 +22,8 @@
 from .fixtures import django_db_keepdb  # noqa
 from .fixtures import django_db_createdb  # noqa
 from .fixtures import django_db_modify_db_settings  # noqa
+from .fixtures import django_db_modify_db_settings_parallel_suffix  # noqa
+from .fixtures import django_db_modify_db_settings_tox_suffix  # noqa
 from .fixtures import django_db_modify_db_settings_xdist_suffix  # noqa
 from .fixtures import _live_server_helper  # noqa
 from .fixtures import admin_client  # noqa


Test patch:
diff --git a/tests/test_db_setup.py b/tests/test_db_setup.py
--- a/tests/test_db_setup.py
+++ b/tests/test_db_setup.py
@@ -288,7 +288,135 @@ def test_a():
 
                 assert conn_db2.vendor == 'sqlite'
                 db_name = conn_db2.creation._get_test_db_name()
-                assert 'test_custom_db_name_gw' in db_name
+                assert db_name.startswith('test_custom_db_name_gw')
+        """
+        )
+
+        result = django_testdir.runpytest_subprocess("--tb=short", "-vv", "-n1")
+        assert result.ret == 0
+        result.stdout.fnmatch_lines(["*PASSED*test_a*"])
+
+
+class TestSqliteWithTox:
+
+    db_settings = {
+        "default": {
+            "ENGINE": "django.db.backends.sqlite3",
+            "NAME": "db_name",
+            "TEST": {"NAME": "test_custom_db_name"},
+        }
+    }
+
+    def test_db_with_tox_suffix(self, django_testdir, monkeypatch):
+        "A test to check that Tox DB suffix works when running in parallel."
+        monkeypatch.setenv("TOX_PARALLEL_ENV", "py37-django22")
+
+        django_testdir.create_test_module(
+            """
+            import pytest
+            from django.db import connections
+
+            @pytest.mark.django_db
+            def test_inner():
+
+                (conn, ) = connections.all()
+
+                assert conn.vendor == 'sqlite'
+                db_name = conn.creation._get_test_db_name()
+                assert db_name == 'test_custom_db_name_py37-django22'
+        """
+        )
+
+        result = django_testdir.runpytest_subprocess("--tb=short", "-vv")
+        assert result.ret == 0
+        result.stdout.fnmatch_lines(["*test_inner*PASSED*"])
+
+    def test_db_with_empty_tox_suffix(self, django_testdir, monkeypatch):
+        "A test to check that Tox DB suffix is not used when suffix would be empty."
+        monkeypatch.setenv("TOX_PARALLEL_ENV", "")
+
+        django_testdir.create_test_module(
+            """
+            import pytest
+            from django.db import connections
+
+            @pytest.mark.django_db
+            def test_inner():
+
+                (conn,) = connections.all()
+
+                assert conn.vendor == 'sqlite'
+                db_name = conn.creation._get_test_db_name()
+                assert db_name == 'test_custom_db_name'
+        """
+        )
+
+        result = django_testdir.runpytest_subprocess("--tb=short", "-vv")
+        assert result.ret == 0
+        result.stdout.fnmatch_lines(["*test_inner*PASSED*"])
+
+
+class TestSqliteWithToxAndXdist:
+
+    db_settings = {
+        "default": {
+            "ENGINE": "django.db.backends.sqlite3",
+            "NAME": "db_name",
+            "TEST": {"NAME": "test_custom_db_name"},
+        }
+    }
+
+    def test_db_with_tox_suffix(self, django_testdir, monkeypatch):
+        "A test to check that both Tox and xdist suffixes work together."
+        pytest.importorskip("xdist")
+        monkeypatch.setenv("TOX_PARALLEL_ENV", "py37-django22")
+
+        django_testdir.create_test_module(
+            """
+            import pytest
+            from django.db import connections
+
+            @pytest.mark.django_db
+            def test_inner():
+
+                (conn, ) = connections.all()
+
+                assert conn.vendor == 'sqlite'
+                db_name = conn.creation._get_test_db_name()
+                assert db_name.startswith('test_custom_db_name_py37-django22_gw')
+        """
+        )
+
+        result = django_testdir.runpytest_subprocess("--tb=short", "-vv", "-n1")
+        assert result.ret == 0
+        result.stdout.fnmatch_lines(["*PASSED*test_inner*"])
+
+
+class TestSqliteInMemoryWithXdist:
+
+    db_settings = {
+        "default": {
+            "ENGINE": "django.db.backends.sqlite3",
+            "NAME": ":memory:",
+            "TEST": {"NAME": ":memory:"},
+        }
+    }
+
+    def test_sqlite_in_memory_used(self, django_testdir):
+        pytest.importorskip("xdist")
+
+        django_testdir.create_test_module(
+            """
+            import pytest
+            from django.db import connections
+
+            @pytest.mark.django_db
+            def test_a():
+                (conn, ) = connections.all()
+
+                assert conn.vendor == 'sqlite'
+                db_name = conn.creation._get_test_db_name()
+                assert 'file:memorydb' in db_name or db_name == ':memory:'
         """
         )