ICVOSS DJANGO PACKAGE REGISTRY

The package index django-boundary Write tenant-safe tests

Write tenant-safe tests

Documentation

Goal

Write tests that activate a tenant before exercising tenant-scoped models, assert that data does not leak between tenants, and avoid the common strict-mode failures that bite tenant-aware test suites.

Prerequisites

Boundary ships three test helpers from boundary.testing:

Steps

1. Activate a tenant with set_tenant

set_tenant wraps TenantContext.using(). Inside the block, the default manager filters every query to that tenant, and save()/create() auto-populate the tenant FK from context. On exit, the context is cleared.

import pytest

from boundary.context import TenantContext
from boundary.testing import set_tenant, tenant_factory


@pytest.mark.django_db
def test_booking_is_scoped_to_active_tenant():
    tenant = tenant_factory()

    with set_tenant(tenant):
        booking = Booking.objects.create(court=1)
        assert booking.tenant == tenant
        assert TenantContext.get() == tenant

    # Context is cleared on exit.
    assert TenantContext.get() is None

tenant_factory() generates a unique slug and name per call, so it is safe to call repeatedly without hitting the slug unique constraint. Pass keyword arguments to override the defaults, for example tenant_factory(name="Acme", slug="acme"). All kwargs are forwarded to the tenant model's create().

2. Use TenantTestMixin for class-based tests

For TestCase classes, TenantTestMixin creates self.tenant in setUp and activates its context for the duration of each test, then tears the context down in tearDown. You do not need a with block: the tenant is active for the whole test method.

from django.test import TestCase

from boundary.context import TenantContext
from boundary.testing import TenantTestMixin


class BookingTests(TenantTestMixin, TestCase):
    def test_tenant_is_active(self):
        assert TenantContext.get() == self.tenant

    def test_create_is_auto_scoped(self):
        booking = Booking.objects.create(court=1)
        assert booking.tenant == self.tenant

Put TenantTestMixin first in the MRO so its setUp/tearDown run around TestCase's.

3. Customise the tenant via get_tenant_factory_kwargs

Override get_tenant_factory_kwargs to control the tenant that TenantTestMixin creates. It returns a dict of kwargs passed straight to tenant_factory.

class RegionalBookingTests(TenantTestMixin, TestCase):
    def get_tenant_factory_kwargs(self):
        return {"name": "UK Club", "region": "uk"}

    def test_region_is_set(self):
        assert self.tenant.region == "uk"

The region field above comes from AbstractTenant. If your tenant model does not define a field, do not pass it. Only return kwargs your tenant model's create() accepts.

4. Test isolation between two tenants

Create data under one tenant, then switch context and assert the other tenant cannot see it. This is the core leakage test and should exist for every scoped model.

@pytest.mark.django_db
def test_tenants_cannot_see_each_others_bookings():
    tenant_a = tenant_factory()
    tenant_b = tenant_factory()

    with set_tenant(tenant_a):
        Booking.objects.create(court=1)
        Booking.objects.create(court=2)

    with set_tenant(tenant_b):
        Booking.objects.create(court=3)

    with set_tenant(tenant_a):
        assert Booking.objects.count() == 2

    with set_tenant(tenant_b):
        assert Booking.objects.count() == 1

Nesting set_tenant blocks works correctly: on exit, boundary restores the previous tenant context rather than simply clearing it, so you can nest a tenant_b block inside a tenant_a block and the outer scope survives.

5. Assert across all tenants with the unscoped manager

The default manager (objects) is filtered, so you cannot use it to assert "the correct total number of rows exists across both tenants". Use the unscoped manager, which returns every row regardless of the active context. It is ideal for admin-style assertions and for confirming a row really was written under the right tenant.

@pytest.mark.django_db
def test_unscoped_sees_all_tenants():
    tenant_a = tenant_factory()
    tenant_b = tenant_factory()

    with set_tenant(tenant_a):
        Booking.objects.create(court=1)
    with set_tenant(tenant_b):
        Booking.objects.create(court=2)

    # Filtered manager only sees the active tenant.
    with set_tenant(tenant_a):
        assert Booking.objects.count() == 1
        # Unscoped manager sees both, even with tenant_a active.
        assert Booking.unscoped.count() == 2

unscoped.create() does not auto-populate the tenant FK, so you must pass the tenant explicitly when you create through it:

with set_tenant(tenant_a):
    # Writes a row for tenant_b while tenant_a is active.
    booking = Booking.unscoped.create(court=1, tenant=tenant_b)
    assert booking.tenant == tenant_b

Omitting the tenant on an unscoped.create() for a non-nullable FK raises IntegrityError, because nothing populates the column for you.

6. Test strict-mode behaviour deliberately

BOUNDARY_STRICT_MODE defaults to True. Under strict mode, any query through the default manager with no active tenant raises TenantNotSetError. Test both paths by flipping the setting.

import pytest

from boundary.exceptions import TenantNotSetError


@pytest.mark.django_db
def test_strict_mode_raises_without_tenant(settings):
    settings.BOUNDARY_STRICT_MODE = True
    with pytest.raises(TenantNotSetError):
        Booking.objects.count()


@pytest.mark.django_db
def test_non_strict_returns_all_without_tenant(settings):
    settings.BOUNDARY_STRICT_MODE = False
    with set_tenant(tenant_factory()):
        Booking.objects.create(court=1)
    # No active tenant, strict mode off: returns unfiltered.
    assert Booking.objects.count() == 1

Use the pytest settings fixture (or Django's override_settings) so the change is scoped to the single test. With TestCase, use @override_settings on the method or class.

7. Call a class-based view directly with call_view

RequestFactory bypasses middleware, so a CBV called directly in a test has no active tenant and any scoped query inside it raises TenantNotSetError. Use call_view from boundary.testing to build the request and activate a tenant in one line.

from boundary.testing import call_view


@pytest.mark.django_db
def test_list_view_is_scoped(tenant_a, tenant_b):
    with set_tenant(tenant_a):
        Booking.objects.create(court=1)
    with set_tenant(tenant_b):
        Booking.objects.create(court=2)

    response = call_view(BookingListView, tenant=tenant_a)
    assert response.status_code == 200
    # the view only saw tenant_a's row

Pass URL kwargs via view_kwargs, choose the HTTP method with method, and forward anything else (request body, headers) as keyword arguments:

response = call_view(
    BookingCreateView,
    tenant=tenant_a,
    method="post",
    data={"court": 1},
)
detail = call_view(
    BookingDetailView, tenant=tenant_a, view_kwargs={"pk": booking.pk}
)

Prefer call_view over the Django test client when you want to exercise the view class directly without routing through URLconf and middleware.

Verify it worked

Run the suite and confirm both the isolation and strict-mode tests pass:

pytest tests/test_models.py tests/test_testing.py -v

A correctly isolated model shows the filtered manager counts differ per tenant while the unscoped count equals the sum. A correctly configured strict mode raises TenantNotSetError when no tenant is active.

Common pitfalls