@sourceloop/load-testing¶
Load tests for the HTTP API of a LoopBack facade or service, and for the screens of a web app. We write scenarios in TypeScript, next to our other tests. The package turns each scenario into an Artillery script, and runs it against an app that already runs. It fails the run when an endpoint is too slow, has too many errors, or got slower than its baseline. It also fails the run when the project has too many heavy APIs.
A browser scenario opens the web app in Chromium, for many users at once. It times the steps that a user waits for, and reads the Web Vitals of each page. The same limits, baseline and report apply. See Browser scenarios.
This package is for load. For a serial latency gate on one operation, with no concurrent users, use @sourceloop/benchmarking. The two work well together. The benchmark guards the code, and the load test guards the running system.
New to the package? Read Install and Quickstart, then Quickstart for a browser scenario. Looking for an option, a default or a message? Go to Reference and Troubleshooting.
Contents:
- Overview
- Get started
- Concepts
- Guides and recipes
- Best practices
- Reference
- Help and internals
- License
Overview¶
When to use it¶
Use this package when a change can make an API or a screen slower, and we want the CI job to fail before a user sees it. load.it registers an HTTP scenario, and load.browser registers a browser scenario.
| We want to know | We use |
|---|---|
| Does each endpoint of a running API stay fast with many users at once? | load.it |
| How long does a user wait on each screen of a web app, with many users? | load.browser |
| Does one operation stay fast in code, with no concurrent users? | @sourceloop/benchmarking |
| Does a screen show the right content, and does a click do the right thing? | Playwright tests |
A run fails, with exit code 1, when an endpoint or a screen step is too slow, has too many errors, or is slower than its baseline. See Limits, baseline and report.
What this is, and what it is not¶
This package load tests the HTTP API of one running app. The app can be a facade or a service. A scenario is a list of requests that a virtual user sends. The package sends many such users at the rate we choose, measures the p95 time of each endpoint, and judges it. A browser scenario does the same for a web app. Each virtual user opens the app in Chromium and runs a flow of steps.
It has limits on purpose.
- It does not start apps or databases. We start them before the run.
- It does not check what a screen does. Our Playwright tests do that. A browser scenario only times the screens.
- A browser scenario does not time the API calls of the page. A
load.itscenario on the same API does that. - It runs on one machine. It does not spread the load over many machines.
- It does not check where the URLs point. The scenarios create and delete data. We run them only against a local or a test environment, never against production.
Get started¶
Install¶
What we need to know before the install.
- We need Node 22 from 22.18, or Node 24. Odd releases are not supported. The
enginesfield of the package says>=22.18.0 <23 || 24. The floor comes from Artillery 2.0.34, which asks for Node 22.18 or later. - Artillery comes with the package as a dependency, pinned to 2.0.34. We install nothing else for it. An install of this package into an empty project adds 468 packages (npm 11, Artillery 2.0.34, measured on 2026-10-09). Artillery has the MPL-2.0 license. We use it unmodified.
- Artillery lists
@playwright/browser-chromium, and that package downloads a browser in its install script. A project with onlyload.itscenarios does not need the browser. It skips the download withnpm install --ignore-scripts, or it setsPLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1before the install. - A project with browser scenarios needs Chromium for the Playwright of Artillery, 1.62.1 today.
npm installalready downloads it, through@playwright/browser-chromium1.62.1, a dependency of Artillery 2.0.34. It does not when npm runs no install scripts (for example withignore-scripts, or when npm blocks the script) or whenPLAYWRIGHT_SKIP_BROWSER_DOWNLOADis set. Then we runnpx playwright@1.62.1 install chromium. On a new Linux machine, we add--with-depsfor the OS libraries. A plainnpx playwright installgets the browser of the project's own Playwright, which can be another build. pgis an optional peer dependency. We install it only when a datasource hastype: 'postgres'. See Datasources.- We compile with
skipLibCheck: true. The type file of Artillery 2.0.34 (artillery/types.d.ts) has an error of its own. The LoopBacktsconfigof an ARC app already sets this option.
The Artillery engine switches the Artillery telemetry off (ARTILLERY_DISABLE_TELEMETRY), so Artillery sends no usage data. Artillery also gets only a short list of the variables of the CLI. See The environment of Artillery.
The package finds Artillery when the first scenario runs, not at import. If it is missing, the run stops with this message:
Quickstart¶
This is the shortest working setup, in the facade orders-facade. We assume that the facade has an npm run build script and a committed src/openapi.json. See Set up a package.
First, src/__tests__/load/config.ts. It holds the default load.
Second, one scenario file, src/__tests__/load/orders.controller.load.ts.
Third, two scripts in the package.json of the facade.
Fourth, the environment. The login user is a test user (see Log in). The password and the secret come from the shell or from CI secrets, never from a file in git.
Last, run it. The orders facade must already run at the base URL.
The CLI prints Scenario: <name> before each scenario, and Artillery prints its own progress while the load runs. At the end the console reporter prints the result. This is the first run, so no baseline exists yet.
The run passes, and the exit code is 0. To record the baseline, run again with LOAD_TESTS_UPDATE_BASELINE=1. Later runs compare each p95 with it. See Baseline. The numbers above are an example, and ours will differ.
Quickstart for a browser scenario¶
This is the shortest working setup for a web app. We assume that the app has an npm run build script, and that we can serve its production build on a port. See Production build or dev server.
First, src/__tests__/load/config.ts. A browser run needs only phases.
Second, src/__tests__/load/home.load.ts. Each vuser gets a Chromium page. A relative URL in page.goto starts at LOAD_TESTS_UI_URL, which we set in step four. The step ends at the first animation frame where its end element is visible. We replace main with an element that only the loaded page shows.
Third, the build. Angular, Vite and webpack do not write dist/__tests__, so the load files get their own tsc build. We copy tsconfig.load.json from Set up a web app, and add two scripts.
Fourth, we build the app for production and serve it. A run of only browser scenarios needs this one variable.
npm install downloads Chromium. If it did not, see Install. The CLI starts and closes Chromium to check it, runs the load, and prints one block for the scenario. This is the end of the output for a page that has a main element. The numbers are an example, and ours will differ.
The exit code is 0. npm run test:load stops with 1 when a metric fails. We run npx load-testing report . to see every metric. See Read the output.
Read the output¶
Each scenario prints one block. The exit code is the verdict: 0 when everything passed, 1 when something failed.
An HTTP scenario prints a table. Each row is one endpoint.
| Column | Meaning |
|---|---|
Workload, Vusers |
The load that ran, and how many vusers started, completed and failed. |
Requests, Req/s |
How many requests the endpoint got, and the rate. |
p95 ms (limit) |
The p95 of the endpoint, and the highest p95 that passes. |
Base ms |
The baseline p95. - means that there is none yet. |
Err % (limit) |
The share of failed requests, and its limit. |
Result |
pass, or FAIL: and the reasons. |
A browser scenario prints a short block: the vusers, the flow total, and how many metrics pass. A table follows only when a metric fails, and it holds only the failed rows. load-testing report <package-dir> prints every metric. See Report for full samples of both.
Artillery is the engine under the package. Each run prints its progress while the load runs, and its counters at the end of the load. See What the Artillery counters mean.
Fix a failure¶
Read the Result column first. Each FAIL: reason has a usual cause.
| The reason says | The usual cause | What we do |
|---|---|---|
no requests |
The endpoint never got a request. An earlier request stopped the vuser, a captureFrom found nothing, or a hook failed. |
Read the Artillery output above the table. Fix the first error. |
error rate X% > Y% |
The app answered with 4xx or 5xx, or the network failed. | Check the app log, the token, and the data that before creates. |
p95 X ms > limit Y ms |
The endpoint is slow, or the load is too high for the machine. | Run with fewer vusers to tell the two apart. If the limit is wrong, set p95 and write the reason. |
p95 +N% (+M ms) over baseline B ms |
The endpoint got slower than its last 10 runs. | Find the change. If it is on purpose, see Baseline. |
no samples |
No vuser ran the step, or a step name has a typo. | Check the names in steps, and the errors of the vusers. |
lost samples: N for M completed vusers |
Some vusers did not run the step, or a page sent no Web Vital. | Make every vuser run every step. |
vuser failure rate X% > Y% |
A browser scenario judges failed vusers. The share of failed vusers is above errorRate. |
Read the errors of the failed vusers. Fix the first one. |
FAIL: <message> and no table |
The scenario stopped before or during the load. | Read the message, and find it in Troubleshooting. |
The full list of reasons is in The reasons in a FAIL line.
Run it locally and in CI¶
Locally, we start the app, build, and run npm run test:load. We add LOAD_TESTS_UPDATE_BASELINE=1 to record the first baseline. In CI, the job starts the app, restores the baseline, and runs the same script. See Run in CI for an API repo and a web app repo.
Concepts¶
Words we use¶
| Word | Meaning |
|---|---|
| Scenario | One load.it. A list of requests that one virtual user sends. We name it after what a user does. |
| Browser scenario | One load.browser. A flow that one virtual user runs in Chromium. |
| Step | A named and timed part of a browser flow, for example step('save', action). |
| End element | The element that the CSS selector of endWhenVisible finds. The step ends at the first frame where this element is visible. |
| Flow total | The time of the whole flow of one vuser. The report shows it, and it has a limit only when the scenario sets flow. |
| Web Vitals | FCP (first contentful paint), LCP (largest contentful paint), CLS (cumulative layout shift), TTFB (time to first byte) and INP (interaction to next paint). Artillery reads them only in the first page that a vuser opens. A second page of the same vuser reports none. |
| Endpoint id | METHOD path, for example GET /orders/{id}. Results and baselines use it. |
| Vuser | A virtual user. It runs the steps of a scenario once. |
| Phase | One step of the load, for example {duration: 60, arrivalRate: 10}. A load is a list of phases. |
| Workload | The load of a scenario as one line of text, for example 10 vusers/s for 60 s. |
| p95 | The time that 95% of the requests beat, in milliseconds. The time runs to the first byte of the response. It does not include the download of the body. |
| Limit | The highest value that passes. Each threshold has one. |
| Baseline | The mean p95 of the last 10 recorded runs of an endpoint. |
| Report | The text that the reporter prints at the end of a run. load-testing report prints it again from the last run. |
| Verdict | Pass or fail, for an endpoint, a scenario and the whole run. The exit code of run gives it. |
| Heavy API | An endpoint with a p95 limit above P95_TARGETS.supportingApi (1000 ms). |
| Hook | A function that runs at a set point of a run, for example before. |
| Value | An object that the engine fills in at run time, for example randomString(). |
| Datasource | A named database connection that ctx.sql can use. |
| Engine | The part that runs the load and measures it. The default is Artillery. |
| Reporter | The part that gets the result of the run. The default prints to the console. |
How a run works¶
load-testing run <package-dir> does these steps, in this order.
- Read
src/__tests__/load/config.tsfromdist, and check its values. - Load every
*.load.tsfile fromdist/__tests__/load. Check the names, the limits, the phases and the values of each scenario. - Choose the scenarios to run (
.onlyand.skip). - Check that every endpoint of the scenarios that run is in
src/openapi.json. A package without this file still runs, and it skips the check. - Read the environment.
LOAD_TESTS_BASE_URLis required when aload.itscenario runs, when a browser scenario hasbeforeorafter, or when the run logs in.LOAD_TESTS_UI_URLis required when a browser scenario runs. Stop with aConfigErrorwhen a scenario sendstoken()and no login is set. - Log in, when
LOAD_TESTS_USERNAMEis set. - Read the baseline file, if it exists.
- For each scenario, one after another:
- Get a fresh access token, if the old one is more than a minute old.
- Run
before. - Run the token check, if the scenario sends
token(). - Write the Artillery script (and the worker hooks file), and try the worker hooks once.
- Run the phases with Artillery, and read its report.
- Run
after, then the cleanups (the last one first), then close the SQL connections. - Judge the scenario against its limits and the baseline.
- Judge the run. Every scenario must pass, and the count of heavy APIs must be at most
maxHeavyApis. - Print the report with the reporters.
- Save the baseline, if
LOAD_TESTS_UPDATE_BASELINE=1. A failed run is not saved, unlessLOAD_TESTS_FORCE_BASELINE=1. - Exit with 0 when the run passed, and with 1 when it did not.
Step 8.6 runs also when before or the load failed, and after a signal.
Before it starts, each load-testing run deletes the report files of the last run. So load-testing report judges only the last run.
A browser scenario has before and after, but no token check or worker hooks. Its script runs the Playwright engine of Artillery. Before each browser scenario, the CLI runs the check of its browser. For Chromium, it starts and closes the browser, to check that it works. The check uses the launchOptions of engines.playwright in artillery.config, as Artillery does, or the ones that we give to chromium(). See Browsers.
Limits, baseline and report¶
A run turns what the engine measured into a verdict.
- The engine (Artillery by default) measures each endpoint: requests, errors and p95. For a browser scenario, it measures each step, each Web Vital and the flow total.
- The package compares each value with its limits.
- The reporter prints the result.
load-testing reportprints it again from the report files of the last run. - The exit code gives the verdict, 0 or 1.
There are four limits for an HTTP endpoint. A browser metric has p95, p95Regression and minDelta. A browser scenario judges failed vusers with the errorRate of thresholds.default.
| Limit | It asks | Needs a baseline | Default |
|---|---|---|---|
p95 |
Is the p95 at or below this many ms? | No | 500 |
errorRate |
Is the share of failed requests at or below this percent? | No | 1 |
p95Regression |
Is the p95 at most this percent above the baseline? | Yes | 20 |
minDelta |
Is the p95 more than this many ms above the baseline? | Yes | 10 |
A regression fails the run only when it is above both p95Regression and minDelta. The baseline is the mean of the last 10 recorded runs. Without a baseline, a run checks p95 and errorRate only. See Thresholds and the verdict and Baseline.
Guides and recipes¶
The guides below follow the work of one project. We set up the package, write HTTP scenarios, write browser scenarios, set limits and a baseline, run in CI, and read the report. Recipes at the end answers short "how do I" questions.
Set up a package in a facade and service monorepo¶
Our monorepos hold facades and services. A facade takes the request from the client, calls one or more services, and each service reads its own database.
Where the tests live¶
We put the load tests in the facade in most cases. A request to the facade passes through the whole path, from the facade to the services and to the databases. So one test covers all of it. A service can have its own load tests too, for an endpoint that clients do not reach through a facade. The set-up is the same for both, and each package has its own config.ts, scenarios and baseline.
Build first¶
The package does not compile our TypeScript. It loads the files that tsc made, from dist. For src/__tests__/load/<name>.load.ts it loads dist/__tests__/load/<name>.load.js. So we build before each run. The pretest:load script of the quickstart does it.
- A missing built file stops the run, and the message names the file.
- A built file that has no source file is never loaded.
- The build step must keep
dist/__tests__. Most services of this catalog runopenapi-specinbuildorpostbuild. Theirpreopenapi-spechook runsrm -rf dist/__tests__, and it runs on every call ofopenapi-spec. So after it, we compile again. Thepretestscript of such a service does it, andpretest:loadcan do the same, for example"pretest:load": "npm run clean && lb-tsc".
The OpenAPI file¶
The CLI checks every endpoint of the scenarios against src/openapi.json. The run stops when a request names an endpoint that the file does not have. The report also shows how many endpoints of the file the scenarios cover.
A package without the file still runs. It skips the check, and the coverage line reads Coverage: not calculated, because the application has no API specification. A web app with only browser scenarios needs no such file. An API package makes the file as shown below.
ARC apps have an openapi-spec script. It boots the built app from dist, and writes the spec to the path that we pass. We build the app first, then we run the script file with node. We generate the file, and we commit it.
In an app that the ARC generator made, npm run openapi-spec -- src/openapi.json also works, because its preopenapi-spec hook builds the app first. Some scripts have several commands, as in some catalog services. Then npm gives the path to the last command only. So we call node directly.
We run this again after each change of a route, so the file stays current. The CLI does not fetch the spec from the running app. The apps serve it behind Basic auth when authenticateSwaggerUI is on in the core config.
Keep load files out of the unit tests¶
The unit test run of the package loads dist/__tests__, and that folder now holds the load files. A load file is not a unit test. We tell Mocha to ignore the folder in .mocharc.json.
Ignore the generated files¶
The CLI writes its generated files to src/__tests__/load/.out/. These are the Artillery scripts, the worker hooks files, the reports and baseline.json. We add this line to .gitignore and to .prettierignore of the package. Prettier reads only the ignore files of the folder where it runs, so without the line npm run lint fails after a load run.
The baseline is the one file that we can keep between CI runs. See Baseline.
Folder layout and naming¶
The CLI finds every *.load.ts file under src/__tests__/load/, also in subfolders, in the order of the paths. We do not list them.
- A load file holds the scenarios. Its hooks only call functions of
fixtures/. A load file never imports another load file. - A fixture file makes and removes the rows of one entity. A function that makes data starts with
given, for examplegivenCustomer. It registers the cleanup of what it made withctx.defer, right after the create. A function that removes data starts withcleanup. - A fixture file can import the fixtures of an entity that it needs. There must be no import cycle. If an order needs a customer,
order.tsimportscustomer.ts, andcustomer.tsnever importsorder.ts. - The CLI loads only
*.load.tsfiles. A file infixtures/is an import. - We name a scenario after what a user does, for example "browse orders and customers". The report shows the name as the heading of the scenario. A name such as "returns 200" tells the reader nothing.
A fixture function takes the RunContext that before gets. We import the type with import type, because the package lints with @typescript-eslint/consistent-type-imports. This is fixtures/customer.ts. The fixture sends the token itself, because ctx.api does not. See The ctx of before and after.
The hook of a scenario calls it, for example runVars.customerId = await givenCustomer(ctx).
Write scenarios¶
load.describe and load.it¶
load.it(name, requests, options?) adds one scenario, like it of Mocha. load.describe(name, options?, fn) groups the scenarios of a file, like describe. A load.describe can hold other load.describe blocks. Its function runs at once, and it cannot be async. A file needs at least one load.it or load.browser.
- The scenarios run one after another. Files run in file-name order, and a file runs in the order of its
load.itcalls. They share the app, its pools and the baseline, so they do not run in parallel. Each scenario has its own load, hooks and data. - The name of a scenario in the report and in
baseline.jsonisgroup › name, for exampleOrderController › browse products. The CLI names the generated files after it, in lower case with-between the words. Two scenarios cannot make the same file name, and a name needs a letter or a digit. A browser scenario runs once for each browser, so its label ends with the browser, for exampleorders › save an order › chromium. See Browsers. - The file name
baselineis reserved for the baseline file. The CLI refuses a scenario whose file name would bebaseline, for examplebaselineorBaseline!, when it loads the scenarios. The message readsThe scenario name "<name>" gives the file name "baseline", which the baseline file uses. Rename the scenario.The namebaseline ordersis fine. - The endpoint id is
METHOD /path, with the path as we wrote it, for exampleGET /orders/{id}.
Run only some scenarios¶
| Call | Effect |
|---|---|
load.it.only(...), load.describe.only(...) |
Only the scenarios with only run. |
load.it.skip(...), load.describe.skip(...) |
The scenario does not run. The report lists it as Skipped. |
- On a
load.describe,.onlyand.skipapply to every scenario inside it. skipwins overonly. Only a scenario with.onlyand without.skipruns. A.onlyon a scenario that also has.skipruns nothing. When no other scenario has.only, nothing runs. The report lists every scenario as skipped. The scenarios that.onlyleaves out are listed as skipped too. This holds only on a local run. WithCIset, such a.onlyis an error (see below).- A
.onlythat stays in a file hides the other scenarios. When the variableCIis set to a value other than empty,0orfalse, a.onlystops the run. The CLI ignores the letter case, soFalseandFALSEalso mean that this is not CI. Any other value means CI. A.onlystops the run in CI, also when the same scenario has.skip. The message names the scenarios, likemocha --forbid-only. We remove the.onlybefore we push. - When
CIis set and every scenario is skipped, the run stops withNo scenario runs: every scenario has .skip. So a green gate cannot hide that nothing ran. A local run printsSkipped:lines and exits with 0. - When
.onlyleaves scenarios out, the CLI prints a line such asOnly 1 of 2 scenarios run (.only).
Requests¶
load.get, load.post, load.put, load.patch and load.delete make a request. Each takes the path and an options object. A path can hold {name} parts, which pathParams fills. A name is any text without braces or a slash, for example {id} or {order-id}.
| Option | Use |
|---|---|
pathParams |
Fills the {name} parts of the path. A string, a number or a value. |
query |
The query string. A number or a boolean becomes its text. |
json |
The JSON body. A value works at any depth, through plain objects and arrays. A Date or a class instance goes as it is. |
headers |
Headers of this request. They win over the headers of the scenario. |
p95, errorRate, p95Regression, minDelta |
Limits of this endpoint. See Thresholds and the verdict. |
beforeRequest, afterResponse |
Worker hooks of this request. See Hooks. |
artillery |
{raw}, the Artillery request fields. See Use any Artillery feature. |
A request object belongs to one place in a flow. To send the same request twice, call load.get twice.
Values¶
A value is an object that the engine fills in when a vuser sends a request. It works in the json, pathParams, headers and query of a request. The scenario holds no {{ }} engine syntax.
| Call | Gives |
|---|---|
vars(name) |
What before set in vars, or what beforeEach returned. |
captureFrom(from, jsonPath) |
A part of the response of an earlier request of the scenario. |
token() |
The access token of the login user. |
randomString(length = 8) |
length letters and digits, new for each use. |
randomNumber(min, max) |
A whole number from min to max, new for each use. |
uuid() |
A UUID, the same in all requests of one vuser. |
template |
Joins strings, numbers and values into one text. |
To put a value inside a string, we use the tag template.
templatetakes strings, numbers and values.- A plain template literal fails on purpose.
${randomString()}in a normal backtick string callstoString()of the value, and this throws aTypeErrorthat says to usetemplate. Without the throw, the text would be[object Object]. - For the same reason, a value cannot go into
JSON.stringify.toJSON()throws aTypeError. We build a JSON text that holds a value, such as afilter, withtemplate(see the second scenario above). - A plain string that contains
{{inpath,pathParams,headers,queryorjsonstops the run with aScenarioError. This keeps the syntax of one engine out of the scenario. The CLI does not check the strings underartillery. A value that a hook or a response gives at run time has its own check. See Text that the engine refuses. - The same check covers the keys of objects in
json,headers,query,pathParamsand theheadersof a scenario. The message readsPOST /orders: json key "{{ x }}" contains "{{": rename the key. For a nested key it readsjson.deep[0] key "a{{" contains "{{": rename the key. A key cannot usetemplate, so we rename it. A body with a key namedconstructor, or an object fromObject.create(null), gets the same checks. - The check reads one string at a time. Two strings that touch can still form
{{after the engine joins them. So in atemplate, we never put a lone{next to a value. captureFromworks in all four places, also inside atemplate. The source must be an earlier request of the same scenario, found by its endpoint id. It cannot be a request of the sameload.parallelgroup. A capture that finds nothing stops the vuser.vars('name')needs anamefrom thevarsofbefore, or from whatbeforeEachreturns. The CLI stops before the load when no hook sets it.- There is no list of custom values. For a value that these calls cannot make, we compute it in a hook (
before,beforeEachorbeforeRequest), and read it withvars(name). - The calls check their arguments when the scenario file loads. For example
randomString(0)andrandomNumber(5, 1)throw aTypeError.randomString(length)needs a whole number from 1. randomNumber(min, max)needs whole numbers from 0 up, andminmust not be abovemax. A negative or fractional value throws aTypeErrorwithrandomNumber: the minimum and the maximum must be whole numbers from 0 up. Artillery reads only letters, digits,_,,and spaces inside$randomNumber(...).- We do not write
{{ $env.LOAD_TESTS_TOKEN }}by hand. The CLI gives the token to Artillery only for a scenario that usestoken(). See Send the token.
Text that the engine refuses¶
Artillery fills a template with a text replace, and it expands a text again after the fill. So a value that holds {{ ... }} can read $env or loop for ever. A value that holds $&, $` or $' can loop or grow, because the replace reads them as patterns. A $$ becomes one $, so pa$$word in a longer text would reach the API as pa$word. We call these five texts the refused text: {{, $&, $`, $' and $$. A $1, a $0 and a $<name> pass.
The package checks the values that arrive at run time. The check reads strings, arrays, and the values and the keys of objects. It reads one value at a time. It never prints the value.
- A text in the
varsofbefore, at any depth, fails an HTTP scenario before its load with aRunError. A browser scenario has no template fill, so its vars can hold the refused text. For a browser scenario, only a value that JSON cannot write (a cycle or aBigInt) fails the scenario before its load. The next scenario still runs. The check reads the vars as JSON, as the generated file holds them. So a result oftoJSONcounts, and a valueundefinedis skipped. A cycle invarsgives aTypeErrorfromJSON.stringify. The<path>looks likevars.order.noteorvars.ids[1].afterand the cleanups still run. - A
beforeEachresult with the refused text fails the vuser. The message starts withload-testing hook beforeEach:and then holds the message ofvarsbelow. The worker finds it, so it appears after the load starts. The worker reads the result as JSON too, so atoJSONresult counts. A value that JSON cannot write is a hook error. - A captured value (
captureFrom) with the refused text fails the vuser before it sends another request. The check runs first in theafterResponselist of the request, before a hook of ours. - An access token with the refused text fails the scenario with a
RunError, before the CLI writes any file. The next scenario still runs. The message has no token in it. - A base URL with the refused text fails the scenario with a
RunError, before the CLI writes any file. Artillery reads the target as{{ $env.LOAD_TESTS_BASE_URL }}, so such a text would change the target. The message does not show the URL.
The messages read like this. The first line is for vars, the second for a capture, the third for the token, and the fourth for the URL. A browser scenario names LOAD_TESTS_UI_URL in place of LOAD_TESTS_BASE_URL.
A refused text in vars is rare. A name or a note that comes from an API can hold it, so we keep such a value out of vars.
The checks belong to the Artillery engine. A custom engine gets none of them from the package. It must refuse such text itself, if its own engine reads templates. See Engines.
Where we see a failure of the worker.
- The
load-testingprocess exits with 1, because the scenario fails. - Artillery prints
errors.<message> <count>in its own summary, with two spaces and no dots for a long message. A short message gets dots between the message and the count. Artillery cuts a line at 79 columns, so a long message starts with the cause. - The report of the package shows the line
Hooks: <n> error(s), not app errors (FAIL). - The full message is the key
errors.<message>underaggregate.countersinsrc/__tests__/load/.out/<slug>.report.json. The<slug>is the label of the scenario,group › name, in lower case. Each run of other characters becomes one-.
Parallel requests¶
load.parallel(requests, {limit?}) sends requests at the same time in one vuser. The flow goes on when all of them finish. In a hook, we use Promise.all instead.
- A group holds only requests. It cannot hold a
load.stepor another group. limitis the most requests that run at the same time. It must be a whole number of 1 or more.- Each request of a group keeps its endpoint id, its limits and its row in the report.
- A request after the group can capture from a request in the group. A request in the group cannot capture from another request of the same group. The run stops with a
ScenarioError, because the response can be missing. - A group can sit in the
loopof aload.step.
Group options and the merge order¶
load.describe(name, options?, define) and load.it(name, requests, options?) both take headers, phases, thresholds, step and vitals. load.it also takes the hooks, tokenCheck and artillery. step and vitals are for browser scenarios. A browser scenario takes no headers and no thresholds from load.describe. It uses step and vitals.
The layers, strongest first:
- the request
load.itload.describe(the inner one first, then the outer one)config.ts- the library default
| Option | How the layers combine |
|---|---|
headers |
They merge by name. Names ignore case. The winner keeps its spelling. |
phases |
The list of the strongest layer replaces the others. There is no merge. |
thresholds |
They merge by field. A browser scenario takes none of them. |
step, vitals |
They merge by field. See Limits of a browser scenario. |
In the example, the scenario gets the authorization and X-Trace headers. It gets its own phases, a p95 of 800 and an errorRate of 2. The request limit p95: 400 wins on GET /order-items.
Set the load¶
The load is phases, a list of phases that run one after another. A phase uses the field names of Artillery. We set it in one of two places.
phasesinconfig.tsis the default of the package. Every scenario uses it.phasesin the options of a scenario (or of aload.describe) replaces the default. The lists do not merge, so we name all the phases that we want. We use it for a flow that the app cannot take at the default rate.
In config.ts:
In orders.controller.load.ts:
| Phase | Meaning |
|---|---|
{duration, arrivalRate} |
arrivalRate new vusers a second |
{duration, arrivalRate, rampTo} |
the rate goes from arrivalRate to rampTo |
{duration, arrivalCount} |
arrivalCount vusers in all |
{pause} |
no new vusers |
name on a phase |
a label that Artillery shows in its output |
maxVusers on a phase |
the most vusers that run at once |
duration and pause are seconds, or a text such as 1m. The CLI checks the values before the load. These rules hold.
- A phase needs a
duration, and one of two forms of load:arrivalCount, orarrivalRatewith an optionalrampTo. A pause needs apausetime. duration,arrivalCountandpausemust be whole numbers above 0.arrivalRateandrampTocan be 0.- A phase with
rampToalso needsarrivalRate. - A text value for
duration,pauseorarrivalCountmust read as more than 0. The CLI reads it as Artillery reads a time:"30"and"1m"pass, and"0","0m","-5"and"soon"give the messages above. A text value forarrivalRateorrampTois not read by the CLI. It only checks that the text is not empty. Artillery reads the text, and a text that it cannot read fails later.
The messages read like this.
phases[i] needs arrivalRate or arrivalCountphases[i] needs arrivalRate with rampTophases[i].duration must be a whole number above 0, or a text such as "1m", and the same forpausephases[i].arrivalCount must be a whole number above 0phases[i].arrivalRate must be 0 or more, and the same forrampTo
The report shows every phase in its Workload line, for example 10 vusers/s for 60 s. A ramp reads ramp 1 to 5 vusers/s over 60 s. A count reads 100 vusers over 30 s. A pause reads pause 10 s. The phases join with , then. A phase with maxVusers ends with (at most N vusers at once). An example is 10 vusers/s for 60 s (at most 50 vusers at once). The name of a phase is not part of the text.
The baseline file keeps this text. A run with another workload does not compare its p95 with the old one. A change of maxVusers counts as a changed workload. See Baseline.
Every vuser of a scenario runs its steps once. A scenario that needs a longer load than the access token lives stops before the load starts. See Log in.
Hooks¶
A scenario can have hooks. They create the data that it needs, and they remove it.
| Hook | Runs | Where |
|---|---|---|
before(ctx, vars) |
once per run, before the load | the CLI |
after(ctx, vars) |
once per run, always | the CLI |
beforeEach({vars, vu}) |
when a vuser starts | a worker of the engine |
afterEach({vars}) |
when the flow of a vuser ends without an error | a worker of the engine |
beforeRequest(req, {vars}) |
before each send of that request | a worker of the engine |
afterResponse(req, res, {vars}) |
after each response of that request | a worker of the engine |
before and after run in the CLI. They use full TypeScript. They can import and call anything. before puts values in vars. after gets the same vars, also when before stopped half way. To run calls at the same time in a hook, we use Promise.all.
The hooks of before call the app with ctx.api. That client sends no token, so a call to a protected endpoint makes a client with the token first, as the example does. This needs a login. See Log in.
The ctx of before and after¶
| Member | Use |
|---|---|
ctx.token |
The access token of the login user. Undefined without a login. |
ctx.claims |
The claims of that token. {} without a login, or for a token that is not a JWT. |
ctx.api |
The HTTP client of the app under test (LOAD_TESTS_BASE_URL). It sends no token by itself. |
ctx.apis.<name> |
The HTTP client of another backend that apis in config.ts names. It works like ctx.api. See Other backends. |
ctx.sql(db, text, params?) |
One SQL statement on a datasource of config.ts. |
ctx.defer(cleanup) |
Registers a cleanup for something that the hook made. |
ctx.api adds the base URL to each path. It sends no Authorization header and no other default header. For a call that needs the token, we make a client that has it with ctx.api.with.
Other backends¶
A hook sometimes needs a backend that is not the app under test, for example the auth service. We name each one in apis of config.ts. The value is the name of the environment variable that holds its URL.
ctx.apis.<name>is a client likectx.api. It has the same methods,.with, errors, redirects and the timeout of 60 s. It sends no token. It adds its own base URL to each path.- TypeScript does not know the names, because a scenario file does not import
config.ts. A name thatapisdoes not have throws aConfigErrorwhen the hook reads it, for exampleNo API 'auht' in config.ts apis. Known: auth, core. - A run that needs the API stops before the first scenario when a variable of
apisis not set, or is not an http or https URL. The message names the variable. - These clients work in
before,afterand the cleanups. A worker hook, and the load itself, still go only toLOAD_TESTS_BASE_URL. - A browser scenario with
beforeorafterstill needsLOAD_TESTS_BASE_URL, also when its hooks use onlyctx.apis.
A path must start with /. Otherwise the call throws a RunError that reads <METHOD> <path>: the path must start with "/", and it sends nothing. A path such as @host/x would change the host and send the token there. A path such as //host/x starts with /, so it goes to the base host as a path.
| Call | Gives |
|---|---|
get(path, options?), delete(path, options?) |
The body, parsed as JSON. |
post, put, patch (path, body?, options?) |
The body, parsed as JSON. |
raw(method, path, body?, options?) |
The Response, for a file, a stream or a header. |
with({headers}) |
A new client that adds these headers to each call. |
- The options of a call are
headers,queryandtimeoutMs. - A header with the value
undefinedremoves a default header of the client. - A
queryvalue goes throughString(), never through JSON. The client skips a value ofundefined. It addsqueryto a query that the path already has. timeoutMsis 60 s by default. The requests of the login have the same limit.- The client sends the body of a request in this order.
undefinedsends no body and no content type. AFormData,URLSearchParams,Blob,ArrayBuffer, typed array or string goes as it is, and the client sets no content type. Anything else goes as JSON withcontent-type: application/json, unless we set a content type. - A response with no body gives
undefined. We ask for<void>in the type. - A 2xx body that is not JSON throws a
RunError. We useraw()for it. - Any status that is not 2xx throws an
HttpError, also fromraw(). - A network failure throws a
RunErrorwith the text<METHOD> <path> failed: <cause>. A call that takes longer than its timeout throws aRunErrorwith<METHOD> <path> timed out after <N> s. This holds also when the body stalls while the client reads it. ctx.apifollows redirects. The login does not. See The flow.- The package exports
ApiClient.new ApiClient(baseUrl, options)makes a client for our own code.optionscan holdtoken,headers,timeoutMsandsignal.tokenadds the bearer header. The type ofsignalis() => AbortSignal | undefined. The client addsbaseUrlto the path as it is, sonew ApiClient('http://h/')makes the URLhttp://h//orders. The CLI removes a slash at the end of its base URL, and this class does not.
Cleanups with ctx.defer¶
ctx.defer(cleanup) registers a cleanup for the data that a hook made. We call it right after the create, as the example above does.
- At the end of the run, the CLI calls the cleanups, the last one first. So a row goes before the rows that it depends on.
- This happens after
after. It also happens whenbeforestopped half way, when the load failed, and when a signal came. - A cleanup can use
ctx.api. It works after a signal too. - A cleanup that throws does not stop the others, and the run fails. When the scenario ran to the end, the report shows each failure as a
FAIL: cleanup failed:line. Whenbeforeor the load failed, the CLI prints<scenario>: cleanup failed: <message>on stderr instead. - A cleanup must accept that its row is gone already.
- Vusers run in workers, so they cannot register a cleanup. We give the rows of the vusers a name tag, and remove them in
after.
Why not only after? after runs once, and it does not know how far before got. Say that before creates a customer and an order, and the order create fails. Then after must check each id to see what exists. A new create needs one more check in after, and a missed check leaves rows behind. With ctx.defer, the function that creates the data also registers its cleanup, right after the create works. So the list holds only what exists. A cleanup can even go before the create. That is safe when the cleanup runs with nothing to remove, because it finds the rows by the name of the run. It also covers a create that committed, but whose reply we lost.
We still use after for rows that no hook created, such as the rows that the vusers made. It runs before the deferred cleanups, so these rows go before the rows that they point to. One rule needs care. We register first the cleanup that must run last.
Worker hooks¶
The workers do not load our scenario file, with one exception. For a browser scenario, the processor loads the built flow file. The engine copies the source of beforeEach, afterEach, beforeRequest and afterResponse into a file for the workers (.out/<slug>.processor.cjs). So these rules hold.
- A worker hook can use only its arguments and JavaScript globals, such as
URL,Buffer,crypto,processandsetTimeout. It cannot use animportor a variable of its module. - A worker hook must be synchronous. A worker does not wait for a promise. An
asynchook would run its work after the vuser went on, and nobody would see a rejection. The CLI fails the scenario before its load when one of the four hooks returns a promise. The next scenario still runs. beforeEachmust give the same result for the same arguments, and it must have no side effects. It must return an object, the vars of the vuser.- Before the load, the CLI runs each hook once, with made-up arguments, from the generated file. A hook that uses a name from outside fails at once, with a
ReferenceErrorin the message. ForbeforeRequest,afterResponseandafterEach, only aReferenceErrorfails the scenario, because the made-up arguments can cause other errors. A side effect of a hook runs once there. This test runs in the CLI process, so it sees all environment variables. A worker sees only the list that the next rule names. - The file writes
vars(whatbeforesets) in plain text. We never put a secret or the token invars.token()is safe. The CLI passes the token to Artillery in the environment and does not write it to disk. It does so only for a scenario that usestoken(). - A worker hook runs inside Artillery. So
process.envthere holds only the allowed names, plusLOAD_TESTS_BASE_URL, andLOAD_TESTS_TOKENwhen the scenario sendstoken(). A browser flow getsLOAD_TESTS_UI_URLand notLOAD_TESTS_BASE_URL. See The environment of Artillery. - A
beforeEachresult with the refused text fails the vuser. See Text that the engine refuses. afterEachdoes not run for a vuser whose flow stopped with an error.- An error in a worker hook stops that vuser. The report counts these errors apart from app errors, in the
Hooks:line, and the scenario fails.
vu counts the vusers that this worker started, from 0. Every worker counts from 0, so it is not unique in the run.
Ctrl-C and signals¶
On SIGINT (Ctrl-C), SIGTERM or SIGHUP the CLI does these steps.
- Stop the
ctx.apicalls ofbefore. - Stop Artillery, and wait until it has exited. It gets SIGKILL after 10 seconds. The wait makes sure that no late request of the load creates a row after the cleanup removes it.
- Run
afterand the cleanups. - Run the reporters, with
stopped: true. The console reporter prints no coverage or heavy API lines then. - Exit with 128 plus the signal number: 130 for SIGINT (Ctrl-C), 143 for SIGTERM and 129 for SIGHUP.
A second signal ends the process at once, with the exit code of the first. A second signal that comes less than 500 ms after the first is ignored, because npm can forward one Ctrl-C twice. A stopped run saves no baseline.
Ctrl-C also works during the login (step 6 of How a run works) and the token refresh (step 8.1). The CLI prints Cancelled. and exits with the code of step 5 above (130, 143 or 129) at once. It does not wait for a request that is still open.
The CLI ignores write errors on the terminal, but only EPIPE, EIO and ERR_STREAM_DESTROYED. After SIGHUP the terminal can be gone, and a failed write cannot stop the cleanups. Any other write error, such as a full disk, is thrown.
When the CLI process exits for any reason, it kills every running Artillery process with SIGKILL. Examples are a second signal, process.exit and an uncaught error. A SIGKILL of the CLI itself cannot be caught, so the Artillery processes can survive that case.
Test data¶
The scenarios make and delete data in the app under test. A good scenario leaves nothing behind, and it never depends on rows that someone made by hand.
Create and remove data¶
There are two ways to create data.
- Through the API, with
ctx.apiinbefore. This goes through the same code as a real client, so it makes audit rows, events and cache entries. - With SQL, with
ctx.sqlinbefore. This goes around the app. It makes no audit rows or events, and it does not clear a cache that the app keeps. We use it for data that no endpoint can create, or to find rows by name inafter.
We always register a cleanup with ctx.defer right after a create. See Hooks. A vuser can also create and delete its own data as steps of the flow. The first request creates a row, captureFrom takes its id, and the last request deletes it. Then no two vusers share a row.
This scenario needs data of its own for each run. The before hook makes the customer and the products, and ctx.defer removes them.
Give each vuser a unique value¶
We put a random part in a string with template. The engine makes a new value for each vuser, in every worker. We share nothing.
- With 1,000 vusers and the default length, the chance that two vusers get the same
randomStringis 2 in a billion. A part of a UUID is worse. Eight hex characters give 1 chance in 8,600.uuid()is unique, but it has 36 characters. A column with a size limit can need a shorter value. We userandomStringthere. randomNumbercan give the same number to two vusers. We do not use it where each vuser needs a unique value.uuid()is the same in all requests of one vuser, so it also links them.- Artillery has no unique index of a vuser. It does not split a CSV
payloadbetween the workers. In local mode, each worker gets the whole payload and starts at the first row. So we do not use a CSV to give each vuser its own row. vuinbeforeEachis not unique in the run either.- The Artillery engine renders these values as
{{ $randomString(8) }},{{ $randomNumber(1, 9) }}and{{ $uuid }}. If Artillery changes them, we change the engine only.
Datasources¶
A datasource is a named database connection. We declare datasources in config.ts. The first argument of ctx.sql is one of the names. A facade can reach several services, and each service has its own database. So we declare one datasource for each database that a scenario touches, for example orders, inventory, audit, suppliers, billing and users.
A datasource has one of three forms.
{type: 'postgres', url, ...}is Postgres. The package has the adapter, and the project installs the driver.{type: 'custom', connector}is any other database, with a connector that we write.- A bare
connectoris the same ascustom, shorter.
In config.ts:
In orders.controller.load.ts:
- The package reads no database variable. Our
config.tsreads the environment, and we choose the name of the variable. type: 'postgres'needs thepgpackage in our project, withnpm install --save-dev pg. The package loadspgfrom our project folder, never from its own, at the firstctx.sqlcall of the datasource. Whenpgis missing, the call fails with aConfigError.- The calls to one datasource share one connection and run one after another, also inside
Promise.all. Calls to different datasources run at the same time. - A Postgres datasource takes
url, orhost,port,userandpassword. It also takesdatabase,ssl,connectionTimeoutMs,statementTimeoutMsandapplicationName. - We read the URL from an environment variable, never from a literal in
config.ts. We keep the certificate checks ofsslon. We turn them off only for a local database. - With
url,databasereplaces the path of the URL. So one server URL serves many datasources. Withurl, the optionshost,port,userandpasswordare ignored, with no message. connectionTimeoutMsis 10,000 ms by default, so a database that does not answer cannot hold the run for ever.statementTimeoutMsis a whole number of milliseconds from 0. Another value in a datasource is aConfigErrorwith the messagedatasources.<name>.statementTimeoutMs must be a whole number of milliseconds from 0 (0 turns the limit off).connectionTimeoutMsfollows the same rule.- By default the client waits at most 60 s for a statement (
query_timeout). The server gets nostatement_timeout, so it keeps running a statement after the client gave up. The CLI then drops that connection. The nextctx.sqlcall opens a new one. - A value above 0 sets both limits. It sends
statement_timeoutto the server as a startup parameter. A proxy such as PgBouncer can refuse that parameter. - Behind such a proxy we leave the option unset (client limit only), or we set
0(no limit at all).
- By default the client waits at most 60 s for a statement (
- With neither
urlnorhost, the first call fails with aConfigErrorthat says what to set. A run whose scenarios use no SQL needs no URL. - A name that
config.tsdoes not declare gives aConfigErrorthat lists the declared names. - The package opens one connection for each datasource, at the first call, and keeps it. It drops a connection that breaks, and the next call opens a new one. The connections close after the cleanups of each scenario.
paramsfill$1,$2and so on in the text. A call with several statements and noparamsgives the rows of the last one.- A connector has one method,
connect(onError). It opens one connection and returns{query, end}. It callsonErrorwhen an idle connection breaks, so that the package can open a new one. It opens nothing when we create the connector.
Log in¶
The login is optional. The CLI logs in only when LOAD_TESTS_USERNAME is set. Then it also needs the password, the client id and the client secret. LOAD_TESTS_BASE_URL is required with a login, also in a run of only browser scenarios. The Environment table lists all six variables.
The flow¶
The login follows the two-step login of the ARC authentication service (@sourceloop/authentication-service).
POST /auth/loginwithusername,password,client_idandclient_secret. The answer holds acode.POST /auth/tokenwith thecodeand theclientId. The answer holds anaccessTokenand arefreshToken. Both must be non-empty texts. If not, the CLI throws aLoginError:<call> gave no accessTokenor<call> gave no refreshToken.- Before each scenario, the CLI gets a new token with
POST /auth/token-refresh. It sends therefreshToken, and the old access token as a bearer token. It skips the refresh when the access token is less than a minute old. The auth service revokes the access token that a refresh replaces. The signed payload has no random part (jti), so two refreshes in the same second give the same token string. The second refresh then revokes the token that it just gave. We refresh at most once a minute to avoid this. The age comes from theiatclaim. A token that has noiat, such as an opaque token, counts from the time that the CLI got it.
All three calls go to LOAD_TESTS_AUTH_SERVICE_URL, or to the base URL when that variable is not set. A call that answers with a status other than 2xx throws a LoginError with the status and the first 200 characters of the body.
The login and the refresh do not follow redirects. A 307 or a 308 would send the password and the client secret again to the new host. A status from 300 to 399 stops with a LoginError. It names the status and the origin of the location header, when the header parses. It never shows the whole location. The message reads POST <url> gave <status>, a redirect to <origin>. The login does not follow redirects, so the password goes only to the URL that we set. Set LOAD_TESTS_AUTH_SERVICE_URL to the final URL. We set LOAD_TESTS_AUTH_SERVICE_URL to the final URL.
We use https for an auth URL that is not on this machine. The package does not enforce it, because it accepts http for any host.
A network failure names the URL and the cause. These are the messages.
POST <url> failed: <cause>, for example with the causeconnect ECONNREFUSED 127.0.0.1:4010. The cause is never empty. Whenfetchgives several errors, the text joins their messages with;.POST <url> timed out after 60 s. This holds also when the body stalls.POST <url> gave <status> with a body that is not JSON, for a 2xx answer whose body is not JSON.POST <url> gave <status> with a body that is not a JSON object, for a 2xx answer whose body is JSON but not an object (null, a list, a number or a text).
Ctrl-C stops a login or a refresh at once. See Ctrl-C and signals.
Send the token¶
The package does not add an Authorization header. A request asks for the token with token(). We set the header once, on the load.describe or on the load.it.
token() works in headers, query, pathParams and json.
- Without a login,
ctx.tokenis undefined andctx.claimsis{}. When a scenario that runs sendstoken(), the whole run stops with aConfigErrorbefore any scenario or hook runs. The message names these scenarios. - With a login, the engine sends one token for the whole load of a scenario.
afterand the cleanups use the same token asctx.token. - The CLI gives the token to Artillery only for a scenario that uses
token(). Another scenario gets no token variable.ctx.tokenandctx.claimsinbeforeandafterdo not change. ctx.apidoes not send the token. See Hooks.
The token check¶
The token check runs for a scenario that sends token(), when there is a login. It runs after before and before the load.
The default check stops the scenario when the load time plus 120 seconds is at least the time that the token has left. The 120 seconds are for the last requests of Artillery, after and the cleanups. The check reads the exp claim of the token. A token without exp passes.
The check also stops the scenario with a RunError when it cannot read the time of a phase. We write a time as a number of seconds, or as a text such as 20m.
The token life comes from the login client of the auth service. A short life limits the length of a load.
tokenCheck replaces the default check. So a custom check must test the expiry itself, as the example does. We set it in config.ts for the package, or in the options of a load.it. The innermost wins. It gets {token, claims, phases}. It throws an error, or rejects when it is async, to stop the scenario before its load. The run waits for an async check. The report shows the message as a FAIL: line. after and the cleanups still run, and the next scenario starts.
Login errors and the rate limit¶
- A 401 from
/auth/loginmeans that the username, the password, the client id or the client secret is wrong. The message readsPOST <url>/auth/login gave 401: .... - A 429 means that something in front of the auth route limits the rate of calls. The package does not retry. The first login happens once, at the start of the run. A 429 there stops the run with a
LoginError. A 429 on a refresh fails that scenario, and the next scenario tries its own refresh. When it happens, we wait, or we raise the limit for the test environment.
The test user¶
The login needs a user that exists in each environment. This checklist tells what that user must be. We do not give SQL, because each project creates users in its own way.
- The user is only for load tests. It is never a real person, and nobody else uses it. A name such as
load-test@example.comshows its purpose. - The user has the smallest role that can call every endpoint under test. A load test needs no more power than that.
- A client exists for the login, with a client id (for example
load-test-client) and a client secret. That client lets the user log in. - The password and the client secret live only in environment variables or in CI secrets. They are never in git, in
config.tsor in a script. - Create the user with the user API or the seed script of the project. Then the project hashes the password in the way that it expects.
- Each environment has its own user, with its own password. We do not share one user over development, test and production-like systems.
Browser scenarios¶
A browser scenario load tests the screens of a web app. Each vuser opens the app in Chromium and runs our flow. The package times the steps of the flow, reads the Web Vitals of each page, and judges them with the targets of the SourceFuse Developer Performance Standard. It uses the Playwright engine that comes with Artillery, so the project installs no other load tool.
The API calls of the page get no gate here. A load.it scenario gates them. A slow API still shows up, in the step that waits for its data.
Set up a web app¶
A web app keeps its load tests where an API keeps them, in src/__tests__/load/, next to a config.ts. The CLI loads the built files from dist/. Angular, Vite and webpack bundle the app and do not write dist/__tests__. So the load files get their own tsc build.
- The build and the type check of the app must leave the load files out, for example with
excludein the tsconfig of the app. nodenextfollows"type"inpackage.json. A package with"type": "module"builds ESM. The CLI loads it withrequire(), which works for ESM without a top-levelawait. We checked this config with TypeScript 5.9.3 and 7.0.2, with and without"type": "module". TypeScript 7 removed"moduleResolution": "node", so we do not use it.- Git ignores
dist/andsrc/__tests__/load/.out/. npm installdownloads Chromium for Artillery. See Install for the cases where it does not.LOAD_TESTS_UI_URLis the origin of the web app, for examplehttp://localhost:4200. A run of only browser scenarios with nobeforeand noafterneeds neitherLOAD_TESTS_BASE_URLnorsrc/openapi.json. A browser scenario withbeforeorafterneedsLOAD_TESTS_BASE_URL, becausectx.apiof its hooks calls the API. A hook that logs in to another service usesctx.apis.<name>. See Other backends.- We load a production build of the app. A dev server sends code that is not minified, so its numbers do not match production.
Write a browser scenario¶
load.browser(name, options?, flow) registers a browser scenario. It has .only and .skip, and it goes into a load.describe as load.it does. The flow gets the page of the vuser, a step function and vars. A relative URL in page.goto starts at LOAD_TESTS_UI_URL.
The first 2 steps end when their end element shows. The print step shows nothing that we can wait for. So it takes the time of its whole action, and the report marks it. save gets 1 s, and the other steps get the default of 1.5 s. See The block of a browser scenario for the output of this scenario.
A project can start small and add exact times later.
| Level | The flow | What the run gates |
|---|---|---|
| 1. No steps | calls page objects, with no step |
the Web Vitals, and the flow total when flow sets a limit |
| 2. Steps | step(name, action) |
also each step, with the time of its whole action |
| 3. Steps with an end element | step(name, action, {endWhenVisible}) |
also each step, with an exact time |
At level 1, a page that paints fast but loads its data late can pass, because the Web Vitals do not wait for the data. So we give each screen one step with endWhenVisible, or we set a flow limit.
End a step at an element¶
step(name, action, options) records the time of the action as browser.step.<name>. With endWhenVisible, the step does these things, in this order.
- It checks that the end element is not visible. If it is, the vuser fails with
'<selector>' is visible before the action, because the step would end at once. We then pick an element that only the action shows. - It takes the start time, and starts a check that runs in the page on each animation frame.
- It runs the action. The action can be a page object that ends with
expect. - It waits for the check. The step time is the page time of the first frame where the element is visible, minus the start time.
So the waits of an expect in the action do not count. In one spike, on one machine, a step saw an element that showed at once 6 ms late, and an element on a new page 110 ms late. At 300 vusers, the step p95 was 433 ms with endWhenVisible and 889 ms with Playwright expect. The frame lag p95 was 16 ms or less. These are single measurements, not a promise. An element is visible by a rule close to the one of Playwright. Its box has a width and a height, and its visibility style is visible.
A step without endWhenVisible takes the time of the whole action, also the waits of its assertions. The counter browser.step_wall_clock.<name> marks it, and the report shows it as (wall clock).
endWhenVisibletakes a CSS selector. A Playwright locator such asgetByRolecannot run in the page. The check usesdocument.querySelector. It takes the first match only. It does not look into shadow DOM, and it does not look into iframes. Playwright locators look into open shadow roots.- The check differs from the rule of Playwright in one case. Playwright treats
display: contentsas visible when a child is visible, and it callselement.checkVisibility(). Our check says "not visible" for an element withdisplay: contents. - When the action loads a new page, Playwright 1.62.1 starts the check again in that page after 100 ms. So the step can end up to about 100 ms late. A route change in a single-page app loads no new page.
- An end element that never shows fails the vuser after 30 s, the default timeout of the engine in Artillery 2.0.34. The message is
'<selector>' is not visible after the action of step <name>: <first line of the Playwright error>, for example'.row' is not visible after the action of step open: page.waitForFunction: Timeout 30000ms exceeded. - A strict Content Security Policy does not block the check. Playwright compiles the check through the DevTools protocol, which the policy does not cover.
- A failed step stops its vuser. The scenario fails when the share of failed vusers is above the
errorRateofthresholds.default.
Limits of a browser scenario¶
The p95 of each step and of each Web Vital has a limit. The defaults come from the standard. A metric passes when its p95 is at or below its limit.
| Metric | Name in the report | Default p95 limit |
|---|---|---|
| Step | browser.step.<name> |
1500 ms, UI_TARGETS.criticalAction |
| FCP | browser.page.FCP.<label> |
1800 ms |
| LCP | browser.page.LCP.<label> |
2500 ms |
| CLS | browser.page.CLS.<label> |
0.1, with no unit |
| TTFB | browser.page.TTFB.<label> |
800 ms |
| INP | browser.page.INP.<label> |
200 ms, only when a level names INP |
| Flow total | browser.flow.<label> |
none, unless the scenario sets flow |
<label> is the name of the scenario with its groups and its browser, for example orders › save an order › chromium. UI_TARGETS holds the step targets of the standard: criticalAction (1500 ms), standardPage (2500 ms), dataHeavyScreen (5000 ms), feedback (1000 ms) and routeChange (1000 ms). WEB_VITALS_TARGETS holds the Web Vitals limits of the table.
Limits merge by field, from the library up to one metric, as the thresholds of load.it do.
| Level | Where | Keys |
|---|---|---|
| 1. Library | built in | the table above |
| 2. Project | thresholds in config.ts |
step, vitals |
| 3. Group | load.describe options |
step, vitals |
| 4. Scenario | load.browser options |
step, vitals |
| 5. One metric | load.browser options |
steps['<name>'], flow |
The options of load.browser are only phases, step, vitals, steps, flow, before and after. Another key in a plain JavaScript file throws a TypeError at load time. before and after have the types and the rules of Hooks: they run once per run, in the CLI, and after runs also when before or the load failed. A browser scenario has no beforeEach, afterEach, beforeRequest or afterResponse, no tokenCheck and no artillery option. The TypeError names the key. A load.describe has no before or after.
Each key takes p95, p95Regression and minDelta, as a threshold does. vitals takes one entry for each Web Vital. An entry for INP, also an empty one, turns INP on. false turns a Web Vital off, for example vitals: {TTFB: false} for a flow that gives no TTFB sample. The last level that names a Web Vital decides: an object after false turns it on again, from the defaults. steps and flow start from the merged step limits. thresholds.default (p95Regression, minDelta) does not change the limits of browser metrics. They use thresholds.step and thresholds.vitals. An unknown Web Vital name, and errorRate in a step or Web Vital limit, are config errors. A limit that is not an object is a config error too. Examples are step: 5 and vitals: {LCP: 3000}.
Here load gets 5 s, filter gets the 2.5 s of the group, and the flow gets 8 s. The click in filter is on the last page of the flow, so it gives INP a value. A limit above its target is a deviation from the standard. As for an endpoint, the test gives the reason next to the limit, and the review approves it.
The run checks these rules on top of the limits.
- A metric that a limit names, or that the defaults expect, fails with
no sampleswhen the report has none. So a step name with a typo fails. - A metric with a limit fails with
lost sampleswhen it has fewer samples than completed vusers. So every vuser must run every step, and its first page must report its Web Vitals. - Regression works as for an endpoint. For CLS,
minDeltais 0.1 by default.
Web Vitals¶
Artillery adds the web-vitals script to each page that a flow opens. It records a value only for a URL under LOAD_TESTS_UI_URL, and it names the metric after the scenario. So two scenarios that open the same page keep their own numbers.
- The script reports LCP after a click or a key press, or when the page closes. It reports CLS and INP when the page closes. The engine closes only the first page of the vuser, after the flow.
- A page that the flow opens with
context.newPage(), and a popup, never report Web Vitals, also when the flow closes them. The engine attaches its console handler only to the first page, and the Web Vitals reach the report only through that handler. - FCP and TTFB come from the first page of the flow, also after a full page load in it, and they go into one metric. A page from
context.newPage()and a popup add nothing. - A full page load inside a flow loses the LCP, CLS and INP of the page that it leaves. Those numbers then come from the last page only.
- INP needs a click or a key press on the last page of the flow. The engine reports INP when that page closes. A flow without such an interaction has no INP, so INP is off until a level turns it on.
- A route change in a single-page app gives no new FCP or LCP. We time it with a step that ends at an element of the new screen.
- Artillery rounds each p95 to 1 decimal. So the CLS p95 is 0, 0.1, 0.2 and so on. That is why the default
minDeltaof CLS is 0.1. A change from 0 to 0.1 is the smallest change that the report can show, and it is not a regression. Artillery 2.0.34 also makes its summaries from a DDSketch with a relative accuracy of 0.01. So every p95 has an error of about 1%. The table shows the limits and the baselines of CLS with up to 2 decimals. - The TTFB of a single-page app measures the server of
index.html, for example a proxy or a CDN. - The standard gives no percentile for Web Vitals. We gate the p95 against the "good" limit of Google, which is the same number as the standard. This is stricter than the p75 rule of Google.
Browsers¶
A browser scenario runs once for each browser in artillery.browsers of config.ts. The default is [chromium()]. The label of each run ends with the browser, for example orders › save an order › chromium. So each browser has its own files, report rows and baseline history. A browser that is added later does not reset the history of the others.
chromium() runs the Playwright engine of Artillery. It takes two options. launchOptions are the Playwright launchOptions, for example {channel: 'chrome'} or {channel: 'msedge'} to run Chrome or Edge. startTimeoutMs is how long the check lets Chromium take to start. The default is 60 s. The launchOptions of engines.playwright in artillery.config work too. When chromium({launchOptions}) is set, its launchOptions replace the whole object of artillery.config. They do not merge.
The Playwright engine of Artillery starts only Chromium (chromium.launch, Artillery issue 1885). To run Firefox, WebKit or another browser, we write an Artillery engine for it and list a BrowserEngine object. The engine is a package that is named artillery-engine-<name>. A copy of the Playwright engine of Artillery, with chromium changed to firefox, is enough to start. NODE_PATH or npm install must let Artillery find it.
| Field | Use |
|---|---|
name |
The name of the browser. It ends the label of each run. It must be unique. |
engine |
The name of the Artillery engine that runs it, for example playwright. |
engineOptions |
Options for that engine. They go over engines.<engine> of artillery.config. They cannot hold aggregateByName. |
vitals |
The Web Vitals that the browser reports. A Web Vital that is not in the list has no limit and no row. Without this, a missing CLS would fail with no samples. |
check |
Optional. It runs before each scenario of the browser, and it throws a ConfigError when the browser cannot start. |
The engine must follow the contract of the Playwright engine of Artillery:
- It runs the
testFunctionof the script with(page, context, events). - It reads
aggregateByName, which the library sets totrue, so that the Web Vital metrics carry the label of the scenario. - It emits
browser.page.<vital>.<label>for each Web Vital invitals.
Firefox and Safari report fewer Web Vitals than Chromium. CLS needs the Layout Instability API, which only Chromium has. In a smoke run with Playwright 1.63.0 and Firefox 155.0, the Playwright engine gave an FCP sample for each user, but no LCP, and a TTFB sample for only one of two users. A Web Vital that is in vitals and has fewer samples than users fails the run. So list only the Web Vitals that the browser reports for each user, and check them with a short run first.
Page objects of Playwright tests¶
A project with Playwright tests can call its page objects in a flow. Artillery runs one function for each vuser. It does not run the test() blocks or the fixtures of a spec file. So a load file imports the page objects, and not the specs.
- A page object that a load file imports must be under
src/. The load build hasrootDir: src, and an import from outsidesrc/stops it. - The
pageof a flow comes from the Playwright of Artillery, 1.62.1 today. A page object that importsexpectfrom the project's own Playwright works with it. We tested this with Playwright 1.63.0. - A page object that ends with
expectgets an exact step time withendWhenVisible. The step ends at the frame of its element, and not when theexpectreturns. expectof@playwright/testoutside its test runner waits 5000 ms by default, and not the 30 s of the engine. A page object that waits longer usesconst slowExpect = expect.configure({timeout: 30_000})and callsslowExpect(...).
Log in, and variables of a flow¶
A browser scenario has no login helper, and token() does not reach the page. To log in once for the whole run, we use before and after. They run once, in the CLI. ctx.api of these hooks calls LOAD_TESTS_BASE_URL, and the load goes to LOAD_TESTS_UI_URL. So a scenario with before or after needs both. A login on a service of its own goes through ctx.apis.<name>. See Other backends.
The flow gets vars: a fresh copy for each vuser of what before set. Only JSON values pass, as for the vars of load.it. before logs in and puts the token in vars. The flow puts it in the page with page.addInitScript. after logs out. All vusers share this token, so it must stay valid for the whole run.
The paths and the body of the login calls are an example. We use the login of our app. The pattern is a login in before that every vuser reuses, as in the reuse-auth example of Artillery, which reuses the session with storageState.
The other way is a login in the flow, for each vuser, for example with the page object of the login screen. Then the scenario needs no hooks and no LOAD_TESTS_BASE_URL. The flow runs inside Artillery, which gets only a short list of variables. See The environment of Artillery. To give the flow a variable, for example the password of a test user, we list its name in artillery.env of config.ts. A name that starts with LOAD_TESTS_ is not allowed there.
Thresholds and the verdict¶
Thresholds are optional. We set them in thresholds.default of config.ts, in the options of a load.describe or a load.it, or in the options of one request. An example is load.get('/orders', {p95: 800}).
The order is the request, then load.it, then load.describe, then thresholds.default of config.ts, then the library default. Layers merge by field. A field that no layer sets takes the library default from the table. The package still checks each value that we give.
| Name | Unit | Default | An endpoint fails when |
|---|---|---|---|
errorRate |
percent | 1 | more than this percent of its requests get a network error or a 4xx or 5xx status |
p95 |
milliseconds | 500 | its p95 is above this value. It needs no baseline, so the limit holds on the first run too |
p95Regression |
percent | 20 | its p95 is more than this percent above the baseline, and also more than minDelta above it |
minDelta |
milliseconds | 10 | its p95 is more than this many milliseconds above the baseline, and also above p95Regression |
The names have no unit. The table gives it. errorRate must be from 0 to 100. The other values must be 0 or more.
Targets for the p95 limit¶
P95_TARGETS holds common p95 targets for HTTP APIs, in milliseconds. We use one as the p95 of an endpoint, so that a reader sees which kind of API the limit is for.
| Key | Value | Kind of API |
|---|---|---|
userBlockingApi |
500 | User-blocking APIs, which a user waits for. The target is 300 to 500 ms where practical. This is the default limit. |
supportingApi |
1000 | Supporting APIs, such as reports and exports. The target is 700 ms to 1 s where practical. It is the highest target for an API. |
heavyOperation |
5000 | Not a target for an API. Work of more than 5 s must be async, or must have an explicit approval. An endpoint with this p95 is performance debt, and a heavy API. |
A limit above the target of its kind is a deviation. We write the reason next to it, and we let the review approve it. The package cannot judge "where practical", so it fails an endpoint that is over its limit. A limit with a reason is the way out.
The package has no number for how much slower than before is too slow, and none for an error rate. So p95Regression (20), minDelta (10) and errorRate (1) are values that the package chose.
Caution
Every endpoint counts as user-blocking until it sets another target. The target for user-blocking APIs is 300 to 500 ms, where practical. The default limit of 500 ms is the top of that range.
We keep 500 ms, and not 300 ms, because of the architecture. In a facade and service architecture, a request passes through three network layers. It goes from the client (the UI, or the load generator) to the facade. Then it goes from the facade to a service, and from the service to its database. Some endpoints add more calls, because a facade can call several services before it answers. Without a cache, it is hard for an API that reads through all these layers to meet the user-blocking target.
A pass on a CI runner does not prove that an API meets the target in production. A CI runner and a small test cluster give other numbers than a production system. We review the default when the tests run on an environment that is close to production.
Heavy APIs¶
A heavy API is an endpoint with a p95 limit above 1000 ms. That is P95_TARGETS.supportingApi, the highest target for an API. A plain number such as p95: 2000 also makes an endpoint heavy. A p95 in thresholds of a load.it, a load.describe or config.ts counts too. So a default above 1000 ms makes every endpoint heavy.
Work of more than 5 s must be async. The endpoint that starts the work answers within its target, and a background job does the work. This package times the response of the endpoint, and it does not test the job.
maxHeavyApis in config.ts is the number of heavy APIs that the package can have. The default is 0, so no heavy API passes. Here is a package with two.
- The package counts the distinct endpoint ids (
METHOD path) over all scenarios of the package. It counts the skipped scenarios and the ones that.onlyleaves out. The same id in two scenarios counts once, with its highest limit. - The report lists the heavy APIs. See Report.
- A package with more heavy APIs than
maxHeavyApisfails the run. The rows keep their own results, and the load still runs. A failed run saves no baseline, unlessLOAD_TESTS_FORCE_BASELINE=1. - We raise the number only for performance debt that the pull request lists, with an owner and a target date. We write the reason in a comment next to the limit.
- When a fix lands, we lower the number, and we lower the
p95limit of the fixed endpoint too. An endpoint stays heavy until its limit goes down, also when it got faster. TheNoteline of the report tells when the number can go down.
The count does not say which endpoints are heavy. We chose a count because a new heavy API then needs one change, its p95, and not a second list of ids. The price is a gap. If a fix lands and the number stays, a new heavy API can take its place without a failure.
Every rule that fails a run¶
| What fails | Rule |
|---|---|
| An endpoint | It got no requests. |
| An endpoint | Its error rate is above errorRate. |
| An endpoint | Its p95 is above its p95 limit. |
| An endpoint | Its p95 is above its baseline by more than p95Regression percent and more than minDelta ms. |
| A browser metric | A limit names it, or the defaults expect it, and the report has no samples of it. |
| A browser metric | It has a limit, and fewer samples than completed vusers. |
| A browser metric | Its p95 is above its limit, or above its baseline by more than p95Regression percent and more than minDelta. |
| A scenario | More than errorRate percent of its vusers failed. The limit is the one of thresholds.default or the library default. A vuser fails when its flow stops with an error, for example a network error, a capture that finds nothing, or a hook that throws. A 4xx or 5xx status does not stop a vuser, but it counts in the error rate of its endpoint. |
| A scenario | A worker hook threw an error. |
| A scenario | after or a cleanup threw. |
| A scenario | It stopped with an error, for example in before, in the token check, in a token refresh or in the engine. The report shows a FAIL: line with the message. The next scenario still runs. |
| The run | The package has more heavy APIs than maxHeavyApis. |
| The run | A reporter threw an error. |
An endpoint without a response has no p95. Then only the error rate and the "no requests" rule can fail it.
A failed run exits with 1. A problem in config.ts, in a scenario file, in the environment, or in the login stops the run before any load. The exit code is 1 then too.
The reasons in a FAIL line¶
The Result column of an endpoint or a browser metric shows FAIL: and one or more reasons, joined by ;. These are the texts. We can search the output for them. CLS has no unit, so its texts have no ms, for example p95 0.2 > limit 0.1.
| Reason text | Meaning |
|---|---|
no requests |
The endpoint got no requests. |
error rate X% > Y% |
The error rate X is above errorRate Y. |
p95 X ms > limit Y ms |
The p95 X is above the p95 limit Y. |
p95 +N% (+M ms) over baseline B ms |
The p95 is N percent and M ms above the baseline B. A baseline of 0 leaves out the +N% part. |
no samples |
A browser metric has no samples. |
lost samples: N for M completed vusers |
A browser metric has N samples, fewer than the M vusers that completed. |
vuser failure rate X% > Y% |
The percent X of started vusers that failed is above errorRate Y. A browser scenario uses it. |
Baseline¶
The file src/__tests__/load/.out/baseline.json keeps the p95 of the last 10 runs of each endpoint, by scenario. The baseline of an endpoint is the mean of these values. The sister package @sourceloop/benchmarking also averages its last ten runs. For a browser scenario, the file keeps each step, each Web Vital and the flow total, by their names in the report.
LOAD_TESTS_UPDATE_BASELINE=1adds the run to the history. The run printsSaved src/__tests__/load/.out/baseline.json.- A run that failed stays out, so that a regression does not become the normal value. This holds also when only
afteror a cleanup failed and every endpoint passed, because rows that stay behind can skew the next run. The run printsBaseline not changed: the run failed. Set LOAD_TESTS_FORCE_BASELINE=1 to add it anyway.on stderr.LOAD_TESTS_FORCE_BASELINE=1adds the run anyway. - A reporter that throws also keeps the run out of the baseline. The CLI prints the same
Baseline not changedline.LOAD_TESTS_FORCE_BASELINE=1withLOAD_TESTS_UPDATE_BASELINE=1still saves the run. - A run that a signal stopped saves nothing.
- Without a baseline, the run checks only
errorRateandp95.p95RegressionandminDeltado nothing. - The file also keeps the workload of each scenario. When a scenario now has another workload, the run does not compare its p95. The result is
pass (workload changed). WithLOAD_TESTS_UPDATE_BASELINE=1, the scenario then starts a new history. -
The file keeps the base URL of the run that wrote it, and the URL of the web app as
- The CLI writes the file touiUrl. When the next run uses another URL, the CLI prints one of these lines. The p95 of another host does not describe this one. The run still compares with that baseline. WithLOAD_TESTS_UPDATE_BASELINE=1, the file keeps the new URL. A run savesbaseUrlonly when it measured an HTTP scenario, anduiUrlonly when it measured a browser scenario. Else the file keeps the stored value. A newLOAD_TESTS_BASE_URLresets the history of the HTTP scenarios only. A newLOAD_TESTS_UI_URLresets the history of the browser scenarios only. So the p95 of two hosts never blend into one mean. A run with only one kind of scenario keeps the URL of the other kind. A baseline with no URL loads.baseline.json.<pid>.tmpin the same folder and renames it, so a killed run leaves no half file. It removes the temp file when the write or the rename fails. A file that is not valid, for example a history that is not a list of numbers, stops the run with aRunError. We delete the file and record a new one. - The history key is the labelgroup › name. A rename of aload.itor of aload.describestarts an empty history, and the scenario passes aspass (no baseline). The CLI never deletes the old key, so we delete it from the file by hand. - A baseline is only good for the machine that made it. A laptop and a CI runner give other numbers.
Where the file lives, and how CI keeps it¶
The file is in src/__tests__/load/.out/. The .gitignore line from Ignore the generated files makes git ignore it. A fresh CI checkout has no baseline, so a CI job must bring it back. The job does this.
- Restore
baseline.jsonbefore the run, for example from the artifact of the last passing run on the main branch. - Run
npm run test:load. On the main branch, setLOAD_TESTS_UPDATE_BASELINE=1for this run. - After a passing run on the main branch, save the file, for example as an artifact.
Pull request runs only restore the file. They do not save it. So the baseline holds the runs of the main branch, and the run judges a pull request against them.
Run in CI¶
A CI job for the load tests does these steps.
- Start the apps that the tests need, and their databases. The package starts nothing.
- Run the migrations and the seed, so that the databases hold the data that the scenarios expect.
- Create the test user, with its client. See Log in.
- Set the environment variables. The password and the client secret come from the secrets of the CI system.
- Restore the baseline. See Baseline.
- Run
npm run test:load. The job fails when the exit code is not 0. - After a passing run on the main branch, save the baseline.
- Artillery does not get the secrets of the CI job. See The environment of Artillery. A script that needs one more variable lists its name in
artillery.env. - GitHub Actions sets
CI=true. So a.onlythat stays in a file fails the job, and so does a package where every scenario has.skip. - A small CI runner gives other numbers than a laptop. We keep the baseline of CI runs apart from the baseline of laptop runs, and we do not copy one to the other.
- A run in CI takes the time of all phases of all scenarios, plus the time of the hooks. We keep the phases short, or we run the load tests in a separate job.
Browser scenarios in CI¶
The workflow of a web app installs Chromium, starts the app, and runs the load. This example is for GitHub Actions. The paths, the port and the start command depend on the project.
npm cialready downloads Chromium. The install step adds the OS libraries of the runner, and it covers a job that skips install scripts.- The Playwright version in the install step must be the one of Artillery. The run names it when Chromium does not start.
serve@14has a fixed version. It ignores--no-port-switching(see serve issue 751) and moves to a free port when port 4200 is busy. So a step beforeservefails the job when port 4200 is busy. The app can use its own static server instead.- The web server writes to
serve.log, so its output stays out of the log of the load run. - The web app calls its APIs. We start them in the job too, or we point the build at a test environment.
- A pull request restores the baseline of the main branch and does not save it. The save step runs only after a passing run on the main branch.
- GitHub removes a cache entry that no run reads for 7 days, and then the baseline is gone. A new run on the main branch starts a new baseline.
- A standard runner of GitHub Actions has 2 to 4 CPU cores. So it can run only a few browser vusers at a time. See Size the load.
Report¶
At the end of the run, the console reporter prints one block for each scenario. This sample comes from a run that had a baseline. One endpoint is too slow, and one has errors.
The columns and lines:
Workloadis the load that the scenario ran. See Set the load.Vuserscounts the virtual users. The limit in brackets is the highest percent of failed vusers that passes. When too many failed, the brackets read(FAIL: vuser failure rate 8% > 1%).Requestsis the count of responses plus network errors for the endpoint.Req/sis the requests divided by the seconds from the first to the last count of the run.p95 ms (limit)is the p95. The brackets hold the highest p95 that passes. That is the lower ofp95and the baseline limit. The baseline limit is the baseline plus the larger ofminDeltamilliseconds andp95Regressionpercent of the baseline. With no baseline, the limit isp95.Base msis the baseline p95 of the endpoint, or-when there is none.Err % (limit)is the error rate, witherrorRatein brackets.Resultispass, orFAIL:with the reasons, joined by;. A passing endpoint can carry a note in brackets,pass (no baseline)orpass (workload changed).Hooks:shows how many errors worker hooks threw. These are not app errors. The scenario fails.- A line
FAIL: after failed: ...orFAIL: cleanup failed: ...shows thatafteror a cleanup threw. The numbers in the table are still good, but the scenario fails, and so does the run. - A scenario that stopped with an error has no table. It shows one line,
FAIL: <message>. A failedafteror cleanup of such a scenario goes to stderr as<scenario>: after failed: <message>or<scenario>: cleanup failed: <message>, and not into the report. - A line
Baseline: recorded with <workload>, so p95 is not comparedshows that the workload changed since the baseline. Skipped: <name>lists a scenario that does not run: it has.skip, or a.onlyon another scenario leaves it out.Coveragecounts the endpoints ofsrc/openapi.jsonthat the scenarios call. It does not count/ping,/openapi.jsonand the/explorerpaths. A package with nosrc/openapi.jsongets the lineCoverage: not calculated, because the application has no API specification.- When no baseline file exists, the reporter prints the line
No src/__tests__/load/.out/baseline.json. Run once with LOAD_TESTS_UPDATE_BASELINE=1 to record it.after the coverage. - The legend at the end says what a vuser is, because "users" can mean the rows of a users table.
The block of a browser scenario¶
A browser scenario prints a short block, with the vusers, the flow total and how many metrics pass. A table follows only when a metric fails, and it holds only the failed rows. This run of the scenario in Write a browser scenario passed.
In the next run, the API behind the Save button took 1.2 s to answer. The save step went over its limit of 1 s, and the run failed with exit code 1.
Flowis the p95 of the flow total. It shows its limit when the scenario setsflow.Metricscounts the steps and the Web Vitals that have a limit. It counts the flow total too, whenflowsets a limit.- A metric name leaves out
browser.and the label of the scenario. Sostep.saveisbrowser.step.save, andpage.LCPisbrowser.page.LCP.orders › save an order › chromium. - A failed step does not show its error here. Artillery prints it during the load, and the line
Vuserscounts the vusers that failed.
The detail of a run¶
load-testing report <package-dir> prints every metric of the last run. It starts no load. It loads config.ts and the built scenario files, so it needs a built package, as run does. It reads the report files that the last run left in src/__tests__/load/.out/, and judges them again with the current limits and baseline. It exits with 0 or 1, as the run did, in most cases. See the last rules below. This is the detail of the passing run above.
- A
(wall clock)step has no end element. Its time includes the waits of its assertions. no limitmarks a metric that no limit gates, here the flow total.- Each run deletes the report files of the last run before it starts. So the command judges only the last run.
- A selected scenario with no report file fails with
No report file: the last run of this scenario failed or did not start. - A run deletes the report file of a scenario when Artillery exits with a non-zero code, for example from the ensure plugin of
artillery.configplugins. It does so also when the run is stopped. Soreportfails that scenario too. - A broken report file fails only its own scenario in
report. The file can hold invalid JSON, or it can have no aggregate. The other scenarios still print. - It judges with the baseline file as it is now. After a run that saved the baseline (
LOAD_TESTS_UPDATE_BASELINE=1, also withLOAD_TESTS_FORCE_BASELINE=1), that baseline already holds the run. Thenreportcompares the run with itself, so its verdict can differ from the exit code of the run. - It does not run
afteror the cleanups, so their failures are not in its verdict. - Only the Artillery engine writes report files. With another engine, the command stops with
No report file in <dir>. Run load-testing run first.
The block of heavy APIs¶
The block comes after the Coverage line. It shows when the package has a heavy API, or a maxHeavyApis above 0. It covers every scenario of the package, so it can name endpoints of scenarios that the report does not show. The reporter pads the ids to the longest id, and each line has its limit. See Heavy APIs.
- When the package has more heavy APIs than the maximum, the block ends with a
FAILline, and the run exits with 1. - When the maximum is higher than the count, the block ends with a
Noteline. With no heavy endpoint and a maximum above 0, only theNoteline shows.
The FAIL and Note lines read like this:
Custom reporters¶
A reporter gets the result of the run once, at the end. reporters in config.ts is a list, and the default is [consoleReporter()]. An empty list prints no result. We write a reporter for our own tools, for example a file, a dashboard or a chat message.
- A
Reporteris{name, onRunEnd(result)}. The function can be async. resultis aRunResult. It is plain data, with no engine names.- The reporters run one after another. A reporter that throws prints
reporter <name> failed: <message>. The run exits with 1, and the baseline does not move. The other reporters still run. A reporter cannot make a failing run pass. - The reporters run also after a signal, with
stopped: true.
The fields of a RunResult:
| Field | Meaning |
|---|---|
scenarios |
A ScenarioResult for each scenario that ended: name, workload, vusers, hookErrors, problems, error, baselineNote, endpoints and passed. |
skipped |
The names of the scenarios that did not run: the ones with .skip, and the ones that .only left out. |
coverage |
{covered, total, specFile}. |
heavyApis |
{max, endpoints: {id, p95}[]}. |
hasBaseline |
False when there is no baseline file. |
passed |
True when every scenario passed and the heavy APIs are within max. |
stopped |
True when a signal ended the run. |
Each entry of endpoints is an EndpointResult. It holds id, requests, perSecond, p95, p95Limit, baselineP95, errorRate, errorRateLimit, failures and note. vusers has the type ScenarioResult['vusers']. It holds started, completed, failed, failedLimit and failures.
Recipes¶
| How do I | Where |
|---|---|
| Run only one scenario while I write it | Run only some scenarios |
| Send the token | Send the token |
| Use the id that an earlier response gave | Values |
| Send requests at the same time | Parallel requests |
| Make data for a run, and remove it | Test data |
| Give each vuser a unique value | Give each vuser a unique value |
| Raise the limit of one endpoint | Thresholds and the verdict |
| Time a screen that loads data late | End a step at an element |
| Log in once for a browser run | Log in, and variables of a flow |
| Record or reset the baseline | Baseline |
| Use an Artillery plugin | Use any Artillery feature |
A browser flow with a form¶
Work between steps, such as page.fill, counts in the flow total and in no step. The submit step ends when the toast shows.
A list and its detail screen¶
The first step loads a page. The second is a route change in a single-page app, so it gets the routeChange target.
A slower endpoint with its reason¶
A report endpoint gets the supporting API target, and its group allows 2% of errors. We write the reason next to the limit.
Best practices¶
One endWhenVisible step per screen¶
Web Vitals do not wait for the data of a screen. A page can paint fast and load its rows late, and still pass. A step with endWhenVisible times the wait that the user feels.
- Give each screen or route change one step with
endWhenVisible. Name the step after the screen, for examplelistordetail. - End the step at the last thing that the user waits for, such as the first data row. Do not end it at the frame of the page, which shows at once.
- Use a step with no end element only for an action that shows nothing to wait for. The report marks it
(wall clock). - Run every step in every vuser. A step that only some vusers run fails with
lost samples.
Choose selectors for end elements¶
endWhenVisible takes a CSS selector. It uses document.querySelector, so it reads the first match only. See End a step at an element.
- Pick an element that only the finished screen shows. The step fails when the element is visible before the action.
- Do not pick an element that a loading placeholder also matches. The step would end at the placeholder.
- Prefer a stable attribute, such as
[data-testid="order-row"], to a class that the build can rename. - An element needs a box with a width and a height. An element with
display: contentscounts as not visible. - Do not use a Playwright locator such as
getByRole. It cannot run in the page. The selector cannot reach into shadow DOM or iframes.
Size the load¶
Each browser vuser runs a real Chromium page, so it needs much more CPU than an HTTP vuser. As a rule, each browser vuser that runs at the same time needs about one CPU core. A load machine that is too small makes the app look slow. In one measurement, on one 22-core machine, the step p95 of a small test app went from 0.7 s to 8 to 10 s at 40 new vusers each second. Its API p95 stayed flat, and no vuser failed. So we keep the phases small, and we run a load.it scenario on the same APIs to tell the two apart.
- Start with a low rate, for example 1 vuser a second, and ramp up with
rampTo. Watch the CPU of the load machine. - The vusers that run at the same time are about the rate times the length of the flow. A 5 s flow at 2 vusers a second keeps about 10 browsers open.
- If the step p95 rises and the API p95 stays flat, the load machine is the limit. Lower the rate, or use a bigger machine.
- A standard GitHub Actions runner has 2 to 4 CPU cores. See Browser scenarios in CI.
Keep a stable baseline¶
- Record the baseline on the main branch, on the same kind of machine that judges the pull requests. A laptop and a CI runner give other numbers.
- Keep the workload fixed. A new workload starts a new history, and the run does not compare p95 until it has one.
- Use
LOAD_TESTS_FORCE_BASELINE=1rarely. It adds a failed run to the history, and that run becomes part of the mean. - Keep the
p95limit. The baseline is the mean of the last 10 runs, so a slow rise moves the mean with it. Thep95limit stays fixed. - On a noisy machine, raise
minDeltabeforep95Regression. A regression must be above both, andminDeltaguards fast endpoints, where a few milliseconds are a large percent. - Use one machine size for the baseline. See Where the file lives, and how CI keeps it.
Production build or dev server¶
Use a production build for browser scenarios. A dev server such as ng serve sends many separate, unbundled files. So the Web Vitals and the request counts differ from the ones of a production build.
- Build the app for production, and serve the files with a static server. The CI example does this.
- Use a dev server only to check that a scenario works while we write it.
- The baseline keeps the URL of the web app. A switch from a dev URL to a production URL resets the history of the browser scenarios.
What the Artillery counters mean¶
Artillery prints counters when the load ends. The report files hold them under aggregate.counters. The counters add up over all vusers of the scenario. For a count per vuser, we divide by vusers.completed.
| Counter | What it counts |
|---|---|
vusers.created, vusers.completed, vusers.failed |
Vusers that started, finished their flow, and stopped on an error. The Vusers line reads them. |
browser.http_requests |
Every browser request that finished, from page.on('requestfinished') of the engine: HTML, JS, CSS, images, and API calls. |
browser.page.codes.<status> |
Responses by status code, from page.on('response') of the engine. |
browser.step.<name> |
The time of a step. A summary, not a counter. |
browser.step_wall_clock.<name> |
Steps that took the time of the whole action, because they have no end element. |
browser.flow.<label>, browser.page.<vital>.<label> |
The flow total and the Web Vitals of a scenario. These are summaries. |
errors.<message> |
Vusers that stopped on this error message. |
browser.http_requestsis not an API call count. A page that loads 80 files and calls 2 APIs adds 82. It counts 4xx and 5xx responses too. It does not count a request that failed with no response. Playwright sendsrequestfailedfor it, notrequestfinished.- The engine attaches its handlers to the first page of each vuser. A page from
context.newPage(), and a popup, add nothing to these counters. - The counts are not gated. Only a metric with a limit can fail a run. To gate API calls, we write a
load.itscenario on the same API. - A dev server raises
browser.http_requests, because it sends more files.
HTTP scenarios¶
- Name a scenario after what a user does, for example "browse orders and customers".
- Make the data of a run in
before, and register each cleanup withctx.deferright after the create. See Test data. - Put the token in the
headersof aload.describe. Do not send it by hand. - Set a
p95that is above its target only with a reason in a comment. See Targets for the p95 limit. - Keep load files out of the unit tests, and keep
.outout of git. See Set up a package.
Reference¶
config.ts¶
src/__tests__/load/config.ts holds the settings of the package. It exports a LoadTestConfig as its default. The CLI loads its built file, like a scenario file. Only phases is required.
The type checks the keys when we build. When the CLI starts, it also checks the values, before it logs in. A problem stops the run with a ConfigError that lists each problem on its own line.
| Key | Type | Default | Use |
|---|---|---|---|
phases |
Phase[] |
none, required | The default load. A list with at least one phase. See Set the load. |
thresholds |
{default?: Thresholds, step?, vitals?} |
p95 500, p95Regression 20, minDelta 10, errorRate 1 |
default holds the limits for each endpoint. step and vitals hold the limits of browser metrics. See Thresholds and the verdict and Limits of a browser scenario. |
maxHeavyApis |
whole number, 0 or more | 0 |
How many heavy APIs the package can have. |
datasources |
Record<string, Datasource> |
none | The databases that ctx.sql can use. See Datasources. |
tokenCheck |
TokenCheck |
the default token check | Replaces the default check of the token life. See Log in. |
reporters |
Reporter[] |
[consoleReporter()] |
Who gets the result. An empty list prints no result. See Report. |
engine |
Engine |
artillery() |
Who runs the load. See Engines. |
apis |
Record<string, string> |
none | Other backends for ctx.apis.<name>. Each value is the name of the variable that holds the URL. See Other backends. |
artillery.browsers |
BrowserEngine[] |
[chromium()] |
The browsers that each browser scenario runs in. See Browsers. |
artillery.config |
ArtilleryConfig |
none | Artillery config options for every scenario. See Use any Artillery feature. |
artillery.env |
string[] |
none | The exact names of more variables to pass to Artillery, only when the CLI has them. For example ['MY_REGION']. See The environment of Artillery. |
An example with every key. A real package sets only the keys that it needs.
config.ts has no headers. We set headers on load.describe or on load.it.
The library reads no database variable. config.ts reads the environment, and we choose the name of the variable. When config.ts loads, it opens nothing.
The check of artillery.env gives these problems.
artillery.env must be an array of variable names, for example ["MY_REGION"]artillery.env[<i>] must be a text that is not emptyartillery.env[<i>] must not have "="artillery.env[<i>] "<name>" is not allowed: these variables are for the CLI only, for a name that starts withLOAD_TESTS_in any letter case.
Limits: levels and merge order¶
Layers merge by field. The strongest layer wins, and a field that no layer sets takes the library default.
| Layer | HTTP endpoint | Browser metric |
|---|---|---|
| 1. Strongest | the request (p95, errorRate, p95Regression, minDelta) |
one metric: steps['<name>'], flow |
| 2 | load.it thresholds |
load.browser step, vitals |
| 3 | load.describe thresholds (inner first) |
load.describe step, vitals |
| 4 | thresholds.default of config.ts |
thresholds.step, thresholds.vitals of config.ts |
| 5. Weakest | the library default | the library default |
headers merge by name. phases do not merge: the strongest list replaces the others. See Group options and the merge order, Thresholds and the verdict and Limits of a browser scenario.
Defaults and limits at a glance¶
| What | Value |
|---|---|
HTTP p95, errorRate, p95Regression, minDelta |
500 ms, 1%, 20%, 10 ms |
maxHeavyApis |
0 |
| Browser step p95 | 1500 ms (UI_TARGETS.criticalAction) |
| FCP, LCP, CLS, TTFB, INP p95 | 1800 ms, 2500 ms, 0.1, 800 ms, 200 ms (INP only when a layer names it; false turns one off) |
| Baseline history | the last 10 runs of each endpoint or metric |
| Token refresh | at most once a minute |
| Token check margin | the load time plus 120 s must stay below the token life |
ctx.api, ctx.apis and login timeout |
60 s |
| Browser start timeout of the check | 60 s, chromium({startTimeoutMs}) |
| Postgres connection timeout | 10,000 ms |
| Postgres statement wait of the client | 60 s |
| End element that never shows | the vuser fails after 30 s |
| Chromium start check | 60 s |
| Artillery stop after a signal | SIGKILL after 10 s |
| A repeated signal | ignored when it comes less than 500 ms after the first |
Command line and environment¶
Each command takes one folder, the package that holds src/__tests__/load. The scripts of the quickstart use .. run runs the load. report prints the detail of the last run, and starts no load. See The detail of a run. load-testing --help prints the usage and exits with 0. The usage lists the LOAD_TESTS_ variables below. A wrong option, or an extra argument after the folder, prints its message and the usage, with exit code 1. A command other than run and report, or a missing folder, prints only the usage, with exit code 1.
Exit codes¶
| Code | Meaning |
|---|---|
| 0 | The run passed. |
| 1 | The run failed, a reporter failed, or the CLI stopped on an error before or during the run. |
| 129 | SIGHUP. |
| 130 | Ctrl-C (SIGINT). |
| 143 | SIGTERM. |
The CLI prints only the message of an error that it throws on purpose. For any other error it prints the stack, because that is a bug. See Errors.
Environment¶
| Variable | Use |
|---|---|
LOAD_TESTS_BASE_URL |
The API under test. Required when a load.it scenario runs, when a browser scenario has before or after, and when LOAD_TESTS_USERNAME is set. |
LOAD_TESTS_UI_URL |
The web app under test, for example http://localhost:4200. Required when a browser scenario runs. |
LOAD_TESTS_AUTH_SERVICE_URL |
The app that serves /auth/login and /auth/token. Default is the base URL. |
The variables that apis names |
The URL of each other backend, for ctx.apis.<name>. Required when the run needs the API. |
LOAD_TESTS_USERNAME |
Set it to log in. Then the three variables below are required too. |
LOAD_TESTS_PASSWORD |
The password of the test user. |
LOAD_TESTS_CLIENT_ID |
The client id for the login. |
LOAD_TESTS_CLIENT_SECRET |
The client secret for the login. |
LOAD_TESTS_UPDATE_BASELINE |
1 adds the run to the baseline history. |
LOAD_TESTS_FORCE_BASELINE |
1 adds a failed run to the history too. It works only with LOAD_TESTS_UPDATE_BASELINE=1. |
CI |
Any value other than empty, 0 or false makes a .only and a run with every scenario skipped fail. The letter case does not matter. |
PLAYWRIGHT_BROWSERS_PATH |
The folder of the Playwright browsers, when it is not the default. The Chromium check and Artillery both read it. |
XDG_CACHE_HOME |
The cache folder of the user. On Linux, Playwright looks for its browsers there. The Chromium check and Artillery both read it. |
- An empty variable counts as not set, because a CI job can leave one empty.
LOAD_TESTS_AUTH_URLis the old name ofLOAD_TESTS_AUTH_SERVICE_URL. A run that finds only the old one stops with aConfigError, also a run of only browser scenarios.LOAD_TESTS_BASE_URL,LOAD_TESTS_UI_URL,LOAD_TESTS_AUTH_SERVICE_URLand the variables ofapismust be http or https URLs, with no user and no password. A bad value stops the run with aConfigErrorthat names the variable. It does not show the value, because a URL can hold a password. The CLI removes a slash at the end of the URL.- The package reads no database variable. Our
config.tsreads its own. - Artillery and its plugins get only a short list of the variables of the CLI. Our secrets stay out of it. See The environment of Artillery.
The environment of Artillery¶
Artillery gets only the names below from the CLI. The match ignores the letter case. The CLI does not pass a name that it does not have.
| Group | Names |
|---|---|
| Paths | PATH, HOME, USERPROFILE, TMPDIR, TMP, TEMP |
| Terminal and locale | LANG, TZ, TERM, COLORTERM, NO_COLOR, FORCE_COLOR, and every name that starts with LC_ |
| CI | CI |
| Proxy and certificates | HTTP_PROXY, HTTPS_PROXY, SSL_CERT_FILE, SSL_CERT_DIR |
| Node | NODE_OPTIONS, NODE_EXTRA_CA_CERTS, NODE_PATH, NODE_ENV, NODE_NO_WARNINGS, NODE_TLS_REJECT_UNAUTHORIZED |
| Browsers | PLAYWRIGHT_BROWSERS_PATH, XDG_CACHE_HOME |
| Windows | SYSTEMROOT, WINDIR, COMSPEC, PATHEXT, APPDATA, LOCALAPPDATA |
- The CLI does not pass these names, for example
NODE_AUTH_TOKEN,ARTILLERY_CLOUD_API_KEY,GITHUB_TOKEN, everyAWS_*name and everyLOAD_TESTS_*name.NODE_andARTILLERY_are not prefixes. - This is not a sandbox. The Artillery process can still read files. The list keeps the secrets of a CI job out of
{{ $env.NAME }}and out of the plugins of Artillery. - To pass more variables, we list their exact names in
artillery.envofconfig.ts, for exampleartillery: {env: ['MY_REGION']}. The CLI passes such a name only when it has the variable. A name that starts withLOAD_TESTS_is a config error. NO_PROXYandALL_PROXYare not in the list. Artillery reads neither of them. A project that needs one lists its name inartillery.env.- A proxy URL with a password (
HTTP_PROXY,HTTPS_PROXY) andNODE_OPTIONSpass to Artillery as they are. So we use a proxy without a password, and we keep secrets out ofNODE_OPTIONS. - The package sets
LOAD_TESTS_BASE_URLfor aload.itscenario, andLOAD_TESTS_UI_URLfor a browser scenario. It setsLOAD_TESTS_TOKENonly for a scenario that usestoken(). The runner setsARTILLERY_DISABLE_TELEMETRYtotrue. - The worker hooks (
beforeEach,afterEach,beforeRequest,afterResponse,functionsandprocessor) run inside Artillery. They see only these names too. So does the flow of a browser scenario.
Errors¶
Every error that the package throws on purpose extends LoadTestError. The CLI prints only the message of such an error. For any other error it prints the stack, because that is a bug.
| Class | When |
|---|---|
ConfigError |
config.ts (also artillery.env, artillery.browsers and apis), the folder layout, the environment, a datasource, token() with no login, or a name that ctx.apis does not have. |
ScenarioError |
A scenario file, a request, a worker hook, a scenario name, a {{ string or a capture. |
HttpError |
A response of ctx.api or ctx.apis.<name> with a status that is not 2xx. |
LoginError |
The login or the refresh of the token failed, also on a redirect. |
RunError |
A fault of the engine, a report, the baseline file or a token check. Also a ctx.api or ctx.apis.<name> path with no leading /. Also refused text in the vars of before or in the token. Also a 2xx body that is not JSON. |
ConfigErrorandScenarioErrorhaveproblems, one line for each problem. The message is the header, then one indented line for each problem.HttpErrorhasmethod,path(with the query),status,statusText,headers,body(the text) anddata.datais the parsed body when the response says it is JSON, elseundefined. Its message holds the first 200 characters of the body.- All classes accept
{cause}. - A network failure of
ctx.apiorctx.apis.<name>is aRunErrorthat keeps the cause. The message reads<METHOD> <path> failed: <cause>, for example with the causeconnect ECONNREFUSED 127.0.0.1:4010. The cause is never empty. A timeout reads<METHOD> <path> timed out after <N> s. A stalled body gives the same text. - A missing or unreadable Artillery report is a
RunErrorwithArtillery wrote no valid report: <reportPath>. It keeps the cause. A report file that is not a JSON object gives the same message. - A report that parses but has no
aggregategives theRunError<reportPath> has no aggregate: Artillery wrote no result. - A report with
aggregatebut nocountersorsummariescounts as no requests.
Engines¶
An engine runs the load of one scenario and returns what it measured. The default is artillery(). The scenarios, the thresholds, the baseline and the reports do not depend on it. To write an engine, we return an object with a name and a run function, and set it as engine in config.ts.
This engine is a stub. It sends nothing and measures nothing. It returns fixed numbers for one endpoint, so any other endpoint of a scenario fails with no requests.
rungets anEngineRun. It haspkgDir,scenario,config,phases,runVars(whatbeforeset),endpointIds,baseUrl,token(undefined without a login) andsignal.runreturns aMeasurement. It hasendpoints(a map of the endpoint id to{count, failed, p95}),hookErrors,vusersandseconds. It can also havemetrics, a map of the name of a browser metric to its stats. An engine with no browser leaves it out.- The package checks the
{{strings of the scenario for every engine. It checks the run-time values only for the Artillery engine:vars, captured values and the token. A custom engine must refuse such text itself, if its tool reads templates. See Text that the engine refuses. - On
signal, the engine stops its work and throws. It throwsScenarioErrorfor a scenario that it cannot run, andRunErrorwhen the load fails. load.stepand theartilleryoptions work only with the Artillery engine. Nothing checks this yet.- The baseline file does not record the engine.
Use any Artillery feature¶
The Artillery engine owns the target, the token, the metrics and the phases. The artillery option reaches everything else in Artillery. Its types come from Artillery, so a typo is a compile error. The engine passes the strings under artillery on as they are, so the {{ check does not read them.
| Option | Where | Use |
|---|---|---|
artillery.config |
config.ts |
Part of the Artillery config (http, plugins and so on), merged into every scenario. It cannot hold phases, processor, target, engines.<engine>.aggregateByName or the metrics-by-endpoint plugin, because the package owns them. We use phases and artillery.processor instead. The launchOptions of engines.playwright are allowed. The Chromium check uses them too. |
artillery.config |
load.it options |
The same, merged over the one of config.ts. Plain objects merge. An array replaces the one before it. A browser scenario takes artillery.config only from config.ts. |
artillery.raw |
scenario options | weight, onError, beforeScenario and afterScenario. The package keeps its own beforeScenario and afterScenario, and adds the ones of artillery.raw after them. |
artillery.raw |
request options | Request fields that the package does not own, such as expect, ifTrue, capture by header and form. The package keeps its own capture, beforeRequest and afterResponse, and adds the ones of artillery.raw after them. url, name, headers, json and qs are not allowed: the request options set them, and name is the key of the metrics of the endpoint. |
artillery.functions |
scenario options | {name: fn} for a {function: 'name'} step. The rules of a worker hook hold, but the CLI does not try them before the load. |
artillery.processor |
scenario options | The absolute path of a built .js file. Its exports are Artillery processor functions. Every worker loads it, so it can import anything. Every worker pays for its imports. |
load.step(step) |
the request list | An Artillery flow step, such as think, log, function or loop. We build a request inside a loop with load.get(...). It gets an endpoint id, limits and hooks. |
In config.ts:
In orders.controller.load.ts:
- A name in
functionsor in the exports ofprocessormust not repeat. It must not be one that the package generates (loadTests...,beforeRequest_<n>andafterResponse_<n>). The run stops on such a name. - Requests with one endpoint id add up to one result.
The public types¶
The package exports these types. We import them with import type, and the values (load, vars, token and so on) with a normal import.
| Use | Types |
|---|---|
Hooks and ctx |
RunContext, Vars, UserContext, BeforeEachContext, HookRequest, HookResponse, Hooks, Api, ApiOptions, ApiClientOptions, Sql, QueryResult |
| Scenarios and requests | ScenarioOptions, GroupOptions, RequestOptions, LoadRequest, Method, ParallelGroup, FlowItem, StepInput, RawStep, Scenario (HttpScenario or BrowserScenario), HttpScenario, Phase, Thresholds, HeaderMap |
| Browser scenarios | BrowserScenario, BrowserOptions, BrowserFlow, BrowserFlowContext, BrowserPage, BrowserStep, StepOptions, BrowserLimitOptions, MetricThresholds, WebVital |
| Values | Value, Var, Capture, Token, Template, Uuid, RandomNumber, RandomString |
config.ts |
LoadTestConfig, TokenCheck, ArtilleryConfig, Datasource, PostgresDatasource, PostgresOptions, CustomDatasource, SqlConnector, Connection |
| Engines and reporters | Engine, EngineRun, Measurement, Reporter, RunResult, ScenarioResult, EndpointResult, EndpointStats, MetricStats, VuserCounts, HeavyApis, HeavyEndpoint |
Help and internals¶
Troubleshooting¶
| What we see | Why, and what to do |
|---|---|
Set LOAD_TESTS_BASE_URL (or other LOAD_TESTS_ names) |
A required variable is missing or empty. Set it. With LOAD_TESTS_USERNAME set, the password, the client id and the client secret are required too. A browser scenario needs LOAD_TESTS_UI_URL. |
Chromium does not start with Playwright 1.62.1, the version that Artillery uses. Install it with `npx playwright@1.62.1 install chromium`. On a new Linux machine, add --with-deps. |
The check before a browser scenario fails with this line, and the next line gives the error of Playwright. When Chromium does not start in 60 s, a detail line Chromium did not start in 60 s. comes with it. Run the command. With browsers in another folder, set PLAYWRIGHT_BROWSERS_PATH. On Linux, Playwright also looks in XDG_CACHE_HOME. Playwright also reads PLAYWRIGHT_HOST_PLATFORM_OVERRIDE and PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS, and a Nix or an unusual CI image often needs LD_LIBRARY_PATH. These names are not in the default list of The environment of Artillery. We pass them with artillery.env in config.ts. |
'<selector>' is not visible after the action of step <name>: ... |
The end element of a step never showed. The vuser failed after 30 s. The text after the colon is the first line of the Playwright error. Check the selector, and check that the action shows the element. |
No report file: the last run of this scenario failed or did not start. |
load-testing report found no report file for a selected scenario. The last run of this scenario failed or did not start. A run deletes the report file when Artillery exits with a non-zero code, or when the run is stopped. Run the load again. |
artillery.config.engines.playwright.aggregateByName is not allowed: the library sets it or artillery.config.target is not allowed: the library sets it from LOAD_TESTS_BASE_URL or LOAD_TESTS_UI_URL |
artillery.config sets an option that the package owns. Remove it. |
Error: '<selector>' is visible before the action, in the output of Artillery |
The end element of a step shows before its action. The step would end at once. Pick an element that only the action shows. |
FAIL: no samples on a step |
No vuser ran the step, or a name in steps has a typo. A step that fails its vusers also gives no samples. |
FAIL: lost samples: N for M completed vusers |
Some vusers did not run the step, or a page did not report a Web Vital. Every vuser must run every step. |
No report file in <dir>. Run load-testing run first. |
load-testing report found no report of a run. Run the load first. |
No src/__tests__/load/config.ts. Add one that exports the default config ... |
The package has no config.ts in the load folder. Add one with phases. See config.ts. |
No *.load.ts file in <dir> |
The load folder has no scenario file. Add a file that ends in .load.ts. |
<file> registers no scenario. Add a load.it(...) or load.browser(...) call to it. If it has one, two copies of @sourceloop/load-testing are installed. |
The file has no load.it or load.browser, or it imports another copy of the package. In a workspace, install one copy, so that the CLI and the scenario file share it. |
No dist/__tests__/load/config.js. Run npm run build. |
The built file is missing. Build the package. The pretest:load script does it. The same message names a missing built scenario file. |
src/openapi.json has no GET /orders/{id} |
A request names an endpoint that the OpenAPI file does not have. Check the path and the method. If the route is new, build the app, run node ./dist/openapi-spec src/openapi.json, and commit the file. |
The load takes 1200 s, and the end of the run needs 120 s more, but the access token expires in 800 s. |
The access token would end before the load ends. Shorten the phases, or give the login client a longer token life. The numbers in the message are ours. |
POST <url>/auth/login gave 401: ... |
The username, the password, the client id or the client secret is wrong, or the user cannot use that client. Check the variables and the test user. |
POST <url>/auth/login gave 429: ... |
Something limits the rate of the login. The package does not retry. We wait, or we raise the limit for the test environment. |
The "pg" package is not installed in <dir>. Install it in the project: npm install --save-dev pg |
A Postgres datasource needs the driver in the project. Run npm install --save-dev pg in the package. |
POST /orders: json.name contains "{{": use template... |
A plain string has {{. Build the text with the tag template. |
POST /orders: json key "{{ x }}" contains "{{": rename the key |
An object key has {{. A key cannot use template. Rename the key. |
vars.<path> contains text that the engine reads as a template ... |
A text in the vars of before has the refused text. Put the value in vars without it, or leave it out. See Text that the engine refuses. |
load-testing hook beforeEach: vars.<path> contains text that the engine reads as a template ... |
The result of beforeEach has the refused text. The vuser failed. Return the value without it. |
load-testing hook capture: ... with in the value of <endpoint id> at <JSON path> |
A response gave a value with the refused text to a captureFrom. The vuser stopped before its next request. We read the full message in the report file. |
The access token contains text that the engine reads as a template ... |
The access token has the refused text. Log in again to get another token. |
The URL in LOAD_TESTS_BASE_URL contains text that the engine reads as a template ... |
LOAD_TESTS_BASE_URL, or LOAD_TESTS_UI_URL in the message of a browser scenario, has the refused text. Use a URL without it. |
errors.load-testing hook ... <count> in the summary of Artillery |
Artillery cuts a line at 79 columns. The full text is the key errors.<message> under aggregate.counters in src/__tests__/load/.out/<slug>.report.json. See Text that the engine refuses. |
{{ $env.MY_REGION }} is empty in a script |
Artillery gets only an allowed list of variables. Add the exact name to artillery.env in config.ts. See The environment of Artillery. |
artillery.env must be an array of variable names, for example ["MY_REGION"] |
artillery.env is not an array. Write a list of names. |
artillery.env[<i>] must be a text that is not empty, artillery.env[<i>] must not have "=", or artillery.env[<i>] "<name>" is not allowed: these variables are for the CLI only |
An entry of artillery.env is not a name, has =, or starts with LOAD_TESTS_. Use the exact name of a variable that is not for the CLI. |
POST <url> gave <status>, a redirect to <origin>. The login does not follow redirects, ... |
The auth URL redirects. Set LOAD_TESTS_AUTH_SERVICE_URL to the final URL. |
No API '<name>' in config.ts apis. Known: ... |
A hook reads ctx.apis.<name> with a name that apis in config.ts does not have. Fix the name, or add it to apis. |
apis.<name> must be the name of a variable, for example LOAD_TESTS_AUTH_SERVICE_URL, or apis must be an object ... |
apis is not an object of names and variable names. Write {auth: 'LOAD_TESTS_AUTH_SERVICE_URL'}. |
artillery.browsers[<i>] ... |
An entry of artillery.browsers is wrong: no name, an engine that is not a text, vitals that are not Web Vitals, a name that is used twice, or aggregateByName in engineOptions. See Browsers. |
artillery.browsers has no browser "<name>" |
A scenario names a browser that the list does not have. Use the same list for the whole run. |
<METHOD> <path>: the path must start with "/" |
A ctx.api call has a path with no leading /. The call sends nothing. Start the path with /. |
beforeEach returned a promise: worker hooks must be synchronous |
A worker hook is async, or returns a promise. Make it synchronous. Do async work in before. |
... can use only its arguments and the globals of a worker, such as URL and Buffer: x is not defined |
A worker hook uses an import or a variable of its module. Pass the value through vars, or use only globals. |
randomNumber: the minimum and the maximum must be whole numbers from 0 up |
A value is negative or has a fraction. Use whole numbers from 0 up, with min not above max. |
The scenario name "<name>" gives the file name "baseline", ... |
The file name baseline is for the baseline file. Rename the scenario. |
phases[i].duration must be a whole number above 0, or a text such as "1m" |
A phase has a bad time or count. Use a whole number above 0, or a text that reads as more than 0. The same holds for pause and arrivalCount. A phase with rampTo needs arrivalRate. |
phases[i] needs arrivalRate or arrivalCount |
A phase has neither a rate nor a count. Add arrivalRate, or arrivalCount. |
POST /orders failed: connect ECONNREFUSED 127.0.0.1:4010 |
The request did not reach the app. Check the base URL and that the app runs. The text after failed: is the cause. |
POST /orders timed out after 60 s |
The app did not answer in time. Check the app. For a slow call, set timeoutMs in the options of the call. |
Artillery wrote no valid report: <reportPath> |
Artillery wrote no report, or the file is broken. Read the output of Artillery above the line. |
<reportPath> has no aggregate: Artillery wrote no result |
The report parses but holds no result. Read the output of Artillery above the line. |
ctx.sql: the connection to <name> broke: ... |
An idle database connection broke. The CLI prints this on stderr. The next call opens a new connection. Check the database if it repeats. |
No scenario runs: every scenario has .skip |
CI is set and every scenario is skipped. Remove a .skip. |
.only is not allowed when CI is set: ... |
A .only stays in a file. Remove it before the push. |
vars("x") is set by neither before nor beforeEach |
A request reads vars('x') that no hook sets. Set it in before or return it from beforeEach. |
capture source "GET /orders" is not a request in this scenario |
No request of the scenario has this endpoint id. Check the id. |
capture source "GET /orders" is not earlier in the flow |
The request exists, but it comes after the captureFrom. Move it before. |
capture source "GET /orders" runs in parallel with this request |
The source is in the same load.parallel group. Capture from a request before the group. A request of the group with the same id does not matter when an earlier request outside the group has it. |
Two scenarios have the same name: ... |
Two scenarios give the same generated file name. Rename one. |
token() needs a login: ... |
A scenario sends token(), but LOAD_TESTS_USERNAME is not set. Set the login variables. |
FAIL: 3 heavy APIs, but maxHeavyApis is 2. ... |
The package has more heavy APIs than it can have. Lower the p95 limit of an endpoint to 1000 ms or less. Raise maxHeavyApis only for performance debt that the pull request lists. |
Artillery stopped with code 1: <script> |
Artillery failed. Its own output, above this line, tells why. |
artillery is missing from @sourceloop/load-testing: reinstall the package |
Artillery is not installed. Reinstall the package, and do not prune its dependencies. |
<baseline file> has a baseUrl or uiUrl that is not a text. Delete it, and record a new one. or src/__tests__/load/.out/baseline.json ... Delete it, and record a new one. |
The baseline file is not valid. It can be bad JSON, or it can miss the history or the workloads, or its baseUrl or uiUrl is not a text. Delete it, and run with LOAD_TESTS_UPDATE_BASELINE=1. |
GET /orders: artillery.raw.name is not allowed: the request options set it |
A request sets url, name, headers, json or qs in artillery.raw. Remove it, and use the request options. |
Every row says pass (workload changed) |
The phases changed since the baseline. Run once with LOAD_TESTS_UPDATE_BASELINE=1 to start a new history. |
Limits¶
- The package runs on one machine, with the local mode of Artillery. The workers are threads of the Artillery process. It does not spread the load over many machines.
- Artillery does not split a CSV
payloadbetween the workers. Each worker gets the whole payload. So we cannot use a CSV to give each vuser its own row. - The scenarios of a package run one after another, never in parallel.
- The CLI kills the Artillery processes when it exits, but it cannot catch a SIGKILL of itself. After such a kill, Artillery processes can stay alive.
- The engine sends one access token for the whole load of a scenario. The token life limits the length of a load. See Log in.
- The count of heavy APIs leaves a gap. If a fix lands and
maxHeavyApisstays the same, a new heavy API can take its place without a failure. See Heavy APIs. - The p95 is the time to the first byte of the response. It does not include the download of the body. Artillery reads this time from its HTTP engine.
- The p95 includes the whole network path. For a request to a facade, that is the client to the facade, the facade to a service, and the service to its database. The number also depends on the machine that runs the load. A pass on a CI runner does not prove the target in production.
- A baseline is good only for the machine that made it, and only for one workload.
- The package does not test a background job that an endpoint starts. It times only the response of the endpoint.
- A value that a request captures through
artillery.rawon the request (itscapturefield) gets no check for the refused text. The package checks onlycaptureFrom. - A
beforeEachresult with the refused text is found in the worker, not before the load starts. - The package does not check where the URLs point. We run it only against a local or a test environment.
- A browser scenario runs Chromium only, and it does not time the API calls of its page.
endWhenVisibletakes a CSS selector, not a Playwright locator. It does not look into shadow DOM or iframes. Its rule of visibility differs from the one of Playwright fordisplay: contents, which it treats as not visible.- A full page load inside a flow loses the LCP, CLS and INP of the page that it leaves. A page from
context.newPage(), and a popup, never report Web Vitals. The engine reads them only on the first page. - Artillery rounds each p95 to 1 decimal, so the CLS p95 moves in steps of 0.1. Its DDSketch also gives every p95 an error of about 1%.
- Each browser vuser that runs at the same time needs about one CPU core. A small load machine makes the app look slow.
load-testing reportshows only the last run, and only for the Artillery engine.
How it works inside¶
tscbuilds the*.load.tsfiles todist/__tests__/load. The CLI requires the built files. Theload.it,load.describeandload.browsercalls register scenarios while a file loads.- For each scenario, the Artillery engine writes an Artillery script, and a processor file for the worker hooks, to
src/__tests__/load/.out/. - It runs
artillery run --output <slug>.report.json <script>as a child process. Artillery prints its own progress. - It reads the report file. Per-endpoint p95 and status counts come from the
metrics-by-endpointplugin of Artillery. Vusers come from thevusers.*counters. - The judge compares the measurement with the limits and the baseline. The reporters print the result.
- A browser scenario uses the Playwright engine of Artillery. Each worker loads the built flow file at the first vuser. The
stepfunction recordsbrowser.step.<name>, and the flow recordsbrowser.flow.<label>. The end element check runs in the page on each animation frame.
See Source layout for the files, and How a run works for the order of the steps.
Source layout¶
This part is for people who change the package. Each folder is one step of a run.
| Path | What it holds |
|---|---|
types.ts |
The public types: scenario, request, hooks, config. |
errors.ts |
All error classes, errorMessage, fetchFailure, guardedStep and MS_PER_SECOND. |
output.ts |
print, printError, output and ignoreOutputErrors (stops a dead terminal from ending the process). No other file writes to the console. |
scenario/ |
What a load file calls (builder.ts), the values (values.ts, braces.ts), headers (headers.ts), token.ts, the name of a scenario (label.ts), .only and .skip (select.ts), the load (workload.ts). brand.ts, guards.ts and types.ts mark and check the objects that the calls make, and hold the scenario types. |
context/ |
What before and after get as ctx: api.ts, sql.ts, postgres.ts, driver.ts and cleanup.ts. types.ts holds the types of ctx. |
project/ |
What the CLI reads from a package: where its files are, and the constants BASE_URL_ENV and UI_URL_ENV (layout.ts), config.ts, the scenario files (loader.ts), spec.ts and heavy.ts. types.ts holds the types of these files. |
report/ |
Judges the measurement against the limits and the baseline (compare.ts, report.ts), the metric names (names.ts, which a worker loads), and the default reporter and the reporter of load-testing report (console.ts). |
engine/ |
The Engine type, in types.ts. engine/artillery/ is the Artillery adapter. index.ts there is the artillery() engine, and types.ts holds the Artillery types. It also holds the transpiler, the worker hooks (processor.ts), requests.ts, the check for the refused text (templates.ts), the environment of the process (env.ts), the process (runner.ts) and the report reader (measure.ts). For browser scenarios, it also holds the flow and its steps in the worker (browser.ts), and the Chromium check (chromium.ts). |
run/ |
One run: the login (target.ts), tokenCheck.ts, Ctrl-C (cancel.ts), prepare.ts, execute.ts, the token refresh (session.ts), the baseline save (baseline.ts) and load-testing report (rejudge.ts). types.ts holds the types of a run. |
cli.ts is the command, and index.ts is what the package exports.
The tests are in src/__tests__/. See Contributing.
The import order is types.ts, errors.ts, output.ts, scenario, context, project, report, engine, run, cli.ts. At run time, a file imports only from the files and folders before it in this list. There is one exception, for types only. types.ts has import type lines for the types.ts files of later folders. They leave no trace in the built code, so they cannot make a cycle. A cycle of run-time imports is a bug. Only cli.ts, index.ts and run/rejudge.ts import the code of engine/artillery/. rejudge.ts reads the report files that Artillery wrote. The unit tests in src/__tests__/unit/ use the same folders.
Contributing¶
Build with npm run build. npm test rebuilds the package and runs the unit tests. It skips the browser tests. Only a describe('browser: ...') marks them (--grep browser:). npm run test:browser runs only those, and it needs Chromium.
The Playwright version 1.62.1 comes from Artillery 2.0.34. When we bump Artillery, we update the version in this README and in src/__tests__/unit/cli.unit.ts.
The README is not under Prettier. We edit it by hand.
License¶
MIT, copyright SourceFuse. See the LICENSE file.
Artillery, which this package installs as a dependency, has the MPL-2.0 license. We use it unmodified.