Building a File Upload Test Matrix: Partitions, Boundaries and Load
"Test the upload" is not a test plan. An upload requirement usually hides four or five separate rules (allowed types, maximum size, minimum size, count per request, total quota), and each rule needs its own inputs. This post shows how we turn one line of requirements into a short list of files, a table of expected results, and a load script. The example numbers are illustrative; replace them with your own.
Step 1: Rewrite the requirement as rules with numbers
Example requirement: "Users can attach PDF, PNG or JPG files up to 10 MB each, up to 5 files per message."
Before generating anything, turn that into rules a machine can check. Ask the product owner (or read the code) until every rule has a number and a unit:
- R1 types:
application/pdf,image/png,image/jpeg. Decided by extension, header, magic bytes, or all three? - R2 max size: 10 MB. Is that 10,000,000 bytes or 10,485,760 bytes (10 MiB)? In our experience the answer is often "whatever the framework default was".
- R3 min size: is a 0-byte file allowed? (It should not be, but it often is.)
- R4 count: 5 files per message. Is the sixth rejected, or silently dropped?
For the rest of this post we assume the answer to R2 is 10 MiB = 10,485,760 bytes.
Step 2: Equivalence partitions
Each rule splits the input space into groups that the system should treat the same way. You need one representative per group, not a hundred.
- Type, valid: a PDF, a PNG, a JPG.
- Type, invalid by extension: a
.txt, a.zip. - Type, invalid by content: a text file renamed to
.pdf. - Type, valid content with wrong extension: a real PNG renamed to
.jpg. Decide whether this is accepted. - Size: empty, normal (say 200 KB), large-but-valid, too large.
Step 3: Boundary values, in bytes
Bugs cluster at the edges of partitions, so each size partition gets its edges tested explicitly:
| File | Bytes | Expected |
|---|---|---|
| empty.pdf | 0 | Rejected: "File is empty" |
| min.pdf | smallest valid PDF | Accepted |
| under.pdf | 10,485,759 | Accepted |
| exact.pdf | 10,485,760 | Accepted (limit is inclusive) |
| over.pdf | 10,485,761 | Rejected: "Max 10 MB" |
| decimal-over.pdf | 10,000,001 | Accepted (catches MB/MiB mix-ups) |
Repeat the three size-edge rows for each accepted type if different types take different code paths (images often go through a resizer, PDFs often do not). You can generate each file directly from a link, for example /generator?ext=pdf&bytes=10485761, and paste those links into the test plan so anyone can regenerate the exact same file.
If you prefer to generate raw files locally, plain padding is enough for size-only rules (not for type rules):
# Linux: sparse file of exact size (contents are zero bytes)
truncate -s 10485761 over.bin
# macOS
mkfile -n 10485761 over.bin
# Windows (cmd, as administrator on some systems)
fsutil file createnew over.bin 10485761
These files have no valid header, so a server that checks magic bytes will reject them for the wrong reason. That is why we use format-valid templates for anything that touches type validation.
Step 4: Write the expected results first
The table above has an "Expected" column filled in before anyone uploads anything. This matters more than it sounds. If you fill in expectations after running the tests, whatever the system did tends to become "expected". Record for each row:
- HTTP status and error code (e.g.
413or422withFILE_TOO_LARGE). - The message the user sees.
- Whether anything was written to storage. A rejected file should leave no orphaned object behind.
- For accepted files: stored size equals original size.
Step 5: Check the count and quota rules
For R4, attach 5 small files, then 6. Watch the network tab: some implementations send all six and let the server reject the request, some send five and quietly drop one. Both may be acceptable, but only one of them is what the spec says.
For a storage quota, upload files of known sizes and check the displayed usage after each. If the quota is 100 MiB and the user has used 95 MiB, a 5 MiB file should fit exactly and a 5 MiB + 1 byte file should not.
Step 6: Concurrency and load
Functional tests use one user. Upload endpoints often behave differently when many requests hold connections open at once: worker pools fill up, temporary disk fills up, memory grows because each request is buffered in full. A short load test with a mid-sized file catches most of this. Here is a working k6 script:
import http from 'k6/http';
import { check, sleep } from 'k6';
// Loaded once per virtual user in the init stage.
const pdf = open('./dummy-5mb.pdf', 'b');
export const options = {
vus: 20,
duration: '2m',
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<15000'],
},
};
export default function () {
const res = http.post('https://staging.example.test/api/upload', {
file: http.file(pdf, 'dummy.pdf', 'application/pdf'),
});
check(res, { 'accepted': (r) => r.status === 201 });
sleep(1);
}
Run it with k6 run upload.js. Two practical notes: each virtual user holds its own copy of the file in memory, so 20 VUs with a 5 MB file means roughly 100 MB on the load generator; and your laptop's upstream bandwidth is often the real bottleneck, so run load tests from a machine close to the staging environment.
While it runs, watch the server rather than the k6 output: memory per worker, temp directory usage, open file handles, and queue depth for any post-processing. A steady increase in memory under constant load usually means request bodies are being buffered instead of streamed.
Step 7: Clean up
Upload tests leave files behind. Tag test uploads (a dedicated test account, a filename prefix like qa-, or object metadata) so a teardown job can delete them. Without this, staging storage fills up and, eventually, a quota test fails for the wrong reason.
The whole plan on one page
- Rules with numbers and units.
- One representative per partition.
- Limit - 1, limit, limit + 1 for every size rule, per code path.
- Expected results written before execution.
- Count and quota edges.
- A short concurrent run with server metrics.
- Teardown.
More detail on each part: file upload testing guide, exact byte sizes for backend QA, and the file size calculator for unit conversions.
