Artillery Scenarios: Multi-Step Flows, Variables, and Realistic User Behavior

Artillery Scenarios: Multi-Step Flows, Variables, and Realistic User Behavior

A load test that only hits one endpoint tells you how fast that endpoint is. It doesn't tell you how your system behaves when users log in, search for products, add items to a cart, and check out — all at the same time, with realistic delays between actions.

Artillery scenarios let you model that. This post covers multi-step flows, variable capture, CSV payloads, think time, and how to build scenarios that reflect what real users actually do.

The Anatomy of a Scenario

scenarios:
  - name: "User journey: search and purchase"
    weight: 3
    flow:
      - post:
          url: "/auth/login"
          json:
            email: "{{ email }}"
            password: "{{ password }}"
          capture:
            - json: "$.token"
              as: "authToken"

      - get:
          url: "/products?q={{ searchTerm }}"
          headers:
            Authorization: "Bearer {{ authToken }}"
          capture:
            - json: "$.products[0].id"
              as: "productId"

      - think: 2

      - post:
          url: "/cart/items"
          headers:
            Authorization: "Bearer {{ authToken }}"
          json:
            productId: "{{ productId }}"
            quantity: 1

      - post:
          url: "/orders"
          headers:
            Authorization: "Bearer {{ authToken }}"
          json:
            paymentMethod: "card_tok_test"

This scenario logs in, searches for a product, waits 2 seconds (think time), adds to cart, and places an order. Each virtual user runs this entire flow. The capture blocks extract values from responses to use in later requests.

Variables and Data Sources

Inline Variables

Define variables in the config and reference them in scenarios:

config:
  target: "https://api.example.com"
  variables:
    searchTerms:
      - "laptop"
      - "headphones"
      - "keyboard"
      - "monitor"
    searchTerm: "{{ searchTerms | random }}"

The | random filter picks a random value from the array for each virtual user. Other filters:

# Pick a random item
value: "{{ items | random }}"

# Uppercase
value: "{{ name | upcase }}"

# Lowercase
value: "{{ name | downcase }}"

CSV Payloads

For realistic data, load it from a CSV file:

email,password,name
alice@example.com,pass123,Alice
bob@example.com,pass456,Bob
carol@example.com,pass789,Carol
config:
  target: "https://api.example.com"
  payload:
    path: "./users.csv"
    fields:
      - email
      - password
      - name
    order: random

Now {{ email }}, {{ password }}, and {{ name }} are available in your scenarios. Each virtual user gets a row from the CSV.

Options for order:

  • random — pick a random row for each VU
  • sequence — iterate through rows in order, wrap around when exhausted

For large-scale tests where you need unique users:

config:
  payload:
    path: "./users.csv"
    fields:
      - email
      - password
    order: sequence
    skipHeader: true

If you have 1000 VUs but only 100 CSV rows, VUs will reuse rows (with sequence, they wrap around). If you need unique credentials per VU, your CSV needs at least as many rows as your peak concurrent users.

Generating Dynamic Data

Use the built-in fake data helpers:

scenarios:
  - name: "Create user"
    flow:
      - post:
          url: "/users"
          json:
            name: "{{ $randomString(8) }}"
            email: "user_{{ $randomNumber(1000, 9999) }}@test.example.com"
            age: "{{ $randomNumber(18, 65) }}"

Built-in generators:

  • $randomString(length) — random alphanumeric string
  • $randomNumber(min, max) — random integer in range
  • $randomUUID() — UUID v4
  • $timestamp — current Unix timestamp

These are handy for creating unique resources in each test run without needing a pre-generated CSV.

Capturing Response Data

The capture block extracts values from responses for use in subsequent requests.

JSON Path Capture

- post:
    url: "/orders"
    json:
      productId: "123"
    capture:
      - json: "$.orderId"
        as: "orderId"
      - json: "$.items[0].sku"
        as: "firstItemSku"
      - json: "$.metadata.region"
        as: "region"

JSONPath expressions follow the standard spec. Common patterns:

$.field           # top-level field
$.nested.field    # nested field
$.array[0].id    # first array element's id
$.array[-1].id   # last array element's id
$..id            # all "id" fields anywhere in the document (recursive)

Header Capture

- post:
    url: "/auth/session"
    capture:
      - header: "set-cookie"
        as: "sessionCookie"
      - header: "x-request-id"
        as: "requestId"

Regex Capture

For non-JSON responses (HTML, XML, plain text):

- get:
    url: "/page"
    capture:
      - regexp: "csrf_token: '([^']+)'"
        group: 1
        as: "csrfToken"

The group field specifies which capture group to extract (1 for the first).

Think Time

Real users don't fire requests as fast as the CPU allows. They read pages, think, scroll, click around. Think time models this.

scenarios:
  - name: "Browse products"
    flow:
      - get:
          url: "/products"
      
      - think: 3        # wait exactly 3 seconds
      
      - get:
          url: "/products/{{ productId }}"
      
      - thinkRange: [2, 8]   # wait between 2 and 8 seconds

think takes a fixed number of seconds. thinkRange picks a random duration within the range. Use thinkRange for more realistic behavior — real users have variable reading speeds.

Without think time, your load test simulates users who are purely machine-driven. With arrivalRate: 10 and no think time, each VU fires requests as fast as it can, which can generate far more load than 10 RPS. With think time, you model more realistic concurrency.

Loops in Flows

scenarios:
  - name: "Poll until complete"
    flow:
      - post:
          url: "/jobs"
          json:
            type: "report"
          capture:
            - json: "$.jobId"
              as: "jobId"

      - loop:
          - get:
              url: "/jobs/{{ jobId }}"
              capture:
                - json: "$.status"
                  as: "jobStatus"
          - think: 2
        count: 10
        whileTrue: "jobStatus !== 'complete'"

The loop runs up to 10 times, checking every 2 seconds, and stops early if jobStatus becomes complete. The whileTrue condition is a JavaScript expression evaluated in the context of the current variables.

Conditional Logic

scenarios:
  - name: "Conditional flow"
    flow:
      - get:
          url: "/user/profile"
          capture:
            - json: "$.onboardingComplete"
              as: "isOnboarded"

      - ifTrue: "isOnboarded === false"
        flow:
          - post:
              url: "/user/onboarding"
              json:
                step: "complete"

ifTrue takes a JavaScript expression. The nested flow runs only if the expression is truthy.

Multiple Scenarios with Weights

scenarios:
  - name: "Browse only"
    weight: 5
    flow:
      - get:
          url: "/products"
      - think: 3
      - get:
          url: "/products/{{ $randomNumber(1, 100) }}"

  - name: "Browse and purchase"
    weight: 2
    flow:
      - post:
          url: "/auth/login"
          json:
            email: "{{ email }}"
            password: "{{ password }}"
          capture:
            - json: "$.token"
              as: "token"
      - get:
          url: "/products"
          headers:
            Authorization: "Bearer {{ token }}"
      - think: 5
      - post:
          url: "/orders"
          headers:
            Authorization: "Bearer {{ token }}"
          json:
            productId: "{{ $randomNumber(1, 100) }}"

  - name: "Search only"
    weight: 3
    flow:
      - get:
          url: "/search?q={{ searchTerm }}"

With weights 5:2:3, out of every 10 VUs: 5 browse, 2 purchase, 3 search. This reflects a typical e-commerce traffic mix where most visitors browse but don't buy.

Before and After Hooks

Some scenarios need setup that shouldn't be counted in the load metrics — creating test data, seeding the database, obtaining auth tokens that are shared across VUs.

config:
  target: "https://api.example.com"
  processor: "./hooks.js"

before:
  flow:
    - post:
        url: "/test/seed"
        json:
          dataset: "standard"

after:
  flow:
    - delete:
        url: "/test/seed"

The before block runs once before the load test starts. The after block runs once after it ends. Neither counts toward scenario metrics.

For per-VU setup, use beforeScenario in a processor (covered in the plugins post).

Handling Authentication Properly

A common pattern: one login request per VU, reuse the token for all subsequent requests.

scenarios:
  - name: "Authenticated API test"
    flow:
      - post:
          url: "/auth/token"
          json:
            clientId: "{{ $processEnvironment.CLIENT_ID }}"
            clientSecret: "{{ $processEnvironment.CLIENT_SECRET }}"
          capture:
            - json: "$.access_token"
              as: "accessToken"

      - get:
          url: "/api/me"
          headers:
            Authorization: "Bearer {{ accessToken }}"

      - get:
          url: "/api/orders"
          headers:
            Authorization: "Bearer {{ accessToken }}"

Each VU authenticates once at the start of its scenario run and reuses the token. This is realistic behavior and also avoids hammering your auth endpoint with unnecessary requests.

If your auth tokens are long-lived (API keys, for example), set them in config.defaults.headers instead and skip the login step entirely.

Assertions in Flows

The expect plugin (covered in the next post) enables response assertions. But basic status code checks work without any plugins:

scenarios:
  - name: "CRUD test"
    flow:
      - post:
          url: "/items"
          json:
            name: "Test Item"
          expect:
            - statusCode: 201

      - get:
          url: "/items/{{ itemId }}"
          expect:
            - statusCode: 200
            - contentType: json

Failed expectations are counted separately from HTTP errors. An endpoint that returns 200 with an empty body when it should return 200 with data will show as an assertion failure, not a request failure.

Debugging Scenarios

Before running your scenario at load, run it with a single VU in verbose mode:

artillery run --count 1 scenario.yaml

For detailed per-request logging:

DEBUG=http artillery run --count 1 scenario.yaml

This prints every request URL, method, headers, body, and response. Noisy but essential when a scenario isn't working as expected.

Check that your captures are working by looking at the verbose output. If a capture fails (the JSONPath doesn't match), the variable becomes undefined and subsequent requests will have undefined in their URLs or bodies — which will likely cause 404s or 400s that are easy to misdiagnose as server-side errors.

Structuring Tests for Maintainability

For larger test suites, split scenarios into separate files and include them:

# main.yaml
config:
  target: "https://api.example.com"
  phases:
    - duration: 300
      arrivalRate: 50

scenarios:
  - $ref: "./scenarios/auth.yaml"
  - $ref: "./scenarios/products.yaml"
  - $ref: "./scenarios/checkout.yaml"

Each referenced file is a scenario object (not a full Artillery config). This keeps individual scenario files focused and easier to maintain.

Keep your CSV data files versioned alongside your test files. When you add new user types or product categories, update the CSV. The test file doesn't change.

Read more

Start now free