AI can scaffold a file upload feature in minutes. That’s the easy part.
The hard part is building one that still works when a real user selects a real file in a real browser, your backend generates a time-limited upload URL, the browser pushes bytes directly to storage, progress updates correctly, metadata gets persisted, and the uploaded file actually appears in the product.
That’s where most teams get false confidence. Unit tests pass. TypeScript compiles. CI/CD goes green. And yet production users hit the exact failures your test suite never exercised: broken CORS, expired presigned URLs, progress bars stuck at 99%, uploads that made it to storage but never attached to the user record, retry logic that duplicates objects, or a UI that says “Done” before the app can actually use the file.
This is the gap between testing code paths and testing user workflows.
In this article, we’ll build a complete upload flow with:
- an Express backend that generates presigned upload URLs
- direct browser-to-storage uploads
- client-side validation for type and size
- progress UI with
XMLHttpRequest - a finalize step that persists metadata after upload succeeds
- Playwright tests that upload real files in a real browser
- CI/CD setup that catches the failures unit tests miss
I’ll use AWS S3-compatible presigned uploads because the pattern is common and production-proven. You can adapt the same structure to Cloudflare R2, MinIO, or another S3-compatible store.
The point is not the cloud vendor. The point is reliability.
What we’re building
The flow looks like this:
- User selects a file in the browser.
- Frontend validates type and size before uploading.
- Frontend requests a presigned upload URL from the backend.
- Backend creates an object key and a short-lived presigned
PUTURL. - Browser uploads the file directly to storage using that URL.
- Frontend tracks progress and shows success or failure.
- Frontend calls a backend
finalizeendpoint. - Backend verifies or records metadata and attaches the upload to the user record.
- UI reloads uploaded files and shows the new asset.
- Playwright proves the whole thing works end-to-end.
That last step matters more than most teams admit.
If you skip browser-level verification, you can ship an upload feature that “works” in unit tests and still fails for users.
Why file uploads break so often
Uploads sit at the boundary between multiple systems:
- browser APIs
- frontend state management
- backend auth and validation
- object storage
- CORS configuration
- persistence layer
- eventual UI refresh and rendering
That makes them a perfect source of silent failures.
Common examples:
- Your backend signs the wrong
Content-Type, and S3 rejects the upload. - Your bucket CORS rules allow
GETbut notPUT. - Your frontend uses
fetch, then realizes upload progress isn’t available in the browser and hacks around it badly. - Your storage upload succeeds, but your finalize request fails, so the object exists but the app never references it.
- Your retry logic creates multiple orphaned objects.
- Your progress bar waits for
100%, but the finalize step is still pending, so users think the upload froze. - Your dev environment works because CORS is wide open, but CI or staging uses a stricter bucket policy.
These are workflow failures, not isolated function failures.
So let’s build the thing in a way that’s easy to debug and easy to test.
Project structure
We’ll keep this implementation small and runnable:
txtfile-upload-demo/ server/ package.json index.js s3.js web/ package.json vite.config.js index.html src/ main.jsx App.jsx styles.css tests/ upload.spec.ts playwright.config.ts docker-compose.yml .github/workflows/ci.yml
To keep the demo practical, we’ll use:
- Express for the backend
- SQLite in-memory or simple in-process storage for metadata in the example
- MinIO locally as an S3-compatible object store
- React + Vite for the frontend
- Playwright for browser-level testing
You can swap MinIO for AWS S3 in production with the same code pattern.
Backend: presigned upload URL generation
First, the server dependencies.
server/package.json
json{ "name": "upload-demo-server", "type": "module", "scripts": { "dev": "node index.js" }, "dependencies": { "@aws-sdk/client-s3": "^3.658.1", "@aws-sdk/s3-request-presigner": "^3.658.1", "cors": "^2.8.5", "express": "^4.21.0", "nanoid": "^5.0.7" } }
server/s3.js
jsimport { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; const requiredEnv = [ "S3_ENDPOINT", "S3_REGION", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY" ]; for (const key of requiredEnv) { if (!process.env[key]) { throw new Error(`Missing env var: ${key}`); } } export const s3 = new S3Client({ region: process.env.S3_REGION, endpoint: process.env.S3_ENDPOINT, forcePathStyle: true, credentials: { accessKeyId: process.env.S3_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_KEY } }); export async function createPresignedUploadUrl({ bucket, key, contentType, expiresInSeconds = 60 }) { const command = new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: contentType }); const url = await getSignedUrl(s3, command, { expiresIn: expiresInSeconds }); return url; }
server/index.js
jsimport express from "express"; import cors from "cors"; import { nanoid } from "nanoid"; import { createPresignedUploadUrl } from "./s3.js"; const app = express(); const PORT = process.env.PORT || 4000; const BUCKET = process.env.S3_BUCKET; const APP_ORIGIN = process.env.APP_ORIGIN || "http://localhost:5173"; app.use(cors({ origin: APP_ORIGIN, credentials: false })); app.use(express.json()); // In a real app, use a database. const uploads = new Map(); const MAX_FILE_SIZE = 5 * 1024 * 1024; const ALLOWED_TYPES = new Set(["image/png", "image/jpeg", "application/pdf"]); app.get("/health", (_req, res) => { res.json({ ok: true }); }); app.get("/uploads", (_req, res) => { const items = Array.from(uploads.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt)); res.json({ items }); }); app.post("/upload/init", async (req, res) => { try { const { fileName, fileType, fileSize } = req.body; if (!fileName || !fileType || typeof fileSize !== "number") { return res.status(400).json({ error: "Missing file metadata" }); } if (!ALLOWED_TYPES.has(fileType)) { return res.status(400).json({ error: "Unsupported file type" }); } if (fileSize > MAX_FILE_SIZE) { return res.status(400).json({ error: "File too large" }); } const uploadId = nanoid(); const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, "_"); const objectKey = `uploads/${uploadId}/${safeName}`; const uploadUrl = await createPresignedUploadUrl({ bucket: BUCKET, key: objectKey, contentType: fileType, expiresInSeconds: 60 }); uploads.set(uploadId, { id: uploadId, fileName, fileType, fileSize, objectKey, status: "initiated", createdAt: new Date().toISOString() }); res.json({ uploadId, objectKey, uploadUrl, maxFileSize: MAX_FILE_SIZE }); } catch (error) { console.error("init failed", error); res.status(500).json({ error: "Failed to initialize upload" }); } }); app.post("/upload/finalize", async (req, res) => { try { const { uploadId } = req.body; if (!uploadId || !uploads.has(uploadId)) { return res.status(404).json({ error: "Upload not found" }); } const upload = uploads.get(uploadId); // In production, verify the object exists in storage using HeadObject. // For this minimal demo, we mark it finalized after the browser reports success. const finalized = { ...upload, status: "complete", attachedToUserId: "demo-user-123", finalizedAt: new Date().toISOString() }; uploads.set(uploadId, finalized); res.json({ item: finalized }); } catch (error) { console.error("finalize failed", error); res.status(500).json({ error: "Failed to finalize upload" }); } }); app.listen(PORT, () => { console.log(`server listening on http://localhost:${PORT}`); });
This backend does three important things right.
First, it validates type and size before generating a presigned URL. That protects your storage layer and avoids wasted uploads.
Second, it stores an upload record before the browser starts the transfer. That gives you a durable ID to reason about in logs, debugging, retries, and support incidents.
Third, it has a separate finalize step.
That step is not optional ceremony. It’s the difference between “a blob exists in storage” and “the application knows the user uploaded a file and can use it.”
A shocking number of upload implementations skip this distinction, which is how teams end up with orphaned objects and broken user flows.
Frontend: validation, progress UI, and finalize logic
Now the browser app.
web/package.json
json{ "name": "upload-demo-web", "private": true, "version": "1.0.0", "type": "module", "scripts": { "dev": "vite" }, "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.1", "vite": "^5.4.8" } }
web/vite.config.js
jsimport { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()] });
web/src/main.jsx
jsximport React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import "./styles.css"; ReactDOM.createRoot(document.getElementById("root")).render( <React.StrictMode> <App /> </React.StrictMode> );
web/src/App.jsx
jsximport { useEffect, useState } from "react"; const API_BASE = "http://localhost:4000"; const MAX_FILE_SIZE = 5 * 1024 * 1024; const ALLOWED_TYPES = ["image/png", "image/jpeg", "application/pdf"]; function formatBytes(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function uploadFileWithProgress(url, file, onProgress) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("PUT", url); 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) { resolve(); } else { reject(new Error(`Upload failed with status ${xhr.status}`)); } }; xhr.onerror = () => reject(new Error("Network error during upload")); xhr.send(file); }); } export default function App() { const [items, setItems] = useState([]); const [error, setError] = useState(""); const [status, setStatus] = useState("idle"); const [progress, setProgress] = useState(0); const [selectedFile, setSelectedFile] = useState(null); async function loadUploads() { const res = await fetch(`${API_BASE}/uploads`); const data = await res.json(); setItems(data.items); } useEffect(() => { loadUploads().catch(err => { console.error(err); setError("Failed to load uploads"); }); }, []); function validateFile(file) { if (!file) return "No file selected"; if (!ALLOWED_TYPES.includes(file.type)) { return "Unsupported file type"; } if (file.size > MAX_FILE_SIZE) { return `File exceeds ${formatBytes(MAX_FILE_SIZE)}`; } return ""; } async function handleUpload() { setError(""); const validationError = validateFile(selectedFile); if (validationError) { setError(validationError); return; } try { setStatus("initializing"); setProgress(0); const initRes = await fetch(`${API_BASE}/upload/init`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileName: selectedFile.name, fileType: selectedFile.type, fileSize: selectedFile.size }) }); if (!initRes.ok) { const data = await initRes.json(); throw new Error(data.error || "Failed to initialize upload"); } const initData = await initRes.json(); setStatus("uploading"); await uploadFileWithProgress(initData.uploadUrl, selectedFile, setProgress); // Important: upload reaching 100% means bytes are transferred, // not that the app is done. We still need finalize. setStatus("finalizing"); const finalizeRes = await fetch(`${API_BASE}/upload/finalize`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uploadId: initData.uploadId }) }); if (!finalizeRes.ok) { const data = await finalizeRes.json(); throw new Error(data.error || "Failed to finalize upload"); } setStatus("complete"); setProgress(100); setSelectedFile(null); document.getElementById("file-input").value = ""; await loadUploads(); } catch (err) { console.error(err); setStatus("error"); setError(err.message || "Upload failed"); } } return ( <div className="page"> <div className="card"> <h1>File Upload Demo</h1> <input id="file-input" type="file" accept=".png,.jpg,.jpeg,.pdf" onChange={e => { setError(""); setSelectedFile(e.target.files?.[0] || null); }} /> {selectedFile && ( <div className="file-meta" data-testid="selected-file"> <div>{selectedFile.name}</div> <div>{selectedFile.type || "unknown"}</div> <div>{formatBytes(selectedFile.size)}</div> </div> )} <button onClick={handleUpload} disabled={!selectedFile || status === "uploading" || status === "finalizing"}> {status === "uploading" || status === "finalizing" ? "Uploading..." : "Upload file"} </button> <div className="status" data-testid="upload-status"> Status: {status} </div> <div className="progress-wrap"> <div className="progress-bar" style={{ width: `${progress}%` }} data-testid="progress-bar" /> </div> <div data-testid="progress-label">{progress}%</div> {error && ( <div className="error" data-testid="upload-error"> {error} </div> )} </div> <div className="card"> <h2>Uploaded files</h2> <ul data-testid="uploads-list"> {items.map(item => ( <li key={item.id}> <strong>{item.fileName}</strong> — {item.status} — {formatBytes(item.fileSize)} </li> ))} </ul> </div> </div> ); }
web/src/styles.css
cssbody { font-family: Arial, sans-serif; background: #f6f7f9; color: #18212b; margin: 0; } .page { max-width: 800px; margin: 40px auto; padding: 0 16px; } .card { background: white; border-radius: 12px; padding: 20px; margin-bottom: 20px; box-shadow: 0 1px 2px rgba(0,0,0,0.08); } .file-meta, .status, .error { margin-top: 12px; } .progress-wrap { height: 12px; background: #e5e7eb; border-radius: 999px; margin-top: 12px; overflow: hidden; } .progress-bar { height: 100%; width: 0; background: #11ab5a; transition: width 0.2s ease; } button { margin-top: 12px; padding: 10px 14px; } .error { color: #b91c1c; }
There are a few deliberate implementation choices here.
Why XMLHttpRequest instead of fetch
Because browser upload progress is still much easier and more reliable with XMLHttpRequest.
A lot of AI-generated upload code reaches for fetch automatically, then handwaves progress UI. That’s how you end up with fake progress bars or no progress at all.
If you need true browser upload progress today, XMLHttpRequest is still the pragmatic choice.
Why status is separate from percentage
Notice we track both status and progress.
That matters because 100% uploaded bytes does not mean the user workflow is complete. The upload still needs to be finalized and reflected in the app state.
Without that distinction, teams often show:
100%- spinner keeps spinning
- user thinks the app is broken
That’s not a cosmetic issue. It’s a debugging and product reliability issue.
Why the list reload happens after finalize
Because the product should only show the file after the system of record says it’s attached.
If you optimistically insert it into the list before finalize succeeds, your UI can lie.
Local object storage with MinIO
You need an S3-compatible target for local development and testing. MinIO is perfect for this.
docker-compose.yml
ymlversion: "3.8" services: minio: image: minio/minio:latest command: server /data --console-address ":9001" ports: - "9000:9000" - "9001:9001" environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin
Start it:
bashdocker compose up -d
Then create a bucket called uploads in the MinIO console at http://localhost:9001.
You also need bucket CORS that allows browser PUT requests from your frontend origin. This is where many upload flows fail.
A basic MinIO/S3-style CORS policy looks like:
json[ { "AllowedHeaders": ["*"], "AllowedMethods": ["PUT", "GET", "HEAD"], "AllowedOrigins": ["http://localhost:5173"], "ExposeHeaders": ["ETag"] } ]
If this is wrong, your unit tests won’t tell you. Your API tests won’t tell you. A real browser test will.
That’s exactly why browser automation belongs in CI/CD.
Running the app
Set server environment variables:
bashexport PORT=4000 export APP_ORIGIN=http://localhost:5173 export S3_ENDPOINT=http://localhost:9000 export S3_REGION=us-east-1 export S3_BUCKET=uploads export S3_ACCESS_KEY=minioadmin export S3_SECRET_KEY=minioadmin
Start the backend:
bashcd server npm install npm run dev
Start the frontend:
bashcd web npm install npm run dev
Now open http://localhost:5173, select a PNG, JPG, or PDF under 5 MB, and upload it.
At this point, many teams stop. They click around once, see it work locally, and call it done.
That’s not enough.
Verifying the whole flow with Playwright
Now we write the test that actually matters: can a browser select a file, upload it, complete finalize, and show the attached record?
Install Playwright at the repo root:
bashnpm init -y npm install -D @playwright/test npx playwright install --with-deps chromium
playwright.config.ts
tsimport { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', timeout: 60_000, use: { baseURL: 'http://localhost:5173', headless: true } });
Create a small fixture file for upload testing, for example tests/fixtures/test-upload.pdf.
tests/upload.spec.ts
tsimport { test, expect } from '@playwright/test'; import path from 'path'; test('user can upload a file and see it attached in the UI', async ({ page }) => { await page.goto('/'); const filePath = path.join(process.cwd(), 'tests/fixtures/test-upload.pdf'); await page.setInputFiles('#file-input', filePath); await expect(page.getByTestId('selected-file')).toContainText('test-upload.pdf'); await page.getByRole('button', { name: 'Upload file' }).click(); await expect(page.getByTestId('upload-status')).toContainText('uploading'); await expect(page.getByTestId('progress-label')).not.toHaveText('0%'); await expect(page.getByTestId('upload-status')).toContainText('finalizing'); await expect(page.getByTestId('upload-status')).toContainText('complete', { timeout: 15000 }); await expect(page.getByTestId('progress-label')).toHaveText('100%'); await expect(page.getByTestId('uploads-list')).toContainText('test-upload.pdf'); await expect(page.getByTestId('uploads-list')).toContainText('complete'); }); test('rejects unsupported file types before upload starts', async ({ page }) => { await page.goto('/'); const filePath = path.join(process.cwd(), 'tests/fixtures/not-allowed.txt'); await page.setInputFiles('#file-input', filePath); await page.getByRole('button', { name: 'Upload file' }).click(); await expect(page.getByTestId('upload-error')).toContainText('Unsupported file type'); await expect(page.getByTestId('upload-status')).toContainText('idle'); });
This test does more than confirm the happy path.
It validates the actual user workflow:
- file selection works
- browser upload begins
- progress updates
- finalize happens after upload
- UI reaches a completed state
- uploaded metadata appears in the product
That’s the standard your testing should aim for.
Failures this test catches that unit tests often miss
This is the important part.
1. Broken bucket CORS
Your backend can generate a valid presigned URL and your unit tests can all pass, but if storage doesn’t allow cross-origin PUT, the browser upload fails.
Only a browser test will surface that correctly.
2. Expired or malformed signatures
You can accidentally sign the wrong path, omit the expected content type, or use URLs that expire too aggressively.
A mocked test won’t catch real object storage behavior. Playwright will.
3. Progress UI bugs
Maybe upload succeeds, but the progress label never updates because event handlers weren’t wired correctly.
That sounds minor until users start retrying working uploads because they think they’re stuck.
4. “Upload complete” before finalize
This is one of the most common state bugs. The object exists, but the database record was never attached.
Storage says success. Product says nothing. User says your app lost their file.
A browser-level test that asserts the file appears in the UI catches this class of failure directly.
5. Retry bugs and duplicate records
If retries call init again and you don’t reconcile previous attempts, you can leak objects or create duplicate metadata.
An end-to-end test can simulate retries and inspect final UI state.
6. UI stuck at 99%
Sometimes code reserves 100% for post-upload work. Sometimes it sets 99% during transfer and forgets to advance after finalize. Sometimes a race condition prevents the final render.
This sounds trivial until it tanks user trust and generates support tickets.
This is exactly the kind of thing that “green CI” often misses.
Make finalize more robust in production
Our demo keeps finalize simple, but production code should verify object existence before marking the upload complete.
For example:
jsimport { HeadObjectCommand } from "@aws-sdk/client-s3"; import { s3 } from "./s3.js"; async function verifyObjectExists(bucket, key) { await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key })); }
Then in finalize:
jsawait verifyObjectExists(BUCKET, upload.objectKey);
That protects you from cases where:
- the browser reports success incorrectly
- the upload was interrupted strangely
- the wrong object key was finalized
- a malicious client tries to finalize without uploading
You can go further by storing and validating:
- ETag
- object size from
HeadObject - content type from storage
- upload ownership and auth claims
- antivirus or post-processing status
The point is this: finalize should represent an application-level truth, not just client optimism.
CI/CD: run the real browser test on every change
Now let’s put this into CI/CD so regressions get caught before production.
.github/workflows/ci.yml
ymlname: ci on: push: pull_request: jobs: test-upload-flow: runs-on: ubuntu-latest services: minio: image: minio/minio:latest ports: - 9000:9000 - 9001:9001 env: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin options: >- --health-cmd "curl -f http://localhost:9000/minio/health/live || exit 1" --health-interval 5s --health-timeout 5s --health-retries 20 command: server /data --console-address ":9001" steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - name: Install root deps run: | npm install npx playwright install --with-deps chromium - name: Install server deps working-directory: server run: npm install - name: Install web deps working-directory: web run: npm install - name: Create bucket and configure CORS run: | curl -O https://dl.min.io/client/mc/release/linux-amd64/mc chmod +x mc ./mc alias set local http://127.0.0.1:9000 minioadmin minioadmin ./mc mb local/uploads || true cat > cors.json <<'EOF' [ { "AllowedHeaders": ["*"], "AllowedMethods": ["PUT", "GET", "HEAD"], "AllowedOrigins": ["http://localhost:5173"], "ExposeHeaders": ["ETag"] } ] EOF ./mc anonymous set none local/uploads || true ./mc cors set local/uploads cors.json - name: Start server env: PORT: 4000 APP_ORIGIN: http://localhost:5173 S3_ENDPOINT: http://127.0.0.1:9000 S3_REGION: us-east-1 S3_BUCKET: uploads S3_ACCESS_KEY: minioadmin S3_SECRET_KEY: minioadmin run: | cd server node index.js > ../server.log 2>&1 & - name: Start web app run: | cd web npx vite --host 0.0.0.0 > ../web.log 2>&1 & - name: Wait for apps run: | npx wait-on http://127.0.0.1:4000/health http://127.0.0.1:5173 - name: Run Playwright tests run: npx playwright test - name: Print logs on failure if: failure() run: | echo '--- server.log ---' cat server.log || true echo '--- web.log ---' cat web.log || true
Also install wait-on as a root dev dependency:
bashnpm install -D wait-on
This pipeline does something many teams still avoid: it tests the actual workflow across the actual system boundary.
That improves debugging, testing quality, and developer productivity because you stop arguing about whether a bug is “frontend” or “backend” or “infra.”
The test answers the only question that matters:
Can the user upload the file successfully or not?
Useful additional tests
Once the happy path exists, add a few more browser-level tests.
Oversized file rejection
Generate or include a file above 5 MB and assert the browser blocks it before network activity starts.
Expired signature handling
In a test environment, set presigned URL expiry to 1 second, wait 2 seconds before upload, then assert the UI shows a useful error and allows retry.
Finalize failure after successful upload
Force /upload/finalize to return 500 for one request. Assert the UI does not claim success and gives the user a recoverable state.
This case matters because it proves your app distinguishes:
- storage success
- application success
Most broken implementations don’t.
Duplicate-click protection
Double-click the upload button or simulate a slow network. Assert only one finalized record appears.
Refresh after upload
Upload a file, reload the page, and verify the uploaded item remains visible from backend state.
That catches state that exists only in memory on the client.
Practical production advice
If you’re building this for a real product, not a demo, here’s what I’d strongly recommend.
1. Treat upload as a state machine
Use states like:
initiateduploadinguploadedfinalizingcompletefailedabandoned
This makes debugging much easier than ad hoc booleans.
2. Log by upload ID everywhere
From init onward, attach uploadId to logs, traces, and analytics.
When support says “my PDF disappeared,” that ID is how you reconstruct what happened.
3. Verify object existence in finalize
Don’t trust the browser blindly.
4. Clean up abandoned uploads
If init succeeded but finalize never happened, schedule a cleanup job for orphaned objects and stale records.
5. Test with real browsers in CI/CD
Not just mocked API tests. Real browsers.
6. Be honest in the UI
“Uploading” and “Processing” or “Finalizing” are not the same thing. Show users the truth.
7. Keep CORS configuration under version control
Don’t leave it as tribal knowledge in a cloud console. Treat it like application code.
Why this matters more in the AI-generated code era
AI dramatically improves implementation speed. That’s real. It can scaffold the Express routes, React components, S3 integration, even a first pass at Playwright tests.
But AI also makes it easier to ship plausible-looking upload code that was never truly verified.
That’s the trap.
Generated code tends to optimize for local completeness:
- endpoint exists
- component renders
- button calls function
- tests mock the right functions
But users don’t interact with mocks. They interact with the full system.
And uploads are exactly the kind of feature where system boundaries are the product.
If your workflow touches:
- browser file input
- network transfer
- CORS
- signed URLs
- storage
- metadata persistence
- UI re-rendering
then action-level verification is not “nice to have.” It’s the real test.
That’s the shift teams need to make in modern debugging and testing.
Don’t ask, “Did we test the upload function?”
Ask, “Can a user choose a file, upload it, and see it attached in the product?”
Those are not the same thing.
Wrap-up
A reliable upload flow is more than a presigned URL and a green button.
You need:
- backend validation before upload
- direct-to-storage transfer
- real progress UI
- a separate finalize step
- persisted metadata
- end-to-end browser verification in CI/CD
That combination is what turns a demo into a feature you can trust.
If you only unit test the helpers, you’ll miss the bugs users actually hit. If you only check that CI is green, you’ll get false confidence. And if you skip browser-level verification, you’re not really testing the workflow at all.
For modern teams, especially those shipping fast with AI-assisted code generation, this is the standard worth adopting:
test the user action, not just the implementation.
That’s better for reliability, better for debugging, and ultimately better for developer productivity than cleaning up silent upload failures in production.
