Introduction
Download flows are everywhere in modern web applications—users generate invoices, export data, fetch reports, or download PDFs. Yet testing these flows often gets overlooked or implemented half-heartedly, leaving blind spots in your test coverage. In this practical guide to testing downloads of reports and PDFs, we'll walk through concrete techniques to verify generated files, validate their content, and keep your test suites maintainable and fast.
Why download testing matters
Download functionality sits at the intersection of backend file generation and frontend user interaction. A broken download flow can silently fail—the button works, the page doesn't error, but the file never arrives or arrives corrupted. We've all seen it: users frustrated because they clicked "Export" and nothing happened, or they got a truncated CSV. Playwright gives us the tools to catch these issues before production.
Capturing downloads with Playwright
Playwright provides a built-in mechanism to intercept and capture downloads without the complexity of managing native file dialogs. The download event fires when the browser receives a file, and we can access its path and metadata directly.
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
test('capture a report download', async ({ page }) => {
// Listen for the download event
const downloadPromise = page.waitForEvent('download');
// Trigger the download
await page.click('button:has-text("Export Report")');
// Wait for the download to complete
const download = await downloadPromise;
// Get the filename and path
console.log('Downloaded file:', download.suggestedFilename);
console.log('Download path:', download.path());
});
The key pattern here is wrapping waitForEvent('download') in a promise and awaiting it after triggering the download. Playwright handles the browser mechanics—you just wait, click, and capture. The download object gives you both the suggested filename and the temporary path where the file lands.
Checking file names and sizes
Once captured, you'll want to validate the basics: does the file have the right name? Is it the right size? These quick checks often catch issues early without needing to parse the entire file.
test('verify report filename and size', async ({ page }) => {
const downloadPromise = page.waitForEvent('download');
await page.click('button:has-text("Export Report")');
const download = await downloadPromise;
// Check filename
const filename = download.suggestedFilename;
expect(filename).toMatch(/report_\d{4}-\d{2}-\d{2}\.csv/);
// Check file size (in bytes)
const filePath = await download.path();
const stats = fs.statSync(filePath);
expect(stats.size).toBeGreaterThan(1000);
expect(stats.size).toBeLessThan(10000000); // reasonable upper bound
console.log(`Downloaded ${filename} (${stats.size} bytes)`);
});
This approach validates structure without parsing. If a report generation fails silently and produces an empty or near-empty file, the size check will catch it. The filename check ensures your backend is timestamping or numbering correctly.
Parsing PDFs in tests
PDFs are binary files, so you can't just read them as text. However, the pdf-parse library extracts readable content from PDFs with minimal setup. This lets you verify that key data made it into the document.
import * as pdfParse from 'pdf-parse';
test('verify content in downloaded PDF', async ({ page }) => {
const downloadPromise = page.waitForEvent('download');
await page.click('button:has-text("Download Invoice")');
const download = await downloadPromise;
const filePath = await download.path();
const fileBuffer = fs.readFileSync(filePath);
// Parse the PDF
const pdfData = await pdfParse(fileBuffer);
const text = pdfData.text;
// Verify expected content
expect(text).toContain('Invoice');
expect(text).toContain('Total: $1,234.56');
expect(text).toContain('Due Date: 2025-02-28');
expect(pdfData.numpages).toBe(1);
});
The pdf-parse package extracts all text from the PDF into a single string, making it searchable. For complex multi-page PDFs, you can also check numpages. This strategy works best when you're verifying that specific data points made it to the file—perfect for invoices, statements, or reports.
Parsing CSVs and other text-based formats
CSV exports are simpler to verify since they're plain text. Node's built-in file system APIs and a lightweight CSV parser give you row-by-row inspection.
import * as csv from 'csv-parse/sync';
test('verify CSV export structure and content', async ({ page }) => {
const downloadPromise = page.waitForEvent('download');
await page.click('button:has-text("Export as CSV")');
const download = await downloadPromise;
const filePath = await download.path();
const fileContent = fs.readFileSync(filePath, 'utf-8');
// Parse CSV
const records = csv.parse(fileContent, {
columns: true,
skip_empty_lines: true,
});
// Verify structure
expect(records.length).toBeGreaterThan(0);
expect(records[0]).toHaveProperty('id');
expect(records[0]).toHaveProperty('name');
expect(records[0]).toHaveProperty('email');
// Verify specific data
const firstRecord = records[0];
expect(firstRecord.email).toContain('@');
expect(parseInt(firstRecord.id)).toBeGreaterThan(0);
});
The csv-parse library handles headers, whitespace, and quoting automatically. With columns: true, each row becomes an object keyed by header name, making assertions natural and readable. This pattern scales well—you can loop through all rows to validate data integrity across the entire export.
Handling downloads in multi-page workflows
Not all downloads are single-click affairs. Sometimes you need to navigate to a form, fill it out, configure options, and then download. The same waitForEvent pattern applies, but the timing requires care.
test('download after form submission', async ({ page }) => {
await page.goto('/reports');
// Fill out filter form
await page.fill('input[name="startDate"]', '2025-01-01');
await page.fill('input[name="endDate"]', '2025-01-31');
await page.selectOption('select[name="reportType"]', 'sales');
// Set up download listener before clicking submit
const downloadPromise = page.waitForEvent('download');
await page.click('button:has-text("Generate & Download")');
// Wait for the download and the page to settle
const download = await downloadPromise;
await page.waitForLoadState('networkidle');
// Verify
const filePath = await download.path();
const stats = fs.statSync(filePath);
expect(stats.size).toBeGreaterThan(100);
});
The critical point: set up the download listener before triggering the action. If you wait until after the click, you might miss the event.
Cleaning up temporary files
Playwright creates temporary files for downloads, but it doesn't automatically clean them up after tests. In CI environments with many tests, these files accumulate and consume disk space. Always clean up after yourself.
test('download with cleanup', async ({ page }) => {
const downloadPromise = page.waitForEvent('download');
await page.click('button:has-text("Export")');
const download = await downloadPromise;
// Get the file path
const filePath = await download.path();
try {
// Do your assertions
const stats = fs.statSync(filePath);
expect(stats.size).toBeGreaterThan(0);
} finally {
// Clean up
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
});
Using a try/finally block ensures cleanup even if assertions fail. For a cleaner approach in larger test suites, consider a test fixture that handles cleanup automatically:
import { test as baseTest, expect } from '@playwright/test';
const test = baseTest.extend({
downloadedFiles: async ({}, use) => {
const files: string[] = [];
await use(files);
// Cleanup after test
files.forEach(filePath => {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
});
},
});
test('download with auto-cleanup fixture', async ({ page, downloadedFiles }) => {
const downloadPromise = page.waitForEvent('download');
await page.click('button:has-text("Export")');
const download = await downloadPromise;
const filePath = await download.path();
downloadedFiles.push(filePath);
// Assertions
expect(fs.existsSync(filePath)).toBeTruthy();
});
This fixture pattern scales cleanly across your entire test suite—each test that uses downloadedFiles automatically gets cleanup.
Common mistakes
- Waiting for download after the click. Set up the listener before triggering the download, not after. Playwright's event arrives quickly.
- Ignoring file size validation. A silent generation failure often produces an empty or tiny file. Size checks catch these bugs fast.
- Forgetting to delete temp files. This creates disk bloat in CI pipelines. Always clean up, especially in fixtures or hooks.
- Parsing PDFs without handling errors. If a PDF is malformed or unreadable,
pdf-parsewill throw. Wrap parsing in try/catch blocks. - Testing only the happy path. Download a valid report, yes—but also test what happens when filters produce zero results, or when the user lacks permission. Does the download still fire? Does an error message appear instead?
- Hardcoding filenames. Report filenames often include timestamps or IDs. Use regex or partial matches rather than exact equality.
Key takeaways
- Capture downloads with
page.waitForEvent('download')before triggering the action; this gives you the download object with filename and temporary path. - Validate filename patterns and file size as a first line of defense—these catch silent failures and corruption quickly.
- Parse PDF content with
pdf-parseand CSV content withcsv-parseto verify that actual data made it into the file, not just the structure. - Clean up temporary files in
finallyblocks or test fixtures to avoid disk bloat in CI. - Test edge cases: zero results, permission errors, malformed requests—not just the happy path.
- Use regex patterns and partial matching for filenames that include dynamic values like timestamps.






Comments
Loading comments…