ES6 Modules in k6 don’t just organize your code; they fundamentally change how you think about test script composition and reuse.

Let’s see it in action. Imagine you have a common set of utility functions for API testing. Instead of copying and pasting them into every test script, you can put them in a separate module:

// utils.js
export function getAuthHeaders(token) {
  return {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json'
  };
}

export function getRandomString(length = 10) {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  let result = '';
  for (let i = 0; i < length; i++) {
    result += chars.charAt(Math.floor(Math.random() * chars.length));
  }
  return result;
}

Now, in your main test script, you can import and use these functions:

// tests/my_api_test.js
import { getAuthHeaders, getRandomString } from '../utils.js';
import http from 'k6/http';
import { sleep } from 'k6';

const BASE_URL = 'https://httpbin.k6.io';
const USER_TOKEN = 'your_super_secret_token'; // In a real scenario, get this dynamically

export default function () {
  const headers = getAuthHeaders(USER_TOKEN);
  const payload = JSON.stringify({
    name: `Test User ${getRandomString()}`,
    email: `test.${getRandomString()}@example.com`
  });

  const res = http.post(`${BASE_URL}/post`, payload, { headers: headers });

  console.log(`Status: ${res.status}`);
  sleep(1);
}

When you run k6 run tests/my_api_test.js, k6 automatically resolves these import statements. It’s not just about export and import; it’s about building a dependency graph for your tests. You can have multiple modules, each exporting specific functionalities, and your main test script can import only what it needs. This drastically improves maintainability. If you need to update a utility function, you change it in one place (utils.js), and all tests that import it automatically benefit from the change. This is the core of "write once, use everywhere" for your test logic.

The problem k6 ES6 Modules solve is the inherent difficulty in managing shared logic across numerous test scripts. Without them, you’d find yourself copying common functions (like authentication helpers, data generation, or common request patterns) into every test file. This leads to:

  1. Maintenance Hell: A bug fix or an update to a common function requires finding and updating it in dozens, if not hundreds, of files.
  2. Code Duplication: Violates the DRY (Don’t Repeat Yourself) principle, making code harder to read and understand.
  3. Inconsistent Behavior: Slight variations in copied logic can lead to subtle, hard-to-debug differences in how tests behave.

ES6 Modules address this by providing a standardized way to define and consume reusable pieces of code. k6’s bundler (based on Rollup) handles the resolution of these modules, packaging them efficiently for execution. This means you can structure your tests into logical units, separating concerns like:

  • Core Test Logic: The specific API calls and assertions for a particular feature.
  • Helper Functions: Common tasks like authentication, data manipulation, or generating test data.
  • Configuration: Shared constants, environment-specific settings, or API endpoints.
  • Custom Assertions: Reusable checks that go beyond k6’s built-in assertions.

You can even create an import chain: test_script.js imports helpers.js, which in turn imports constants.js. k6 will resolve this entire dependency tree.

A common pattern is to have a lib or modules directory at the root of your project, containing all your shared code.

project/
├── tests/
│   ├── api_tests/
│   │   └── user_creation.js
│   └── auth_tests/
│       └── login.js
└── lib/
    ├── auth.js
    ├── data_generators.js
    └── utils.js

In tests/api_tests/user_creation.js:

import { loginUser } from '../../lib/auth.js';
import { generateUserData } from '../../lib/data_generators.js';
import http from 'k6/http';
import { check, sleep } from 'k6';

export default function () {
  const userData = generateUserData();
  const token = loginUser(userData.email, userData.password); // Assuming loginUser returns a token

  const res = http.post('https://api.example.com/users', JSON.stringify(userData), {
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json'
    }
  });

  check(res, {
    'status is 201': (r) => r.status === 201,
    'user ID is present': (r) => r.json('id') !== undefined
  });

  sleep(1);
}

And in lib/auth.js:

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

export function loginUser(email, password) {
  const res = http.post('https://api.example.com/login', JSON.stringify({ email, password }));
  sleep(0.5); // Simulate network latency for login
  if (res.status === 200) {
    return res.json('token');
  }
  throw new Error('Login failed');
}

The bundler is smart enough to handle relative paths (../ or ../../). When k6 executes a script, it first analyzes all import statements to build a complete dependency graph. It then bundles all required modules and the main script into a single executable unit. This means you don’t need to worry about the order of files or explicit pre-processing; k6 handles it.

When importing, you can use named exports (like export function myFunction() {} and import { myFunction } from './module.js') or default exports (export default function() {} and import myDefaultFunction from './module.js'). You can even mix them: import myDefaultFunction, { namedFunction } from './module.js'. This flexibility allows you to structure your modules in the most logical way.

The one thing most people don’t realize is that k6’s module system is not designed for complex, multi-file, large-scale application development where you might need circular dependency resolution or advanced module loading strategies. It’s optimized for test script composition. If you find yourself needing to import a module that itself imports the module you’re currently in, you’re likely venturing into territory where k6’s bundler might struggle or produce unexpected results. Stick to clear, directed dependencies for optimal results.

The next step is exploring how to manage configuration and environment-specific settings using modules.

Want structured learning?

Take the full K6 course →