Setting up and tearing down test state for performance tests is often an afterthought, leading to flaky tests and inaccurate metrics, but it’s the secret sauce to reproducible and meaningful load testing.

Let’s see k6 in action with a simple example. Imagine we need to create a user in our system before we can test its login endpoint.

import http from 'k6/http';
import { check } from 'k6';

// Setup function runs once before the test begins
export function setup() {
  console.log('Setting up test environment...');
  const createUserPayload = JSON.stringify({
    username: 'testuser_' + __VU, // Use VU to make it unique per virtual user
    email: 'testuser_' + __VU + '@example.com',
    password: 'password123'
  });
  const createUserParams = {
    headers: {
      'Content-Type': 'application/json',
    },
  };

  const createUserRes = http.post('http://localhost:3000/users', createUserPayload, createUserParams);

  if (createUserRes.status !== 201) {
    console.error('Failed to create user:', createUserRes.body);
    return null; // Indicate setup failure
  }

  const userId = JSON.parse(createUserRes.body).id;
  console.log(`User created with ID: ${userId}`);

  // Return data that will be available to all VUs in the default function
  return { userId: userId };
}

// Default function runs for each iteration of the test
export default function (data) {
  // We can access the userId returned by setup
  const loginPayload = JSON.stringify({
    username: 'testuser_' + __VU,
    password: 'password123'
  });
  const loginParams = {
    headers: {
      'Content-Type': 'application/json',
    },
  };

  const loginRes = http.post('http://localhost:3000/login', loginPayload, loginParams);

  check(loginRes, {
    'Login successful': (r) => r.status === 200,
  });

  // In a real scenario, you might do other actions here
}

// Teardown function runs once after the test finishes
export function teardown(data) {
  console.log('Tearing down test environment...');
  if (data && data.userId) {
    const deleteUserRes = http.del(`http://localhost:3000/users/${data.userId}`);
    if (deleteUserRes.status !== 200) {
      console.error(`Failed to delete user ${data.userId}:`, deleteUserRes.body);
    } else {
      console.log(`User ${data.userId} deleted.`);
    }
  } else {
    console.warn('No userId found in teardown data. Skipping user deletion.');
  }
}

In this example, setup() creates a user, and teardown() deletes that user. The data object returned by setup() is passed to teardown(), allowing us to use the created userId for cleanup.

The core problem setup and teardown solve is state management for performance tests. Without them, you’d either have to:

  1. Pre-populate your test environment: This is brittle. What if your test runs on a shared staging environment and your setup pollutes it for other teams? What if the pre-population fails?
  2. Create state within each virtual user’s iteration: This is inefficient and often leads to inaccurate metrics. If every single virtual user creates a record, your "create" operation itself becomes part of the load test, skewing results for your actual target endpoint (e.g., login). It also means you’re testing the creation performance, not the login performance.

setup() and teardown() provide a lifecycle for your test execution. setup() runs once before any virtual users (VUs) start executing your main test logic (default function). teardown() runs once after all VUs have finished. This allows you to perform actions that are necessary for your test but shouldn’t be part of the measured load.

The data object returned by setup() is serialized and passed to the teardown() function. This is crucial for passing information needed for cleanup. If setup() returns null or undefined, teardown() will not be executed.

The most surprising thing about setup and teardown is how they can be used to orchestrate complex test environments that might involve multiple services or even external dependencies, all managed within a single k6 script. You can spin up Docker containers, provision cloud resources (though this is less common and usually better handled by CI/CD), or seed databases before your load begins, and then clean them up afterward.

Consider a scenario where your API relies on a specific background job to have completed. You could use setup to trigger that job and then poll a status endpoint until it’s done, ensuring your load test starts only when the prerequisite is met.

import http from 'k6/http';
import { check, sleep } from 'k6';

export function setup() {
  console.log('Triggering background job...');
  const triggerJobRes = http.post('http://localhost:5000/jobs/trigger');

  if (triggerJobRes.status !== 202) {
    console.error('Failed to trigger job:', triggerJobRes.body);
    return null;
  }

  const jobId = JSON.parse(triggerJobRes.body).id;
  console.log(`Job triggered with ID: ${jobId}`);

  // Wait for the job to complete
  let jobStatus = 'pending';
  let attempts = 0;
  while (jobStatus === 'pending' && attempts < 20) { // Timeout after 20 attempts (approx 20s)
    sleep(1); // Wait 1 second between checks
    const statusRes = http.get(`http://localhost:5000/jobs/${jobId}/status`);
    if (statusRes.status === 200) {
      jobStatus = JSON.parse(statusRes.body).status;
      console.log(`Job status: ${jobStatus}`);
    } else {
      console.error(`Failed to get job status for ${jobId}:`, statusRes.body);
      break; // Exit loop on error
    }
    attempts++;
  }

  if (jobStatus !== 'completed') {
    console.error(`Job ${jobId} did not complete in time.`);
    return null;
  }

  console.log('Background job completed. Ready to start test.');
  return { jobId: jobId };
}

export default function () {
  const res = http.get('http://localhost:3000/data');
  check(res, {
    'Data endpoint OK': (r) => r.status === 200,
  });
  sleep(1);
}

export function teardown(data) {
  console.log('Cleaning up after test...');
  // In this example, we don't need to clean up the job itself,
  // but you could add logic here if the job created resources.
  if (data && data.jobId) {
    console.log(`Test completed for job ${data.jobId}.`);
  }
}

The setup and teardown functions are executed in the same process as your k6 test. This means you have access to all k6 modules (http, sleep, check, etc.) and can perform complex logic. However, it also means that any unhandled error in setup will cause the entire test to fail before any VUs even start. Likewise, an unhandled error in teardown might prevent cleanup from completing. The data object passed between setup and teardown is a plain JavaScript object and is serialized/deserialized; it does not retain complex object references.

The next logical step after mastering state management is understanding how to integrate k6 with your CI/CD pipeline for automated performance testing.

Want structured learning?

Take the full K6 course →