DEV Community

Sayak Naskar
Sayak Naskar

Posted on

Stop Writing Fake Pytest Fixtures and Capture Real API Responses Safely using Snapfix

So, What Snapfix Does

Wraps any Python function with @capture, intercepts the return value when
called once against staging, serializes it (preserving 15+ Python types
including datetime, Decimal, UUID, bytes), scrubs 22 default PII field patterns by name, then writes a valid @pytest.fixture file. Also ships: a
CLI (list, show, diff, audit, verify, clear), a pytest plugin with
auto-discovery, snapshot diffing to detect API schema drift, and a
regex-based second-pass PII scanner.

Who It Is For

Python backend/API developers who write pytest and integrate with third-party APIs (Stripe, Twilio, internal microservices). Also, QA engineers are inheriting messy test suites, and any dev who has ever committed a real email address to git by accident.

Problem It actually Solves

There are two compounding problems -

  1. Hand-written fixtures are the wrong shape that reflect what you guessed the API returns, not what it actually returns.
  2. The fastest path to a realistic fixture is pasting a live response, which ships PII into git history permanently.

GitHub Link

https://github.com/hacky1997/snapfix

Example Use

@capture("stripe_invoice", scrub=["metadata"])
Enter fullscreen mode Exit fullscreen mode

Call once in dev/staging → review generated file → commit → remove decorator.

Key Differentiators from VCR / pytest-recording / responses

Those tools mock at the HTTP layer and replay requests. Snapfix captures
Python objects after your own deserialization logic runs. It cares about the
shape of the data your code returns, not the raw HTTP bytes. It also has no
opinion on networking at all — works for database results, gRPC, internal
function calls, anything.


Last year, I found a real customer email address inside a test fixture in our repository. It had been there for eight months. Sitting in git log, immovable, committed by someone who copied a live Stripe response to debug a billing edge case and forgot to clean it up before pushing.

That's how snapfix started.


The Problem Nobody Talks About (But Every Team Has)

When you're building against a third-party API like Stripe, Twilio, or an internal microservice, you eventually need realistic test data. There are two obvious options, and both are quietly broken.

Option 1: Write fake fixtures by hand.

@pytest.fixture
def fake_invoice():
    return {
        "id": "in_test_abc123",
        "status": "paid",
        "amount": 14999,  # is this cents or dollars? Let me guess
        "customer": {
            "email": "alice@example.com",  # is this actually how Stripe nests this?
        },
    }
Enter fullscreen mode Exit fullscreen mode

Looks fine... Runs green... Ships to production and breaks. Because the real Stripe response has amount_due, not amount. And customer is sometimes a string ID and sometimes a nested object, depending on your expand parameters. And there are seventeen other fields you didn't know about that your code silently relies on.

Option 2: Paste a real response.

This is what most developers actually do under deadline pressure. You print() the live response, paste it into a fixture, push it. Fast, accurate, and a compliance violation waiting to be discovered.

GitHub reported 39 million leaked secrets across public repositories in 2024.
Stripe API keys and customer PII travel this exact path: production API → developer debug session → test fixture → git commit.

git history does not forget. Even after you delete the file, it remains in the history permanently unless you run git filter-branch, a disruptive operation most teams never do.


There Is a Third Option

Capture the real object. Strip the sensitive fields automatically. Write the fixture for you.

That's snapfix. One decorator, one staging run, one review step, done.

pip install snapfix
Enter fullscreen mode Exit fullscreen mode

How It Works in Practice

Step 1 — Add the decorator

# myapp/billing.py
from snapfix import capture

@capture(
    "stripe_invoice_paid",  # the fixture name
    scrub=["metadata"],     # extra fields beyond the 22 sensitive defaults
)
async def fetch_invoice(invoice_id: str) -> dict:
    invoice = await stripe.Invoice.retrieve(
        invoice_id,
        expand=["customer", "lines.data"],
    )
    return invoice.to_dict()
Enter fullscreen mode Exit fullscreen mode

@capture is transparent — it never changes what your function returns, never swallows exceptions, and becomes a complete no-op when SNAPFIX_ENABLED=false is set (which you should set in production).

Step 2 — Call it once against staging

python -c "
import asyncio
from myapp.billing import fetch_invoice
asyncio.run(fetch_invoice('in_1OkRealInvoiceId'))
"
Enter fullscreen mode Exit fullscreen mode

You'll see this in your terminal:

  snapfix ✓ fixture written: tests/fixtures/snapfix_stripe_invoice_paid.py
  snapfix scrubbed: customer.email, customer.address, metadata.internal_ref
  snapfix review before committing — value-level PII is not detected
Enter fullscreen mode Exit fullscreen mode

Step 3 — Look at what was written

# Generated by snapfix — do not edit manually
# Captured    : 2026-03-24T14:22:01
# Source      : myapp.billing.fetch_invoice (myapp/billing.py)
# Scrubbed    : customer.email, customer.address, metadata.internal_ref
#
# ⚠  Review before committing: value-level PII is not detected.
import pytest
from snapfix import reconstruct

@pytest.fixture
def stripe_invoice_paid():
    return reconstruct({
        'id': 'in_1OkRealInvoiceId',
        'object': 'invoice',
        'amount_due': 14999,
        'amount_paid': 14999,
        'amount_remaining': 0,
        'currency': 'usd',
        'status': 'paid',
        'customer_email': '***SCRUBBED***',
        'created': {'__snapfix_type__': 'datetime', 'value': '2026-03-01T09:00:00'},  # datetime
        'lines': {
            'object': 'list',
            'data': [{'amount': 14999, 'description': 'Pro plan (March 2026)'}],
            'has_more': False,
            'total_count': 1,
        },
        'metadata': '***SCRUBBED***',
    })
Enter fullscreen mode Exit fullscreen mode

The # Scrubbed: header lists every field that was removed. The reconstruct()
call converts type markers back to real Python objects — that datetime marker
becomes an actual datetime.datetime, not a string, when your test runs.

Step 4 — Review, commit, and remove the decorator

This is the step most tools skip. Snapfix makes it explicit: it's your code,
you review it before it goes anywhere. Once committed, remove @capture from
your function. The fixture is self-contained.

Step 5 — Write tests against the actual data shape

def test_invoice_is_paid(stripe_invoice_paid):
    assert stripe_invoice_paid["status"] == "paid"

def test_invoice_amount_in_cents(stripe_invoice_paid):
    # Stripe always returns integer cents — this is what the real API sends
    assert stripe_invoice_paid["amount_paid"] == 14999
    assert isinstance(stripe_invoice_paid["amount_paid"], int)

def test_pii_is_scrubbed(stripe_invoice_paid):
    # Documents your compliance behavior explicitly
    assert stripe_invoice_paid["customer_email"] == "***SCRUBBED***"

def test_created_is_real_datetime(stripe_invoice_paid):
    import datetime
    # reconstruct() restored the type — not a string
    assert isinstance(stripe_invoice_paid["created"], datetime.datetime)
Enter fullscreen mode Exit fullscreen mode

What Gets Scrubbed Automatically

Snapfix scrubs any field whose name contains one of 22 default substrings
(case-insensitive, substring match):

email · password · token · secret · api_key · access_token ·
refresh_token · ssn · credit_card · card_number · cvv · phone ·
mobile · dob · date_of_birth · address · ip_address · auth ·
bearer

So customer_email gets scrubbed (contains email), billing_phone_number
gets scrubbed (contains phone), retry_count does not.

You add your own with scrub=["metadata", "customer_id", "tax_ref"].


Types That Survive the Round-Trip

This was the part I cared most about getting right. Real API responses contain
datetime objects, Decimal for money, UUID for IDs. If your fixture
converts these to strings, your tests are testing the wrong types.

Type What you get back
datetime.datetime datetime.datetime
decimal.Decimal decimal.Decimal
uuid.UUID uuid.UUID
bytes / bytearray bytes / bytearray
pathlib.Path pathlib.Path
set / frozenset set / frozenset
dataclass dict (via dataclasses.asdict())
Pydantic model dict (via .model_dump())
circular reference sentinel dict (capture still completes)

tuple becomes list on roundtrip — JSON has no tuple type. This is
documented and intentional.


The Second Layer of Defense: snapfix audit

Field-name scrubbing has a gap: payload["tags"][0] containing an email won't
be caught by name matching. So Snapfix ships a second-pass regex scanner:

snapfix audit

# snapfix audit — tests/fixtures/
# ────────────────────────────────────────────────────────────
#   Files scanned : 8
#   Findings      : 0
#   Status        : ✓ PASSED — no PII patterns detected
Enter fullscreen mode Exit fullscreen mode

It checks for email patterns, US phone numbers, SSNs, credit card numbers (Visa/MC/Amex/Discover), AWS access keys, and long API-key-like strings. Lines already containing ***SCRUBBED***, example.com, test@, placeholder, or dummy are skipped to keep false positives low.

Add it as a pre-commit hook:

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: snapfix-audit
        name: snapfix PII audit
        entry: snapfix audit --strict
        language: system
        files: ^tests/fixtures/snapfix_.*\.py$
Enter fullscreen mode Exit fullscreen mode

Catching API Schema Changes: snapfix diff

When you upgrade your Stripe SDK and re-capture a fixture, Snapfix automatically compares to the previous capture:

snapfix diff stripe_invoice_paid

# ✗  Changes detected in 'stripe_invoice_paid':
# --- stripe_invoice_paid (previous)
# +++ stripe_invoice_paid (current)
# @@ ...
#  lines.data[0].amount: 14999
# +lines.data[0].amount_excluding_tax: 14999
# +lines.data[0].tax_amounts: []
Enter fullscreen mode Exit fullscreen mode

Stripe added two fields in a recent API version. Your CI catches this before production does. Run snapfix diff after every dependency upgrade that touches an external API.


Pytest Plugin: Zero Config Required

Snapfix registers itself as a pytest plugin when you install it. No
conftest.py changes. Generated fixture files are auto-discovered and show up in pytest --collect-only and pytest --fixtures.

pytest --snapfix-capture    # enable capture for this session
pytest --no-snapfix-capture # explicitly disable
pytest --snapfix-dir /path  # write to a different directory
Enter fullscreen mode Exit fullscreen mode

Capture auto-disables in CI environments (CI=true) unless you explicitly pass --snapfix-capture. This prevents staging calls from running in your automated test pipeline.


Works With Anything That Returns a Python Object

Snapfix doesn't touch HTTP. It intercepts function return values. That means it works for:

  • Stripe, Twilio, Sendgrid, any REST client
  • gRPC response objects
  • SQLAlchemy query results (serialize the dict form)
  • Internal microservice responses
  • Any function your codebase owns that calls something external

Honest Limitations

PII detection is field-name only by default. An email stored under payload["user_tags"][0] won't be caught by the scrubber — only by snapfix audit. Always review the generated file before committing.

Not for production traffic. Use it against staging or development only.
The warning is in the generated file header.

Python 3.10+ required. This is a hard constraint, not a soft preference.
If your team is on 3.9, it won't install.

tuple becomes list on roundtrip. JSON's type system. Documented.

Enum class not preserved. reconstruct() returns .value, not the enum
instance. Cast manually if your tests check enum identity.


Try It in 3 Minutes

pip install snapfix
Enter fullscreen mode Exit fullscreen mode
  1. Add @capture("my_api_response") to one function that calls a third-party API
  2. Call that function once in your dev or staging environment
  3. Open the generated file in tests/fixtures/
  4. Read the # Scrubbed: header — verify what was stripped
  5. Run snapfix audit for a second-pass check
  6. Commit the fixture, remove the decorator

If you have existing fixtures, run snapfix verify to confirm they're all
healthy:

snapfix verify
# ✓  snapfix_stripe_invoice_paid.py  [stripe_invoice_paid]
# ✓  snapfix_stripe_subscription.py  [stripe_subscription]
# Status : ✓ ALL VALID
Enter fullscreen mode Exit fullscreen mode

⭐ Star the repo if this solves a real problem for you. Open an issue if something doesn't work. I read everyone.

If you've ever found something sensitive in a test file, tell me about it in
the comments.

Top comments (0)