File upload looks simple right up until real users touch it.
You add a dropzone. You wire an endpoint. You show a progress bar. CI passes. The demo works on localhost. Then production starts teaching you what your tests never covered: uploads that hang at 97%, drag-and-drop that works in Chrome but not in your actual component tree, files that validate on the client but get rejected on the server, requests that succeed while background processing fails, and the worst one of all: the UI says “uploaded” but the asset is not actually usable.
This is exactly the kind of feature AI can scaffold fast and teams still ship broken.
A code generator can give you a drag-and-drop component, an Express route, and a Playwright test that calls setInputFiles(). That is not the same thing as verifying the workflow. Traditional testing often stops at “the request returned 200” or “the DOM contains success text.” CI stays green. Users still lose time and trust.
If you care about reliability, the definition of done for file upload is not “the POST completed.” It is “a user can select or drop a valid file, see accurate progress, recover from transient failures, and use the uploaded asset after processing completes.”
That is what we’re building here.
What we’re building
We’ll build a modern upload flow with:
- drag-and-drop and click-to-select
- client-side size and MIME validation
- upload progress UI
- server-side storage and processing
- explicit failure states
- retry support
- a final usable asset URL
- Playwright tests that verify the real workflow end to end
The stack is intentionally boring:
- Frontend: React + Vite
- Backend: Node + Express + Multer
- Storage: local disk for demo purposes
- Processing: server copies the upload to a processed directory and exposes a public URL
- E2E testing: Playwright
You can adapt the same patterns to S3, Cloudflare R2, signed URLs, Rails Active Storage, Django, Next.js route handlers, or whatever your stack is. The point is not the framework. The point is the workflow.
Why upload flows break silently
Before code, it’s worth being blunt about where teams get false confidence.
Unit tests are too narrow
A unit test can prove your validator rejects files over 5 MB. Great. It tells you nothing about whether drag-and-drop actually populates the browser file list correctly, whether the progress bar updates, whether the network layer retries sanely, or whether the processed file is accessible after upload.
CI often tests a fake path
A lot of file upload tests do one of these:
- mock the upload service completely
- assert only that a request was sent
- stub the backend to always return success
- bypass drag-and-drop and only test the hidden file input
- assert only that a success toast appears
Those tests are fast, but they verify almost none of the user risk.
Backend success is not product success
Upload systems often involve two phases:
- receive the binary
- process or publish it
If phase one succeeds and phase two fails, your request can still return 200 while the user gets a broken result. This is common with image resizing, virus scanning, metadata extraction, OCR, PDF conversion, and storage replication.
Progress bars lie easily
It’s very easy to implement a progress bar that advances based on assumptions rather than actual progress events. It is also easy to forget what happens when progress stops because of a timeout or network reset. Users interpret a stuck progress bar as a broken product, because that’s usually what it is.
Project structure
Here’s the shape of the demo:
txtfile-upload-ci/ client/ src/ App.jsx UploadWidget.jsx api.js server/ index.js uploads/ processed/ tests/ upload.spec.ts package.json
Backend: receive, validate, process, and expose the uploaded file
Let’s start with the server. We want a real upload endpoint, not a fake stub.
Install dependencies:
bashnpm install express multer cors uuid mime-types npm install -D concurrently playwright
Now the server:
js// server/index.js const express = require('express'); const cors = require('cors'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const fsp = require('fs/promises'); const { v4: uuid } = require('uuid'); const app = express(); const PORT = process.env.PORT || 4000; const uploadDir = path.join(__dirname, 'uploads'); const processedDir = path.join(__dirname, 'processed'); fs.mkdirSync(uploadDir, { recursive: true }); fs.mkdirSync(processedDir, { recursive: true }); app.use(cors()); app.use(express.json()); app.use('/assets', express.static(processedDir)); const storage = multer.diskStorage({ destination: (_req, _file, cb) => cb(null, uploadDir), filename: (_req, file, cb) => { const id = uuid(); const ext = path.extname(file.originalname); cb(null, `${id}${ext}`); }, }); const allowedMimeTypes = ['image/png', 'image/jpeg']; const maxFileSizeBytes = 5 * 1024 * 1024; const upload = multer({ storage, limits: { fileSize: maxFileSizeBytes }, fileFilter: (_req, file, cb) => { if (!allowedMimeTypes.includes(file.mimetype)) { return cb(new Error('INVALID_FILE_TYPE')); } cb(null, true); }, }); function maybeFail(req, stage) { const failStage = req.query.failStage; if (failStage === stage) { const error = new Error(`Forced failure at ${stage}`); error.statusCode = 500; throw error; } } async function processFile(req, file) { maybeFail(req, 'processing'); const ext = path.extname(file.originalname).toLowerCase(); const processedName = `${path.parse(file.filename).name}-processed${ext}`; const dest = path.join(processedDir, processedName); // Simulate async processing time. await new Promise((resolve) => setTimeout(resolve, 500)); await fsp.copyFile(file.path, dest); return { assetUrl: `/assets/${processedName}`, originalName: file.originalname, mimeType: file.mimetype, size: file.size, }; } app.post('/upload', (req, res) => { maybeFail(req, 'request'); upload.single('file')(req, res, async (err) => { try { if (err) { if (err.code === 'LIMIT_FILE_SIZE') { return res.status(400).json({ error: 'FILE_TOO_LARGE' }); } if (err.message === 'INVALID_FILE_TYPE') { return res.status(400).json({ error: 'INVALID_FILE_TYPE' }); } return res.status(500).json({ error: 'UPLOAD_FAILED' }); } if (!req.file) { return res.status(400).json({ error: 'FILE_REQUIRED' }); } const result = await processFile(req, req.file); return res.status(201).json(result); } catch (error) { return res.status(error.statusCode || 500).json({ error: error.message || 'PROCESSING_FAILED', }); } }); }); app.get('/health', (_req, res) => { res.json({ ok: true }); }); app.listen(PORT, () => { console.log(`Server listening on http://localhost:${PORT}`); });
A few important details here:
- validation exists on the server, not just the client
- processing happens after upload, because that is where real systems often fail
- we return an asset URL that can actually be loaded later
- we expose failure injection via
failStage, which makes debugging and testing much easier
That last point matters. If you cannot intentionally break your upload path in CI, you are not really testing failure handling.
Frontend: build the upload widget users actually interact with
Now the React side.
Install the client pieces:
bashnpm install react react-dom npm install axios npm install -D vite @vitejs/plugin-react
Create a tiny API helper:
js// client/src/api.js import axios from 'axios'; export async function uploadFile(file, { onProgress, failStage } = {}) { const formData = new FormData(); formData.append('file', file); const response = await axios.post( `http://localhost:4000/upload${failStage ? `?failStage=${failStage}` : ''}`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress: (event) => { if (!event.total) return; const progress = Math.round((event.loaded / event.total) * 100); onProgress?.(progress); }, } ); return response.data; }
And here is the upload widget:
jsx// client/src/UploadWidget.jsx import React, { useRef, useState } from 'react'; import { uploadFile } from './api'; const MAX_FILE_SIZE = 5 * 1024 * 1024; const ACCEPTED_TYPES = ['image/png', 'image/jpeg']; function validateFile(file) { if (!file) return 'Please choose a file.'; if (!ACCEPTED_TYPES.includes(file.type)) return 'Only PNG and JPEG files are allowed.'; if (file.size > MAX_FILE_SIZE) return 'File must be 5 MB or smaller.'; return null; } export default function UploadWidget() { const inputRef = useRef(null); const [dragActive, setDragActive] = useState(false); const [status, setStatus] = useState('idle'); const [progress, setProgress] = useState(0); const [error, setError] = useState(''); const [uploadedAsset, setUploadedAsset] = useState(null); const [lastFile, setLastFile] = useState(null); async function handleSelectedFile(file, failStage) { setError(''); setUploadedAsset(null); const validationError = validateFile(file); if (validationError) { setStatus('error'); setProgress(0); setError(validationError); return; } setLastFile(file); setStatus('uploading'); setProgress(0); try { const result = await uploadFile(file, { onProgress: setProgress, failStage, }); setStatus('success'); setProgress(100); setUploadedAsset(result); } catch (err) { const serverError = err?.response?.data?.error; const messageMap = { FILE_TOO_LARGE: 'File must be 5 MB or smaller.', INVALID_FILE_TYPE: 'Only PNG and JPEG files are allowed.', }; setStatus('error'); setError(messageMap[serverError] || 'Upload failed. Please retry.'); } } function onInputChange(event) { const file = event.target.files?.[0]; if (file) handleSelectedFile(file); } function onDrop(event) { event.preventDefault(); setDragActive(false); const file = event.dataTransfer.files?.[0]; if (file) handleSelectedFile(file); } return ( <div style={{ maxWidth: 480, margin: '40px auto', fontFamily: 'sans-serif' }}> <h1>Upload an image</h1> <div data-testid="dropzone" onDragOver={(e) => { e.preventDefault(); setDragActive(true); }} onDragLeave={() => setDragActive(false)} onDrop={onDrop} onClick={() => inputRef.current?.click()} style={{ border: `2px dashed ${dragActive ? '#11AB5A' : '#999'}`, borderRadius: 12, padding: 32, textAlign: 'center', cursor: 'pointer', background: dragActive ? '#f0fff6' : '#fff', }} > <input ref={inputRef} data-testid="file-input" type="file" accept="image/png,image/jpeg" hidden onChange={onInputChange} /> <p>Drag and drop a PNG or JPEG here, or click to browse.</p> <p>Maximum file size: 5 MB</p> </div> {status === 'uploading' && ( <div data-testid="upload-progress" style={{ marginTop: 16 }}> <div>Uploading: {progress}%</div> <progress value={progress} max="100" style={{ width: '100%' }} /> </div> )} {status === 'error' && ( <div data-testid="upload-error" style={{ marginTop: 16, color: 'crimson' }}> <div>{error}</div> {lastFile && ( <button data-testid="retry-button" onClick={() => handleSelectedFile(lastFile)}> Retry upload </button> )} </div> )} {status === 'success' && uploadedAsset && ( <div data-testid="upload-success" style={{ marginTop: 16 }}> <div>Upload complete.</div> <div>File: {uploadedAsset.originalName}</div> <img data-testid="uploaded-image" src={`http://localhost:4000${uploadedAsset.assetUrl}`} alt="Uploaded asset" style={{ marginTop: 12, maxWidth: '100%' }} /> </div> )} <div style={{ marginTop: 24 }}> <button data-testid="force-processing-failure" onClick={() => lastFile && handleSelectedFile(lastFile, 'processing')}> Simulate processing failure </button> </div> </div> ); }
And mount it:
jsx// client/src/App.jsx import React from 'react'; import UploadWidget from './UploadWidget'; export default function App() { return <UploadWidget />; }
This is enough to demonstrate the real UX states:
- idle
- dragging
- uploading
- success
- error
- retry
Notice what we are not doing:
- pretending frontend validation is enough
- treating “request returned” as the final success condition
- hiding failures behind generic toasts with no state recovery
Why this implementation is still not enough
At this point many teams stop. They click around manually once, maybe write a couple of unit tests, and move on.
That is where bugs survive.
Here are some failures this UI can still have even if your component tests pass:
- the hidden input works but the drag-and-drop path is broken
- the upload request succeeds but the asset URL 404s
- retries invoke stale state or an empty file reference
- progress events never update in headless CI
- a processing failure returns 500 but the UI still shows success because of a bad promise chain
- the image renders from cache locally but fails in CI because the server never actually copied it
This is why end-to-end testing matters. Not because E2E is trendy. Because this feature crosses browser APIs, network behavior, backend parsing, filesystem writes, async processing, and asset serving.
Playwright setup
Install Playwright if you haven’t:
bashnpx playwright install
Add scripts:
json{ "scripts": { "dev:client": "vite --config client/vite.config.js", "dev:server": "node server/index.js", "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"", "test:e2e": "playwright test" } }
Now configure Playwright:
ts// playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', use: { baseURL: 'http://localhost:5173', headless: true, }, webServer: [ { command: 'npm run dev:server', port: 4000, reuseExistingServer: !process.env.CI, timeout: 120000, }, { command: 'npm run dev:client', port: 5173, reuseExistingServer: !process.env.CI, timeout: 120000, }, ], });
Create real test files
Put some fixtures in tests/fixtures/:
valid-image.pngvalid-image-2.jpginvalid-file.txttoo-large.jpg
For too-large.jpg, make sure it’s actually over your 5 MB limit. This matters. Too many tests fake “large file” behavior with mocks instead of giving the browser and server a real oversized payload.
Test 1: prove a real upload becomes a usable asset
This is the baseline test most teams should have and many do not.
ts// tests/upload.spec.ts import { test, expect } from '@playwright/test'; import path from 'path'; const validImage = path.resolve(__dirname, 'fixtures/valid-image.png'); const validImage2 = path.resolve(__dirname, 'fixtures/valid-image-2.jpg'); const invalidFile = path.resolve(__dirname, 'fixtures/invalid-file.txt'); const tooLargeFile = path.resolve(__dirname, 'fixtures/too-large.jpg'); test('uploads a real file and the processed asset is actually usable', async ({ page, request }) => { await page.goto('/'); await page.getByTestId('file-input').setInputFiles(validImage); await expect(page.getByTestId('upload-progress')).toBeVisible(); await expect(page.getByTestId('upload-success')).toBeVisible(); const image = page.getByTestId('uploaded-image'); await expect(image).toBeVisible(); const src = await image.getAttribute('src'); expect(src).toBeTruthy(); const response = await request.get(src!); expect(response.ok()).toBeTruthy(); expect(response.headers()['content-type']).toContain('image'); });
That final assertion is the whole point.
If your test stops at “success message is visible,” it misses one of the most common production failures: the upload request completed but the generated asset is missing, inaccessible, corrupted, or never processed.
This is the difference between testing code paths and testing user workflows.
Test 2: verify drag-and-drop, not just the file input
A hidden input test is useful, but if your UI advertises drag-and-drop, test drag-and-drop.
tstest('supports drag-and-drop upload', async ({ page }) => { await page.goto('/'); const dataTransfer = await page.evaluateHandle(() => new DataTransfer()); const fileBuffer = require('fs').readFileSync(validImage2); await page.evaluate( async ({ dataTransfer, name, mimeType, buffer }) => { const file = new File([new Uint8Array(buffer).buffer], name, { type: mimeType }); dataTransfer.items.add(file); const dropzone = document.querySelector('[data-testid="dropzone"]'); dropzone.dispatchEvent(new DragEvent('drop', { dataTransfer, bubbles: true })); }, { dataTransfer, name: 'valid-image-2.jpg', mimeType: 'image/jpeg', buffer: Array.from(fileBuffer), } ); await expect(page.getByTestId('upload-success')).toBeVisible(); await expect(page.getByTestId('uploaded-image')).toBeVisible(); });
This catches wiring errors between your dropzone and your upload handler. It also proves that your drag event path is not just decorative UI around an untested input.
Test 3: validate type and size on the real path
Now verify invalid inputs.
tstest('rejects invalid file types', async ({ page }) => { await page.goto('/'); await page.getByTestId('file-input').setInputFiles(invalidFile); await expect(page.getByTestId('upload-error')).toContainText('Only PNG and JPEG files are allowed.'); }); test('rejects files that are too large', async ({ page }) => { await page.goto('/'); await page.getByTestId('file-input').setInputFiles(tooLargeFile); await expect(page.getByTestId('upload-error')).toContainText('File must be 5 MB or smaller.'); });
These look simple, but they matter because they exercise both browser file handling and your app’s validation flow.
You can go further and assert the server rejected the upload too, not just the client. In production, client-side validation is convenience. Server-side validation is enforcement.
Test 4: simulate server-side processing failure
This is where most upload tests stop being useful. They never test what happens after the file has been accepted but before it is usable.
Let’s cover that.
First upload a valid file so lastFile exists, then trigger processing failure:
tstest('shows a failure state when server-side processing fails', async ({ page }) => { await page.goto('/'); await page.getByTestId('file-input').setInputFiles(validImage); await expect(page.getByTestId('upload-success')).toBeVisible(); await page.getByTestId('force-processing-failure').click(); await expect(page.getByTestId('upload-error')).toContainText('Upload failed. Please retry.'); });
This proves your app handles the nasty middle ground between “file was selected” and “asset is ready.”
If you skip this, CI can happily approve a workflow where your UI claims upload success while processing failures are dropped on the floor.
Test 5: simulate network failure and verify retry works
Now let’s hit another real production problem: transient network errors.
This one is especially important in CI because many teams only test happy-path localhost conditions. In the real world, flaky connections happen. Even inside corporate networks.
We’ll intentionally break the upload request on the first attempt and let the second attempt succeed.
tstest('recovers from a network failure with retry', async ({ page }) => { await page.goto('/'); let firstRequest = true; await page.route('http://localhost:4000/upload', async (route) => { if (firstRequest) { firstRequest = false; await route.abort('failed'); return; } await route.continue(); }); await page.getByTestId('file-input').setInputFiles(validImage); await expect(page.getByTestId('upload-error')).toContainText('Upload failed. Please retry.'); await page.getByTestId('retry-button').click(); await expect(page.getByTestId('upload-success')).toBeVisible(); await expect(page.getByTestId('uploaded-image')).toBeVisible(); });
This test is doing something valuable:
- it verifies the app stores enough state to retry
- it verifies the retry uses the original file
- it verifies a network-layer failure does not wedge the component permanently
That is practical reliability work. Not checkbox testing.
Test 6: assert progress actually updates
Progress UI is notorious for being implemented and never verified.
In CI, you can’t always depend on granular native progress events for tiny files because uploads complete too quickly. The practical fix is to make the server intentionally slower for a dedicated test environment or use larger files where appropriate.
For this demo, we already added a processing delay, but upload progress itself may still finish quickly on localhost. In a production-grade setup, I recommend a dedicated test route or middleware that throttles upload reads so progress changes are observable.
Even with that caveat, you can still assert that the progress UI appears during upload:
tstest('shows upload progress during the request', async ({ page }) => { await page.goto('/'); await page.getByTestId('file-input').setInputFiles(validImage); await expect(page.getByTestId('upload-progress')).toBeVisible(); await expect(page.getByTestId('upload-success')).toBeVisible(); });
If you want stronger progress verification, add a test-only throttle on the server. For example, behind process.env.NODE_ENV === 'test', introduce a stream transform that delays chunks. Then assert progress reaches intermediate values like 10, 30, 60 before completion.
The exact mechanism matters less than the principle: don’t ship progress UI you’ve never observed under realistic timing.
CI configuration
Now wire this into CI/CD. Here’s a GitHub Actions example:
yaml# .github/workflows/e2e.yml name: E2E Upload Flow on: push: branches: [main] pull_request: jobs: e2e: 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 E2E tests run: npm run test:e2e - name: Upload Playwright report if: always() uses: actions/upload-artifact@v4 with: name: playwright-report path: playwright-report/
This is where the article title matters: actually works in CI.
That means:
- your app boots from scratch
- the backend handles real multipart uploads in the CI environment
- the frontend connects to the backend without local assumptions
- Playwright drives the browser through the real workflow
- failures produce enough debugging output to fix issues quickly
Debugging upload failures in CI
This is the part people leave out and then wonder why E2E feels painful.
When upload tests fail in CI, collect evidence aggressively.
Turn on Playwright traces and screenshots
Update config:
tsuse: { baseURL: 'http://localhost:5173', trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure', }
With uploads, traces are especially useful because they show whether:
- the file input was populated
- the drop event fired
- the request was sent
- the response returned an error
- the UI transitioned to the wrong state
Log backend errors clearly
Your upload route should produce useful logs for:
- rejected MIME types
- file size limit failures
- processing exceptions
- filesystem write failures
If CI only gives you “expected success, got error,” you are going to waste time.
Keep failure injection in the app
Do not strip every failure hook out of non-production environments. Controlled failure injection is one of the best debugging and testing tools you can have.
For file uploads, useful injection points include:
- fail before body parsing
- fail during upload handling
- fail during processing
- fail when publishing the final asset
Verify the final artifact, not just UI state
Again, this is the big one.
A green “uploaded” badge means almost nothing by itself.
The robust assertion is:
- upload completed
- server returned an asset reference
- asset is fetchable
- asset has expected type
- optionally, asset content is valid for downstream usage
For images, that might mean response headers and image renderability. For PDFs, it might mean file size and page count metadata. For CSV imports, it might mean records were actually ingested.
What breaks if you skip end-to-end verification
Let’s be explicit.
If you do not test the full upload workflow in CI, you will miss bugs like:
- a CSS overlay intercepts drop events so drag-and-drop never fires
- the browser accepts a file but your request uses the wrong multipart field name
- the server stores the upload but processing fails silently
- success renders from stale frontend state while the asset URL is invalid
- a retry button appears but retries with no file object attached
- client validation accepts a type the server rejects
- headless browser timing hides a race condition in status updates
- environment-specific path issues break asset serving only in Linux CI
- uploads pass on a warm local machine but fail in CI due to startup timing or missing directories
Every one of these can happen while unit tests stay green.
Practical improvements for production systems
This demo uses local disk and a synchronous-ish response for clarity. Real systems usually need more.
Prefer direct-to-object-storage for large uploads
For large files, route binaries directly to S3 or equivalent via signed URLs. Your app server should coordinate metadata and workflow state, not become a giant file proxy.
But the testing principle stays the same: verify the user workflow, not just the presigned URL generation.
Separate upload completion from processing readiness
If processing is asynchronous, model that explicitly.
Example states:
uploadinguploadedprocessingreadyfailed
Then test transitions accordingly. Don’t collapse everything into “success” if the asset is not ready.
Add idempotency and resumability where needed
If uploads matter to revenue or operations, build for retries as a first-class concern. That may include resumable protocols like tus, multipart S3 uploads, or backend idempotency keys.
Again, write tests for the recovery path. Resilience you never exercise is mostly imaginary.
Track workflow metrics
Monitor:
- upload success rate
- processing success rate
- median time to usable asset
- retry rate
- validation failure rate by reason
These metrics help debugging and improve developer productivity because they reveal whether your failures are in code, infrastructure, or user input.
Tooling comparison: what each layer is good for
You do not need one kind of test. You need the right stack.
Unit tests
Use them for:
- file validator logic
- error mapping
- reducer/state machine behavior
- utility formatting
They are fast and worth having. They are not enough.
Integration tests
Use them for:
- upload route behavior
- multer limits and MIME checks
- processing service logic
- storage adapter behavior
These reduce backend regressions. They still do not prove the browser workflow works.
Playwright E2E tests
Use them for:
- hidden input selection
- drag-and-drop
- progress visibility
- error states
- retry behavior
- final asset usability
This is the layer that catches cross-boundary failures and gives CI/CD a chance to detect user-visible breakage before release.
Actionable practices I’d recommend to any team
If file upload is part of your product, here’s the practical checklist.
- Define success as a usable artifact, not a completed request.
- Validate on both client and server. Client validation improves UX; server validation protects the system.
- Test both click-to-upload and drag-and-drop. They are different paths.
- Exercise failure intentionally. Network aborts, processing failures, and invalid files should all be covered.
- Make retries real. Store enough state to retry without forcing the user to start over where appropriate.
- Assert the final asset is accessible. Fetch it. Render it. Use it.
- Collect traces and logs in CI. Upload bugs are much easier to debug with artifacts.
- Throttle or slow upload paths in test mode if needed. Otherwise progress UI can be impossible to verify reliably.
- Keep the workflow observable. Clear status states beat vague toasts.
- Don’t trust AI scaffolding by default. Generated upload code is a starting point, not evidence of reliability.
Wrap-up
File upload is a perfect example of where modern teams fool themselves.
The code compiles. The unit tests pass. CI is green. The happy-path demo works. And users still hit broken uploads because nobody verified the full workflow from browser selection to usable asset.
That gap is getting worse as AI accelerates implementation. More code gets produced faster, but the hard part is still the same: proving the feature works under real conditions.
If you take one thing from this article, make it this:
For uploads, the only success criterion that matters is whether the user ends up with a real, usable result.
Build the UI carefully. Validate on both sides. Handle failure states explicitly. Add retries. Then use Playwright to upload real files in CI, simulate broken networks and server-side failures, and assert the final asset can actually be used.
That is what reliable testing looks like. And that is what gives CI/CD a chance to represent reality instead of just reporting that a few code paths executed without crashing.
