File upload looks boring right up until it starts failing in production.
A user drops a PDF into your app. On your laptop, the happy path works. In CI, your unit tests pass. Your mocked integration tests pass. The release goes out. Then support tickets start arriving:
- “The spinner never ends.”
- “It worked on Wi-Fi, then failed on hotel internet.”
- “This PDF uploads but never finishes processing.”
- “Your app says invalid file type, but it’s literally a PDF.”
- “Retry just duplicates the document.”
This is exactly the kind of feature that exposes the gap between code coverage and real reliability.
Most teams test file upload at the wrong level. They unit-test a validator, mock fetch, assert that a helper returned true, and call it done. That gives you confidence in isolated code paths, not confidence that a user can actually complete the workflow under normal failure conditions.
If AI is writing more of your app code, this matters even more. Generated code tends to look plausible and pass shallow tests, while workflow bugs survive because nobody exercised the system end-to-end. CI/CD pipelines go green, but the feature is still brittle.
So let’s build this properly.
In this article, we’ll implement a real upload flow in a small JavaScript app with:
- drag-and-drop upload
- upload progress
- server-side validation
- simulated virus scan / processing state
- status polling
- retry logic
- clear user-facing error states
Then we’ll verify the whole thing with Playwright against real failure modes:
- oversized files
- corrupted PDFs
- slow networks
- delayed processing
- transient upload failures
- CI artifact capture for debugging
The goal is not just to ship code that compiles. The goal is to make a green pipeline actually mean the upload workflow worked.
What we’re building
We’ll build a small Node + Express app with a static frontend.
Workflow:
- User drags in a file.
- Frontend uploads it with progress reporting.
- Server validates size and MIME/extension.
- Server stores it and creates a processing job.
- Frontend polls job status.
- Server marks the file as either:
completedrejectedfor corrupt PDFinfectedfailedfor transient processing issues
- Frontend renders the final state and offers retry when appropriate.
For testing, we’ll intentionally keep the architecture simple and runnable locally. We’ll also add a few hooks to make failure modes deterministic in Playwright.
Project structure:
txtupload-flow/ package.json server.js uploads/ public/ index.html app.js styles.css tests/ fixtures/ valid.pdf corrupt.pdf infected.pdf large.pdf upload.spec.ts playwright.config.ts .github/workflows/ci.yml
Why most upload tests are misleading
Before code, it’s worth being blunt: the default testing approach for upload flows is weak.
A typical team does some variation of this:
- unit-test
isValidPdf(file) - mock
fetch('/api/upload') - assert “Upload successful” text appears
- run in CI on a pristine network
What goes untested?
- drag-and-drop event wiring
- actual multipart upload behavior
- progress updates during slow transfer
- retry semantics after partial failure
- polling while processing is delayed
- server-side rejection despite client-side acceptance
- malformed PDFs that look like PDFs by extension
- race conditions when users re-upload quickly
- what the UI looks like when the backend takes 20 seconds instead of 200 ms
That’s the core insight: users experience workflows, not helper functions.
A file upload feature is not “a validator plus an API call.” It’s a distributed interaction between browser events, network conditions, request bodies, backend validation, async processing, and UI state management.
If your testing strategy doesn’t cover that system, your CI/CD pipeline is giving false confidence.
Step 1: Set up the app
Here’s package.json:
json{ "name": "upload-flow", "version": "1.0.0", "type": "module", "scripts": { "start": "node server.js", "dev": "node server.js", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed" }, "dependencies": { "cors": "^2.8.5", "express": "^4.19.2", "multer": "^1.4.5-lts.1", "uuid": "^9.0.1" }, "devDependencies": { "@playwright/test": "^1.48.0", "typescript": "^5.6.3" } }
Install dependencies:
bashnpm install npx playwright install --with-deps
Step 2: Build the backend with validation, processing, and retry support
Here’s the server.
js// server.js import express from 'express'; import cors from 'cors'; import multer from 'multer'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { v4 as uuidv4 } from 'uuid'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const port = process.env.PORT || 3000; app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); const uploadDir = path.join(__dirname, 'uploads'); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir); } const jobs = new Map(); const storage = multer.diskStorage({ destination: (_, __, cb) => cb(null, uploadDir), filename: (_, file, cb) => { const id = uuidv4(); cb(null, `${id}-${file.originalname}`); } }); const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 } }); function inspectPdf(filePath) { const buffer = fs.readFileSync(filePath); const head = buffer.subarray(0, 5).toString('utf8'); return head === '%PDF-'; } function detectScenario(filename) { const lower = filename.toLowerCase(); if (lower.includes('infected')) return 'infected'; if (lower.includes('corrupt')) return 'corrupt'; if (lower.includes('fail-processing')) return 'failed'; return 'completed'; } function startProcessing(job) { const delayMs = Number(process.env.PROCESSING_DELAY_MS || 3000); job.status = 'processing'; job.updatedAt = Date.now(); setTimeout(() => { if (job.scenario === 'corrupt') { job.status = 'rejected'; job.message = 'File contents are not a valid PDF.'; } else if (job.scenario === 'infected') { job.status = 'infected'; job.message = 'File failed malware scan.'; } else if (job.scenario === 'failed') { job.status = 'failed'; job.message = 'Transient processing error.'; } else { job.status = 'completed'; job.message = 'File processed successfully.'; } job.updatedAt = Date.now(); }, delayMs); } app.post('/api/upload', (req, res) => { upload.single('file')(req, res, err => { if (err instanceof multer.MulterError) { if (err.code === 'LIMIT_FILE_SIZE') { return res.status(413).json({ error: 'File exceeds 5 MB limit.' }); } return res.status(400).json({ error: err.message }); } if (err) { return res.status(500).json({ error: 'Upload failed.' }); } const file = req.file; if (!file) { return res.status(400).json({ error: 'No file provided.' }); } const ext = path.extname(file.originalname).toLowerCase(); if (ext !== '.pdf') { fs.unlinkSync(file.path); return res.status(400).json({ error: 'Only PDF files are allowed.' }); } const looksLikePdf = inspectPdf(file.path); const scenario = detectScenario(file.originalname); const jobId = uuidv4(); const job = { id: jobId, fileName: file.originalname, storedPath: file.path, status: 'queued', scenario: looksLikePdf ? scenario : 'corrupt', message: 'Queued for processing.', updatedAt: Date.now() }; jobs.set(jobId, job); startProcessing(job); return res.status(202).json({ jobId, fileName: file.originalname, status: job.status, message: job.message }); }); }); app.get('/api/uploads/:jobId/status', (req, res) => { const job = jobs.get(req.params.jobId); if (!job) { return res.status(404).json({ error: 'Job not found.' }); } res.json({ jobId: job.id, fileName: job.fileName, status: job.status, message: job.message, updatedAt: job.updatedAt }); }); app.post('/api/uploads/:jobId/retry', (req, res) => { const job = jobs.get(req.params.jobId); if (!job) { return res.status(404).json({ error: 'Job not found.' }); } if (job.status !== 'failed') { return res.status(400).json({ error: 'Only failed jobs can be retried.' }); } job.status = 'queued'; job.message = 'Retry queued.'; job.updatedAt = Date.now(); job.scenario = 'completed'; startProcessing(job); return res.json({ jobId: job.id, status: job.status, message: job.message }); }); app.get('/health', (_, res) => res.json({ ok: true })); app.listen(port, () => { console.log(`Server running at http://localhost:${port}`); });
A few things matter here:
- We validate file size on the server, not just the client.
- We validate extension, then inspect file content for a basic PDF signature.
- We treat processing as asynchronous because real systems do.
- We expose job status via polling because the upload finishing is not the workflow finishing.
- Retry is explicit and constrained.
That last point is where many teams get burned. They verify “POST /upload returned 202” and stop there. That only proves the handoff started. It says nothing about whether the document became usable.
Step 3: Build the frontend with drag-and-drop, progress, polling, and retry
Here’s the HTML.
html<!-- public/index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Upload Flow</title> <link rel="stylesheet" href="/styles.css" /> </head> <body> <main class="container"> <h1>Upload a PDF</h1> <div id="dropzone" class="dropzone"> <p>Drag and drop a PDF here, or click to choose one.</p> <input id="fileInput" type="file" accept=".pdf,application/pdf" hidden /> </div> <section id="uploadCard" class="card hidden"> <div class="row"> <strong id="fileName"></strong> <span id="statusBadge" class="badge">idle</span> </div> <div class="progress-shell"> <div id="progressBar" class="progress-bar"></div> </div> <p id="message"></p> <button id="retryBtn" class="hidden">Retry processing</button> </section> </main> <script type="module" src="/app.js"></script> </body> </html>
Basic styles:
css/* public/styles.css */ body { font-family: Arial, sans-serif; background: #f7f7f7; color: #111; margin: 0; } .container { max-width: 720px; margin: 48px auto; padding: 24px; } .dropzone { border: 2px dashed #888; border-radius: 12px; background: white; padding: 40px; text-align: center; cursor: pointer; } .dropzone.dragover { border-color: #0a7; background: #f0fff8; } .card { margin-top: 24px; background: white; border-radius: 12px; padding: 20px; } .row { display: flex; justify-content: space-between; gap: 16px; } .progress-shell { width: 100%; height: 12px; background: #eee; border-radius: 999px; margin: 16px 0; overflow: hidden; } .progress-bar { width: 0%; height: 100%; background: #0a7; transition: width 0.15s linear; } .badge { padding: 4px 10px; border-radius: 999px; background: #eee; } .hidden { display: none; }
Now the client logic.
js// public/app.js const dropzone = document.getElementById('dropzone'); const fileInput = document.getElementById('fileInput'); const uploadCard = document.getElementById('uploadCard'); const fileNameEl = document.getElementById('fileName'); const statusBadge = document.getElementById('statusBadge'); const progressBar = document.getElementById('progressBar'); const messageEl = document.getElementById('message'); const retryBtn = document.getElementById('retryBtn'); let activeJobId = null; let activeFile = null; function setStatus(status, message) { statusBadge.textContent = status; messageEl.textContent = message; statusBadge.dataset.status = status; } function showCard(fileName) { uploadCard.classList.remove('hidden'); fileNameEl.textContent = fileName; } function setProgress(value) { progressBar.style.width = `${Math.max(0, Math.min(100, value))}%`; } async function pollStatus(jobId) { let done = false; while (!done) { const res = await fetch(`/api/uploads/${jobId}/status`); const data = await res.json(); setStatus(data.status, data.message); if (data.status === 'processing') { setProgress(95); } if (['completed', 'rejected', 'infected', 'failed'].includes(data.status)) { done = true; setProgress(100); retryBtn.classList.toggle('hidden', data.status !== 'failed'); break; } await new Promise(r => setTimeout(r, 1000)); } } function uploadFile(file) { activeFile = file; activeJobId = null; showCard(file.name); setProgress(0); setStatus('uploading', 'Uploading file...'); retryBtn.classList.add('hidden'); const formData = new FormData(); formData.append('file', file); const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload'); xhr.upload.onprogress = event => { if (event.lengthComputable) { const percent = Math.round((event.loaded / event.total) * 90); setProgress(percent); } }; xhr.onload = async () => { const isJson = xhr.getResponseHeader('content-type')?.includes('application/json'); const data = isJson ? JSON.parse(xhr.responseText) : null; if (xhr.status >= 200 && xhr.status < 300) { activeJobId = data.jobId; setStatus(data.status, data.message); await pollStatus(data.jobId); } else { setStatus('error', data?.error || 'Upload failed.'); setProgress(0); } }; xhr.onerror = () => { setStatus('error', 'Network error during upload.'); setProgress(0); }; xhr.send(formData); } retryBtn.addEventListener('click', async () => { if (!activeJobId) return; retryBtn.classList.add('hidden'); setStatus('queued', 'Retrying processing...'); setProgress(95); const res = await fetch(`/api/uploads/${activeJobId}/retry`, { method: 'POST' }); const data = await res.json(); if (!res.ok) { setStatus('error', data.error || 'Retry failed.'); return; } setStatus(data.status, data.message); await pollStatus(activeJobId); }); dropzone.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', e => { const file = e.target.files?.[0]; if (file) uploadFile(file); }); dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('dragover'); }); dropzone.addEventListener('dragleave', () => { dropzone.classList.remove('dragover'); }); dropzone.addEventListener('drop', e => { e.preventDefault(); dropzone.classList.remove('dragover'); const file = e.dataTransfer?.files?.[0]; if (file) uploadFile(file); });
There are a few deliberately practical choices here.
First, I used XMLHttpRequest for the upload instead of fetch. Why? Because upload progress with fetch is still awkward for this use case. If you need real progress today in a browser app, XMLHttpRequest is still the boring, reliable option.
Second, the progress bar only goes to 90% during transfer. The remaining 10% is reserved for processing. That’s not cosmetic. It reflects the actual workflow boundary: upload complete is not job complete.
Third, retry only retries processing, not the file transfer. That matters because retries should be scoped to the failure domain. If processing failed after the file was already received, re-uploading the binary is wasteful and creates duplicate state.
Step 4: Create real test fixtures
A huge mistake in testing is using fake fixtures that don’t resemble the files users actually upload.
Create these files under tests/fixtures/.
Minimal valid PDF
txt%PDF-1.4 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj trailer << /Root 1 0 R >> %%EOF
Save as valid.pdf.
Corrupt PDF pretending to be PDF
txtTHIS IS NOT REALLY A PDF
Save as corrupt.pdf.
Infected PDF fixture
Use the same minimal PDF content as valid.pdf, but save as infected.pdf.
Oversized file
For large.pdf, create a file over 5 MB. Quick Node script:
bashnode -e "const fs=require('fs'); const header='%PDF-1.4\n'; const body='A'.repeat(6*1024*1024); fs.writeFileSync('tests/fixtures/large.pdf', header+body)"
You can also add a processing-failure fixture by naming a valid PDF fail-processing.pdf.
This fixture strategy looks simplistic, but it’s enough to drive the workflow branches deterministically. If your production logic depends on actual PDF parsing libraries or malware scanners, the same principle applies: feed realistic files through the real path, don’t just mock the validator boundary.
Step 5: Write Playwright tests for the actual workflow
Here’s the Playwright config.
ts// playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', timeout: 60_000, use: { baseURL: 'http://127.0.0.1:3000', trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure' }, webServer: { command: 'npm start', url: 'http://127.0.0.1:3000/health', reuseExistingServer: !process.env.CI, timeout: 120_000, env: { PROCESSING_DELAY_MS: '2000' } } });
And now the spec.
ts// tests/upload.spec.ts import { test, expect } from '@playwright/test'; import path from 'path'; const fixture = (name: string) => path.join(process.cwd(), 'tests', 'fixtures', name); test('uploads a valid PDF and completes processing', async ({ page }) => { await page.goto('/'); await page.setInputFiles('#fileInput', fixture('valid.pdf')); await expect(page.locator('#fileName')).toHaveText('valid.pdf'); await expect(page.locator('#statusBadge')).toHaveText(/processing|completed/); await expect(page.locator('#message')).toContainText(/Queued|processed successfully/i); await expect(page.locator('#statusBadge')).toHaveText('completed', { timeout: 10_000 }); await expect(page.locator('#message')).toHaveText('File processed successfully.'); await expect(page.locator('#progressBar')).toHaveCSS('width', '100%'); }); test('rejects oversized files server-side', async ({ page }) => { await page.goto('/'); await page.setInputFiles('#fileInput', fixture('large.pdf')); await expect(page.locator('#statusBadge')).toHaveText('error'); await expect(page.locator('#message')).toHaveText('File exceeds 5 MB limit.'); }); test('rejects corrupted PDFs during processing', async ({ page }) => { await page.goto('/'); await page.setInputFiles('#fileInput', fixture('corrupt.pdf')); await expect(page.locator('#statusBadge')).toHaveText('rejected', { timeout: 10_000 }); await expect(page.locator('#message')).toHaveText('File contents are not a valid PDF.'); }); test('marks infected PDFs after scan', async ({ page }) => { await page.goto('/'); await page.setInputFiles('#fileInput', fixture('infected.pdf')); await expect(page.locator('#statusBadge')).toHaveText('infected', { timeout: 10_000 }); await expect(page.locator('#message')).toHaveText('File failed malware scan.'); }); test('supports retry after transient processing failure', async ({ page }) => { await page.goto('/'); await page.setInputFiles('#fileInput', fixture('fail-processing.pdf')); await expect(page.locator('#statusBadge')).toHaveText('failed', { timeout: 10_000 }); await expect(page.locator('#retryBtn')).toBeVisible(); await page.click('#retryBtn'); await expect(page.locator('#statusBadge')).toHaveText('completed', { timeout: 10_000 }); await expect(page.locator('#message')).toHaveText('File processed successfully.'); }); test('survives slow network upload and still completes', async ({ page, context }) => { await context.route('**/api/upload', async route => { await new Promise(r => setTimeout(r, 1500)); await route.continue(); }); await page.goto('/'); await page.setInputFiles('#fileInput', fixture('valid.pdf')); await expect(page.locator('#statusBadge')).toHaveText(/uploading|processing|completed/); await expect(page.locator('#statusBadge')).toHaveText('completed', { timeout: 15_000 }); }); test('shows network error on upload failure', async ({ page, context }) => { await context.route('**/api/upload', async route => { await route.abort(); }); await page.goto('/'); await page.setInputFiles('#fileInput', fixture('valid.pdf')); await expect(page.locator('#statusBadge')).toHaveText('error'); await expect(page.locator('#message')).toHaveText('Network error during upload.'); });
These tests are doing something important that mocked tests usually don’t: they verify observable user states over time.
That distinction matters.
We are not merely asserting that /api/upload returned some JSON shape. We are asserting that:
- the user can select a real file
- the browser sends it
- the server enforces limits
- async processing transitions happen
- polling updates the UI
- retry works after a real failed state
- network problems surface as understandable feedback
That’s workflow testing, not just endpoint testing.
Step 6: Add a drag-and-drop test instead of only input-file tests
setInputFiles() is convenient, but don’t stop there. If your product supports drag-and-drop, test the drag-and-drop path too.
Here’s one practical way:
tstest('supports drag and drop upload', async ({ page }) => { await page.goto('/'); const buffer = Buffer.from('%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF'); await page.dispatchEvent('#dropzone', 'drop', { dataTransfer: { files: [{ name: 'dragged.pdf', mimeType: 'application/pdf', buffer }] } }); await expect(page.locator('#fileName')).toHaveText('dragged.pdf'); await expect(page.locator('#statusBadge')).toHaveText('completed', { timeout: 10_000 }); });
This catches a class of bugs that file-input-only tests miss: broken drop listeners, missing preventDefault, bad drag state cleanup, and event handling regressions.
Step 7: Make CI/CD capture enough artifacts to debug failures
A test suite that fails without context is just a slower manual QA loop.
If your upload test flakes in CI and all you get is “expected completed, got processing,” you still don’t know whether the issue was:
- the server not starting
- slow processing
- route interception mismatch
- file fixture missing
- a browser-only issue
- a race in polling
Capture artifacts by default.
GitHub Actions workflow:
yaml# .github/workflows/ci.yml name: ci on: push: pull_request: jobs: test-upload-flow: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: 20 cache: npm - name: Install dependencies run: npm ci - name: Install Playwright browsers run: npx playwright install --with-deps - name: Run end-to-end tests run: npm run test:e2e - name: Upload Playwright report if: always() uses: actions/upload-artifact@v4 with: name: playwright-report path: playwright-report/ retention-days: 14 - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: test-results path: test-results/ retention-days: 14
You should also enable the HTML reporter if you want richer CI debugging:
ts// playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ reporter: [['html', { open: 'never' }]], // ...rest stays the same });
Now when CI fails, you can inspect:
- trace viewer
- screenshot
- video
- per-test artifacts
- HTML report
This is where debugging and testing meet developer productivity in a real way. Good artifacts reduce time-to-diagnosis dramatically. That matters far more than shaving five seconds off the pipeline while remaining blind when things break.
What silently breaks if you skip full-flow verification
This is the part teams underestimate.
If you only unit-test validators and mock APIs, here’s what can still be broken while CI stays green.
1. Upload progress never updates
Maybe the refactor changed from XMLHttpRequest to fetch and nobody re-implemented progress. Unit tests for validators still pass.
User experience: the UI looks frozen.
2. Server-side limits disagree with client-side limits
Client allows 10 MB, server rejects at 5 MB. Mocked frontend tests pass because the upload API was stubbed to succeed.
User experience: “Why does it fail after I already picked the file?”
3. Corrupt PDFs pass extension checks
The browser happily accepts .pdf. Your client-side accept attribute means almost nothing for trust.
User experience: upload looks fine, then downstream systems choke.
4. Polling logic gets stuck forever
A tiny bug in status transition handling leaves the UI spinning when the server returns failed or infected.
Unit tests for the reducer may still pass if nobody covered the real timing behavior.
5. Retry duplicates records
If retry re-uploads instead of re-processing, users may create duplicate documents or trigger duplicate billing side effects.
6. Slow CI machines expose timing assumptions
Locally, your 500 ms polling and 2-second processing look fine. In CI, process startup lags, browser timings shift, and hidden race conditions surface.
That’s not CI being annoying. That’s CI revealing the truth your laptop hid.
Practical improvements you’d make in production
This demo is intentionally small. In a production system, I’d usually push further in these areas.
Stronger file validation
Checking for %PDF- is enough for demonstration, not enough for a security boundary. In production, use a proper parser and verify MIME based on content, not just file extension or browser-reported type.
Object storage + signed uploads
Large files should usually go directly to S3/GCS/Azure Blob with signed URLs or multipart upload. Your app server shouldn’t become the bottleneck if users upload large documents.
But even if you move to direct-to-storage uploads, the workflow testing idea stays exactly the same. You still need to verify the user-visible flow end-to-end.
Push status instead of polling
WebSockets or Server-Sent Events can improve responsiveness for long-running processing jobs. But polling is fine for many internal and mid-scale apps and is much easier to reason about.
Idempotency
Retries should be idempotent. If the same file gets re-submitted or the browser retries after an uncertain network failure, your backend should avoid duplicate job creation where possible.
Better observability
In addition to Playwright artifacts, add structured logs and correlation IDs around upload and processing jobs. A support issue becomes much easier to debug when a user can give you a job ID.
Tools comparison: what to use where
Let’s be practical about the testing stack.
Unit tests
Good for:
- file size helpers
- reducer/state transition logic
- PDF inspection helpers
- retry eligibility logic
Bad at proving:
- the whole upload workflow works
Unit tests are still useful. They’re just insufficient.
API/integration tests
Good for:
- upload endpoint contract
- processing state transitions
- retry endpoint behavior
Bad at proving:
- actual browser UX and drag-and-drop behavior
- progress UI
- end-user workflow completion
Playwright end-to-end tests
Good for:
- validating the real user journey
- catching timing bugs
- verifying network and processing failure states
- producing debugging artifacts in CI/CD
Tradeoff:
- slower than unit tests
- requires more discipline to keep deterministic
This is the right tradeoff for critical workflows.
If the feature affects revenue, onboarding, compliance, or user trust, you should absolutely spend the extra time on end-to-end verification.
Actionable practices that keep upload flows reliable
Here’s the short list I’d actually enforce on a team.
1. Define “done” as workflow complete, not request accepted
For uploads, “done” means the file reached a usable terminal state, not just that the initial POST returned success.
2. Test server-side validation with real files
Don’t trust browser filters or mocked MIME types. Use fixtures that represent valid, invalid, and malicious-ish inputs.
3. Simulate slow and failing networks in Playwright
Happy-path broadband is not enough. Add throttling, aborts, and delayed processing.
4. Make async processing visible in the UI
Queued, processing, failed, infected, rejected, completed. These states should be explicit and testable.
5. Separate upload retry from processing retry
Users shouldn’t pay the cost of retransferring data when only downstream processing failed.
6. Capture traces, screenshots, and video in CI
A failing test without artifacts is a productivity drain.
7. Keep fixtures deterministic
The easiest way to make E2E useful is to control the backend scenarios. In this article, we mapped outcomes from filenames. In production-like test environments, you can use seeded test data, special headers, or a controlled test service.
8. Don’t over-mock critical paths
Mocking is fine at lower levels. But if you mock away the upload itself, you are not testing the upload feature.
Running the app and tests
Start the app:
bashnpm start
Open:
txthttp://localhost:3000
Run tests:
bashnpm run test:e2e
If you want to inspect failures interactively:
bashnpm run test:e2e:headed
The bigger point: stop trusting green checks that never exercised the workflow
File upload is a perfect example of modern software reliability problems.
The implementation spans browser behavior, backend constraints, async processing, network variability, and user-facing state transitions. That means a shallow testing strategy will almost always miss something important.
And this gets worse as developer productivity tools and AI-generated code accelerate output. More code gets written faster. More “reasonable-looking” implementations make it into branches. More CI pipelines pass because they’re checking narrow code paths instead of reality.
The answer is not more ceremony. It’s better verification.
Build the real flow. Test it the way users experience it. Make CI/CD collect enough evidence that a green build actually means something.
For a file upload feature, that means verifying:
- real files
- real browser behavior
- real server validation
- real async completion
- real failure states
- real recovery paths
If you do that, you stop treating testing as a checkbox and start using it as a debugging tool and a reliability contract.
That’s what turns “works on my machine” into “works for users.”
