Testing¶
Philosophy¶
- Code does a lot of data analysis/transformation, which is tricky to test and generate data for.
- We use
faker,factoryboy, andpytest-factoryboyto easily generate test data. The combination allows us to generate fake data for fields our models need, but our tests don't use. - For pieces of data that our tested functions will use, we should always set them to static values.
- This way we reduce test flakiness (passing in one run, but failing the next because of random data).
Running Tests¶
# Run all tests (parallelized across 4 workers)
just test
# Run a specific test
just test path/to/test_file.py::test_function_name
# Run tests matching a name pattern
just test -k "test_name"
# Run tests with coverage report
just cov
# Run all checks (lint + tests + coverage)
just check-all
IDE Shortcuts (VSCode / Cursor)¶
Make sure the Python extension is installed and the Testing tab discovers tests correctly.
CMD + ;thenC— run test at cursorCMD + ;thenCMD + C— debug test at cursorCMD + ;thenF— run tests in current fileCMD + ;thenA— run all tests
Dependency Injection with Monkeypatch¶
The rule: Always use monkeypatch.setattr(). Never use unittest.mock.patch, MagicMock, or @patch decorators.
Why monkeypatch over patch/MagicMock¶
MagicMock returns a truthy mock object for any attribute access, which hides real bugs. If your code accidentally calls mock.some_method_that_doesnt_exist(), MagicMock silently returns another mock. monkeypatch.setattr forces you to provide an explicit replacement — if your code calls something you didn't mock, it hits the real implementation and fails loudly.
Canonical example¶
Testing a method that calls an integration:
def test_sync_recipients_calls_klaviyo(monkeypatch):
"""Test that recipient sync extracts profiles from Klaviyo segment."""
expected_profiles = [{"email": "test@example.com", "first_name": "Test"}]
# Mock the integration call — use the full module path where it's imported
monkeypatch.setattr(
"app.methods.campaigns.recipient_sync.extract_integration_recipients",
lambda campaign, integration: expected_profiles,
)
# Mock dependent functions
monkeypatch.setattr(
"app.methods.campaigns.recipient_sync.get_mail_integration",
lambda organization_id: IntegrationFactory(obj__organization_id=organization_id),
)
monkeypatch.setattr(
"app.methods.campaigns.recipient_sync.process_automation_recipients",
lambda incoming_recipients, campaign, organization: None,
)
campaign = CampaignFactory(obj__status=CampaignStatus.Active)
sync_automation_recipients(campaign=campaign, organization=campaign.organization_id)
# For more specific assertions, use a local function to capture call args:
call_args = {}
def capture_process(incoming_recipients, campaign, organization):
call_args["recipients"] = incoming_recipients
monkeypatch.setattr(
"app.methods.campaigns.recipient_sync.process_automation_recipients",
capture_process,
)
sync_automation_recipients(campaign=campaign, organization=campaign.organization_id)
assert call_args["recipients"] == expected_profiles
Common patterns¶
Monkeypatching a model function:
monkeypatch.setattr(
"app.core.models.campaigns.get_campaign",
lambda campaign_id: CampaignFactory(obj__campaign_id=campaign_id),
)
Monkeypatching an integration client:
monkeypatch.setattr(
"app.core.integrations.klaviyo.client.get_segment_profiles",
lambda segment_id, **kwargs: [{"email": "test@example.com"}],
)
Monkeypatching a utility:
monkeypatch.setattr(
"app.core.utils.dates.get_current_timestamp",
lambda: datetime(2026, 1, 15, tzinfo=timezone.utc),
)
Using a local function for complex mocks:
def mock_create_mailer(recipient, front, back, size, **kwargs):
return {"id": "mailer_123", "status": "created"}
monkeypatch.setattr(
"app.core.integrations.printers.lob_client.generate_mailer",
mock_create_mailer,
)
DO NOT do this¶
# WRONG — never use unittest.mock
from unittest.mock import patch, MagicMock
@patch("app.methods.klaviyo.sync_segment")
def test_bad_example(mock_sync):
mock_sync.return_value = MagicMock() # Hides bugs
# ...
# WRONG — never use MagicMock
mock_service = MagicMock(spec=Integration)
mock_service.get_profiles.return_value = [...] # Fragile, hard to trace
Tenacity and Retrying¶
- We use
tenacityto retry operations that can fail due to temporary issues (e.g. network errors, rate limits). - This is especially useful for operations that depend on external APIs or services.
- In testing, we have disabled tenacity retries by default to avoid long test runs and flakiness. See
app/tests/conftest.pyfor details.
Factories¶
- See existing factory implementation for each model in
app/tests/factories/. - Where the model takes in an
obj: dictfor initialization, that makes factories a bit more verbose because each model needs two factories: one for the model itself (takingobjas an argument) and one for theobjdictionary. Example:app/tests/factories/orders.py(seeOrderEventFactoryandOrderEventObjectFactory). - In factories, always try to restrict random values to a reasonable set of values that reflects our business logic (e.g. use
intinstead ofuuid, and set statuses to one of the possible choices from our pre-defined enums, etc).
FactoryBoy vs. pytest-factoryboy¶
Use FactoryBoy directly when you need multiple instances with different values in the same test:
all_recipients = [
ReportingRecipientFactory(
status=CampaignRecipientStatus.Sent,
campaign_id=campaign_id,
),
ReportingRecipientFactory(
status=CampaignRecipientStatus.Holdout,
campaign_id=campaign_id,
),
]
Use pytest-factoryboy fixtures when you need a single default instance as a test fixture:
@pytest.mark.parametrize(
"order_json__shipping_address",
[{"address1": "bobsmith", "address2": "flat2", "zip": "3456"}],
)
def test_order_address(order_event):
assert order_event.get_address_str() == "bobsmithflat23456"
Subfactory values¶
Pass values to nested factories using the double-underscore syntax:
config = OrganizationConfigurationObjectFactory(
currency_code="USD",
excluded_campaigns=[],
enabled=True,
excluded_attributions=[],
test_mode=False,
)
org = OrganizationFactory(obj__id=organization_id, obj__configuration=config)
Registering pytest-factoryboy fixtures¶
Register factories in an appropriate conftest.py that runs before your test — same directory as the test file or any parent up to app/tests/:
from pytest_factoryboy import register
from app.tests.factories.orders import OrderEventFactory, OrderEventRowFactory
register(OrderEventFactory)
register(OrderEventRowFactory)
Creating a new factory¶
- Check
app/tests/factories/for an existing factory for your model - If the model uses
__init__(obj: dict), you need two factories: - An "object" factory for the dict (e.g.,
CampaignObjectFactory) - A model factory that wraps it (e.g.,
CampaignFactory) - Use
factory.LazyAttributefor computed fields - Restrict random values to business-valid sets using enums
- Register in the nearest
conftest.pyif using pytest-factoryboy
DB and Redis in Tests¶
Tests do NOT hit a real database. Database calls are mocked via monkeypatch.setattr on the model functions (e.g., monkeypatch.setattr("app.core.models.campaigns.get_campaign", ...)).
Redis calls are routed to an in-memory fakeredis.FakeStrictRedis by the autouse fake_redis fixture in app/tests/conftest.py. The fixture patches RedisClient.get_conn, so any code path using RedisClient.get_value(), set_str(), get_dict(), get_client(), bloom filters, pipelines, etc. uses the same per-test fake Redis instance and never opens a real Redis connection.
Most tests do not need to mention Redis at all. When a test needs to seed or inspect Redis state, either use the real RedisClient helper methods or request the fake_redis fixture directly:
from app.core.db.redis_client import RedisClient
def test_uses_cached_value(fake_redis):
RedisClient.set_str("klaviyo:account_industry:42", "Health & Beauty")
assert RedisClient.get_value("klaviyo:account_industry:42") == "Health & Beauty"
assert fake_redis.exists("klaviyo:account_industry:42") == 1
Only monkeypatch Redis methods directly when the behavior under test is a Redis failure or a call that should not happen. Do not create a local MockRedisClient or import a shared Redis mock class for normal cache tests.
Common Gotchas¶
Fixture not found error:
You forgot to register the factory in conftest.py. Add register(YourFactory) in the nearest conftest.
Monkeypatch doesn't seem to work (original function still runs):
You're patching the wrong import path. Patch where the function is used, not where it's defined. If app/methods/runner.py imports from app.core.models.campaigns import get_campaign, patch app.methods.runner.get_campaign, not app.core.models.campaigns.get_campaign.
Flaky tests (pass sometimes, fail sometimes): Factory random values are leaking into your assertions. Always set tested fields to static values: