Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 059 / 100

Testing downloads of reports and PDFs: a practical guide

Verify generated files.

Expona Admin8 min read

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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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:

typescript
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-parse will 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-parse and CSV content with csv-parse to verify that actual data made it into the file, not just the structure.
  • Clean up temporary files in finally blocks 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles