Skip to main content

Unit Testing

The app-lib/ test suite uses pytest with unittest.mock for external dependencies. Tests mirror the source directory structure and run against mocked AWS services — no live infrastructure required.

Overview​

Tests live in app-lib/tests/ and mirror the source layout under src/app_lib/:

tests/
├── conftest.py # Shared fixtures
├── test_basic.py # Package-level smoke test
├── common/
│ └── util/
│ ├── test_pynamodb_util.py
│ ├── test_path_util.py
│ └── test_observability.py
├── features/
│ ├── passengers/
│ │ ├── test_passenger_table.py
│ │ ├── test_passenger_data_service.py
│ │ └── test_load_dynamodb_util.py
│ ├── jobs/
│ │ ├── test_job_table.py
│ │ ├── test_job_data_service.py
│ │ └── test_job_routes.py
│ └── inference/
│ └── test_inference_sse_routes.py

Each feature's tests correspond one-to-one with its source modules: model tests, service tests, and route tests.

Key Concepts​

Configuration​

pytest.ini defines the test discovery rules:

[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --strict-markers

All test files must be named test_*.py, all test classes Test*, and all test functions test_*. The --strict-markers flag rejects any undeclared marker.

Mocking External Dependencies​

Tests mock AWS services rather than calling them. The two primary patterns are @patch.object for PynamoDB model methods and @patch for module-level dependencies.

PynamoDB model methods — mock get, save, scan, count, and delete on the model class directly:

from unittest.mock import MagicMock, patch

@patch.object(TitanicPassengerTable, "get")
def test_get_success(mock_get, repository, mock_passenger):
mock_get.return_value = mock_passenger
result = repository.get("24160")
assert result == mock_passenger
mock_get.assert_called_once_with("24160")

Module-level services — mock the service instance referenced by route modules:

@patch("app_lib.features.jobs.routes.job_routes.job_service")
def test_get_job(mock_service, client, mock_job):
mock_service.get.return_value = mock_job
resp = client.get("/api/v1/jobs/abc-123")
assert resp.status_code == 200

boto3 clients — mock the client factory function rather than boto3 itself:

@patch(
"app_lib.features.inference.routes.inference_sse_routes._get_bedrock_client"
)
def test_converse_stream(mock_get_client, client):
mock_client = MagicMock()
mock_client.converse_stream.return_value = {"stream": []}
mock_get_client.return_value = mock_client
# ...

Fixtures​

Shared fixtures go in tests/conftest.py. Feature-specific fixtures go in the test file that uses them.

Mock entity fixtures create MagicMock objects with spec= set to the PynamoDB model. This ensures that accessing non-existent attributes raises AttributeError:

@pytest.fixture
def mock_passenger():
passenger = MagicMock(spec=TitanicPassengerTable)
passenger.id = "24160"
passenger.name = "Allen, Miss. Elisabeth Walton"
passenger.pclass = 1
return passenger

Test client fixtures disable JWT auth and create a FastAPI TestClient:

@pytest.fixture
def client():
with patch.dict("os.environ", {"AUTH_ENABLED": "false"}):
from app_lib.common.app import app
return TestClient(app)

Test Organization by Layer​

Each feature has up to three test categories, corresponding to the feature's subdirectories:

LayerTestsWhat to Verify
Modeltest_{name}_table.pyAttribute assignment, nullable fields, table name prefix
Servicetest_{name}_data_service.pyCRUD operations against mocked PynamoDB methods
Routestest_{name}_routes.pyHTTP status codes, response shapes, service delegation

Route tests exercise the full request path through FastAPI using TestClient, while model and service tests call Python methods directly.

Environment Patching​

Tests that depend on environment variables use patch.dict("os.environ", ...) to set values without affecting other tests:

def test_custom_env(self):
with patch.dict(os.environ, {"APP_ENV": "staging"}, clear=True):
assert PynamodbUtil.env_table_name("passengers") == "staging_passengers"

Pass clear=True when the test requires a clean environment with no inherited variables.

Usage​

Running Tests​

From the app-lib/ directory:

# Run all tests
make test

# Run with coverage report
pytest --cov=app_lib --cov-report=term-missing

# Run a specific feature's tests
pytest tests/features/passengers/

# Run a single test file
pytest tests/features/jobs/test_job_routes.py

# Run a single test by name
pytest -k "test_get_not_found"

Adding Tests for a New Feature​

  1. Create tests/features/{name}/ with an __init__.py file.

  2. Add a model test file (test_{name}_table.py). Verify attribute assignment and nullable fields:

    def test_model_attributes():
    record = YourTable(id="123", name="Test")
    assert record.id == "123"

    def test_table_name():
    assert "your_table" in YourTable.Meta.table_name
  3. Add a service test file (test_{name}_data_service.py). Mock the PynamoDB model and verify each CRUD operation:

    @patch.object(YourTable, "get")
    def test_get_success(mock_get, service, mock_entity):
    mock_get.return_value = mock_entity
    result = service.get("123")
    assert result == mock_entity
  4. Add a route test file (test_{name}_routes.py). Use TestClient with auth disabled and mock the service:

    @patch("app_lib.features.{name}.routes.{name}_routes.data_service")
    def test_list(mock_service, client, mock_entity):
    mock_service.query.return_value = [mock_entity]
    resp = client.get("/api/v1/{name}s")
    assert resp.status_code == 200