Learning & reference
Testing a web app in the browser, and the API behind it
Most web app bugs are not in the happy path, they are in what the browser hands you and what the API does under stress. This page pairs each of those with something you can point at right now: a fixture file, or a live endpoint that misbehaves on purpose. Server-side accept-or-reject rules live on upload validation; this is the half in front of it.
1. What the file picker hands your JavaScript
A File object is not a promise of anything. The name, size and type are all attacker or
accident controlled, and file.type comes from the operating system's guess, not the bytes.
A name your UI has to survive
Render the selected filename somewhere and watch what happens with a very long name, a name with no extension, or one carrying a right-to-left override. The last is the one that makes a displayed name disagree with the real extension.
A type that lies
file.type is a hint. A PNG renamed to .jpg reports image/jpeg, and a file with no
extension often reports an empty string, which trips accept filters and any code that
switches on type.
A size you did not plan for
A zero-byte file is a legal selection and a common crash. So is a file larger than your limit, where the question is whether you reject it before or after reading it into memory.
2. The upload that does not finish
Progress bars are written against the case where the transfer completes. The interesting states are the other ones.
Pick a large file, start the upload, then kill your network. What does the UI say? A surprising number of forms sit at 99% forever, because the progress event stops arriving and nothing treats silence as a failure. Then try the reverse: a file that uploads fine but whose response never comes back.
/anything to see what your request actually looked like on the wire: it echoes the method,
headers, query and body back to you. Bodies are truncated at 100,000 characters and are not echoed at
all above 10 MB, so use it to inspect the shape of a request, not to move a large file.
3. Download flows as the user experiences them
Four ways a download goes wrong, each with a route that reproduces it on demand.
| Behaviour | Endpoint | What it catches |
|---|---|---|
| Stalls before the first byte | /delay?ms=5000 |
Connect and read timeouts. The request is open but nothing has arrived. |
| Trickles slowly | /slow?size=10mb&bps=50000 |
The timeout that never fires, because bytes keep arriving. Different bug from the one above. |
| Lies about its length | /truncate?size=10mb |
Declares a Content-Length then closes at half. Clients that trust the header report success on a partial file. |
| Declares no length at all | /no-content-length?size=5mb |
Chunked with no Content-Length, so a percentage progress bar has nothing to divide by. |
4. Forms: maxlength, paste, and the mismatch
Client-side validation is a convenience, never a guarantee, and the two halves drift apart.
The classic failure is a client regex that is stricter or looser than the server's. Paste a string that
passes one and fails the other and you get either an error the user cannot act on, or data the server
should have refused. maxlength is the same shape of problem: it truncates typing but not
always pasting, and never a scripted submit.
For every place you accept content, find where it is rendered back. That is where a stored value stops being data and starts being markup.
QA test strings: Unicode, emoji and injection cases · Regex tester
5. API contract: the shape your front end is built on
A deterministic API you can assert against, rather than a staging server that changes under you.
Pagination, both styles
/api/products?page=2&limit=50 returns page, limit, total, totalPages and hasNext in
the body, and Link plus X-Total-Count in the headers. Test whichever your client reads, then test
what it does when the other one disagrees.
Stable records
250, 500 and 1000 record collections where the same id always returns the same record, so a
snapshot assertion does not rot. /api/users/42 for one record; an out-of-range id
returns a JSON 404 rather than HTML.
Sort, filter, delay
?sort=-price for descending, ?q=cache for a substring filter, and
?delay=2000 to hold the response so you can see your loading state for longer than a
blink.
6. The values JavaScript quietly changes
Not every API bug is in the API. Some are in the parse.
JSON numbers become doubles, which represent integers exactly only to 2^53-1. An ID of
9007199254740993 parses as 9007199254740992, so the record you send back is not
the one you were given, and nothing raises an error. Large IDs belong in strings. The same class of
problem hits money stored as a float, and timestamps that lose their timezone on the way through.
JSON that loses precision on parse · Dates and times · JSON formatter
7. Loading, empty, error, partial
Four states, and most UIs are only designed for one of them.
Use ?delay=2000 to hold the loading state still. Use a filter that matches nothing for the
empty state, which is where "0 results" and "something went wrong" are most often confused. Use
/status/503 for a clean error, and /flaky?rate=30 for the intermittent one that
only shows up for real users. Add a seed, /flaky?rate=50&seed=7, when a test needs the
same outcome every run.
8. Auth and session inside a real browser tab
The parts that only break once a browser, not a test client, is holding the credentials.
/bearer returns 401 unless an Authorization header is present; /basic-auth/user/pass
returns 401 with a WWW-Authenticate challenge. Together they let you check that your client attaches
credentials, and more importantly that it stops rather than looping when it gets a 401 back.
The race worth reproducing: open the app in two tabs, let the token expire, then act in both. Two refresh calls fire, one wins, and the loser may write a stale token over the fresh one. Decode what you are actually holding with the JWT decoder, which runs in your browser.
/cookies and /cookies/set?a=1 echo and set cookies,
but cookie attributes beyond the basics are not configurable here, so SameSite and __Host-
prefix behaviour needs your own origin to test properly.9. Volume, and one rule about measuring it
Four fixtures and two generator shapes cover most of it.
A table that is fine with 20 rows and unusable with 20,000 is a rendering problem, not a data problem.
/api/posts?limit=1000 gives a large collection; /gen?type=json&records=500000
and /gen?type=csv&rows=1000000&cols=20 give something far past what any UI should
render at once.
The measuring rule: throttle the network in devtools before you judge anything. A payload that feels instant on your machine is the same payload a user waits eleven seconds for, and the bug you are looking for only appears at the second speed.
10. A run sheet you can finish in an hour
In order. Each step is one of the sections above.
| # | Do this | Pass looks like |
|---|---|---|
| 1 | Select a 0-byte file and a file with no extension in your upload form | A specific message, not a crash and not silent acceptance |
| 2 | Start a large upload, kill the network | The UI reports a failure and offers a retry, rather than sitting at 99% |
| 3 | Download from /truncate?size=10mb |
The client notices the file is short instead of reporting success |
| 4 | Load a list from /api/products?page=2&limit=50 |
Page 2 shows, and the last page does not offer a next |
| 5 | Point the same list at /flaky?rate=50&seed=7 |
A retry happens, and the failure is surfaced if it keeps failing |
| 6 | Filter to something that matches nothing | An empty state, clearly distinct from an error state |
| 7 | Throttle to slow 3G and repeat step 4 | A loading state exists and nothing double-fires |
Questions
How do I test a slow API response?
Two different routes, because they are two different bugs. /delay?ms=5000 stalls before the first byte, which a connect or read timeout should catch. /slow?size=10mb&bps=50000 sends the first byte immediately then trickles, and many clients never time out on that at all because data keeps arriving.
How do I simulate a flaky API for testing retries?
/flaky?rate=30 fails roughly that share of requests with 503 and a Retry-After header. Add a seed, /flaky?rate=50&seed=7, when a test needs the same outcome every run. The seed pins one outcome so the assertion is stable; it does not play back a sequence of failures and successes.
Is there a fake REST API for testing pagination?
Yes. /api/users, /api/products and /api/posts are deterministic collections of 250, 500 and 1000 records. /api/products?page=2&limit=50 returns page, limit, total, totalPages and hasNext in the body plus Link and X-Total-Count in the headers, so you can test either pagination style. The same id always returns the same record.
Why does my JavaScript change the ID my API returned?
JSON numbers become IEEE 754 doubles, which hold integers exactly only to 2^53-1. An id above that is silently rounded, so 9007199254740993 becomes 9007199254740992 and the record you PUT is not the one you GET. Send large ids as strings. There is no error anywhere in that sequence.
How is this different from the upload validation page?
Upload validation is the server deciding whether to accept a file. This page is the browser half in front of it: what the picker hands your JavaScript, what the progress bar does when a transfer stalls, and whether the UI recovers when a request never completes.
Take it further
The pages this one deliberately does not repeat.
Open the generator Upload validation → HTTP headers → Status codes →
More from Learning
Guides and references for test data, file handling and AI evals. All free, no sign-up. See the full hub.