You can actually test every service in a microservices architecture with k6, and the most surprising part is how much less brittle your tests become when you embrace this.

Let’s see it in action. Imagine we have two services: users and orders. The users service handles user creation and retrieval, and the orders service, when creating an order, needs to call the users service to validate the user ID.

Here’s a k6 script that tests both, and crucially, simulates the dependency:

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

const BASE_URL_USERS = 'http://localhost:3001'; // Assuming users service runs on port 3001
const BASE_URL_ORDERS = 'http://localhost:3002'; // Assuming orders service runs on port 3002

export const options = {
  vus: 10,
  duration: '30s',
};

// Scenario 1: Test the Users Service
export function usersServiceTest() {
  const userId = Math.floor(Math.random() * 10000); // Simulate a random user ID

  // Test user creation
  const createUserRes = http.post(`${BASE_URL_USERS}/users`, JSON.stringify({ id: userId, name: `User ${userId}` }), {
    headers: { 'Content-Type': 'application/json' },
  });
  check(createUserRes, {
    'POST /users status is 201': (r) => r.status === 201,
  });
  sleep(1); // Simulate user think time

  // Test user retrieval
  const getUserRes = http.get(`${BASE_URL_USERS}/users/${userId}`);
  check(getUserRes, {
    'GET /users/{id} status is 200': (r) => r.status === 200,
    'GET /users/{id} response has correct user ID': (r) => r.json().id === userId,
  });
  sleep(1);
}

// Scenario 2: Test the Orders Service, including its dependency on Users
export function ordersServiceTest() {
  const userId = Math.floor(Math.random() * 10000); // Simulate a user ID that might exist

  // Simulate creating an order for a user
  const createOrderRes = http.post(`${BASE_URL_ORDERS}/orders`, JSON.stringify({ userId: userId, orderDetails: 'Item A, Item B' }), {
    headers: { 'Content-Type': 'application/json' },
  });

  // The Orders service *should* call the Users service internally.
  // We check the *outcome* of the orders service operation.
  check(createOrderRes, {
    'POST /orders status is 201 (user exists)': (r) => r.status === 201, // Assuming 201 if user exists and order created
    'POST /orders status is 404 (user not found)': (r) => r.status === 404, // Assuming 404 if user doesn't exist
  });
  sleep(2); // Simulate longer think time for order placement
}

// You can run different scenarios by exporting them and selecting which ones to run
// For example, to run both: k6 run --script-path your_script.js
// To run only users: k6 run --script-path your_script.js --env SCENARIO=usersServiceTest
// To run only orders: k6 run --script-path your_script.js --env SCENARIO=ordersServiceTest
// Or a simpler approach for this example is to just have one main function that calls others.
export default function() {
  usersServiceTest();
  ordersServiceTest();
}

This script demonstrates a fundamental principle: you test the service’s public API and its expected behavior, not its internal implementation details. When ordersServiceTest hits POST /orders, it doesn’t explicitly call GET /users/{id}. Instead, it sends a request to the orders service and asserts the response from the orders service. The orders service, in turn, is responsible for its own internal calls to users.

The mental model here is about defining clear contracts between services. The orders service contract includes: "If you send me a valid userId, I will create an order and respond with 201. If the userId is invalid, I will respond with 404." Your k6 test verifies this contract. This is sometimes called "contract testing" but applied at the load testing level.

The problem this solves is the common misconception that in microservices, you must stub out dependencies during load testing. While stubbing is useful for isolating a single service’s performance under extreme load, it doesn’t tell you how the system behaves when components interact. By testing services with their live dependencies (even if those dependencies are other services you’re also testing concurrently or have deployed separately), you get a realistic picture of end-to-end performance and identify bottlenecks that arise from inter-service communication.

The exact levers you control are the endpoints you hit, the payloads you send, and the assertions you make on the responses. In the usersServiceTest function, we’re checking status codes and specific JSON fields. In ordersServiceTest, we’re checking that the orders service correctly interprets the outcome of its own internal call to the users service.

The underlying mechanism that makes this work without your tests becoming a tangled mess is k6’s ability to manage multiple HTTP requests within a single VU iteration and its flexible export default or named function execution. You can orchestrate complex user journeys that span multiple services. For instance, a user might first register (hitting users), then browse products, and finally place an order (hitting orders, which internally hits users again). Your k6 script can model this entire flow.

What most people miss is that when one service fails to communicate with another, it’s often due to network issues, misconfigurations (like incorrect service discovery addresses), or resource contention between services, not necessarily a bug within the service itself. Testing services in conjunction, where they interact with their actual dependencies, surfaces these inter-service coordination problems. For example, if the orders service starts returning 5xx errors under load, and you know it calls users, your test immediately flags that the problem could be in orders or in its ability to reach users.

The next step is to start thinking about how to manage the lifecycle of these dependencies during your tests, perhaps by deploying a minimal, independent version of each service to a dedicated test environment.

Want structured learning?

Take the full K6 course →