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 -
- Hand-written fixtures are the wrong shape that reflect what you guessed the API returns, not what it actually returns.
- 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"])
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?
},
}
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
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()
@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'))
"
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
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***',
})
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)
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
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$
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: []
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
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
- Add
@capture("my_api_response")to one function that calls a third-party API - Call that function once in your dev or staging environment
- Open the generated file in
tests/fixtures/ - Read the
# Scrubbed:header — verify what was stripped - Run
snapfix auditfor a second-pass check - 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
⭐ 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)