DEV Community

Shreyash Mishra
Shreyash Mishra

Posted on

Writing Scalable & Maintainable Unit Tests in Django — A Practical Guide with Real Examples

When building production-ready Django applications, writing robust unit tests is non-negotiable. A well-structured unit testing strategy ensures:

  • Changes don’t break existing functionality.
  • Business logic works as expected.
  • You have confidence in refactoring. This guide walks through how to write scalable unit tests using a structured and reusable pattern—complete with mock data, a shared test base, map factories, and advanced mocking techniques like MagicMock and @patch.

Project Test Structure Overview

Let’s use a modular and DRY (Don’t Repeat Yourself) structure for our Django test suite:

your_project/
├── base_test/
│ ├── base_map_factory.py
│ ├── constant_model_map.py
│ └── base_test_case.py
├── your_app/
│ ├── tests/
│ │ ├── maps/
│ │ │ └── merchant_map.py
│ │ └── test_send_key_salt.py

🔧 1. Base Map Factory — Reusable Test Data Provider

📁 base_test/base_map_factory.py

import copy

class BaseMapFactory:
    def __init__(self, map=None):
        self.map = map or {}
    def get_map(self, key=None, updates={}):
        try:
            data = self.map
            for k in key:
                data = copy.deepcopy(data[k])
            if updates:
                if isinstance(data, list):
                    for item in data:
                        item.update(updates)
                elif isinstance(data, dict):
                    data.update(updates)
            return data
        except (KeyError, TypeError) as e:
            print("Error fetching map:", e)
            return None
Enter fullscreen mode Exit fullscreen mode

🔍 Why This?

  • Encapsulates test data in an extendable pattern.
  • Prevents mutation of original data.
  • Enables easy overrides using updates.

2. Constant Test Data for DB Models

📁 base_test/constant_model_map.py

from base_test.base_map_factory import BaseMapFactory

class ConstantModelMap(BaseMapFactory):
    def __init__(self):
        self.map = {
            "merchant_credentials": {
                "id": 1,
                "merchant_id": "EXAMPLE123",
                "api_key": "secureapikey",
                "callback_url": "https://callback.test.com"
            },
            "merchant_info": {
                "merchant_id": 123,
                "merchant_name": "Test Merchant",
                "email": "merchant@example.com"
            }
        }
        super().__init__(self.map)
Enter fullscreen mode Exit fullscreen mode

🔍 Why This?

  • Maintains a single source of truth for model test data.
  • Easy to maintain and change without digging into tests.

🧪 3. Shared Base Test Case

📁 base_test/base_test_case.py

from django.test import TestCase, Client
from base_test.constant_model_map import ConstantModelMap
from your_app.models import Merchant, MerchantCredentials

class BaseDjangoTestCase(TestCase):
    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.client = Client()
        cls.models_map = ConstantModelMap()

        cls.merchant = Merchant.objects.create(
            **cls.models_map.get_map(["merchant_info"])
        )
        cls.credentials = MerchantCredentials.objects.create(
            **cls.models_map.get_map(["merchant_credentials"])
        )

Enter fullscreen mode Exit fullscreen mode

🔍 Why This?

  • Promotes code reuse across test files.
  • Sets up test models in a shared, structured way.

💼 4. Map for Service/API-Specific Static Data

📁 your_app/tests/maps/merchant_map.py

from base_test.base_map_factory import BaseMapFactory

class MerchantTestMap(BaseMapFactory):
    def __init__(self):
        self.map = {
            "common_request_data": {
                "merchant_id": 123,
                "key": "value"
            },
            "successful_response": {
                "status": "success",
                "data": {"message": "Key sent"}
            },
            "error_response": {
                "status": "error",
                "data": {"message": "Invalid merchant_id"}
            }
        }
        super().__init__(self.map)

Enter fullscreen mode Exit fullscreen mode

🚀 5. Test File — Using All the Building Blocks

📁 your_app/tests/test_send_key_salt.py

from django.urls import reverse
from unittest.mock import patch, MagicMock

from base_test.base_test_case import BaseDjangoTestCase
from your_app.tests.maps.merchant_map import MerchantTestMap
from shared.utility.loggers.logging import AppLogger
from shared.utility.push import Push

class SendKeySaltTests(BaseDjangoTestCase):
    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.endpoint = reverse("send-key-salt")
        cls.map = MerchantTestMap()
        cls.error_prefix = ":: SendKeySaltTests :: "

    @patch.object(AppLogger, "info")
    def test_successful_key_sending(self, mock_info):
        response = self.client.post(self.endpoint, self.map.get_map(["common_request_data"]))
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json(), self.map.get_map(["successful_response"]))
        self.assertTrue(mock_info.called)

    @patch.object(AppLogger, "info")
    @patch.object(AppLogger, "exception")
    def test_missing_merchant_id(self, mock_exception, mock_info):
        bad_data = self.map.get_map(["common_request_data"], updates={"merchant_id": ""})
        response = self.client.post(self.endpoint, bad_data)
        self.assertEqual(response.status_code, 400)
        self.assertEqual(response.json(), self.map.get_map(["error_response"]))
        self.assertTrue(mock_exception.called)

    @patch.object(Push, "push_mail")
    def test_push_notification_mock(self, mock_push):
        mock_push.return_value = True
        response = self.client.post(self.endpoint, self.map.get_map(["common_request_data"]))
        self.assertEqual(response.status_code, 200)
        mock_push.assert_called_once()

Enter fullscreen mode Exit fullscreen mode

📚 Concepts Explained

🔧 MagicMock and @patch

  • @patch.object(SomeClass, "method") dynamically replaces a method for the duration of a test.
  • MagicMock is used to create dummy objects or simulate return values.

Use case:

@patch.object(Logger, "info")
def test_logs_info(self, mock_info):
    call_my_view()
    mock_info.assert_called_once()
Enter fullscreen mode Exit fullscreen mode

✅ This avoids real logging and isolates the unit of work.

A Quick Summary About Packages And Concept

✅ Django Test Framework (django.test)

  • django.test.TestCase: Django’s built-in test class (inherits from Python’s unittest.TestCase).
  • client = Client(): Simulates HTTP requests for views (like POST, GET, PUT).
  • Used for integration-style tests that hit the full Django stack including URLs, middleware, views, etc.

✅ unittest.mock

  • patch: A decorator/context manager to replace objects with mocks during tests.
  • MagicMock: A flexible mock object that simulates return values, method calls, etc.
  • Why use?
  • Prevent hitting external APIs, file systems, DBs, or logs.
  • Assert if external services like Push.push_mail() or PGLogger.info() were called with correct data. Example:
@patch.object(PGLogger, "info")
def test_logging(self, mock_log):
view()
assert mock_log.called
Enter fullscreen mode Exit fullscreen mode

✅ openpyxl

  • Used to create in-memory Excel files in tests for upload scenarios.
  • Workbook(), ws.append(...): Used to mock file content for forms, upload testing.

✅ django.urls.reverse

  • Dynamically builds URLs from view names.
  • Helps you avoid hardcoding endpoint paths, improving test portability.

✅ django.core.files.uploadedfile.SimpleUploadedFile

  • Used to create mock files in memory (text, Excel, PDF).
  • Useful for testing file upload views.

✅ copy.deepcopy

  • Used in BaseMapFactory to return cloned test data and prevent mutation of original test dictionaries.
  • Ensures that get_map(..., updates={}) doesn’t affect future test cases.

📈 Final Thoughts

Investing time in writing clean, isolated, and scalable unit tests pays off enormously in the long run. With a base test case, reusable factory maps, and clever mocking, your Django tests can be as maintainable as your production code.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.