Every k6 test follows a structured lifecycle with four distinct phases. Understanding these phases is essential for writing effective load tests.
Test Lifecycle Phases
k6 executes tests in the following order:
Init Phase
Code in the init context runs once per VU at the beginning of the test.
Setup Phase
The setup() function runs once before the test starts.
VU Phase (Default Function)
The default function executes repeatedly for each VU iteration.
Teardown Phase
The teardown() function runs once after the test completes.
Init Context
The init context is where k6 prepares your test script. Code at the top level of your script runs during initialization.
import http from 'k6/http';
import { check } from 'k6';
import { Counter } from 'k6/metrics';
// Init context - runs once per VU
const myCounter = new Counter('my_counter');
export const options = {
vus: 10,
duration: '30s',
};
console.log('Init stage'); // Runs once per VU
What Happens in Init
Based on the k6 source code in internal/js/bundle.go, the init context:
- Imports modules
- Defines the
default function and options
- Creates custom metrics
- Loads local files
- Initializes module-provided types
The init context runs once per VU. With 10 VUs, init code executes 10 times total.
Do not make HTTP requests in the init context. They won’t be measured and will slow down test startup.
Setup Phase
The optional setup() function runs once before the test begins, regardless of VU count.
export function setup() {
// Runs once before the test starts
const res = http.post('https://api.example.com/login', {
username: 'admin',
password: 'secret',
});
// Return data to be used by VUs
return { token: res.json('token') };
}
export default function(data) {
// Use setup data
const headers = { 'Authorization': `Bearer ${data.token}` };
http.get('https://api.example.com/users', { headers });
}
export function teardown(data) {
// Clean up using setup data
http.post('https://api.example.com/logout', null, {
headers: { 'Authorization': `Bearer ${data.token}` }
});
}
Setup Use Cases
- Authenticate and obtain tokens
- Prepare test data
- Configure the system under test
- Retrieve configuration values
Setup data is serialized to JSON and distributed to all VUs. Keep it lightweight.
Default Function (VU Phase)
The default function is the heart of your test. Each VU executes it repeatedly for the duration of the test.
export default function() {
// This runs many times per VU
const response = http.get('https://quickpizza.grafana.com/api/pizza');
check(response, {
'status is 200': (r) => r.status === 200,
'response time < 200ms': (r) => r.timings.duration < 200,
});
sleep(1);
}
Iteration Behavior
From the execution state implementation in lib/execution.go:
- Each VU is tracked with counters for full and interrupted iterations
fullIterationsCount increments when iterations complete normally
interruptedIterationsCount increments when iterations are cut short
- The
activeVUs counter tracks currently executing VUs
An iteration is the complete execution of the default function, from start to finish.
Teardown Phase
The optional teardown() function runs once after all VUs finish executing.
export function teardown(data) {
// Runs once at the end of the test
console.log('Test completed');
// Clean up resources
http.post('https://api.example.com/cleanup', {
testId: data.testId
});
}
Teardown Use Cases
- Clean up test data
- Log out or revoke tokens
- Reset system state
- Perform final validation
If the test is aborted (Ctrl+C), teardown may not execute. Design your tests to handle incomplete cleanup.
Execution Flow Diagram
Init Context (once per VU)
↓
Setup (once)
↓
┌─────────────────────┐
│ VU 1: default() │
│ VU 2: default() │ ← Runs repeatedly
│ VU 3: default() │ until duration/iterations complete
│ ... │
└─────────────────────┘
↓
Teardown (once)
Execution Status
k6 tracks test execution through multiple states defined in lib/execution.go:
ExecutionStatusCreated - Test created
ExecutionStatusInitVUs - Initializing VUs
ExecutionStatusInitExecutors - Initializing executors
ExecutionStatusInitDone - Initialization complete
ExecutionStatusSetup - Running setup
ExecutionStatusRunning - Default function executing
ExecutionStatusTeardown - Running teardown
ExecutionStatusEnded - Test complete
Best Practices
Keep Init Lightweight
Avoid expensive operations in init. Use it for imports and metric definitions only.
Use Setup for Authentication
Obtain tokens once in setup and distribute to VUs, rather than authenticating in every iteration.
Make Default Function Repeatable
Ensure the default function can run many times without side effects.
Handle Teardown Failures
Don’t rely on teardown for critical cleanup - it may not run if the test is interrupted.
Complete Example
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { Rate } from 'k6/metrics';
// Init context
const errorRate = new Rate('errors');
export const options = {
stages: [
{ duration: '30s', target: 10 },
{ duration: '1m', target: 10 },
{ duration: '30s', target: 0 },
],
};
export function setup() {
// Authenticate once
const loginRes = http.post('https://api.example.com/login', {
username: 'testuser',
password: 'testpass',
});
return { authToken: loginRes.json('token') };
}
export default function(data) {
const params = {
headers: { 'Authorization': `Bearer ${data.authToken}` },
};
// Make requests
const res = http.get('https://api.example.com/data', params);
// Validate response
const success = check(res, {
'status is 200': (r) => r.status === 200,
});
errorRate.add(!success);
sleep(1);
}
export function teardown(data) {
// Logout
http.post('https://api.example.com/logout', null, {
headers: { 'Authorization': `Bearer ${data.authToken}` },
});
}
Understanding the test lifecycle helps you structure tests efficiently and avoid common pitfalls like making unnecessary requests or reinitializing data in every iteration.