Skip to main content

Testing Patterns

Maybern uses pytest for all testing. This guide covers testing patterns for APIs, services, selectors, and models.

Test Organization

Tests live alongside the code they test:
my_app/
├── api/
│   ├── my_entity.py
│   └── tests/
│       └── test_my_entity.py    # API tests
├── private/
│   ├── selectors/
│   │   ├── entity.py
│   │   └── tests/
│   │       └── test_entity.py   # Selector tests
│   └── services/
│       ├── entity.py
│       └── tests/
│           └── test_entity.py   # Service tests
└── public/
    ├── services.py
    └── tests/
        └── test_services.py     # Integration tests

Testing Strategies

Unit Tests

Mock dependencies, test in isolation. Use for private services and selectors.

Integration Tests

Test with real dependencies. Use for public services.

Unit Testing with Mocks

Use @patch to mock dependencies and test in isolation:
import pytest
from unittest.mock import patch
from datetime import date

from server.apps.core.types.muuid import MUUID
from server.apps.my_app.private.services.entity import create_entity
from server.apps.my_app.public.constants import EntityStatus


@pytest.mark.django_db
class TestEntityService:
    @pytest.fixture
    def mock_entity_model(self, mocker):
        return mocker.patch(
            "server.apps.my_app.private.services.entity.MyEntity"
        )
    
    def test_create_entity(self, mock_entity_model, ctx_no_db):
        # Arrange
        mock_entity = mock_entity_model.return_value
        mock_entity.id = MUUID("ENTY_00000000000000000000001")
        
        # Act
        result = create_entity(
            ctx=ctx_no_db,
            parent_id=None,
            name="Test Entity",
            description="Test Description",
            status=EntityStatus.DRAFT,
            effective_date=date(2023, 1, 1),
        )
        
        # Assert
        mock_entity_model.assert_called_once_with(
            customer_id=ctx_no_db.customer_id,
            parent_id=None,
            name="Test Entity",
            description="Test Description",
            status=EntityStatus.DRAFT,
            effective_date=date(2023, 1, 1),
        )
        mock_entity.save.assert_called_once()
        assert result == mock_entity

API Testing

Mock the service layer when testing APIs:
import json
import pytest

from server.apps.core.types.muuid import MUUID
from server.apps.my_app.api.my_entity import MyEntityAPI
from server.apps.my_app.public.dataclasses import EntityResponse


@pytest.mark.django_db
class TestMyEntityAPI:
    @pytest.fixture
    def view(self):
        return MyEntityAPI.as_view()

    @pytest.fixture
    def mock_service(self, mocker):
        return mocker.patch(
            "server.apps.my_app.api.my_entity.MyAppService"
        )

    def test_get_returns_entity(
        self,
        mock_service,
        ctx_no_db,
        view,
        request_factory,
    ):
        # Arrange
        entity_id = MUUID("ENTY_00000000000000000000001")
        mock_service.get_entity.return_value = EntityResponse(
            id=entity_id, 
            name="Test Entity",
            description="Description",
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )
        request = request_factory.get(f"/api/entities/{entity_id}/")
        
        # Act
        response = view(request, entity_id=str(entity_id))
        
        # Assert
        assert response.status_code == 200
        response_data = json.loads(response.content)
        assert response_data["id"] == str(entity_id)
        assert response_data["name"] == "Test Entity"
        
    def test_get_returns_404_when_not_found(
        self,
        mock_service,
        ctx_no_db,
        view,
        request_factory,
    ):
        # Arrange
        entity_id = MUUID("ENTY_00000000000000000000001")
        mock_service.get_entity.side_effect = MaybernError("Not found")
        request = request_factory.get(f"/api/entities/{entity_id}/")
        
        # Act
        response = view(request, entity_id=str(entity_id))
        
        # Assert
        assert response.status_code == 404

Factory Usage

Use factories to create test data:
import factory
from factory.django import DjangoModelFactory

from server.apps.my_app.models.entity import MyEntity
from server.apps.clients.factories import CustomerModelFactory


class MyEntityFactory(DjangoModelFactory):
    """Factory for creating MyEntity instances for testing."""
    
    class Meta:
        model = MyEntity
    
    customer = factory.SubFactory(CustomerModelFactory)
    name = factory.Sequence(lambda n: f"Entity {n}")
    description = "Test entity description"
    
    class Params:
        """Defines traits that can be applied to the factory."""
        is_active = factory.Trait(
            status="active"
        )
        is_draft = factory.Trait(
            status="draft"
        )
        has_parent = factory.Trait(
            parent=factory.SubFactory(
                "server.apps.my_app.factories.MyEntityFactory"
            )
        )

Factory Best Practices

When you need an object but don’t need it in the database:
# Creates object in memory only - no DB write
entity = MyEntityFactory.build()

# Creates object and saves to DB
entity = MyEntityFactory.create()
When overriding foreign keys:
# Bad - creates an extra customer
entity = MyEntityFactory.create(customer_id=customer.id)

# Good - uses the provided customer
entity = MyEntityFactory.create(customer=customer)
Ensure related factories share the same parent objects:
class OrderFactory(DjangoModelFactory):
    customer = factory.SubFactory(CustomerModelFactory)
    # BAD: Creates a new customer
    order_item = factory.SubFactory(OrderItemFactory)
    
    # GOOD: Share the same customer
    order_item = factory.SubFactory(
        OrderItemFactory,
        customer=factory.SelfAttribute("..customer")
    )
# Create an active entity
active_entity = MyEntityFactory.create(is_active=True)

# Create a draft entity with parent
draft_with_parent = MyEntityFactory.create(is_draft=True, has_parent=True)

Integration Testing Public Services

Public services can be integration tested (no mocks):
@pytest.mark.django_db
class TestMyAppServiceIntegration:
    def test_create_and_get_entity(self, ctx):
        # Arrange
        create_data = EntityCreateData(
            name="Test Entity",
            description="Test Description",
        )
        
        # Act
        created = MyAppService.create_entity(ctx=ctx, entity_data=create_data)
        retrieved = MyAppService.get_entity(ctx=ctx, entity_id=created.id)
        
        # Assert
        assert retrieved.id == created.id
        assert retrieved.name == "Test Entity"
        assert retrieved.description == "Test Description"

Testing Fixtures

Common fixtures available in conftest.py:
@pytest.fixture
def ctx(customer, user):
    """Request context with real customer and user."""
    return RequestCtx(
        customer_id=customer.id,
        user_id=user.id,
        request_source=RequestSource.TEST,
        feature_flags=[],
    )

@pytest.fixture
def ctx_no_db():
    """Request context without database objects."""
    return RequestCtx(
        customer_id=MUUID("CUST_00000000000000000000001"),
        user_id=MUUID("USER_00000000000000000000001"),
        request_source=RequestSource.TEST,
        feature_flags=[],
    )

@pytest.fixture
def request_factory():
    """Django REST framework request factory."""
    return APIRequestFactory()

Test Naming Convention

Follow the Arrange-Act-Assert pattern with clear test names:
class TestEntityService:
    def test_create_entity_with_valid_data_succeeds(self):
        """Test name describes scenario and expected outcome."""
        # Arrange - set up test data
        # Act - call the function
        # Assert - verify results
        
    def test_create_entity_with_duplicate_name_raises_error(self):
        """Error cases should describe the error condition."""
        pass
        
    def test_get_entity_returns_none_when_not_found(self):
        """Edge cases should be explicit."""
        pass

Running Tests

# Run all tests
just test

# Run specific test file
just test server/apps/my_app/private/services/tests/test_entity.py

# Run specific test class
just test server/apps/my_app/private/services/tests/test_entity.py::TestEntityService

# Run specific test
just test server/apps/my_app/private/services/tests/test_entity.py::TestEntityService::test_create_entity

# Run with verbose output
just test -v

# Run with coverage
just test --cov=server/apps/my_app

Best Practices Summary

  • Private services/selectors: Mock dependencies
  • Public services: Test with real database
  • APIs: Mock service layer
  • .build() for in-memory objects
  • .create() for database objects
  • Pass objects, not IDs, for foreign keys
  • Use traits for common scenarios
  • Arrange: Set up test data and mocks
  • Act: Call the function being tested
  • Assert: Verify the results
Test names should describe:
  • The scenario being tested
  • The expected outcome
  • Any important conditions

Next Steps