File upload looks simple right up until real users touch it.
An AI agent can scaffold a drag-and-drop uploader in minutes. Your unit tests can happily assert that onDrop() was called, that your validation function rejects files over 10 MB, and that your API route returns a presigned URL. CI/CD goes green. Everyone moves on.
Then production happens.
A customer drags in a 24 MB PDF and gets a spinner forever because the upload URL expired while they were idle. Another user uploads the same file twice and your backend stores duplicate metadata records. Safari reports progress differently than Chrome, your retry logic silently reuses an invalid presigned URL, and a successful object upload never becomes downloadable because the metadata confirmation call failed after storage already accepted the file.
That’s the gap: most teams test code paths, not the user workflow.
If you care about debugging real failures, improving developer productivity, and trusting your CI/CD pipeline, file upload is one of the best examples of why browser-level testing matters. A working uploader is not “the button called fetch.” It’s a multi-system workflow across browser APIs, storage, your backend, and UI state.
In this walkthrough, we’ll build a real upload flow with:
- drag-and-drop and file picker support
- client-side validation
- presigned URL uploads
- upload progress UI
- retry handling
- backend metadata confirmation
- duplicate detection
- preview/download after upload
- Playwright verification for real browser interactions and failure modes
I’ll use Next.js for the app and API routes, with an S3-compatible presigned upload flow. The same architecture works with Express just as well. The point is not the framework. The point is proving the whole flow works end-to-end.
What we’re building
We’ll build a document uploader with this sequence:
- User selects or drags a file into the browser.
- Client validates type and size before upload.
- Client asks the backend for a presigned upload URL.
- Backend generates a unique object key and returns a temporary URL.
- Browser uploads the raw file directly to object storage.
- Client confirms upload with the backend, sending metadata.
- Backend persists a document record.
- UI shows success and exposes preview/download.
This flow matters because each step can fail independently:
- validation can reject wrong files
- presign can fail due to auth or server bugs
- storage upload can fail mid-stream
- confirmation can fail after a successful object upload
- duplicate detection can reject a second submit
- preview can fail if the stored key is wrong or inaccessible
If you only unit test helper functions, you will miss workflow bugs.
Architecture: separate the upload into explicit phases
The biggest implementation mistake is treating upload as one action. It’s not. It’s a state machine.
Use explicit states instead:
idlevalidatingrequesting_urluploadingconfirmingsuccesserror
This makes debugging dramatically easier. When users report “upload failed,” you want to know where.
For this article, we’ll store documents in an in-memory repository for simplicity, but the pattern maps directly to Postgres.
Project setup
Assume a Next.js app router project with TypeScript.
bashnpx create-next-app@latest file-upload-demo --typescript cd file-upload-demo npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner npm install playwright @playwright/test -D
We’ll create:
textapp/ api/ uploads/presign/route.ts uploads/confirm/route.ts documents/route.ts documents/[id]/download/route.ts page.tsx components/ UploadWidget.tsx lib/ documents.ts s3.ts upload.ts playwright.config.ts tests/ upload.spec.ts
Backend: storage and metadata are different responsibilities
This is where many generated implementations get sloppy. Uploading bytes to storage is not the same thing as creating a valid application record.
Treat them as separate operations.
lib/documents.ts
tsexport type DocumentRecord = { id: string; originalName: string; mimeType: string; size: number; objectKey: string; checksum: string; createdAt: string; }; const documents = new Map<string, DocumentRecord>(); export function listDocuments() { return Array.from(documents.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt) ); } export function findDuplicate(checksum: string) { return Array.from(documents.values()).find((doc) => doc.checksum === checksum); } export function createDocument(record: DocumentRecord) { documents.set(record.id, record); return record; } export function getDocument(id: string) { return documents.get(id) ?? null; }
In production, duplicate detection should probably be scoped to user/account plus checksum. Here we’ll keep it simple.
lib/s3.ts
tsimport { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; const bucket = process.env.S3_BUCKET!; const region = process.env.S3_REGION!; export const s3 = new S3Client({ region, endpoint: process.env.S3_ENDPOINT || undefined, forcePathStyle: !!process.env.S3_ENDPOINT, credentials: { accessKeyId: process.env.S3_ACCESS_KEY_ID!, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!, }, }); export async function createPresignedUploadUrl(params: { key: string; contentType: string; expiresInSeconds?: number; }) { const command = new PutObjectCommand({ Bucket: bucket, Key: params.key, ContentType: params.contentType, }); const url = await getSignedUrl(s3, command, { expiresIn: params.expiresInSeconds ?? 60, }); return url; } export async function createPresignedDownloadUrl(key: string) { const command = new GetObjectCommand({ Bucket: bucket, Key: key, }); return getSignedUrl(s3, command, { expiresIn: 300 }); }
The upload URL is intentionally short-lived. That’s realistic and useful for testing retries and expiration.
API route: presign upload
We need a route that validates the request, chooses an object key, and returns the presigned URL.
app/api/uploads/presign/route.ts
tsimport { NextRequest, NextResponse } from 'next/server'; import crypto from 'node:crypto'; import { createPresignedUploadUrl } from '@/lib/s3'; const MAX_FILE_SIZE = 10 * 1024 * 1024; const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'application/pdf']; export async function POST(req: NextRequest) { const body = await req.json(); const { fileName, fileType, fileSize } = body; if (!fileName || !fileType || !fileSize) { return NextResponse.json({ error: 'Missing file metadata' }, { status: 400 }); } if (!ALLOWED_TYPES.includes(fileType)) { return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 }); } if (fileSize > MAX_FILE_SIZE) { return NextResponse.json({ error: 'File too large' }, { status: 400 }); } const ext = fileName.includes('.') ? fileName.split('.').pop() : 'bin'; const objectKey = `uploads/${crypto.randomUUID()}.${ext}`; const uploadUrl = await createPresignedUploadUrl({ key: objectKey, contentType: fileType, expiresInSeconds: 30, }); return NextResponse.json({ objectKey, uploadUrl }); }
A key detail: the backend, not the client, decides the final object key. Never trust user-provided storage paths.
API route: confirm upload metadata
Once the browser uploads directly to storage, the app still needs a durable metadata record.
app/api/uploads/confirm/route.ts
tsimport { NextRequest, NextResponse } from 'next/server'; import crypto from 'node:crypto'; import { createDocument, findDuplicate } from '@/lib/documents'; export async function POST(req: NextRequest) { const body = await req.json(); const { originalName, mimeType, size, objectKey, checksum } = body; if (!originalName || !mimeType || !size || !objectKey || !checksum) { return NextResponse.json({ error: 'Missing metadata' }, { status: 400 }); } const existing = findDuplicate(checksum); if (existing) { return NextResponse.json( { error: 'Duplicate upload', existingDocumentId: existing.id }, { status: 409 } ); } const record = createDocument({ id: crypto.randomUUID(), originalName, mimeType, size, objectKey, checksum, createdAt: new Date().toISOString(), }); return NextResponse.json({ document: record }); }
This route is where business truth is established. If this call fails, the upload is not “done” from the app’s perspective, even if the object exists in storage.
That distinction matters for debugging orphaned uploads.
API routes: list and download
app/api/documents/route.ts
tsimport { NextResponse } from 'next/server'; import { listDocuments } from '@/lib/documents'; export async function GET() { return NextResponse.json({ documents: listDocuments() }); }
app/api/documents/[id]/download/route.ts
tsimport { NextResponse } from 'next/server'; import { getDocument } from '@/lib/documents'; import { createPresignedDownloadUrl } from '@/lib/s3'; export async function GET( _req: Request, { params }: { params: { id: string } } ) { const doc = getDocument(params.id); if (!doc) { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } const url = await createPresignedDownloadUrl(doc.objectKey); return NextResponse.json({ url, document: doc }); }
Client: compute checksum and upload with progress
You can use fetch for direct PUT uploads, but upload progress is still awkward in many setups. For dependable progress UI, XMLHttpRequest is still practical.
lib/upload.ts
tsexport async function sha256(file: File): Promise<string> { const buffer = await file.arrayBuffer(); const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); } export function uploadFileWithProgress(params: { file: File; uploadUrl: string; onProgress: (percent: number) => void; signal?: AbortSignal; }) { const { file, uploadUrl, onProgress, signal } = params; return new Promise<void>((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', uploadUrl); xhr.setRequestHeader('Content-Type', file.type); xhr.upload.onprogress = (event) => { if (event.lengthComputable) { const percent = Math.round((event.loaded / event.total) * 100); onProgress(percent); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { onProgress(100); resolve(); } else { reject(new Error(`Upload failed with status ${xhr.status}`)); } }; xhr.onerror = () => reject(new Error('Network error during upload')); xhr.onabort = () => reject(new Error('Upload aborted')); if (signal) { signal.addEventListener('abort', () => xhr.abort(), { once: true }); } xhr.send(file); }); }
This is not fancy, but it is debuggable. That matters more.
React uploader component
Now the main UI: drag-and-drop, file picker, validation, progress, retry, and final preview.
components/UploadWidget.tsx
tsx'use client'; import { useRef, useState } from 'react'; import { sha256, uploadFileWithProgress } from '@/lib/upload'; type UploadState = | 'idle' | 'validating' | 'requesting_url' | 'uploading' | 'confirming' | 'success' | 'error'; type UploadedDocument = { id: string; originalName: string; mimeType: string; size: number; }; const MAX_FILE_SIZE = 10 * 1024 * 1024; const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'application/pdf']; export function UploadWidget() { const fileInputRef = useRef<HTMLInputElement | null>(null); const [state, setState] = useState<UploadState>('idle'); const [progress, setProgress] = useState(0); const [error, setError] = useState<string | null>(null); const [uploaded, setUploaded] = useState<UploadedDocument | null>(null); const [lastFile, setLastFile] = useState<File | null>(null); function validateFile(file: File) { if (!ALLOWED_TYPES.includes(file.type)) { throw new Error('Unsupported file type'); } if (file.size > MAX_FILE_SIZE) { throw new Error('File exceeds 10 MB limit'); } } async function processFile(file: File) { setError(null); setUploaded(null); setProgress(0); setLastFile(file); try { setState('validating'); validateFile(file); const checksum = await sha256(file); setState('requesting_url'); const presignRes = await fetch('/api/uploads/presign', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: file.name, fileType: file.type, fileSize: file.size, }), }); if (!presignRes.ok) { const body = await presignRes.json(); throw new Error(body.error || 'Failed to get upload URL'); } const { uploadUrl, objectKey } = await presignRes.json(); setState('uploading'); await uploadFileWithProgress({ file, uploadUrl, onProgress: setProgress, }); setState('confirming'); const confirmRes = await fetch('/api/uploads/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ originalName: file.name, mimeType: file.type, size: file.size, objectKey, checksum, }), }); if (!confirmRes.ok) { const body = await confirmRes.json(); throw new Error(body.error || 'Failed to confirm upload'); } const { document } = await confirmRes.json(); setUploaded(document); setState('success'); } catch (err) { setState('error'); setError(err instanceof Error ? err.message : 'Upload failed'); } } async function onFileInputChange(e: React.ChangeEvent<HTMLInputElement>) { const file = e.target.files?.[0]; if (file) await processFile(file); } async function onDrop(e: React.DragEvent<HTMLDivElement>) { e.preventDefault(); const file = e.dataTransfer.files?.[0]; if (file) await processFile(file); } return ( <div className="max-w-xl space-y-4"> <div data-testid="dropzone" onDragOver={(e) => e.preventDefault()} onDrop={onDrop} className="rounded-lg border-2 border-dashed p-8" > <p className="mb-4">Drag and drop a PNG, JPEG, or PDF up to 10 MB.</p> <button type="button" onClick={() => fileInputRef.current?.click()} className="rounded bg-black px-4 py-2 text-white" > Choose file </button> <input data-testid="file-input" ref={fileInputRef} type="file" accept=".png,.jpg,.jpeg,.pdf" className="hidden" onChange={onFileInputChange} /> </div> <div data-testid="upload-state">State: {state}</div> {state === 'uploading' && ( <div> <div>Uploading: {progress}%</div> <progress data-testid="progress" value={progress} max={100} /> </div> )} {error && ( <div data-testid="error" className="text-red-600"> {error} </div> )} {state === 'error' && lastFile && ( <button data-testid="retry-button" type="button" onClick={() => processFile(lastFile)} className="rounded border px-4 py-2" > Retry upload </button> )} {uploaded && ( <div data-testid="upload-success" className="rounded border p-4"> <div>{uploaded.originalName}</div> <div>{uploaded.mimeType}</div> <div>{uploaded.size} bytes</div> <a data-testid="preview-link" href={`/api/documents/${uploaded.id}/download`} target="_blank" rel="noreferrer" > Preview / Download </a> </div> )} </div> ); }
app/page.tsx
tsximport { UploadWidget } from '@/components/UploadWidget'; export default function Page() { return ( <main className="p-10"> <h1 className="mb-6 text-2xl font-semibold">Document uploader</h1> <UploadWidget /> </main> ); }
At this point, you have a usable feature. But this is exactly where most teams stop.
They manually try one happy-path upload, maybe write a couple of unit tests, and call it done. That’s how broken upload flows make it to production.
What breaks silently if you stop here
Let’s be blunt. A file uploader can look correct and still fail in ways your current testing stack won’t catch.
1. Drag-and-drop isn’t actually wired correctly
The drop event may not transfer files as expected in the browser. Your component test can mock the handler and still miss that real drag payload handling is broken.
2. Oversized files pass client validation under some paths
Maybe the file picker validates but the drop handler doesn’t. Maybe MIME type checking depends on inconsistent browser behavior. Unit tests often exercise one route only.
3. Progress UI never updates in real uploads
Mocked uploads resolve instantly. In production, users see a frozen bar or no indication anything is happening.
4. Retry logic is fake
A lot of retry buttons simply rerun the previous function with the same expired presigned URL. It passes local mocks because your mock URL never expires.
5. Storage succeeds but backend confirmation fails
This creates orphaned objects. The UI may report failure, but the bytes are already stored and billable.
6. Duplicate uploads create inconsistent state
Object storage happily stores duplicates unless your application layer enforces business rules.
7. Preview/download doesn’t work after upload
A successful PUT to storage is not proof that your post-upload experience works.
This is why debugging upload systems needs browser-level verification with controlled failure injection.
Playwright: verify the actual workflow, not assumptions
Playwright is the right level for this because it exercises:
- the actual file input
- real drag-and-drop browser behavior
- network interactions
- UI transitions
- retry flows
- backend responses
- CI/CD reproducibility
The goal is not “more tests.” The goal is fewer false positives.
Playwright config
playwright.config.ts
tsimport { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', use: { baseURL: 'http://127.0.0.1:3000', headless: true, }, webServer: { command: 'npm run dev', url: 'http://127.0.0.1:3000', reuseExistingServer: !process.env.CI, }, });
You can swap npm run dev for a production build command in CI for stricter verification.
Test helpers: generate files and simulate drag-and-drop
tests/upload.spec.ts
tsimport { expect, test } from '@playwright/test'; import path from 'node:path'; import fs from 'node:fs'; function createTempFile(name: string, size: number, mimeType: string) { const filePath = path.join(process.cwd(), 'tests', name); fs.writeFileSync(filePath, Buffer.alloc(size, 'a')); return { filePath, name, mimeType }; } async function dragAndDropFile(page: any, selector: string, file: { name: string; mimeType: string; buffer: Buffer }) { const dataTransfer = await page.evaluateHandle(async ({ name, mimeType, buffer }) => { const dt = new DataTransfer(); const file = new File([new Uint8Array(buffer.data)], name, { type: mimeType }); dt.items.add(file); return dt; }, { name: file.name, mimeType: file.mimeType, buffer, }); await page.dispatchEvent(selector, 'drop', { dataTransfer }); }
That helper is worth its weight in gold because drag-and-drop bugs often hide behind fake component-level event mocks.
Test 1: actual file selection happy path
tstest('uploads a valid file through the file picker', async ({ page }) => { await page.goto('/'); const file = createTempFile('sample.pdf', 1024, 'application/pdf'); await page.getByTestId('file-input').setInputFiles(file.filePath); await expect(page.getByTestId('upload-state')).toContainText('success'); await expect(page.getByTestId('upload-success')).toContainText('sample.pdf'); await expect(page.getByTestId('preview-link')).toBeVisible(); });
This test already gives you more confidence than a dozen isolated unit tests because it verifies the real browser selection flow.
Test 2: drag-and-drop upload
tstest('uploads a valid file through drag and drop', async ({ page }) => { await page.goto('/'); const buffer = Buffer.from('hello world'); await dragAndDropFile(page, '[data-testid="dropzone"]', { name: 'hello.pdf', mimeType: 'application/pdf', buffer, }); await expect(page.getByTestId('upload-state')).toContainText('success'); await expect(page.getByTestId('upload-success')).toContainText('hello.pdf'); });
This catches a whole class of UI bugs that teams regularly miss.
Test 3: oversized file is blocked before upload
tstest('rejects oversized files client-side', async ({ page }) => { await page.goto('/'); const file = createTempFile('too-large.pdf', 11 * 1024 * 1024, 'application/pdf'); await page.getByTestId('file-input').setInputFiles(file.filePath); await expect(page.getByTestId('error')).toContainText('File exceeds 10 MB limit'); await expect(page.getByTestId('upload-state')).toContainText('error'); });
A strong follow-up is asserting that /api/uploads/presign was never called. That’s useful for performance and cost.
tstest('does not request presign URL for oversized files', async ({ page }) => { let presignCalled = false; await page.route('**/api/uploads/presign', async (route) => { presignCalled = true; await route.continue(); }); await page.goto('/'); const file = createTempFile('too-large.pdf', 11 * 1024 * 1024, 'application/pdf'); await page.getByTestId('file-input').setInputFiles(file.filePath); await expect(page.getByTestId('error')).toContainText('File exceeds 10 MB limit'); expect(presignCalled).toBe(false); });
That’s workflow testing, not just UI testing.
Test 4: interrupted network during upload
This is where browser automation starts paying for itself.
tstest('shows an error and allows retry after upload network interruption', async ({ page }) => { let firstUpload = true; await page.route('**/uploads/**', async (route) => { if (firstUpload) { firstUpload = false; await route.abort('failed'); return; } await route.continue(); }); await page.goto('/'); const file = createTempFile('retry.pdf', 1024, 'application/pdf'); await page.getByTestId('file-input').setInputFiles(file.filePath); await expect(page.getByTestId('error')).toContainText(/Network error|Upload failed/); await expect(page.getByTestId('retry-button')).toBeVisible(); await page.getByTestId('retry-button').click(); await expect(page.getByTestId('upload-state')).toContainText('success'); });
There’s an implementation detail hidden here: your retry needs to request a fresh presigned URL, not reuse the old one. Our component does that correctly because it reruns the full upload sequence.
That is the kind of bug CI/CD misses when developers test only helpers in isolation.
Test 5: expired upload URL
You can simulate this by intercepting the upload request and returning a 403, which is what storage often does for expired signatures.
tstest('handles expired upload URLs by surfacing a useful error and retrying', async ({ page }) => { let firstUpload = true; await page.route('**/*', async (route) => { const url = route.request().url(); if (url.includes('X-Amz-Signature') && firstUpload) { firstUpload = false; await route.fulfill({ status: 403, body: 'Request has expired', }); return; } await route.continue(); }); await page.goto('/'); const file = createTempFile('expired-url.pdf', 1024, 'application/pdf'); await page.getByTestId('file-input').setInputFiles(file.filePath); await expect(page.getByTestId('error')).toBeVisible(); await page.getByTestId('retry-button').click(); await expect(page.getByTestId('upload-state')).toContainText('success'); });
In a production implementation, I’d improve the user-facing error copy here to explicitly mention expiration and suggest retrying.
Test 6: backend confirmation fails after storage upload
This is one of the most important tests in the suite.
tstest('fails visibly if metadata confirmation fails after bytes upload', async ({ page }) => { let confirmCalls = 0; await page.route('**/api/uploads/confirm', async (route) => { confirmCalls += 1; if (confirmCalls === 1) { await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Database unavailable' }), }); return; } await route.continue(); }); await page.goto('/'); const file = createTempFile('confirm-failure.pdf', 1024, 'application/pdf'); await page.getByTestId('file-input').setInputFiles(file.filePath); await expect(page.getByTestId('upload-state')).toContainText('error'); await expect(page.getByTestId('error')).toContainText('Database unavailable'); });
This test is critical because it proves your system doesn’t equate “storage accepted bytes” with “workflow succeeded.”
If you skip this, your app can silently accumulate orphaned objects and your support team will spend months cleaning it up.
Test 7: duplicate upload handling
We use checksum-based detection, so uploading the same content twice should result in a conflict.
tstest('detects duplicate uploads', async ({ page }) => { await page.goto('/'); const file = createTempFile('duplicate.pdf', 1024, 'application/pdf'); await page.getByTestId('file-input').setInputFiles(file.filePath); await expect(page.getByTestId('upload-state')).toContainText('success'); await page.getByTestId('file-input').setInputFiles(file.filePath); await expect(page.getByTestId('upload-state')).toContainText('error'); await expect(page.getByTestId('error')).toContainText('Duplicate upload'); });
This also verifies a subtle but important reality: storage may allow the second upload, but the application should still enforce business rules during confirmation.
Test 8: preview/download after upload
A lot of teams never test this, which is absurd because this is what users actually care about.
tstest('returns a download URL for uploaded documents', async ({ page }) => { await page.goto('/'); const file = createTempFile('preview.pdf', 1024, 'application/pdf'); await page.getByTestId('file-input').setInputFiles(file.filePath); await expect(page.getByTestId('upload-state')).toContainText('success'); const responsePromise = page.waitForResponse((response) => response.url().includes('/api/documents/') && response.url().includes('/download') ); await page.getByTestId('preview-link').click(); const response = await responsePromise; expect(response.ok()).toBeTruthy(); const json = await response.json(); expect(json.url).toBeTruthy(); });
That’s not just testing a link. It’s testing that the document record exists, the route resolves, and your backend can derive a usable download URL from stored metadata.
CI/CD: run these tests where confidence actually matters
If these tests only run on your laptop, they are not protecting you.
GitHub Actions example
yamlname: upload-flow on: pull_request: push: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - run: npx playwright install --with-deps - run: npm run test:e2e env: S3_BUCKET: test-bucket S3_REGION: us-east-1 S3_ACCESS_KEY_ID: test S3_SECRET_ACCESS_KEY: test S3_ENDPOINT: http://127.0.0.1:9000
In a real pipeline, I’d run against a local S3-compatible service like MinIO for determinism. That gives you realistic presigned URL behavior without depending on external cloud flakiness.
You can also split the suite:
- fast PR checks for core upload workflow
- nightly runs for larger-browser matrix or more disruptive network fault injection
The point is to make CI/CD reflect user reality, not just compile status.
Practical debugging advice from teams that actually ship this
Here’s what improves developer productivity on upload systems more than adding another layer of abstractions.
Log phase transitions
When a support ticket says “upload failed,” phase-level logs let you immediately ask:
- did validation fail?
- did presign fail?
- did upload fail?
- did confirmation fail?
That cuts debugging time dramatically.
Persist enough metadata to reconcile failures
Store:
- object key
- checksum
- size
- MIME type
- upload attempt timestamp
- user/account context
If your confirmation fails after storage succeeds, this information helps you clean up or reconcile orphaned files.
Make retry restart the workflow, not just the request
This is non-negotiable for presigned URL systems. Retrying a stale URL is one of the most common broken implementations I see.
Test duplicate behavior intentionally
Decide what duplicate means:
- exact checksum?
- same name plus size?
- duplicates allowed but flagged?
Don’t let storage semantics accidentally define product behavior.
Treat preview/download as part of the upload feature
Users don’t care that a PUT returned 200 if they can’t open the file afterward.
Prefer a few real browser tests over many shallow mocks
This article is not anti-unit-test. Unit tests are good for validation helpers, formatting, reducers, and edge-case pure logic.
But unit tests are terrible substitutes for workflow verification when the feature spans:
- browser file APIs
- drag-and-drop events
- network retries
- object storage
- backend persistence
- user-visible state transitions
That’s the kind of system where Playwright earns its keep.
Tooling comparison: where each layer helps
A practical stack for upload features looks like this.
Unit tests
Good for:
- file validation helpers
- checksum utility wrappers
- state transition logic if extracted cleanly
Bad for:
- proving drag-and-drop works
- proving upload progress renders correctly
- proving presigned URL retries work
- proving download links resolve
API/integration tests
Good for:
- presign route validation
- confirmation route duplicate rules
- document listing logic
Bad for:
- proving browser file selection works
- proving the UX survives network interruptions
Playwright end-to-end tests
Good for:
- real upload workflows
- browser-level event behavior
- cross-system verification
- preventing false confidence in CI/CD
Bad for:
- tiny pure logic edge cases where unit tests are faster and clearer
The right answer is not choosing one. It’s using each layer for the failures it can actually detect.
What AI-generated code gets right, and what it misses
Generated code is pretty good at scaffolding upload UI.
It can usually produce:
- a dropzone
- a file input
- a presign request
- a PUT upload
- a success message
What it usually misses is production survivability:
- phase-specific error handling
- stale presigned URL retries
- confirmation after upload
- duplicate handling
- orphaned object scenarios
- verification that preview/download works
- CI tests that simulate real browser and network behavior
That’s why the hard part is no longer generating upload code. The hard part is proving the workflow is reliable.
This is the bigger shift in modern testing: as AI increases code output, your leverage moves away from writing another happy-path component and toward building stronger workflow verification. That is where debugging gets cheaper and confidence becomes real.
Wrap-up
File upload is deceptively common and disproportionately fragile.
If your current process says “unit tests passed, ship it,” upload flows will humble you fast. They cross too many boundaries: browser interaction, validation, network behavior, storage, backend metadata, and post-upload access.
The reliable pattern is straightforward:
- validate early on the client
- generate presigned URLs on the backend
- upload directly to storage
- confirm metadata separately
- model explicit UI states
- make retry restart the full workflow
- detect duplicates intentionally
- verify preview/download after success
- run browser-level tests in CI/CD
That last point is the difference between confidence and theater.
If you want better testing, faster debugging, and real developer productivity, stop asking whether the upload function was called. Start asking whether a real user can drag a file into the browser, survive failures, and still end up with a usable document in the system.
That’s the workflow that matters. And that’s the one your tests should prove.
