AI can scaffold a file upload feature in an afternoon. That’s the easy part. The hard part is making sure the upload flow still works when a real user drags in the wrong file type, uploads a 40 MB image on bad Wi‑Fi, refreshes during processing, retries after a timeout, and then expects to actually use the uploaded file inside the product.
That gap is where a lot of teams get burned. The API returns 200, the server logs say upload complete, CI/CD is green, unit tests pass, and the feature still fails in production in all the ways users actually experience it. The preview never appears. The progress bar gets stuck. The retry button does nothing. The metadata record exists, but the object storage write failed. Or the upload technically succeeded, but the asset is not associated with the right account, so the user can’t find it afterward.
This is exactly the kind of modern reliability problem traditional testing misses. You do not ship confidence by asserting that a function returned the right shape. You ship confidence by verifying the full workflow from the browser to storage to processing to final product behavior.
In this article, we’ll build a real file upload flow and then test it the way users break it. The stack is intentionally boring and runnable:
- Frontend: React + Vite
- Backend: Node.js + Express
- Storage: local mock of presigned uploads
- Processing: async image metadata extraction
- End-to-end verification: Playwright
The point is not the exact framework choices. The point is the workflow:
- Validate file type and size before upload.
- Request a presigned upload target.
- Upload directly.
- Mark the upload complete.
- Process the file asynchronously.
- Render preview and asset library entry.
- Verify the file is actually usable in the product.
- Break the network and prove retry and recovery work.
If you only test steps 2 through 4, you’ll miss the failures users care about most.
What we’re building
We’ll build an “asset uploader” for a product that lets users upload images. The user experience includes:
- Drag-and-drop file input
- Click-to-select fallback
- Client-side size/type validation
- Upload progress
- Error state with retry
- Server-side processing state
- Final preview in the uploader
- Asset appearing in the library view
We’ll also simulate a production-ish backend flow:
POST /api/uploads/presignreturns an upload target and asset IDPUT /upload/:assetIdstores the filePOST /api/uploads/:assetId/completemarks upload complete and triggers processingGET /api/assetslists uploaded assetsGET /api/assets/:assetIdreturns current state
To keep this hands-on, we’ll use the local filesystem instead of S3, but the control flow maps directly to a real presigned upload setup.
Why file uploads fail silently
Before the code, it’s worth naming the failure modes. This is where debugging gets real.
A lot of upload implementations are “green” in CI/CD because they test the wrong boundary:
- Unit tests confirm validation logic rejects
.exefiles. - API tests confirm
/presignreturns JSON. - Integration tests confirm the database record gets inserted.
- Mocked frontend tests assert that a success message renders after a resolved promise.
None of that proves the user can upload a real file and use it afterward.
Real upload failures usually happen in between systems:
- Browser says drag-and-drop happened, but the drop zone never forwards the file.
- Presign succeeds, but the direct upload fails due to headers, content type mismatch, or timeout.
- File reaches storage, but completion call never fires.
- Completion call succeeds, but async processing crashes.
- Asset is processed, but listing API doesn’t include it due to tenant or permission mismatch.
- Preview uses a stale URL or wrong path, so the uploaded file cannot be viewed.
- Retry UI resets local state incorrectly and creates duplicate assets.
This is why end-to-end testing is not optional for file uploads. This is not “extra QA.” It is core product verification.
Project setup
A minimal structure:
txtfile-upload-demo/ client/ src/ App.jsx uploader.jsx api.js server/ index.js uploads/ storage/ tests/ upload.spec.ts package.json
Install dependencies:
bashnpm init -y npm install express cors multer uuid image-size npm install react react-dom npm install -D vite @vitejs/plugin-react concurrently playwright
Add scripts:
json{ "scripts": { "dev:client": "vite", "dev:server": "node server/index.js", "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"", "test:e2e": "playwright test" } }
Backend: presign, upload, complete, process
Here’s a simple Express backend that simulates a presigned upload flow.
js// server/index.js const express = require('express'); const cors = require('cors'); const fs = require('fs'); const path = require('path'); const { v4: uuid } = require('uuid'); const sizeOf = require('image-size'); const app = express(); app.use(cors()); app.use(express.json()); const STORAGE_DIR = path.join(__dirname, 'storage'); fs.mkdirSync(STORAGE_DIR, { recursive: true }); const assets = new Map(); function assetFilePath(assetId, filename) { return path.join(STORAGE_DIR, `${assetId}-${filename}`); } app.post('/api/uploads/presign', (req, res) => { const { filename, contentType, size } = req.body; const assetId = uuid(); const asset = { id: assetId, filename, contentType, size, status: 'pending_upload', createdAt: new Date().toISOString(), filePath: null, width: null, height: null, error: null }; assets.set(assetId, asset); res.json({ assetId, uploadUrl: `http://localhost:3001/upload/${assetId}` }); }); app.put('/upload/:assetId', express.raw({ type: '*/*', limit: '50mb' }), (req, res) => { const asset = assets.get(req.params.assetId); if (!asset) return res.status(404).json({ error: 'Asset not found' }); const filePath = assetFilePath(asset.id, asset.filename); fs.writeFileSync(filePath, req.body); asset.filePath = filePath; asset.status = 'uploaded'; assets.set(asset.id, asset); res.status(200).end(); }); app.post('/api/uploads/:assetId/complete', async (req, res) => { const asset = assets.get(req.params.assetId); if (!asset) return res.status(404).json({ error: 'Asset not found' }); if (!asset.filePath) return res.status(400).json({ error: 'Upload missing' }); asset.status = 'processing'; assets.set(asset.id, asset); setTimeout(() => { try { const dimensions = sizeOf(asset.filePath); asset.width = dimensions.width; asset.height = dimensions.height; asset.status = 'ready'; assets.set(asset.id, asset); } catch (err) { asset.status = 'failed_processing'; asset.error = 'Could not process file'; assets.set(asset.id, asset); } }, 1200); res.json({ ok: true }); }); app.get('/api/assets', (req, res) => { res.json(Array.from(assets.values())); }); app.get('/api/assets/:assetId', (req, res) => { const asset = assets.get(req.params.assetId); if (!asset) return res.status(404).json({ error: 'Asset not found' }); res.json(asset); }); app.get('/files/:assetId', (req, res) => { const asset = assets.get(req.params.assetId); if (!asset || !asset.filePath) return res.status(404).end(); res.sendFile(asset.filePath); }); app.listen(3001, () => { console.log('server listening on 3001'); });
This is deliberately simple, but it contains the workflow that matters:
- The app creates an asset record before upload.
- The file upload itself is separate from API completion.
- Processing is async.
- The final usable state is
ready, notuploaded.
That last point matters. Many teams mistakenly treat “bytes reached storage” as success. Users do not care that bytes reached storage. They care that the asset is visible and usable.
Frontend: the upload component
Now let’s build the uploader UI.
js// client/src/api.js export async function presignUpload(file) { const res = await fetch('http://localhost:3001/api/uploads/presign', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: file.name, contentType: file.type, size: file.size }) }); if (!res.ok) throw new Error('Failed to presign upload'); return res.json(); } export async function uploadFile(uploadUrl, file, onProgress) { await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', uploadUrl); xhr.setRequestHeader('Content-Type', file.type); xhr.upload.onprogress = (event) => { if (event.lengthComputable) { onProgress(Math.round((event.loaded / event.total) * 100)); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) resolve(); else reject(new Error('Upload failed')); }; xhr.onerror = () => reject(new Error('Network error during upload')); xhr.send(file); }); } export async function completeUpload(assetId) { const res = await fetch(`http://localhost:3001/api/uploads/${assetId}/complete`, { method: 'POST' }); if (!res.ok) throw new Error('Failed to complete upload'); } export async function getAsset(assetId) { const res = await fetch(`http://localhost:3001/api/assets/${assetId}`); if (!res.ok) throw new Error('Failed to fetch asset'); return res.json(); } export async function listAssets() { const res = await fetch('http://localhost:3001/api/assets'); if (!res.ok) throw new Error('Failed to list assets'); return res.json(); }
And the uploader itself:
jsx// client/src/uploader.jsx import React, { useRef, useState } from 'react'; import { completeUpload, getAsset, listAssets, presignUpload, uploadFile } from './api'; const MAX_SIZE = 5 * 1024 * 1024; const ALLOWED_TYPES = ['image/png', 'image/jpeg']; export default function Uploader() { const inputRef = useRef(null); const [status, setStatus] = useState('idle'); const [error, setError] = useState(''); const [progress, setProgress] = useState(0); const [asset, setAsset] = useState(null); const [assets, setAssets] = useState([]); const [lastFile, setLastFile] = useState(null); function validateFile(file) { if (!ALLOWED_TYPES.includes(file.type)) { throw new Error('Only PNG and JPEG files are allowed'); } if (file.size > MAX_SIZE) { throw new Error('File exceeds 5MB limit'); } } async function pollUntilReady(assetId) { for (let i = 0; i < 20; i++) { const next = await getAsset(assetId); setAsset(next); if (next.status === 'ready' || next.status === 'failed_processing') return next; await new Promise((r) => setTimeout(r, 500)); } throw new Error('Processing timed out'); } async function refreshAssets() { const all = await listAssets(); setAssets(all.filter((a) => a.status === 'ready')); } async function startUpload(file) { setLastFile(file); setError(''); setAsset(null); setProgress(0); try { validateFile(file); setStatus('presigning'); const { assetId, uploadUrl } = await presignUpload(file); setStatus('uploading'); await uploadFile(uploadUrl, file, setProgress); setStatus('processing'); await completeUpload(assetId); const finalAsset = await pollUntilReady(assetId); if (finalAsset.status !== 'ready') { throw new Error(finalAsset.error || 'Processing failed'); } setStatus('ready'); await refreshAssets(); } catch (err) { setError(err.message); setStatus('error'); } } function onDrop(e) { e.preventDefault(); const file = e.dataTransfer.files?.[0]; if (file) startUpload(file); } return ( <div style={{ maxWidth: 640, margin: '40px auto', fontFamily: 'sans-serif' }}> <div data-testid="dropzone" onDragOver={(e) => e.preventDefault()} onDrop={onDrop} onClick={() => inputRef.current?.click()} style={{ border: '2px dashed #999', padding: 32, borderRadius: 12, cursor: 'pointer' }} > Drag and drop an image here, or click to select </div> <input data-testid="file-input" ref={inputRef} type="file" accept="image/png,image/jpeg" style={{ display: 'none' }} onChange={(e) => { const file = e.target.files?.[0]; if (file) startUpload(file); }} /> <div style={{ marginTop: 16 }}> <strong>Status:</strong> <span data-testid="status">{status}</span> </div> {status === 'uploading' && ( <div data-testid="progress">Upload progress: {progress}%</div> )} {error && ( <div data-testid="error" style={{ color: 'crimson', marginTop: 12 }}> {error} {lastFile && ( <button data-testid="retry" onClick={() => startUpload(lastFile)} style={{ marginLeft: 12 }}> Retry </button> )} </div> )} {asset?.status === 'ready' && ( <div data-testid="preview" style={{ marginTop: 16 }}> <img src={`http://localhost:3001/files/${asset.id}`} alt={asset.filename} width="240" /> <div>{asset.filename} — {asset.width}x{asset.height}</div> </div> )} <h2>Asset Library</h2> <ul data-testid="asset-list"> {assets.map((item) => ( <li key={item.id}>{item.filename}</li> ))} </ul> </div> ); }
Mount it:
jsx// client/src/App.jsx import React from 'react'; import Uploader from './uploader'; export default function App() { return <Uploader />; }
At this point, AI can generate most of this for you. That’s fine. Use it. It’s a productivity multiplier for repetitive wiring.
But here’s the catch: generated upload code often looks correct while hiding workflow bugs. That is exactly why you need end-to-end verification.
What to verify manually before writing tests
Before Playwright, do one honest local run.
Try these manually:
- Drop a valid PNG.
- Select a JPEG with the file picker.
- Upload a
.txtfile renamed to.png. - Upload a file larger than 5 MB.
- Reload while processing.
- Click retry after forcing a network failure.
This is not because manual QA is sufficient. It’s because it helps you find the actual product states worth automating.
Good testing starts with understanding where the system lies to you.
Playwright setup
Install browsers:
bashnpx playwright install
Basic config:
ts// playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ use: { baseURL: 'http://localhost:5173' }, webServer: [ { command: 'npm run dev:server', port: 3001, reuseExistingServer: true }, { command: 'npm run dev:client', port: 5173, reuseExistingServer: true } ] });
Add a few fixture files under tests/fixtures/:
tiny.pngtiny.jpgbad.txttoo-large.jpg
End-to-end tests that prove the flow works
Here’s where the article earns its keep. We are not checking that an upload handler was called. We are verifying that the feature works from the user’s perspective.
ts// tests/upload.spec.ts import { test, expect } from '@playwright/test'; import path from 'path'; const fixture = (name: string) => path.join(__dirname, 'fixtures', name); test('uploads an image and makes it usable in the product', async ({ page }) => { await page.goto('/'); await page.setInputFiles('[data-testid="file-input"]', fixture('tiny.png')); await expect(page.getByTestId('status')).toHaveText('uploading'); await expect(page.getByTestId('status')).toHaveText('processing'); await expect(page.getByTestId('status')).toHaveText('ready', { timeout: 10000 }); await expect(page.getByTestId('preview')).toBeVisible(); await expect(page.locator('[data-testid="preview"] img')).toHaveAttribute('src', /files\//); await expect(page.getByTestId('asset-list')).toContainText('tiny.png'); }); test('rejects invalid file types before upload starts', async ({ page }) => { await page.goto('/'); await page.setInputFiles('[data-testid="file-input"]', fixture('bad.txt')); await expect(page.getByTestId('status')).toHaveText('error'); await expect(page.getByTestId('error')).toContainText('Only PNG and JPEG files are allowed'); }); test('rejects oversized files', async ({ page }) => { await page.goto('/'); await page.setInputFiles('[data-testid="file-input"]', fixture('too-large.jpg')); await expect(page.getByTestId('status')).toHaveText('error'); await expect(page.getByTestId('error')).toContainText('File exceeds 5MB limit'); }); test('shows an error and supports retry when upload request fails', async ({ page }) => { await page.route('http://localhost:3001/upload/**', async (route) => { await route.abort(); }); await page.goto('/'); await page.setInputFiles('[data-testid="file-input"]', fixture('tiny.jpg')); await expect(page.getByTestId('status')).toHaveText('error'); await expect(page.getByTestId('error')).toContainText('Network error during upload'); await expect(page.getByTestId('retry')).toBeVisible(); await page.unroute('http://localhost:3001/upload/**'); await page.getByTestId('retry').click(); await expect(page.getByTestId('status')).toHaveText('ready', { timeout: 10000 }); await expect(page.getByTestId('asset-list')).toContainText('tiny.jpg'); }); test('supports drag and drop, not just file input', async ({ page }) => { await page.goto('/'); const dataTransfer = await page.evaluateHandle(() => new DataTransfer()); const filePath = fixture('tiny.png'); await page.locator('[data-testid="dropzone"]').dispatchEvent('drop', { dataTransfer: await page.evaluateHandle(async ({ filePath }) => { const dt = new DataTransfer(); const response = await fetch('/' + filePath); const buffer = await response.arrayBuffer(); const file = new File([buffer], 'tiny.png', { type: 'image/png' }); dt.items.add(file); return dt; }, { filePath }) }); await expect(page.getByTestId('status')).toHaveText('ready', { timeout: 10000 }); await expect(page.getByTestId('preview')).toBeVisible(); });
The exact drag-and-drop implementation can vary depending on your fixture setup. In practice, many teams create the File directly in the browser context rather than trying to fetch local files through the app server. The key idea is to test the actual drop interaction, not just the hidden input.
A more reliable drag-and-drop test pattern
If you want the drag-and-drop test to be more self-contained, use buffers directly:
tstest('drag and drop upload works', async ({ page }) => { await page.goto('/'); const buffer = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9/8AAAAASUVORK5CYII=', 'base64' ); await page.locator('[data-testid="dropzone"]').dispatchEvent('drop', { dataTransfer: await page.evaluateHandle((data) => { const dt = new DataTransfer(); const file = new File([new Uint8Array(data)], 'tiny.png', { type: 'image/png' }); dt.items.add(file); return dt; }, [...buffer]) }); await expect(page.getByTestId('status')).toHaveText('ready', { timeout: 10000 }); });
This is the kind of detail that matters in real testing work. If your test only sets the hidden input, you are not actually verifying your drag-and-drop feature.
The failure modes these tests catch
These tests are useful because they catch failures that CI/CD often misses:
1. Broken state transitions
If the UI jumps from uploading to ready without handling processing, you’ll miss backend delays or processing failures.
2. Progress UI that never updates
Plenty of upload widgets show a progress bar that is purely cosmetic. Using XMLHttpRequest and asserting upload states gives you real coverage.
3. Retry flows that only work in demos
Retry logic often fails because local component state gets reset incorrectly, old asset IDs linger, or the retry button simply replays the wrong step.
4. Storage/API mismatch
A file may upload to storage but never become visible in the asset list. Your end-to-end test should confirm the asset appears where the user expects it.
5. Preview path bugs
It’s common to persist the object but break URL generation. The image exists, but the product can’t render it. Unit tests won’t catch that.
6. Validation gaps
Client-side file size and type validation are part of the product, not just convenience. If they break, users feel it immediately.
What AI helps with, and what it doesn’t
AI is genuinely helpful here in a narrow, practical way:
- scaffolding React components
- generating boilerplate API handlers
- wiring upload progress callbacks
- drafting Playwright test cases
- suggesting validation logic
That saves time and improves developer productivity.
But AI also introduces a reliability tax if you stop at generated code:
- It frequently assumes success-path networking.
- It often omits awkward states like retries, stale previews, and polling timeouts.
- It may write tests that assert implementation details instead of user outcomes.
- It tends to over-mock, which creates false confidence.
The right workflow is: let AI help build, then use real end-to-end testing and debugging to prove the workflow survives failure.
CI/CD: how to run this without fooling yourself
A lot of teams add Playwright to CI/CD and still get low signal because they run only “happy path” tests or allow flaky retries to hide issues.
A better approach:
yaml# .github/workflows/e2e.yml name: e2e on: [push, pull_request] jobs: test-upload-flow: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install --with-deps - run: npm run test:e2e
Then make the tests meaningful:
- Include one happy path upload.
- Include one interrupted upload.
- Include one validation failure.
- Include one final “asset usable in product” assertion.
That last one matters most. If your CI/CD only checks that /upload returned 200, you’re not validating the product. You’re validating a narrow transport event.
Practical debugging advice when these tests fail
This is where senior teams distinguish themselves from cargo-cult automation.
When an upload E2E test fails, don’t just rerun it. Inspect where the workflow broke.
If status gets stuck on uploading
Check:
- Was the
PUTrequest made? - Did it complete?
- Did CORS or content-type headers block it?
- Did the upload route write the file?
If status gets stuck on processing
Check:
- Was
/completecalled? - Did the backend mark the asset as
processing? - Did the async processor crash?
- Is polling looking at the right asset ID?
If preview doesn’t render but status is ready
Check:
- Is the file URL correct?
- Does the file endpoint return the actual bytes?
- Is the MIME type acceptable to the browser?
- Did multi-tenant lookup or auth hide the asset?
If retry fails intermittently
Check:
- Are you reusing a dead asset record?
- Does retry create a new asset ID?
- Is
lastFilestill available after the error? - Are event handlers firing twice?
This is why good end-to-end testing is inseparable from debugging. The test is not just a gate. It is an observability tool for the workflow.
Tools comparison: what belongs where
For this kind of feature, use layers of testing, but be honest about what each layer can prove.
Unit tests
Good for:
- file size/type validation helpers
- state reducer transitions
- utility formatting
Bad at proving:
- drag-and-drop actually works
- browser upload behavior
- retry/recovery across network failures
- final asset usability
API/integration tests
Good for:
- presign contract
- completion endpoint behavior
- processing job behavior
- asset list filtering
Bad at proving:
- UI shows the right status at the right time
- user can recover from failures
- preview really renders in the browser
Playwright end-to-end tests
Good for:
- complete workflow verification
- realistic browser interactions
- network interruption testing
- product-level confidence
Bad at replacing:
- fast feedback on pure logic
- narrow debugging of utility functions
Use all three, but don’t confuse their coverage.
Practices that keep upload flows reliable
If you want this feature to survive real users, not just demos, follow these practices:
Treat upload success as ready, not uploaded
Storage success is not user success.
Always test at least one real file in the browser
No mocked File object-only strategy should be your only proof.
Verify the asset is discoverable after upload
Users care that the file shows up where they need it.
Force network failures intentionally
If you never simulate failure, your retry UX is probably broken.
Keep processing state explicit
n
Don’t collapse uploaded, processing, and ready into one generic success state.
Don’t let CI/CD hide flakiness
Flaky upload tests usually point to actual workflow timing or state problems.
Make debugging artifacts easy to inspect
Save request logs, screenshots, and Playwright traces for failed runs.
What breaks if you skip end-to-end verification
If you skip this kind of verification, you usually ship one of these bugs:
- Upload appears complete but file is missing from the product.
- Drag-and-drop silently fails while click-to-upload works.
- Validation only works on one code path.
- Processing errors never reach the UI.
- Retry creates duplicates or dead references.
- The file exists but preview or usage is broken.
- CI/CD gives a green build while users hit a broken workflow.
This is the broader lesson for modern engineering teams. As AI increases code output, the bottleneck moves from writing code to verifying workflows. More code is cheap. Confidence is not.
Wrap-up
A real file upload feature is not an endpoint and not a component. It is a workflow across browser interactions, network reliability, storage, async processing, and product visibility.
That’s why “upload succeeded” logs are almost meaningless on their own. So are passing unit tests. So is a green CI/CD pipeline that never checks whether the user can drag, retry, preview, or find the file afterward.
The practical path is straightforward:
- let AI accelerate the scaffolding
- build the upload flow with explicit states
- verify the final usable outcome, not just transport success
- write Playwright tests that upload real files and break the network on purpose
- use failures as debugging signals, not just pass/fail metrics
If you do that, you won’t just ship a file uploader that compiles. You’ll ship one that actually works.
