Skip to content

Testing Guide

pytest-django for the backend, Vitest for both frontends. 1200+ backend tests, 360+ admin tests.

Backend Testing

Running Tests

cd backend
uv run pytest                         # All tests
uv run pytest apps/loans/tests/       # App-specific
uv run pytest -k "test_create_loan"   # By name pattern
uv run pytest -v --tb=long            # Verbose with full tracebacks

pytest Configuration

# backend/pyproject.toml
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings.test"
python_files = ["tests.py", "test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-v --tb=short"
asyncio_mode = "auto"

asyncio_mode = "auto" means async test functions run automatically without needing @pytest.mark.asyncio.

Test Infrastructure (conftest.py)

The root backend/conftest.py provides session-scoped tenant setup:

Custom model-bakery generators:

generators.add("djmoney.models.fields.MoneyField", lambda: Decimal("100.00"))
generators.add("phonenumber_field.modelfields.PhoneNumberField", lambda: "+12025551234")
generators.add("django.db.models.DecimalField", lambda: Decimal("0.50000"))
generators.add("timezone_field.TimeZoneField", lambda: "UTC")

Key fixtures:

Fixture Scope Purpose
test_tenant Session Creates a test tenant + domain once per run
tenant_schema Function (autouse) Activates test tenant schema for every test
_close_db_connections Function (autouse) Closes DB connections after each test
_terminate_db_connections_on_teardown Session Terminates all PostgreSQL sessions before DB drop
user_factory Function Creates User instances with custom email/password
user Function Single test user
api_user Function Superadmin user for API tests
tenant_user_factory Function Creates user with specific tenant role

model-bakery Fixtures

Use baker.make() for test data. Never manually construct model instances with hardcoded fields:

from model_bakery import baker

# Auto-generates all required fields
loan = baker.make("loans.Loan")

# Override specific fields
loan = baker.make("loans.Loan", status="active", term_months=36)

# Prepare without saving (unit tests)
loan = baker.prepare("loans.Loan")

# Bulk creation
loans = baker.make("loans.Loan", _quantity=5)

# Override nested relationships
payment = baker.make("payments.Payment", loan__status="active")

Recipes for reusable scenarios live in baker_recipes.py per app:

# apps/loans/baker_recipes.py
from model_bakery.recipe import Recipe
active_loan = Recipe(Loan, status="active", term_months=36)
delinquent_loan = active_loan.extend(days_past_due=45)

Test Patterns

Service Tests (Primary Focus)

pytestmark = pytest.mark.django_db(transaction=True)

class TestCreateNote:
    async def test_creates_note(self):
        note = await services.create_note(
            content_type="loans.loan",
            object_id=uuid.uuid4(),
            body="Important note.",
            created_by=uuid.uuid4(),
        )
        assert note.body == "Important note."
        assert note.note_type == "general"

Selector Tests

class TestNoteList:
    async def test_returns_all_notes(self):
        baseline = await Note.objects.acount()
        borrower = baker.make("borrowers.Borrower")
        baker.make("notes.Note", content_object=borrower, _quantity=3)
        qs = await selectors.note_list()
        assert await qs.acount() == baseline + 3

API Tests

from ninja_extra.testing import TestAsyncClient

class TestNoteController:
    @pytest.fixture()
    def client(self):
        return TestAsyncClient(NoteController)

    async def test_list_notes(self, client, api_user, test_tenant):
        baker.make("notes.Note", _quantity=2)
        response = await client.get("", user=api_user, tenant=test_tenant)
        assert response.status_code == 200

TestAsyncClient tests controllers directly, bypassing middleware for faster execution.

Provider Tests

@pytest.fixture()
def mock_s3_client():
    client = MagicMock()
    client.generate_presigned_url.return_value = "https://s3.amazonaws.com/..."
    return client

class TestS3StorageProvider:
    async def test_generate_upload_url(self, storage, mock_s3_client):
        result = await storage.generate_upload_url("doc.pdf", "application/pdf")
        assert result["method"] == "PUT"
        mock_s3_client.generate_presigned_url.assert_called_once()

Test File Organization

apps/{module}/tests/
├── test_services.py     # Service layer tests (primary focus)
├── test_selectors.py    # Selector/query tests
└── test_api.py          # API integration tests

Frontend Testing

Admin Dashboard

cd frontend/admin
npm run test              # All tests
npm run test -- --watch   # Watch mode

Uses renderWithAdmin() and renderWithRecord() helpers from test-utils.tsx. See Admin Testing.

Borrower Portal

cd frontend/borrower
npm run test              # All tests
npm run test -- --watch   # Watch mode

Uses renderWithProviders() helper from test-utils.tsx. See Portal Testing.

Frontend Test Conventions

  • Use Testing Library queries (getByRole, getByText, getByLabelText)
  • Prefer userEvent over fireEvent for user interactions
  • Mock API calls at the data provider or fetch level
  • Test behavior, not implementation details

See Also