A download needs three checks
- Select the filter: the two unpaid invoices should match; the paid invoice should not.
- Register the download wait before the click and wait for the file to finish.
- Parse the CSV, then compare its header and exact rows with the synthetic fixture.
The Export button works. A file lands in Downloads. The test passes. Then someone opens the spreadsheet and discovers that “unpaid invoices” includes paid invoices, or that the export stopped at the first page.
A filename check cannot catch either bug. Neither can a screenshot of a cheerful “Export complete” message. The browser has delivered an envelope; your test still needs to read the letter.
This guide builds a small export contract: the right selection, a completed download, and the exact expected data. The example uses a fictional invoice application and ordinary Node.js Playwright code. Adapt the route, selectors, authentication, and fixture setup to your application; this is not a claim that TestVibe automatically generates or retains arbitrary downloaded files.
Start with a tiny dataset that can prove the filter
“Export unpaid invoices” is too vague to verify. Give the test a deliberately boring, synthetic dataset in an isolated test account:
- Invoice
INV-001: unpaid, customerNorth, Studio, total125.00, currencyUSD. - Invoice
INV-002: unpaid, customerCafé Test, total80.50, currencyUSD. - Invoice
INV-003: paid, customerPaid Fixture, total30.00, currencyUSD.
The contract is that selecting Unpaid exports exactly the first two records with the columns invoice_id,customer,total,currency. Order does not matter here. Decide that explicitly: if your product promises a particular sort order, assert it instead.
The paid record is important. Without it, an exporter that ignores the filter could still pass. The comma and accented character also earn their keep: they exercise CSV quoting and UTF-8 text without requiring a thousand-row fixture.
Create these fixtures through your existing test-data setup, and remove them afterward. Fixed IDs are safe only inside a dataset reserved for this scenario; parallel tests need isolated accounts or distinct records. The test data management guide covers that setup separately.
Register the download wait before the click
Playwright emits a download event when a download starts. Register the wait first, trigger the export, then await that particular download. Saving it waits for completion. These are documented behaviors in the Playwright download guide and Download API.
If selecting a filter refreshes the report asynchronously, wait for an application-specific ready signal before exporting, such as a loading indicator clearing and the expected results appearing. Selecting an option alone does not establish that the export has picked up the new filter.
A sleep is not a substitute. “Wait two seconds and inspect a folder” invites the suite to pick up an old file, or race a slow export on CI. Your test should follow the export it just requested.
For the example below, install playwright and csv-parse in your test project, install the Playwright browser, and save the script as check-invoice-export.mjs. Use an isolated test account authenticated through your normal test setup. If it needs stored browser state, supply it when creating the context; keep that state out of source control.
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parse } from 'csv-parse/sync';
const baseURL = process.env.APP_BASE_URL;
assert.ok(baseURL, 'Set APP_BASE_URL to your isolated test app');
const dir = await mkdtemp(join(tmpdir(), 'invoice-export-'));
let browser;
try {
browser = await chromium.launch();
const context = await browser.newContext({ acceptDownloads: true });
// Add your test-account authentication here before opening the report.
const page = await context.newPage();
await page.goto(new URL('/invoices', baseURL).href);
await page.getByLabel('Status', { exact: true }).selectOption('unpaid');
// If filtering refreshes asynchronously, wait for your app's ready cue.
const pending = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export CSV', exact: true }).click();
const download = await pending;
assert.match(download.suggestedFilename(), /^invoices.*\.csv$/i);
assert.equal(await download.failure(), null, 'Download must complete');
// Use our own path, not a server-provided filename, for filesystem writes.
const file = join(dir, 'invoices.csv');
await download.saveAs(file);
const csv = await readFile(file, 'utf8');
const records = parse(csv, { bom: true });
const [header, ...rows] = records;
assert.deepEqual(header, ['invoice_id', 'customer', 'total', 'currency']);
assert.equal(rows.length, 2, 'Exactly two unpaid invoices');
const byId = (a, b) => a[0].localeCompare(b[0]);
assert.deepEqual(rows.sort(byId), [
['INV-001', 'North, Studio', '125.00', 'USD'],
['INV-002', 'Café Test', '80.50', 'USD'],
]);
} finally {
try { await browser?.close(); }
finally { await rm(dir, { recursive: true, force: true }); }
}
Run it with APP_BASE_URL=https://your-test-app.example node check-invoice-export.mjs, replacing that example address. The assumed fixture and authentication are application-specific prerequisites, not hidden helper functions supplied by this article.
This is intentionally a small-file example. The CSV parser’s synchronous API reads the whole input into memory; use a streaming parser and bounded assertions for large exports. Its BOM option handles an optional UTF-8 byte-order mark before the header.
Parse the format instead of inventing one
Do not split CSV with text.split('\n').map(line => line.split(',')). A customer called North, Studio is one field, not a surprise extra column. Quoted fields can also contain line breaks and escaped quotes. A CSV parser lets the test check your product instead of accidentally checking your homemade parser.
The example compares arrays so the header is checked independently, including column order. It also keeps totals as strings. The contract here promises two decimal places; silently converting 125.0 and 125.00 to the same number would erase a formatting regression.
Choose what to normalize with care:
- If row order is unspecified, sort by a stable ID before comparing.
- If a timestamp is generated at export time, check its format and an agreed time range rather than deleting the whole column from the test.
- If the export promises UTF-8, preserve non-ASCII fixture text in your assertions.
- If column order is a compatibility promise to downstream importers, keep the exact header assertion.
An empty file, an HTML error page named invoices.csv, a duplicate record, an unexpected column, and a wrong total all fail this particular contract. The test does not need to guess which green toast was lying.
Add the next case based on how your export works
Once the small contract passes, choose the failure mode your implementation is most likely to hide:
Pagination. Seed more matching records than fit on one page. Assert the exported ID set equals the complete fixture set and that no IDs repeat. A test with two records cannot prove an “export all results” promise.
An empty filter. Decide whether the product should download a header-only file or show an explicit empty-state message. Test that behavior. Do not accept either outcome just because it is easier to automate.
Background jobs. If clicking Export queues a job, first wait for that job’s user-visible completion state. Register the download wait immediately before the later Download button, not before an unrelated click that merely creates the job. Give the job an explicit, reasonable timeout.
Failure and retry. In a controlled test environment, make the export service fail once. Check that the user sees a useful error and can retry without losing the selected filter. A later successful download must still satisfy the contents contract. The form recovery guide uses the same principle for submitted forms.
Access boundaries. Use synthetic data for another account or tenant and verify it is excluded. That is a separate authorization case, not something a two-row happy-path export proves.
Do not turn every row permutation into a browser test. Keep a few browser cases for selection and delivery; exercise the detailed formatting rules closer to the export code.
Carry the contract into your TestVibe feature
TestVibe’s project documentation describes keeping features, generated tests, configurations, and run history together. Make the expected output part of the feature intent rather than asking only for a click:
Scenario: Export contains only unpaid invoices
Given an isolated account has two unpaid invoices and one paid invoice
And I am signed in to that account
When I select the unpaid invoice filter
And I export the results as CSV
Then the downloaded CSV contains exactly the two unpaid invoice IDs
And its customer names, totals, currency, and column order match the fixture
That is a specification to implement and review, not proof that the generated test already validates bytes. Inspect the resulting code and verify that your execution environment supports the required download access and parser dependency. The Node.js script above runs in your own Playwright environment; it is not presented as a drop-in TestVibe sandbox script.
Likewise, TestVibe’s run artifacts and the file your application exports are different things. Confirm your runner’s retention behavior before relying on the CSV as a downloadable run artifact. Keep failure evidence synthetic or redact it before sharing; exported reports can contain far more than the screenshot you intended to attach.
The useful finish line
A good export test answers three questions: did the user choose the right data, did the file finish downloading, and did that file contain the promised result?
Start with one filtered report, two included records, and one excluded record. Prove the values, not just the filename. Then add pagination or recovery where your application needs it. Your future spreadsheet-opening colleague will have one fewer reason to send you “quick question” at 4:59.