Back to Blog

Testing Data-Heavy SaaS Apps with Lastest: CSVs, Sheets, Variable I/O, and Network Interception (Not Just Screenshots)

Visual regression testing is great when the thing you're protecting is what the screen looks like. The trouble is that for an entire class of SaaS - billing consoles, analytics dashboards, ops back-offices, finance tools, multi-tenant admin panels - the thing you're actually protecting is what the numbers are. The chart can render perfectly while the totals row is silently wrong. The CSV export can match the UI while the API the UI is reading from has rounded the wrong way. The screenshot diff is green, the customer is on the phone, the on-call engineer is grepping production logs at 11pm. We've all been there.

This post is about how to test data-heavy SaaS in a way that actually protects the data, not just the chrome around it. Specifically, four moves you can compose in Lastest: parameterised inputs from CSV and Google Sheets, variable input/output reconciliation across UI / API / export, file-download parsing for CSV and XLSX exports, and network interception that turns the request waterfall into a first-class assertion surface. Three mid-fi diagrams below show the shape of the system. The rest is plumbing.

Why screenshots stop working when the app is mostly data

The case for visual regression testing is strongest where rendering is the contract - marketing pages, design systems, component libraries, anything where pixels mean what they look like. As soon as the rendered surface is a derivative of a backing dataset, the contract drifts. The 2026 visual testing landscape is candid about this: data-heavy dashboards and shared components need different comparison strategies, and forcing a single comparison method everywhere either floods you with false positives or hides the real ones.

The deeper problem isn't false positives. It's false negatives. A pixel diff cannot tell you that the totals row used the previous month's exchange rate. A screenshot can't fail when the CSV export is missing a column on Tuesdays because of a feature flag. The whole layout can be byte-identical to the baseline and the report you just emailed to a finance VP can still be wrong by $14,000. The question for data-heavy apps isn't "does it look the same?" - it's "do the numbers reconcile?"

That's the framing change. Tests for data-heavy SaaS need to assert on three surfaces, not one:

TRIANGULATED VERIFICATION · UI · API · CSV UI · TABLE rendered rows · DOM checked at row level API · /reports.json raw response · network tap checked at row level CSV · download export · file bytes checked at row level 3-WAY DIFF row count · totals · hash Any two of these can agree while the third is wrong. Assert on all three or you ship the wrong number.
A screenshot tells you the table rendered. It doesn't tell you the totals row matches the API, and neither tells you the CSV export agrees with both. For data-heavy SaaS, you need all three.

Any two of those can agree while the third is wrong. The classic example: the API rounds half-up, the UI floors, the CSV export uses a third library. Your Storybook screenshot is pristine. Your API contract test passes. Your CSV download has a totals row that's off by a cent on every fifth row. Until you reconcile all three on the same scenario, you don't know.

Parameterised inputs: CSV and Google Sheets as fixture sources

Hardcoded test data is the silent killer of data-heavy test suites. You write a test for "user with three invoices and one credit note", it passes for six months, then accounting onboards a tenant in JPY with twelve invoices and a refund, and everything falls over because nobody had that fixture in mind. The fix isn't writing more tests - it's making one test consume many inputs.

The pattern, which goes by various names - data-driven testing, table tests, parameterised tests - is well-trodden in unit-test land. Strapi's writeup on data-driven Playwright covers it in depth at the framework layer: keep the test logic and the test data in different places, source the data from external files, and let the test loop materialise one run per row. The same shape works at the e2e level - and it's where Lastest's CSV and Google Sheets fixture sources earn their keep.

Two practical patterns we use:

  • CSV-as-fixture, checked into the repo. Best for stable scenarios - tenants, plans, currencies, test users. The CSV lives next to the test, version control tracks every change, and the test runs N times where N = rows. When a finance engineer adds a new currency edge case, they edit the CSV and the test suite picks it up automatically.
  • Google Sheet as a live fixture, pulled at run start. Best for scenarios non-engineers maintain - finance, ops, customer success teams who think in spreadsheets, not git. The sheet has a known tab name and column schema; the test pulls the rows once at the start of the run and parameterises from there. The value is letting the people closest to the data own the fixtures, without making them learn to open a PR.

Pick CSV when test data ages slowly and engineers own it. Pick a Sheet when test data ages fast and someone else owns it. You can mix - most of our internal suites have a base CSV with the "always test these" rows and a Sheet for "this quarter's edge cases."

The shape of a parameterised test, then, looks like this:

VARIABLE I/O · ONE TEST · MANY ROWS · ONE VERDICT PER ROW INPUTS · fixtures.csv # tenant currency plan expected_total 1 acme.io USD pro 12,400.00 2 globex EUR enterprise 48,210.55 3 umbrella JPY pro 1,820,300 4 soylent GBP starter 299.00 5 hooli USD enterprise 97,002.18 TEST ENVELOPE · per-row run scenario(row) 1 · seed tenant 2 · login as admin 3 · open /billing 4 · INTERCEPT /api/totals capture json · hash by tenant 5 · click "Export CSV" 6 · parse downloaded file 7 · assert(row, ui, api, csv) OUTPUTS · per-row verdict ROW UI = API = CSV ? 1 12,400.00 OK 2 48,210.55 OK 3 1,820,300 OK 4 299.00 OK 5 97,002.18 FAIL csv 97,002.81 ≠ api 97002.18
A single scenario, parameterised by every row of a fixtures CSV. Notice row 5 - the UI and API agree, but the CSV export has a transposed digit. Without per-row reconciliation, the screenshot would have passed.

One scenario, expressed once. One CSV (or Sheet) of inputs, owned by whoever knows the data. One verdict per row, in a single dashboard view. When row 5 fails - UI says $97,002.18, CSV export says $97,002.81 - you don't have to debug across five tests. You have one test that ran six times, with one row that disagreed, and the diff is right there.

Variable I/O: reconciling inputs against expected outputs

The "expected_total" column in the wireframe above is doing a specific job. It's not just a value to render-test against - it's the contract, the thing that has to come out of the system regardless of which surface you ask. Variable I/O testing means: for every input row, you have an expected output, and you assert that the same expected output appears on every surface that should show it.

This is the discipline that catches the rounding-disagreement bugs that everything else misses. The Green Report's piece on validating generated reports makes the same point about file exports specifically: structural validation isn't enough, you have to compare against expected values, ideally derived from the backend rather than re-typed in the test. The risk of re-typing the expected value is that you bake the bug into the assertion. The right move is to either (a) compute the expected value independently in the test, or (b) include it as a fixture column owned by someone who didn't write the production code.

Concretely, for each row in the fixture, the test does the work three times:

  • UI assertion. Render the page, locate the cell or row, read the displayed value, normalise it (strip thousand separators, parse currency), compare against expected.
  • API assertion. Intercept the network call the page made, snapshot the response body, locate the same value in the JSON, compare against expected.
  • Export assertion. Trigger the CSV/XLSX download, parse the file, locate the matching row, compare against expected.

Three assertions, one source of truth. If two pass and one fails, the test fails - and the failure tells you exactly which surface is wrong. Most testing tools surface that as a generic "test failed"; the value of treating each surface as its own assertion is that the report tells you the UI passed, the API passed, but the export rounded a sub-cent. That's a fifteen-minute debug instead of a two-hour one.

Network interception: the request waterfall is a test surface

Playwright's network APIs are well-known - the official docs cover page.route() and browserContext.route() for intercepting, mocking, and modifying requests, and most teams use them for stubbing out third-party APIs or simulating slow connections. Useful, but a fraction of what's available. The richer use, and the one that matters most for data-heavy testing, is treating the entire request waterfall as a ledger you can assert against:

NETWORK LEDGER · WHAT THE TEST ACTUALLY SAW REQUEST TIMELINE · 0 → finish GET /api/me 200 1.2 kB GET /api/tenants/acme 200 4.4 kB GET /api/reports/q2 200 184 kB snapshotted POST /api/totals/recalc 202 0.3 kB GET /api/totals 200 6.1 kB asserted vs UI GET /api/export.csv 200 2.1 MB downloaded · parsed GET /api/audit/track 204 0 PII · blocked at proxy snapshot · ASSERT body download · PARSE bytes blocked · PII firewall ignored · auth/telemetry
Every request the test triggered, classified by what we did with it. The /api/totals body is snapshot-asserted against the UI. /api/export.csv is downloaded and parsed. /api/audit/track is blocked at the proxy because the test fixture is fake-PII and we don't want it shipped to telemetry.

Three things this enables that screenshot diffs can't:

Body-level assertions on real responses. When the test loads the dashboard, the API call to /api/totals is captured. The test asserts on the body - the totals JSON - independently of how the UI rendered it. If the UI is showing the right numbers but the API contract is silently breaking (a renamed field, a removed column, a type change), the contract test catches it before the UI eventually breaks.

Download interception and parsing. When the user clicks "Export CSV", that's a download. Most visual tests stop at the click. Network interception captures the file bytes; the test then parses them with a CSV/XLSX library and asserts row-by-row against the same expected values it asserted against in the UI. The pattern is well-documented for Cypress, and it works essentially identically in any modern e2e runner - the trick is just remembering to do it. Most teams discover the bug where their export silently drops a column only after a customer reports it.

Out-of-band call detection. Tests can fail not just on what was supposed to happen but on what wasn't. If the test fixture contains synthetic-but-realistic data - fake-PII, for example - and the test catches an unexpected call to /api/audit/track shipping a payload to a third-party telemetry endpoint, that's a finding. We've caught entire classes of "the dev forgot to gate the analytics SDK" bugs this way. The ledger view above marks those calls as blocked at the proxy because the test environment is intentionally outbound-restricted; in production, the same check would either pass or fail depending on whether the gate works.

The combination - captured request bodies for assertions, captured file downloads for parsing, captured outbound calls for detection - means the test sees the same surface the browser sees, and can be picky about all of it. FlightAware's writeup on Playwright at scale hits this point: integrating network interception with mocking and assertion is what lets a single test file cover scenarios that would otherwise require dozens of static fixtures.

What "not just screenshots" looks like in practice

Concretely, a single Lastest scenario for the billing console looks like this - and bear with the bullet form, the structure matters:

  1. Fixture source: fixtures/billing.csv with columns tenant, currency, plan, expected_total, expected_invoice_count. Owned by finance, edited via PR.
  2. Setup hook: for each row, seed the tenant in the test environment via API. (One row → one tenant.)
  3. Navigation step: log in as the tenant admin, navigate to /billing.
  4. Network setup: register an interceptor on /api/totals* and on the export endpoint. Block all calls to /api/audit/*.
  5. UI assertions: visual snapshot of the billing page (yes, still a screenshot - for layout) plus a text-level assertion on the totals row matching expected_total, and a count assertion on the invoice list matching expected_invoice_count.
  6. API assertion: from the captured /api/totals response body, assert the JSON's amount field equals expected_total (after normalisation).
  7. Export step: click "Export CSV", capture the download via the interceptor, parse the file.
  8. Export assertion: walk the parsed CSV, find the row for this tenant, assert its amount column equals expected_total, and assert the row count equals expected_invoice_count.
  9. Negative assertion: assert that no calls to /api/audit/* were attempted with non-test domains in the payload (caught via interceptor logs).

One scenario, eight assertions per row, ~20 rows in the fixture. ~160 individual checks per run. The screenshot is one of them, not the only one. If the layout breaks, the screenshot fails. If the rounding breaks, the API or export assertion fails. If a dev re-enables analytics, the negative assertion fails. The test report tells you which surface broke, on which row, with the diff in context.

Patterns we'd recommend (and a few we'd skip)

A few practical things we've learned running this style of suite over multiple data-heavy products. Take or leave:

Keep fixtures small and orthogonal. Twenty rows that each exercise one dimension - currency, plan, edge-case discount, refund - is more useful than two hundred rows that all exercise the same paths. The point of variable I/O is coverage breadth, not row count.

Compute expected values, don't re-type them. Where possible, the expected_total column is filled in by a small script that computes it from the input data using the documented business rules - not by an engineer reading the production code and copying the answer. The test then catches the case where production code drifts away from the documented rules. (Re-typing from production code is how you bake the bug into your assertion.)

Use Sheets when the data lives outside engineering. Finance and ops teams will keep their canonical lists of edge cases in spreadsheets whether you want them to or not. Letting your test suite consume from those sheets directly is much, much better than the alternative, which is the tests drifting from the actual edge-cases-of-record.

Don't over-mock. The temptation with network interception is to mock every response - and then your test stops testing the real system. We mock third-party calls (payment processors, SMS, geocoding) and intercept-only-for-assertion on first-party calls. The first-party API responses come from the real backend hitting real test data; the test just observes them.

Skip "test the database directly." A lot of data-heavy testing advice tells you to skip the UI and assert against the DB. That tests the wrong thing. The contract that matters is what the user sees and what the export contains; the database is implementation. If the DB is right and the UI is wrong, your customer is still on the phone. Test the surfaces the user touches, against expected values, and let the DB be a black box.

Don't only assert on totals. Totals are easy to test and easy to game. The bugs that have actually shipped on data-heavy SaaS in recent years - the ones that made the news - were almost always row-level (one tenant's row went to the wrong column, one currency was misrendered, one filter silently dropped a category). Assert on row counts, on per-row values, on the existence of expected columns, not just on the bottom line.

Where this fits with visual regression testing

Visual regression testing isn't replaced by any of this - it's complemented. The screenshot of the billing dashboard still catches the case where someone refactored the layout and broke the totals card on tablet. The text/API/export assertions catch the case where the layout is fine and the numbers are wrong. They protect different surfaces and they fail for different reasons. Our take on visual vs e2e testing goes deeper into where that line is, but the short version: a data-heavy SaaS test suite should run both, in the same scenario, against the same fixture row, with separate assertion outputs.

What the variable-I/O layer adds is that the screenshot is no longer carrying weight it can't carry. You stop pretending the rendered pixels are a proxy for the underlying data, you stop trying to eyeball a totals diff from a slider, and you let each surface assert against the contract that's actually appropriate for it. Layout assertions for layout. Number assertions for numbers. File-structure assertions for files. Negative assertions for things that shouldn't happen.

Wiring it up in Lastest

If you're starting from a Lastest project today, the four pieces map onto specific runner features:

  • Fixture sources: CSV files in your test directory or a Google Sheet referenced by URL - the runner pulls and parameterises automatically.
  • Variable I/O envelope: the recorder generates a parameterised scenario; you bind UI assertions, API assertions, and export assertions to the same row context.
  • Network interception: built on top of Playwright's page.route, with a UI on top for declaring "snapshot this body" / "parse this download" / "block this domain" without writing the boilerplate.
  • Per-row dashboard: the test report shows verdicts per row per surface - so a CSV-export rounding bug on row 5 of 20 is immediately visible without re-running the suite to bisect.

You can compose all of this in raw Playwright if you prefer the from-scratch route, and that's a perfectly fine choice for a small team that loves control. The reason we wrap it is the part nobody enjoys writing: the per-row report, the download parser plumbing, the stable handling of network-call ledgers across retries, and the integration with the visual snapshot side so layout and data assertions live in the same scenario. Our self-testing post covers how the runner uses this same shape on its own dogfood suite.

The honest version

Testing data-heavy SaaS is more work than testing a marketing site, and there's no clever framework that erases the difference. What you can do is stop relying on the surface that's least equipped to protect the data - the screenshot - and start asserting on the surfaces that actually carry the contract: the API body, the downloaded file, and the expected-value-per-row from a fixture owned by someone who knows the domain. The four moves above - CSV/Sheet inputs, variable I/O reconciliation, file-download parsing, network interception - aren't separately revolutionary; they've been in the practitioner literature for years. What's new is wiring them into a single scenario, against the same fixture row, with one report.

The reward is small and unglamorous: fewer "my dashboard renders perfectly but the totals row is wrong" incidents, fewer support tickets that begin with "the CSV export and the screen disagree", fewer 11pm pages about exchange rates. That is, in our experience, what data-heavy SaaS engineering teams actually want from their test suite. Not screenshots. Reconciliation.