17 KiB
File Upload and Download Testing
When to use: Testing file uploads (single, multiple, drag-and-drop), downloads (content verification, filename, type), upload progress indicators, or file type/size restrictions.
Table of Contents
- Downloading Files
- Single File Upload
- Multiple File Upload
- Drag-and-Drop Zones
- File Chooser Dialog
- Upload Progress and Cancellation
- Retry After Failure
- File Type and Size Restrictions
- Image Preview
- Authenticated Downloads
- Tips
Downloading Files
Capturing Downloads and Verifying Content
import { test, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
test('verifies downloaded CSV content', async ({ page }) => {
await page.goto('/exports');
// Set up download listener BEFORE triggering the download
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'transactions.csv' }).click();
const download = await downloadPromise;
const savePath = path.join(__dirname, '../tmp', download.suggestedFilename());
await download.saveAs(savePath);
const content = fs.readFileSync(savePath, 'utf-8');
expect(content).toContain('id,amount,date');
expect(content).toContain('1001,250.00,2025-01-15');
const rows = content.trim().split('\n');
expect(rows.length).toBeGreaterThan(1);
fs.unlinkSync(savePath);
});
test('reads download via stream without disk I/O', async ({ page }) => {
await page.goto('/exports');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'transactions.csv' }).click();
const download = await downloadPromise;
const readable = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of readable!) {
chunks.push(Buffer.from(chunk));
}
const content = Buffer.concat(chunks).toString('utf-8');
expect(content).toContain('id,amount,date');
});
Verifying Filename and Format
test('export filename matches selected format', async ({ page }) => {
await page.goto('/analytics');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export PDF' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^analytics-\d{4}-\d{2}-\d{2}\.pdf$/);
});
test('format selector changes output extension', async ({ page }) => {
await page.goto('/analytics');
await page.getByLabel('Format').selectOption('csv');
const csvDownload = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download' }).click();
expect((await csvDownload).suggestedFilename()).toMatch(/\.csv$/);
await page.getByLabel('Format').selectOption('xlsx');
const xlsxDownload = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download' }).click();
expect((await xlsxDownload).suggestedFilename()).toMatch(/\.xlsx$/);
});
Checking Response Headers
test('download response has correct MIME type', async ({ page }) => {
await page.goto('/analytics');
const responsePromise = page.waitForResponse('**/api/analytics/export**');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export PDF' }).click();
const response = await responsePromise;
expect(response.headers()['content-type']).toContain('application/pdf');
expect(response.headers()['content-disposition']).toContain('attachment');
await downloadPromise;
});
Handling Download Failures
test('shows error when download fails', async ({ page }) => {
await page.route('**/api/analytics/export**', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Generation failed' }),
});
});
await page.goto('/analytics');
await page.getByRole('button', { name: 'Export PDF' }).click();
await expect(page.getByRole('alert')).toContainText(/failed|error/i);
});
Single File Upload
From Fixture File
import path from 'path';
test('uploads document from fixture', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/invoice.pdf'));
await expect(page.getByText('invoice.pdf')).toBeVisible();
await page.getByRole('button', { name: 'Upload' }).click();
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
await expect(page.getByRole('link', { name: 'invoice.pdf' })).toBeVisible();
});
From In-Memory Buffer
test('uploads in-memory CSV', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'contacts.csv',
mimeType: 'text/csv',
buffer: Buffer.from('name,email\nAlice,alice@acme.com\nBob,bob@acme.com'),
});
await expect(page.getByText('contacts.csv')).toBeVisible();
await page.getByRole('button', { name: 'Upload' }).click();
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
});
Clearing Selection
test('clears selected file', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'draft.txt',
mimeType: 'text/plain',
buffer: Buffer.from('draft content'),
});
await expect(page.getByText('draft.txt')).toBeVisible();
// Clear via API
await fileInput.setInputFiles([]);
await expect(page.getByText('draft.txt')).not.toBeVisible();
});
Multiple File Upload
test('uploads multiple files at once', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles([
{ name: 'doc1.pdf', mimeType: 'application/pdf', buffer: Buffer.from('pdf1') },
{ name: 'doc2.pdf', mimeType: 'application/pdf', buffer: Buffer.from('pdf2') },
{ name: 'doc3.pdf', mimeType: 'application/pdf', buffer: Buffer.from('pdf3') },
]);
await expect(page.getByText('doc1.pdf')).toBeVisible();
await expect(page.getByText('doc2.pdf')).toBeVisible();
await expect(page.getByText('doc3.pdf')).toBeVisible();
await expect(page.getByText('3 files selected')).toBeVisible();
await page.getByRole('button', { name: 'Upload all' }).click();
await expect(page.getByRole('alert')).toContainText('3 files uploaded');
});
test('removes one file from selection', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles([
{ name: 'keep.txt', mimeType: 'text/plain', buffer: Buffer.from('keep') },
{ name: 'discard.txt', mimeType: 'text/plain', buffer: Buffer.from('discard') },
]);
const discardRow = page.getByText('discard.txt').locator('..');
await discardRow.getByRole('button', { name: /remove|delete|×/i }).click();
await expect(page.getByText('discard.txt')).not.toBeVisible();
await expect(page.getByText('keep.txt')).toBeVisible();
});
Drag-and-Drop Zones
Drop zones always have an underlying input[type="file"]—target it directly instead of simulating OS-level drag events.
test('uploads via drop zone', async ({ page }) => {
await page.goto('/attachments');
const dropZone = page.locator('[data-testid="drop-zone"]');
await expect(dropZone).toContainText(/drag.*here|drop.*files/i);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'dropped.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('pdf-content'),
});
await expect(dropZone.getByText('dropped.pdf')).toBeVisible();
await page.getByRole('button', { name: 'Upload' }).click();
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
});
test('shows visual feedback on drag-over', async ({ page }) => {
await page.goto('/attachments');
const dropZone = page.locator('[data-testid="drop-zone"]');
await dropZone.dispatchEvent('dragenter', {
dataTransfer: { types: ['Files'], files: [] },
});
await expect(dropZone).toHaveClass(/active|highlight|drag-over/);
await expect(dropZone).toContainText(/release|drop now/i);
await dropZone.dispatchEvent('dragleave');
await expect(dropZone).not.toHaveClass(/active|highlight|drag-over/);
});
File Chooser Dialog
test('uploads via native file chooser', async ({ page }) => {
await page.goto('/attachments');
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Choose file' }).click();
const fileChooser = await fileChooserPromise;
expect(fileChooser.isMultiple()).toBe(false);
await fileChooser.setFiles({
name: 'selected.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('pdf-content'),
});
await expect(page.getByText('selected.pdf')).toBeVisible();
});
Upload Progress and Cancellation
test('displays upload progress for large file', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
const largeBuffer = Buffer.alloc(5 * 1024 * 1024, 'x');
await fileInput.setInputFiles({
name: 'dataset.bin',
mimeType: 'application/octet-stream',
buffer: largeBuffer,
});
await page.getByRole('button', { name: 'Upload' }).click();
const progressBar = page.getByRole('progressbar');
await expect(progressBar).toBeVisible();
await expect(async () => {
const value = await progressBar.getAttribute('aria-valuenow');
expect(Number(value)).toBeGreaterThan(0);
}).toPass({ timeout: 10000 });
await expect(progressBar).not.toBeVisible({ timeout: 60000 });
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
});
test('cancels in-progress upload', async ({ page }) => {
await page.route('**/api/attachments/upload', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 10000));
await route.continue();
});
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'large.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(5 * 1024 * 1024, 'x'),
});
await page.getByRole('button', { name: 'Upload' }).click();
await expect(page.getByRole('progressbar')).toBeVisible();
await page.getByRole('button', { name: 'Cancel upload' }).click();
await expect(page.getByRole('progressbar')).not.toBeVisible();
await expect(page.getByText(/cancelled|aborted/i)).toBeVisible();
await expect(page.getByRole('link', { name: 'large.bin' })).not.toBeVisible();
});
Retry After Failure
test('retries failed upload', async ({ page }) => {
let attempt = 0;
await page.route('**/api/attachments/upload', async (route) => {
attempt++;
if (attempt === 1) {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Server error' }),
});
} else {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'abc', name: 'data.csv' }),
});
}
});
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'data.csv',
mimeType: 'text/csv',
buffer: Buffer.from('col1,col2\nval1,val2'),
});
await page.getByRole('button', { name: 'Upload' }).click();
await expect(page.getByText(/upload failed|error/i)).toBeVisible();
await page.getByRole('button', { name: /retry/i }).click();
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
expect(attempt).toBe(2);
});
File Type and Size Restrictions
Validating Allowed Types
test('accepts allowed file types', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
await expect(fileInput).toHaveAttribute('accept', /\.pdf|\.doc|\.docx|\.txt/);
await fileInput.setInputFiles({
name: 'report.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('pdf-content'),
});
await expect(page.getByText('report.pdf')).toBeVisible();
await expect(page.getByText(/not allowed|invalid/i)).not.toBeVisible();
});
test('rejects disallowed file types', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
// setInputFiles bypasses the accept attribute—tests JavaScript validation
await fileInput.setInputFiles({
name: 'malware.exe',
mimeType: 'application/x-msdownload',
buffer: Buffer.from('exe-content'),
});
await expect(page.getByRole('alert')).toContainText(
/not allowed|unsupported file type|only .pdf, .doc/i
);
await expect(page.getByText('malware.exe')).not.toBeVisible();
});
Enforcing Size Limits
test('rejects oversized file', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
const oversizedBuffer = Buffer.alloc(11 * 1024 * 1024, 'x');
await fileInput.setInputFiles({
name: 'huge.pdf',
mimeType: 'application/pdf',
buffer: oversizedBuffer,
});
await expect(page.getByRole('alert')).toContainText(/file.*too large|exceeds.*10 ?MB/i);
await expect(page.getByText('huge.pdf')).not.toBeVisible();
});
Enforcing File Count Limits
test('rejects too many files', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
const files = Array.from({ length: 6 }, (_, i) => ({
name: `file-${i + 1}.txt`,
mimeType: 'text/plain' as const,
buffer: Buffer.from(`content ${i + 1}`),
}));
await fileInput.setInputFiles(files);
await expect(page.getByRole('alert')).toContainText(/maximum.*5 files|too many files/i);
});
Validating Image Dimensions
test('rejects image below minimum dimensions', async ({ page }) => {
await page.goto('/profile/avatar');
const fileInput = page.locator('input[type="file"]');
// Minimal 1x1 PNG
const tinyPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64'
);
await fileInput.setInputFiles({
name: 'tiny.png',
mimeType: 'image/png',
buffer: tinyPng,
});
await expect(page.getByRole('alert')).toContainText(/minimum.*dimensions|too small/i);
});
Image Preview
test('shows image preview after selection', async ({ page }) => {
await page.goto('/profile/avatar');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/photo.jpg'));
const preview = page.getByRole('img', { name: /preview|avatar/i });
await expect(preview).toBeVisible();
const src = await preview.getAttribute('src');
expect(src).toMatch(/^(blob:|data:image)/);
});
Authenticated Downloads
test('downloads file requiring authentication', async ({ page, request }) => {
await page.goto('/attachments');
// Browser download works because cookies are sent
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'confidential.pdf' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('confidential.pdf');
// Verify via API request (carries auth context)
const response = await request.get('/api/attachments/456/download');
expect(response.ok()).toBeTruthy();
expect(response.headers()['content-type']).toContain('application/pdf');
});
Tips
-
Use
setInputFilesfor uploads. Even drag-and-drop zones have an underlyinginput[type="file"]. Target it directly instead of simulating OS-level drag events. -
Prefer in-memory buffers. Creating files with
Buffer.from()keeps tests self-contained. Use fixture files only when you need real content (e.g., a valid PDF your app parses). -
Set up download listener before clicking. Call
page.waitForEvent('download')before the click that triggers the download—otherwise you may miss the event. -
Use
createReadStream()for content verification. Reading directly from the stream avoids disk I/O and cleanup of temporary files. -
Test both
acceptattribute and JavaScript validation. The HTMLacceptattribute only filters the OS file dialog.setInputFiles()bypasses it, which is exactly what you need to test your app's JavaScript validation.