Load tests check how the API holds up when many people use it at the same time. They measure response times, throughput (requests per second) and error rate, so slowdowns and failures under load show up before real users find them. They don't check that features work correctly; unit, integration and E2E tests do that (see testing.md).
Load tests use Apache JMeter. The test plan, tests/load/compass-testPlan.jmx, load
plays a typical browsing session against the API, with real, temporary accounts. Each run has three stages:
- Create a set of temporary accounts.
- Load test: all the accounts browse at the same time, for a set amount of time.
- Delete the accounts again.
In this guide:
- Prerequisites, including setups for Windows, WSL, Linux and macOS
- JMeter basics
- How the plan works and the test accounts
- Running the test: commands, stopping early, flags and properties
- Element reference
- Adding a new test to this plan
- Reading the results
- Automated runs: the smoke and weekly full tests in GitHub Actions
- Troubleshooting
Values in angle brackets are placeholders: <API_HOST> and <FIREBASE_API_KEY> are apiHost and firebaseApiKey in
the plan's User Defined Variables (the dev key is also in common/src/envs/dev.ts).
- Java 8 or newer (17+ recommended), installed on the system where JMeter runs.
- Apache JMeter 5.6+, set up as described in Choose your setup.
The plan's files live in tests/load/: compass-testPlan.jmx (the test plan) and check_results.py (checks a
results file against limits, used by CI, see check_results.py).
A run creates these files next to the plan. They are git-ignored:
| File or folder | What it is |
|---|---|
created_accounts.txt |
The accounts that exist right now, passed between stages (see below) |
errors.xml |
Details of failed requests, added to on every run |
jmeter.log |
JMeter's own log, including the warnings the plan's scripts write |
results/, report/ |
The raw results of each run (.jtl files), and the HTML report |
created_accounts.txt and errors.xml always land next to the plan, wherever JMeter was started. jmeter.log goes to
the folder JMeter was started from, so start the GUI from tests/load/ too.
The commands use short paths such as results/smoke.jtl, which only work when JMeter runs inside
tests/load/ on the same system as the project. Check your setup with jmeter --version in the terminal you'll run
the commands from. Java must be installed on that same system; in WSL that means in WSL (use the full package, as
the -headless one can't show the GUI: sudo apt install openjdk-21-jre).
Project and JMeter on the same system (everything on Linux, on macOS, or on Windows without WSL): install JMeter
and add its bin/ folder to your PATH. On Linux or macOS:
cd ~ && curl -LO https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-5.6.3.tgz && tar -xzf apache-jmeter-5.6.3.tgz
echo 'export PATH="$HOME/apache-jmeter-5.6.3/bin:$PATH"' >> ~/.bashrc && source ~/.bashrcProject in WSL, JMeter installed in WSL (recommended for WSL): run the same two commands in a WSL terminal. Putting
bin/ at the front of the PATH means this JMeter wins over any on your Windows PATH; check with
command -v jmeter. The GUI opens as a normal Windows window through WSLg.
Project in WSL, JMeter installed on Windows: the JMeter download has a Linux launcher (bin/jmeter) as well as
jmeter.bat, so WSL can run it from /mnt/c with WSL's Java. Add the bin/ folder to your WSL PATH, e.g.
echo 'export PATH="$PATH:/mnt/c/tools/apache-jmeter-5.6.3/bin"' >> ~/.bashrc (find the path with
wslpath 'C:\tools\apache-jmeter-5.6.3'). Start-up is about a second slower; a running test is just as fast. Open
the GUI on Windows with the plan's \\wsl.localhost\… path (wslpath -w compass-testPlan.jmx prints it).
Don't run the commands with
jmeter.batagainst a WSL folder.cmd.execan't use a\\wsl.localhost\…path as its working folder and switches toC:\Windows, so the short paths point to the wrong place.
To open the plan in the GUI: run jmeter -t compass-testPlan.jmx from tests/load/, or start JMeter and use
File → Open with the plan's full path.
A JMeter test plan is a tree of elements. Each element type has one job:
| Term | What it means | Example in this plan |
|---|---|---|
| Thread group | A group of threads that all run the same steps | 1 - Create accounts |
| Thread | One simulated user. Each uses one test account | One of the 100 threads in a thread group |
| Sampler | Something a thread does, usually one request | SETUP | Firebase Signup POST |
| Config element | Settings or data used by samplers | CSV Data Set Config, HTTP Header Manager |
| Pre/Post-processor | Runs before/after a sampler, e.g. to pull a value out of a reply | JSON Extractor |
| Assertion | Checks a response and marks the request failed if it's wrong | Response Assertion (code must be 200) |
| Timer | Adds a pause before requests | Think time |
| Logic controller | Controls how often or whether its children run | Runtime Controller, If Controller |
| Listener | Collects or shows results | Simple Data Writer |
- Variables belong to one thread and are read with
${name}. A variable a thread sets while it runs (likeauthToken) doesn't carry over to other thread groups. Variables in User Defined Variables are copied to every thread, which is why${apiHost}works everywhere. - Properties are global to the run. Read them with
${__P(name,default)}, set them with-Jname=value. - Scope: an element applies to its parent and everything under it. A Header Manager under one request only affects that request.
- Order for each request: config elements → pre-processors → timers → the request → post-processors → assertions → listeners. So a post-processor can't see whether an assertion passed.
compass-testPlan.jmx
├── User Defined Variables hosts, endpoints and the run ID
├── 0 - Check for leftover accounts (setUp) stops the run from creating accounts if earlier ones remain
├── 1 - Create accounts (setUp) creates accounts, saves them to created_accounts.txt
├── 2 - Browsing session (Thread Group) the load test, reads created_accounts.txt
├── 3 - Delete accounts (tearDown) reads created_accounts.txt, deletes each account
├── 4 - Forget deleted accounts (tearDown) removes the deleted accounts from created_accounts.txt
└── Listeners Summary Report, Simple Data Writer, View Results Tree
The stages run one after another (Run Thread Groups consecutively is on). setUp groups run before the main test, tearDown groups after it, and tearDown also runs after a graceful shutdown.
0 - Check for leftover accounts (setUp, 1 thread). After a run, created_accounts.txt only lists accounts that
couldn't be deleted. If it isn't empty, an earlier run was killed or failed to clean up. Emptying the file would lose
the only record of those accounts, so instead this stage fails with a message, and sets setupThreads, threads and
sessionThreads to 0: this run creates nothing and only deletes the leftovers. Run again afterwards. Skipped when
checkLeftovers=0.
In the GUI, properties last until JMeter is closed, so those zeros would also stop the next run. That's why this stage
first puts back any thread counts an earlier run set to 0 (it saves them in the leftoverCheckSaved property). The
first run after updating from an older version of the plan is usually cleanup-only once: older versions left deleted
accounts in the file, and teardown reports them as Account already deleted.
1 - Create accounts (setUp, setupThreads threads, one account each):
- Make this thread's account (JSR223 PreProcessor) sets the thread's email, random password, username and name (see The test accounts).
SETUP | Firebase Signup POSTcreates the Firebase login. A JSON Extractor saves the token asauthToken.SETUP | create-user-and-profile POSTcreates the app user and profile.- Save account for test and teardown (JSR223 PostProcessor) appends
email|password|token|name|profileCreatedtocreated_accounts.txt. An account whose profile creation failed is still saved, since its Firebase login exists;profileCreated=falsetells teardown how to delete it.
2 - Browsing session (sessionThreads threads). Each thread takes one line of created_accounts.txt, then a
Runtime Controller repeats a visit for sessionDuration seconds. A thread whose account has no profile
(profileCreated=false) skips the visit, since every request would fail. A visit makes the calls the web app makes when
someone opens it and browses profiles:
TEST | /me GET: the signed-in user. Passes if the code is200and the returnednamematches the account. Saves the account'suserIdand username for the requests below.TEST | get-profile GET (own),TEST | set-last-online-time POST,TEST | get-notifications GET(limit=450, asuse-notifications.tsasks),TEST | get-channel-memberships GET(limit=500, for the messages icon) andTEST | compatible-profiles GET: what loads in the background on every page. When the account has message channels,TEST | get-channel-seen-time GETalso runs, with onechannelIdsparameter per channel (added by a JSR223 PreProcessor). Test accounts have no channels, so it's skipped for now.TEST | get-profiles GET (feed): the first page of the profile grid, asprofiles-home.tsxasks for it (20 cards, ordered by compatibility with this account). Saves the usernames and the last profile's id.TEST | get-profile GET (feed profile),profileViewstimes (3 by default): opens a random profile from that page.TEST | get-profiles GET (next page), in half the visits (a Throughput Controller): scrolls to the next page withafter.
Steps 2 to 5 are skipped when /me fails, and 4 and 5 when the feed is empty. Think time (a Uniform Random Timer)
pauses 2 to 5 seconds before each request, like a person reading. It also keeps each account under the API's rate
limit of 120 requests a minute per user (API_RATE_LIMIT_PER_MIN_AUTHED): with no pause, most rate-limited requests
would come back 429. One HTTP Header Manager (with the token) and one Response Assertion (code 200) sit directly
under the Runtime Controller and cover every request in the visit.
3 - Delete accounts (tearDown, 1 thread, one loop per line of created_accounts.txt):
TEARDOWN | Firebase Sign in POSTgets a fresh token (setUp's may have expired after 1 hour). If Firebase says the account doesn't exist, it is counted as already deleted and passes.- If the profile was created:
TEARDOWN | Delete app usercalls/me/delete, which deletes the app user, their files and their Firebase login. It sends the reasonjmeter-load-test, stored indeleted_users. ThenTEARDOWN | Check login is gone POSTsigns in again, because/me/deletereturns200even when deleting the Firebase login fails. A400there is the expected answer: it passes and counts the account as deleted. - If the login still exists (there's no profile,
/me/deletefailed, or the login survived it):TEARDOWN | Firebase delete accountdeletes the Firebase login directly, so it doesn't stay behind.
Each successful delete is remembered, and 4 - Forget deleted accounts then rewrites created_accounts.txt with
only the accounts that couldn't be deleted. A warning in jmeter.log says how many remain.
The API only accepts most requests from signed-in accounts, so each thread needs its own real account. The plan makes them itself; there's no account file to maintain.
| Value | Made as | Example |
|---|---|---|
loadtest<runId>x<n>@example.com |
loadtest28120940x1@example.com |
|
| password | A random UUID | |
| username | loadUser<runId>x<n> |
loadUser28120940x1 |
| name | Load Test <n> |
Load Test 1 |
<n>is the thread number.<runId>is therunIdproperty, by default the day, hour, minute and second the run started (ddHHmmss). CI passes its own. A fresh ID every run means an overlapping run, or leftovers from an earlier one, can never causeEMAIL_EXISTSor a taken username.example.comis reserved for testing (RFC 2606) and can't receive mail. The API'ssendEmailskips such addresses, so creating accounts sends no welcome emails (once the API with that check is deployed).- Usernames are cut to 25 characters, and the API cleans names (no emoji or symbols, at most 30 characters, no staff
words like "admin"). The
/merequest checks the name comes back unchanged, so keep both short and plain if you change the script.
Always run load tests from the command line (non-GUI mode, -n). The GUI uses far more CPU and memory, which can
make JMeter itself the bottleneck. Use the GUI only to build and debug the plan.
Run these from tests/load/. The test runs against the shared dev API and Firebase, not your local stack.
Smoke test (1 thread, 10 seconds). Run it before every full run to check each step works. Firebase allows 100 new accounts per hour from one IP address, so leave an hour between a smoke test and a full run if you're near that.
jmeter -n -f -t compass-testPlan.jmx -l results/smoke.jtl -e -o report -Jthreads=1 -JsetupRamp=1 -Jramp=1 -Jduration=10 -JthinkTimeMs=0 -JthinkRandomMs=0The think-time flags matter. With the default 2 to 5 second pauses, 10 seconds only covers the first 3 or so requests of a visit. Without pauses it runs about 3 full visits, so every request is checked, and one account for 10 seconds stays well under the rate limit.
Full run with HTML report (the defaults: 100 threads, 120 seconds):
jmeter -n -f -t compass-testPlan.jmx -l results/results.jtl -e -o reportReport with only the load test requests (hides setup and cleanup; in PowerShell use double quotes):
jmeter -n -f -t compass-testPlan.jmx -l results/results.jtl -e -o report '-Jjmeter.reportgenerator.sample_filter=^TEST \|.*'Another environment. An API only accepts tokens from its own Firebase project, so pass both:
jmeter -n -f -t compass-testPlan.jmx -l results/results.jtl -JapiHost=<API_HOST> -JfirebaseApiKey=<FIREBASE_API_KEY>PowerShell: wrap any
-Jargument whose value contains a dot in double quotes, or PowerShell may split it.
Cleanup only. Deletes the accounts still listed in created_accounts.txt, without creating any. A normal run
already does this when it finds leftovers, so you only need it to clean up without starting a test:
jmeter -n -f -t compass-testPlan.jmx -l results/cleanup.jtl -JcheckLeftovers=0 -Jthreads=0Rebuild a report from an existing results file (the report folder must be empty or missing):
jmeter -g results/results.jtl -o reportTo stop a run and still delete the accounts, run shutdown.sh (shutdown.cmd on Windows) from another terminal of
the same kind. It's in JMeter's bin/ folder. Type the full name: plain shutdown turns your computer off. In the GUI,
use Run → Shutdown.
Ctrl+C, stoptest.sh or Run → Stop stop straight away and cleanup doesn't run. The accounts stay listed in
created_accounts.txt, and the next run deletes them before doing anything else.
| Flag | Meaning |
|---|---|
-n |
Non-GUI mode |
-t <file> |
The test plan to run |
-l <file> |
Where to save the raw results (.jtl). Without -f, it adds to an existing file |
-e -o <dir> |
After the run, build an HTML report into this folder (must be empty without -f) |
-f |
Delete the old results file and report folder first |
-g <file> |
Build a report from an existing .jtl without running a test |
-J<k>=<v> |
Set a property the plan reads (see below) |
| Property | Default | What it controls |
|---|---|---|
threads |
100 |
Threads for account creation and every load test, unless overridden |
ramp |
10 |
Seconds to start all threads in each load test, unless overridden |
duration |
120 |
Seconds each load test runs, unless overridden |
setupThreads |
threads |
Number of accounts to create |
setupRamp |
10 |
Seconds to start all account-creation threads |
sessionThreads |
threads |
Threads in the browsing session |
sessionRamp |
ramp |
Seconds to start all browsing session threads |
sessionDuration |
duration |
Seconds the browsing session runs |
profileViews |
3 |
Feed profiles opened per visit |
thinkTimeMs |
2000 |
Shortest pause before each load test request, per thread |
thinkRandomMs |
3000 |
Random extra pause on top of thinkTimeMs (0 to this) |
apiHost |
<API_HOST> |
API host to test against |
firebaseApiKey |
<FIREBASE_API_KEY> |
Firebase key for sign-up and sign-in. Must match apiHost's environment |
runId |
start time | Added to every email and username (see The test accounts) |
checkLeftovers |
1 |
0 skips the leftover check |
Specific settings override general ones: -Jthreads=50 sets everything to 50 threads; adding -JsessionThreads=200
changes only the browsing session. Keep setupThreads at least as high as the largest load test, since each load test
thread needs an account. Ramp-up is how long JMeter takes to start all threads; starting them all at once can
overload sign-in. Lowering the think time sends more load per account, but in runs longer than a short smoke test
keep the average pause above about 0.5 seconds, or the rate limit turns requests into 429s.
To add an element in the GUI, right-click the element it should go under → Add. To turn one off, right-click → Disable. Save with Ctrl+S.
| Element | How it's used here |
|---|---|
| Test Plan | Run Thread Groups consecutively on, so stages run in order. Run tearDown Thread Groups after shutdown on, so a graceful stop still cleans up. |
| Thread groups | Thread counts are properties with fallbacks, e.g. ${__P(setupThreads,${__P(threads,100)})}, read when the group starts. A group with 0 threads is skipped. All groups Continue after a sampler error. |
| User Defined Variables | baseAuthUrl, apiHost, firebaseApiKey, runId, and the Firebase endpoint paths (signupUrl, signinUrl, deleteAccountUrl; getUserDataUrl is unused). Wrap a value in __P to make it settable with -J. |
| CSV Data Set Config | Two, both reading created_accounts.txt with | as the delimiter and the sharing mode Current thread group, so the load test and teardown each go through every account. Stop thread on EOF is on, Recycle off. |
| HTTP Request | One per step. Names start with SETUP |, TEST | or TEARDOWN |, so stages are easy to tell apart and the report can filter. Server is ${baseAuthUrl} or ${apiHost}, without https://. Query parameters go in the Parameters tab, not the path. |
| HTTP Header Manager | In setUp and teardown, under each request: Content-Type: application/json, plus Authorization: Bearer ${authToken} where signed in. In the browsing session, one under the Runtime Controller sends both for every request. |
| JSON Extractor | Saves $.idToken from sign-up and the teardown sign-ins as authToken. In the session: /me's id and username, the feed's usernames and last profile id, and the message channel IDs ($.channels[*].channel_id, all matches). |
| JSR223 (Groovy) | The leftover check (including putting back thread counts an earlier GUI run set to 0), account generation, saving accounts, adding the channel IDs to get-channel-seen-time, the teardown sign-in checks, remembering deletes and rewriting the file. Scripts use vars, props, prev, log, and find files next to the .jmx with FileServer.getFileServer().getBaseDir(). Cache compiled script is on. |
| Response Assertion | Response code must equal 200. In setUp and teardown, one under each request; in the browsing session, one under the Runtime Controller checks every request. |
| JSON Assertion | On TEST | /me GET: $.name must equal ${name}, not as a regex (a . in a name would break it). |
| If Controller | In the session, skip the whole visit for an account without a profile, and skip requests whose input is missing (/me failed, empty feed, no message channels). In teardown, run /me/delete when the profile was created, and the direct Firebase delete when the login still exists (signedIn and not yet in deletedAccounts). |
| Loop, Throughput Controller | Open profileViews feed profiles per visit; load the next page in 50% of visits. A random feed profile is ${__V(feedUsername_${__Random(1,${feedUsername_matchNr})})}. |
| Runtime Controller | Repeats a visit for ${__P(sessionDuration,${__P(duration,120)})} seconds. |
| Uniform Random Timer | Think time: ${__P(thinkTimeMs,2000)} ms plus a random 0 to ${__P(thinkRandomMs,3000)} ms before each session request. |
| Listeners | Simple Data Writer saves failed requests only to ~/errors.xml, with response bodies but not request bodies (they hold passwords and tokens). The ~/ means the plan's folder, so the file lands there wherever JMeter was started. Each run adds to it. Summary Report is GUI only. View Results Tree is disabled: great for debugging, too heavy for load runs. |
| Debug Sampler | Disabled, at the end of 1 - Create accounts. Enable it with View Results Tree to see a thread's variables in the GUI; disable both before a load run. |
If you add a column to created_accounts.txt, add it to the join in Save account for test and teardown and to
the Variable Names of both CSV configs, in the same position.
New load tests reuse the accounts created by setUp and deleted by tearDown, so you only build the test itself.
- Right-click
2 - Browsing session→ Duplicate, and drag the copy after the last load test. (To add a request to the session instead, put it inside the existing Runtime Controller with aTEST |name. It gets the shared Header Manager and Response Assertion automatically; put its parameters in the Parameters tab.) - Rename it
N - <new test name>and pick a property prefix, e.g.<newtest>:- Number of Threads:
${__P(<newtest>Threads,${__P(threads,100)})} - Ramp-up period:
${__P(<newtest>Ramp,${__P(ramp,10)})} - Runtime Controller → Runtime:
${__P(<newtest>Duration,${__P(duration,120)})}
- Number of Threads:
- Add
<newtest>Threadstokeysat the top of SETUP | Check created_accounts.txt is empty. The leftover check uses that list both to set the thread counts to 0 and to put them back on the next run. - Keep
CSV - Created accountsas it is. - Change the HTTP Request (name
TEST | <new request>, method, path, body) and update the assertions so they check this response. - Make sure
setupThreadscovers the new test's thread count, then save and run a smoke test.
Keep in mind: tests run one after another and share the same accounts, so total time is the sum of their durations. Tokens expire after 1 hour; if tests together run longer, add a sign-in request (like the teardown one) at the start of the new group. A test that changes account data (e.g. the name) affects later tests, so put it last.
The results file (.jtl), named with -l, has one CSV row per request, in every stage. The HTML report is built
from it. Useful columns: elapsed (response time in ms), label, responseCode, success, failureMessage,
Latency, Connect and allThreads (the load at that moment). Sort by elapsed for the slowest requests, or keep
files from several runs to compare them (-f overwrites a file with the same name). It holds no passwords or tokens.
Only command-line runs with -l create it; a run started in the GUI doesn't.
The HTML report: in Statistics, check #Samples fits what you asked for (e.g. 100 sign-ups for 100 threads).
Failures in SETUP | rows mean less load than intended. Average hides slow outliers, so look at the 90th/95th/99th
pct columns. SETUP | create-user-and-profile POST is slow on dev (9 to 15 seconds per account in recent runs), so
account creation takes a while; it isn't part of the p95 check.
The session requests don't all get the same number of samples, by design:
TEST | get-profile GET (feed profile)runsprofileViews(3) times per visit.TEST | get-profiles GET (next page)runs in about half the visits.TEST | get-channel-seen-time GETdoesn't appear while the test accounts have no message channels.- Each thread's last visit stops when the time runs out, so the requests early in a visit get one more sample than the later ones.
errors.xml holds only failed requests, with the response body that shows why. It adds to itself on every run,
so delete it first if you only want one run's errors. Check each entry's ts (a timestamp in milliseconds) to see
which run it came from.
Two workflows run the plan, using the shared steps in .github/workflows/load-test.yml:
Smoke test (load-smoke-test.yml) |
Full test (load-full-test.yml) |
|
|---|---|---|
| Runs when | A PR into main changes backend/api/, backend/shared/, common/src/api/, tests/load/ or the load workflows |
Every Monday at 06:00 UTC |
| Size | 3 threads for 30 seconds, with a fixed 200 ms think time so each thread completes several full visits | 100 threads for 120 seconds, with the default 2 to 5 second think time |
| Passes when | No request fails, and the p95 of TEST | requests is at most 2000 ms |
At most 1% of each request fails, and TEST | p95 is at most 1200 ms |
| Artifact | load-smoke-report |
load-full-report |
Either can also be started by hand from the Actions tab → Run workflow.
They test the API deployed to dev, not the pull request's code. Dev only changes when someone runs
./deploy-api.sh devinbackend/api/. To load test a change, deploy it to dev first.
A run installs JMeter (cached, checked against Apache's SHA-512), runs the plan with runId set from the workflow run
ID and attempt number, checks the results with check_results.py, runs Cleanup only if
JMeter didn't finish, and uploads the report, results, errors.xml and logs.
Concurrency: each PR has its own smoke group, where a newer push cancels the older run (its cleanup step still runs), and the full test has its own group. A shared group would let one PR's push silently cancel another PR's queued run or the weekly full run, because GitHub keeps only one pending run per group.
To change a workflow's size or limits, edit the values under with:; think_time_ms and think_random_ms set the
plan's thinkTimeMs and thinkRandomMs (the full test leaves them at the defaults). Going above 100 accounts needs a
higher sign-up quota in the Firebase console.
JMeter exits with 0 even when requests fail, so CI uses tests/load/check_results.py to pass or fail a run. It
checks, per request label:
- Error rate, for every request. A failed setup request means less load; a failed cleanup request leaves accounts behind.
- p95 response time, for
TEST |requests with at least 20 samples.
It also fails when there are no TEST | results at all. Run it locally from tests/load/ (use python on Windows):
python3 check_results.py results/results.jtl --max-error-rate 1 --max-p95 1200It prints each label's samples, error rate and p95, then PASS or one FAIL: line per broken limit, and exits with
0 or 1. The defaults are 1% and 1200 ms.
The run says created_accounts.txt lists N accounts from an earlier run. An earlier run didn't finish cleaning up.
This run deleted those accounts instead of testing; check the TEARDOWN | rows, then run again. Accounts that still
couldn't be deleted stay in the file, and jmeter.log says how many. Don't delete test accounts in the Firebase console
instead: that only removes the login, and the app user stays in the database. The first run after updating from an
older version of the plan usually does this once, and its TEARDOWN | rows say Account already deleted: that's
expected.
Requests fail with 429. The think time is too short for the rate limit; raise thinkTimeMs.
A smoke test only shows a few requests, one sample each. The 2 to 5 second think time used up the run. Add
-JthinkTimeMs=0 -JthinkRandomMs=0, as in the smoke test command.
jmeter.log isn't in tests/load/. JMeter writes it to the folder it was started from. Start JMeter from
tests/load/, or look in the folder you started it in.
Fewer load test threads than expected. created_accounts.txt has fewer lines than the test has threads. Check the
SETUP | rows for failures, and that setupThreads is high enough.
TEST | /me GET fails the JSON Assertion. The API changed the name when saving it. Keep names plain (see
The test accounts).
${something} appears literally in a request. The variable was never set: it's misspelled (names are
case-sensitive), set in another thread group, or the extractor found nothing. Enable View Results Tree (and the
Debug Sampler) and run a smoke test in the GUI.
Report folder error. JMeter won't write a report into a folder that isn't empty. Add -f, or delete the folder.