Skip to content

Local Development and Testing

Copilot edited this page Jul 11, 2026 · 1 revision

Local Development and Testing

Everything below runs inside .venv, managed by the Makefile - make creates it on first use and only reinstalls dependencies when requirements*.txt actually change, so repeat runs are fast. Run make help for the full list of targets.

Automated tests (primary workflow - no AWS calls, no Docker)

make test            # unit + moto-backed integration tests
make lint             # ruff
make format-check     # black --check
make check             # all three - what CI runs

If you'd rather manage the virtual environment yourself instead of going through make:

python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt

...then run pytest / ruff check . / black --check . directly from inside it.

Test layout

tests/
  conftest.py                                    # moto fixtures, make_*_item() builders, make_http_event(), import_lambda_app()
  table_defs.py                                    # hand-maintained mirror of template.yaml's DynamoDB tables
  unit/                                            # fast, no moto
  integration/
    test_schema_matches_template.py                # fails the build if table_defs.py drifts from template.yaml
pytest                    # everything
pytest tests/unit          # fast, no moto
pytest tests/integration   # moto-backed, exercises real Lambda handlers end-to-end
ruff check . && black --check .

Notable conventions:

  • tests/conftest.py's import_lambda_app(function_dir, module_name) loads src/<function_dir>/app.py by explicit file path under a unique sys.modules name. Both Lambdas ship a top-level app.py (required by SAM), so a plain import app would collide between them if both were ever imported normally in the same test session.
  • Turnstile verification calls a real third-party HTTP endpoint that moto can't mock - integration tests stub it with monkeypatch.setattr("nmailx_common.turnstile.verify_turnstile_token", ...).
  • If you add or change a DynamoDB table in template.yaml, update tests/table_defs.py to match - tests/integration/test_schema_matches_template.py will fail the build otherwise. That's the point: it's the drift guard between the hand-maintained test fixtures and the real CloudFormation source of truth.
  • db.reset_cache() / ses_client.reset_cache() exist because both modules lru_cache their boto3 client/resource for warm-Lambda-container reuse; the aws autouse fixture in conftest.py clears them between tests so a cached client from a previous test's (now torn-down) moto mock doesn't leak into the next one.

Manual end-to-end loop (optional)

sam local start-api emulates API Gateway + Lambda locally (via a local Docker container for the Lambda runtime itself - the deployed app remains container-free; this is dev tooling only). Pair it with DynamoDB Local's downloadable jar (no Docker needed for that piece - grab DynamoDBLocal.jar from the official download):

make local-db          # java -jar DynamoDBLocal.jar -sharedDb -inMemory, in one terminal
make local-db-tables    # creates the 5 tables against it
make local-api          # sam local start-api, wired to DynamoDB Local, in another terminal

This loop never makes real AWS or network calls - pytest similarly never does (moto + a stubbed Turnstile client), so the full automated test suite is safe to run anywhere, including CI, without AWS credentials.

CI

.github/workflows/ci.yml runs lint + tests (make check) on every PR - no AWS credentials needed. .github/workflows/deploy.yml deploys on every push to main via GitHub OIDC (no long-lived AWS keys in GitHub Secrets). See the README's CI/CD section for one-time OIDC bootstrap instructions.

Troubleshooting

  • make venv seems to hang or reinstall every time - check that requirements.txt/requirements-dev.txt timestamps aren't being touched by something else (e.g. a formatter or editor); the Makefile compares against a stamp file to decide whether to reinstall.
  • Integration tests fail with real AWS errors (e.g. NoCredentialsError, region errors) instead of moto behavior - make sure the aws autouse fixture in conftest.py is actually running (it should be picked up automatically for any test in tests/integration); a fixture-scoping mistake is the most common cause of moto not intercepting calls.
  • Two Lambda app.py files stepping on each other in the same pytest run - use import_lambda_app() from conftest.py rather than a plain import app if you're writing a new test that needs to load one of the Lambda entry points directly.

Clone this wiki locally