Multiple Scenarios in One Test

9 min read

A single export default function with one VU count and one stage pattern cannot model mixed workloads. Real systems serve multiple user types simultaneously: some users browse, some search, some complete purchases. Scenarios let you run distinct user flows — each with its own VU count, executor, and timing — in a single test run.

Three scenarios running in parallel

Step 1 of 3

browsers (50 VUs)

ramping-vus executor. Calls browseProducts(). 50 VUs ramping up over 2 minutes, holding for 5 minutes. Represents users browsing the product catalogue — high volume, simple reads.

All three scenarios hit the same backend simultaneously. The system must handle mixed load — not just one kind of traffic.

Defining scenarios

Scenarios are declared in options.scenarios. Each scenario names a function to execute:

import http from 'k6/http';
import { check, sleep } from 'k6';
 
export const options = {
  scenarios: {
    browsers: {
      executor: 'ramping-vus',
      exec: 'browseProducts',
      startVUs: 0,
      stages: [
        { duration: '2m', target: 50 },
        { duration: '5m', target: 50 },
        { duration: '2m', target: 0 },
      ],
      tags: { scenario: 'browse' },
    },
    checkout: {
      executor: 'constant-vus',
      exec: 'checkoutFlow',
      vus: 10,
      duration: '9m',
      startTime: '30s',
      tags: { scenario: 'checkout' },
    },
    api_load: {
      executor: 'constant-arrival-rate',
      exec: 'apiCall',
      rate: 100,
      timeUnit: '1s',
      duration: '9m',
      preAllocatedVUs: 50,
      maxVUs: 200,
      tags: { scenario: 'api' },
    },
  },
};
 
export function browseProducts() {
  const res = http.get('https://shop.example.com/products', {
    tags: { name: 'ListProducts' },
  });
  check(res, { 'products listed': (r) => r.status === 200 });
  sleep(2);
}
 
export function checkoutFlow() {
  const cartRes = http.post('https://shop.example.com/cart/items',
    JSON.stringify({ productId: Math.ceil(Math.random() * 100) }),
    { headers: { 'Content-Type': 'application/json' }, tags: { name: 'AddToCart' } }
  );
  check(cartRes, { 'added to cart': (r) => r.status === 201 });
 
  const orderRes = http.post('https://shop.example.com/orders',
    JSON.stringify({ paymentMethod: 'card_test' }),
    { headers: { 'Content-Type': 'application/json' }, tags: { name: 'PlaceOrder' } }
  );
  check(orderRes, { 'order placed': (r) => r.status === 201 });
  sleep(5);
}
 
export function apiCall() {
  http.get('https://api.example.com/products/1', {
    tags: { name: 'ApiGetProduct' },
  });
}

Each exported function is independent. browseProducts, checkoutFlow, and apiCall run concurrently — browseProducts VUs never call checkoutFlow logic and vice versa.

Scenario tags for metric filtering

The tags: { scenario: 'browse' } on each scenario automatically tags all requests made within that scenario. Combined with request-level tags, this gives you two dimensions of filtering in the output and Grafana:

http_req_duration{scenario:browse,name:ListProducts}...: p(95)=180ms
http_req_duration{scenario:checkout,name:AddToCart}.....: p(95)=420ms
http_req_duration{scenario:checkout,name:PlaceOrder}....: p(95)=680ms
http_req_duration{scenario:api,name:ApiGetProduct}......: p(95)=95ms

Add thresholds per scenario using this tag syntax:

thresholds: {
  'http_req_duration{scenario:checkout}': ['p(95)<1000'],
  'http_req_duration{scenario:browse}':   ['p(95)<300'],
  'http_req_duration{scenario:api}':      ['p(95)<200'],
},

Sequential scenarios with startTime

By default all scenarios start at t=0. Use startTime to sequence them:

scenarios: {
  warmup: {
    executor: 'constant-vus',
    exec: 'warmupCaches',
    vus: 5,
    duration: '2m',
    startTime: '0s',
  },
  main_load: {
    executor: 'ramping-vus',
    exec: 'mainTest',
    startVUs: 0,
    stages: [
      { duration: '3m', target: 100 },
      { duration: '5m', target: 100 },
      { duration: '2m', target: 0 },
    ],
    startTime: '2m',  // starts after warmup completes
  },
},

startTime is an offset from the beginning of the test. Set it to the duration of the preceding scenario to create strictly sequential execution.

setup() and teardown() with scenarios

setup() and teardown() still run once each, before and after all scenarios. The data returned by setup() is passed to every function called by every scenario:

export function setup() {
  // Log in once — token available to all scenario functions
  const res = http.post('https://api.example.com/auth/login', JSON.stringify({
    email: 'loadtest@example.com', password: 'TestPass@1234',
  }), { headers: { 'Content-Type': 'application/json' } });
  return { token: res.json('token') };
}
 
export function browseProducts(data) {
  http.get('https://shop.example.com/products', {
    headers: { Authorization: `Bearer ${data.token}` },
  });
}
 
export function checkoutFlow(data) {
  http.post('https://shop.example.com/orders',
    JSON.stringify({ productId: 1 }),
    { headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${data.token}`,
      }
    }
  );
}

⚠️ Common mistakes

  • Not exporting scenario functions. Scenario functions must be export function browseProducts() — not just function browseProducts(). K6 can only find exec targets that are exported. An unexported function silently causes the scenario to fail to start.
  • Expecting default() to run when using scenarios. When options.scenarios is defined, K6 only runs the functions named in each scenario's exec field. export default function is ignored unless a scenario explicitly references it with exec: 'default'.
  • No startTime for sequential scenarios. Without startTime, all scenarios start simultaneously. If your setup_data scenario is supposed to run before main_test, they will overlap unless you set startTime on main_test.
  • Missing maxVUs on arrival-rate executors. constant-arrival-rate spawns new VUs to maintain the rate if existing VUs are too slow. Without maxVUs, it can spawn unlimited VUs. Always cap with maxVUs to prevent runaway resource consumption.

🎯 Practice task

Build a multi-scenario test against a public API. 40 minutes.

Use https://jsonplaceholder.typicode.com.

  1. Write a script with two scenarios:
    • readers: constant-vus, exec: 'readPosts', 5 VUs, 2-minute duration.
    • writers: constant-vus, exec: 'createPost', 2 VUs, 90-second duration, startTime: '15s'.
  2. Add scenario tags: tags: { scenario: 'read' } and tags: { scenario: 'write' }.
  3. Write export function readPosts(): GET /posts (tagged ListPosts), check status 200, sleep(1).
  4. Write export function createPost(): POST /posts with JSON.stringify({ title: 'K6 Test', body: 'load test', userId: 1 }), check status 201, sleep(2).
  5. Add thresholds: 'http_req_duration{scenario:read}': ['p(95)<400'] and 'http_req_duration{scenario:write}': ['p(95)<800'].
  6. Run the test. Verify in the output that metric rows appear for both scenarios. Note the startTime delay — confirm the writers scenario metrics only appear after 15 seconds of the test.
  7. Add export function setup() that makes one request and returns { startedAt: Date.now() }. Modify both exported functions to accept data and log data.startedAt. Confirm setup runs once.

// tip to track lessons you complete and pick up where you left off across devices.