Skip to content

Tests & Coverage


Overview

CLOSRADS has 18 unit tests across two test files. All 18 pass. The test suite is entirely offline — it never calls CLOSRTECH, Facebook, or Slack. This is a deliberate design choice, not a limitation: tests that depend on external APIs are slow, flaky, and stop working when credentials rotate or APIs change.

Run command:

pytest tests/ -v


Offline Testing Strategy

Why no real API calls in tests: External API tests require valid credentials (can't be in the repo), network access (fails in CI without IP whitelist), stable API responses (a state being added/removed by CLOSRTECH would break a test), and time (real HTTP calls are slow). Instead, every external dependency is mocked using pytest-mock. Tests run in under 2 seconds on any machine, with no credentials, no network, and no flakiness.

The conftest.py approach

The most important testing infrastructure decision is tests/conftest.py. It runs before any test module is imported and injects stub environment variables into os.environ.

This is necessary because src/config.py calls _require() at import time. If you import any src/ module in a test without the env vars present, Python raises ValueError: Missing required env var: FB_ACCESS_TOKEN before the test even runs.

os.environ.setdefault('CLOSRTECH_EMAIL', 'test@example.com')
os.environ.setdefault('CLOSRTECH_PASSWORD', 'test_password')
os.environ.setdefault('CLOSRTECH_CAMPAIGN', 'TEST_CAMPAIGN')
os.environ.setdefault('FB_ACCESS_TOKEN', 'fake_token_123')
os.environ.setdefault('FB_AD_ACCOUNT_ID', 'act_123456789')
os.environ.setdefault('FB_CAMPAIGN_ID', '987654321')

Using setdefault (not os.environ[key] = ...) means that if a real .env file is loaded first, the real values take precedence.

The demand_response.json fixture

tests/fixtures/demand_response.json is a snapshot of a real CLOSRTECH API response (reflecting what was seen during the dry-run on 2026-04-15). Tests use this fixture to have a realistic, stable input without calling the API.


Test Files

tests/test_state_mapper.py — 8 tests

Tests the USPS → Facebook region key translation logic in state_mapper.py.

Test What it verifies
test_known_state_returns_correct_key usps_to_fb_region('OH') returns {key: '3878', name: 'Ohio', country: 'US'}
test_case_insensitive_lookup 'oh', 'OH', ' OH ' all return the same result
test_unknown_state_returns_none usps_to_fb_region('XX') returns None, does not raise
test_dc_maps_correctly DC maps to 'Washington D. C.' (Facebook's non-standard name)
test_build_fb_regions_filters_none States that don't map are omitted from the output list
test_build_fb_regions_empty_input Empty demand dict returns empty list
test_build_fb_regions_all_unknown All-unknown states returns empty list (sync.py catches this)
test_mapping_cache_used After the first call, subsequent calls read from memory, not disk (verifies _MAPPING cache)

Why these tests matter: The mapping file is static but it's the only source of truth for state-to-key translation. A silent bug here (wrong key for a state) would mean Facebook gets incorrect region IDs and targets the wrong state.

tests/test_sync_logic.py — 10 tests

Tests the orchestration logic in sync.py and the Facebook targeting logic in facebook_client.py, using mocked versions of all external dependencies.

Test What it verifies
test_full_sync_dry_run Full run with DRY_RUN=True: correct modules called in order, no Facebook write, report shows dry_run=True
test_full_sync_live Full run with DRY_RUN=False: update API called for each adset with different targeting
test_idempotency_skip If current targeting already matches desired, update_adset_geo is not called (zero API writes)
test_all_zeros_failsafe If CLOSRTECH returns all demand=0, sync raises ClosrtechDataError and Facebook is never touched
test_empty_regions_abort If build_fb_regions returns empty list, sync aborts before calling Facebook
test_per_adset_error_isolation If one adset fails, the others are still updated; error appears in report.errors
test_sync_report_fields SyncReport fields (adsets_processed, adsets_updated, adsets_skipped, errors, success) are correctly populated
test_deepcopy_preserves_other_targeting After update, only geo_locations.regions changed; all other targeting fields identical to original
test_dry_run_does_not_call_update_api With DRY_RUN=True, the Facebook update API is never called even when targeting differs
test_closrtech_retry_on_network_error Network error triggers retry up to 3 times before raising ClosrtechError

Why these tests matter: These cover the two highest-risk behaviors in the system — the fail-safe (all-zeros abort) and the deepcopy (don't touch non-geo targeting). A regression in either would be silent and potentially catastrophic.


What Is NOT Tested

Gap Reason Risk level
Real CLOSRTECH API call Requires credentials + IP whitelist; tested manually during dry-run Low (tested manually)
Real Facebook Graph API call Requires credentials; tested manually during dry-run Low (tested manually)
notifier.py Slack delivery Would require a real or mock HTTP server; stdout path is implicitly exercised Low (Slack failure is non-blocking)
main.py exit codes Not unit tested; covered by integration test if/when added Low (2-line function)
GitHub Actions workflow YAML syntax Not validated by pytest; caught by GitHub on push Low (syntax errors are obvious)
Token expiration handling System User tokens don't expire; not a realistic scenario Low

The most meaningful untested path is the full end-to-end run from GitHub Actions against both real APIs. That requires the IP whitelist issue to be resolved first. Once it is, a manual workflow_dispatch run with DRY_RUN=true should be the integration test.


Running Tests Locally

# Install dependencies (if not already done)
pip install -r requirements.txt

# Run all tests with verbose output
pytest tests/ -v

# Run a single test file
pytest tests/test_state_mapper.py -v

# Run a specific test by name
pytest tests/test_sync_logic.py::test_all_zeros_failsafe -v

No .env file is required to run tests — conftest.py injects stubs automatically. Tests run offline with zero credentials.