How to generate python test fixture data from excel for fastapi unit tests
- Step 1Lay out cases as rows, fields as columns — Header row = the field names your endpoint or model expects (
email,age,expected_status). Each subsequent row is one test case. Keep it all on the first sheet — later tabs are ignored. - Step 2Upload and keep format = list_of_dicts — Drop the
.xlsx/.csv; the defaultlist_of_dictsis what you want for parametrize. The generator assigns the list todata. - Step 3Rename the variable — The output is
data = [...]. Rename to something meaningful likeCASESorLOGIN_PAYLOADSso the import reads well:from fixtures.data import CASES. - Step 4Wire it into pytest — Use it directly in
@pytest.mark.parametrize("case", CASES), or wrap it in a@pytest.fixturethat returns the list. - Step 5Cast comparison fields — Numeric assertions need real numbers.
expected_statusarrives as"200"; compare withint(case["expected_status"]), or cast in a fixture before the test sees it. - Step 6Build Pydantic models from each row — The output is plain dicts, not model instances. Construct your model per case:
MyModel(**{k: v for k, v in case.items() if k != "expected_status"}). If you need seed rows for the database instead of request fixtures, the same sheet can become SQL INSERT statements.
Mapping spreadsheet cells to fixture values
How a test-case row becomes a Python dict. Numeric and boolean fields arrive as text — cast where the test compares them.
| Excel cell | In the dict | Use in a test |
|---|---|---|
alice@x.com | "alice@x.com" | request body as-is |
200 (expected status) | "200" | assert r.status_code == int(case["expected_status"]) |
29 (age) | "29" | int(case["age"]) before payload, or send as string |
TRUE (active flag) | "TRUE" | case["active"] == "TRUE" |
| (blank optional field) | None | models optional / missing field |
O'Brien | "O'Brien" | apostrophe preserved (double-quoted literal) |
Three ways to consume the generated list
The same data list feeds parametrize, a fixture, or model construction.
| Pattern | Code | When |
|---|---|---|
| parametrize | @pytest.mark.parametrize("case", CASES) | table-driven endpoint tests |
| fixture | @pytest.fixture\ndef cases(): return CASES | shared across many tests |
| model build | [MyModel(**c) for c in CASES] | validation / schema tests |
| id labels | ids=[c["name"] for c in CASES] | readable test names in output |
Limits that affect a fixture file
The 500-row inline cap is fixed; tier limits govern upload size. The generator needs Pro or above.
| Constraint | Value | Implication for fixtures |
|---|---|---|
| Inline rows | 500 (all tiers) | Keep fixture sheets ≤ 500 cases |
| Free tier | tool gated | Cannot generate fixtures on Free |
| Pro upload | 50 MB / 100,000 rows | Plenty for any sane fixture file |
| Sheets read | first only | One fixture set per sheet/file |
Cookbook
Real pytest wiring around the generated list. Output values are quoted because spreadsheet cells arrive as text — cast at the assertion boundary.
Generated fixture list
A login test sheet becomes a list of dicts. Rename data → CASES on paste.
Excel (Sheet1):
name | email | password | expected_status
valid_login | a@x.com | pw123 | 200
bad_pass | a@x.com | wrong | 401
no_user | ghost@x.com | pw123 | 404
format: list_of_dicts → data.py (renamed CASES)
CASES = [
{"name": "valid_login", "email": "a@x.com", "password": "pw123", "expected_status": "200"},
{"name": "bad_pass", "email": "a@x.com", "password": "wrong", "expected_status": "401"},
{"name": "no_user", "email": "ghost@x.com", "password": "pw123", "expected_status": "404"}
]Drive parametrize with the list
Feed CASES straight into parametrize. Cast expected_status to int because it arrived as a string.
import pytest
from fixtures.data import CASES
@pytest.mark.parametrize("case", CASES, ids=[c["name"] for c in CASES])
def test_login(client, case):
r = client.post("/login", json={
"email": case["email"], "password": case["password"]
})
assert r.status_code == int(case["expected_status"])Build Pydantic payloads per case
The output is plain dicts. Strip the meta column, then construct the model — casting age to int first.
from pydantic import BaseModel
from fixtures.data import CASES
class SignUp(BaseModel):
email: str
age: int
for c in CASES:
model = SignUp(email=c["email"], age=int(c["age"]))
# send model.model_dump() to the endpointOptional fields via blank cells
A blank cell becomes None, which models an absent optional field directly.
Excel:
email | phone
a@x.com | <- blank (optional)
b@x.com | 555-0100
CASES = [
{"email": "a@x.com", "phone": None},
{"email": "b@x.com", "phone": "555-0100"}
]
# Pydantic Optional[str] = None accepts the None caseWrap in a fixture instead of importing the list
Some teams prefer a fixture function. Wrap the generated list so tests request it by name.
import pytest
from fixtures.data import CASES
@pytest.fixture
def login_cases():
return CASES
def test_count(login_cases):
assert len(login_cases) == 3Edge cases and what actually happens
Expected-status / numeric fields are strings
Expectedexpected_status of 200 is emitted as "200". An assertion like assert r.status_code == case["expected_status"] compares int to str and always fails. Cast at the boundary: int(case["expected_status"]). This trips up almost every first run.
Output is dicts, not Pydantic models
By designThe generator emits plain dicts. To get model instances, construct them yourself — MyModel(**case) — and cast any int/float fields first, since they arrived as strings. Drop meta columns like name/expected_status before unpacking into a model that doesn't expect them.
Variable is named data, not your fixture name
PreservedEvery run assigns to data. There's no option to set the variable name, so rename it after pasting (CASES, PAYLOADS). If you import without renaming you'll be importing data, which reads poorly in tests.
More than 500 test rows
TruncatedOnly the first 500 cases are inlined; list_of_dicts appends # ... N more rows truncated. For a fixture sheet that grew past 500, split it across files or load the remainder at runtime with pandas.read_excel() / openpyxl in a fixture.
A cell contains a line break
Invalid PythonMulti-line cells (e.g. a multi-paragraph expected message) aren't newline-escaped and produce a broken string literal — the fixture file won't import. Keep test strings single-line, or post-process the literal.
Blank cells become None
SupportedBlank optional fields map cleanly to None, which is exactly what you want for Optional[...] model fields and missing-value cases. A cell with only spaces is NOT blank — it becomes a quoted whitespace string and may fail validation unexpectedly, so trim whitespace on the source sheet first if testers may have entered stray spaces.
Duplicate / colliding headers
CollisionTwo columns that sanitise to the same key collapse — the later one wins inside each dict, so a field silently disappears from every case. Make headers distinct first with csv-header-rename.
Only the first sheet is used
By designA workbook with happy_path and error_cases tabs yields only the first tab's fixtures. Generate each tab separately (or split into files) and import both lists into your test module.
File exceeds the tier limit
RejectedUploads above Pro 50 MB / 100,000 rows (or your tier's ceiling) are rejected. Fixture files are tiny, so you'll hit the 500-row inline cap long before the size limit matters.
Run on Free tier
RejectedThe generator is Pro-gated (Python Generator requires Pro tier.). Free-tier CI users can't generate fixtures with it — upgrade or commit the generated file from a Pro machine.
Frequently asked questions
Can I feed the output straight into @pytest.mark.parametrize?
Yes. Rename data to something like CASES, then @pytest.mark.parametrize("case", CASES). Add ids=[c["name"] for c in CASES] for readable test names in the report.
Why does my status-code assertion fail?
Numeric fields arrive as strings ("200"), so comparing r.status_code (an int) to case["expected_status"] (a str) never matches. Cast: int(case["expected_status"]).
Does it generate Pydantic model instances?
No — the output is plain dicts. Build models yourself with MyModel(**case), casting any numeric fields first since they come through as strings.
How do I name the fixture variable?
There's no naming option — the output is always data = [...]. Rename it after pasting (e.g. CASES) so imports read cleanly.
How many test cases can I include?
Up to 500 are inlined (a fixed constant). list_of_dicts adds a # ... N more rows truncated comment past that. Split large suites across files or load extras at runtime.
Can non-developers maintain the data?
That's the point — keep cases in a shared spreadsheet, regenerate the fixture when it changes. Just remember to re-run the generator and commit the new data.py; the link isn't live.
Are blank cells safe for optional fields?
Yes. A blank cell becomes None, which maps directly to an Optional[...] Pydantic field or a missing-value test. A space-only cell is treated as a non-empty string, so trim those first.
What about apostrophes and quotes in test strings?
Double quotes inside cells are escaped to \" and backslashes are doubled, so quoted text and Windows paths stay valid in the literal. Newlines are not escaped — keep strings single-line.
Does it read multiple sheets?
No, only the first sheet. Put each fixture set on its own first sheet / file and import multiple lists.
Will my test data be uploaded anywhere?
No. Generation is entirely in-browser via SheetJS, so real customer values or secrets in your fixtures never leave your machine.
What does it download as?
A plain data.py text file. Copy is also available from the result panel.
Does the fixture run in CI without extra packages?
The list-of-dicts output needs no third-party package — it's pure Python. You only need pytest (and Pydantic, if you build models). No openpyxl is required at test time because the data is inline.
Privacy first
Every JAD Excel tool runs entirely in your browser using SheetJS and ExcelJS. Your spreadsheets, formulas, and data never leave your device — verified by zero outbound network requests during processing.