PostMedium ⏱ ~5 min

Нагрузочное тестирование: сценарии в k6

Table of contents

В прошлых частях я писал про то, когда нагрузка нужна, и как поставить k6. Теперь — про самое интересное: как собрать скрипт, который имитирует реального пользователя, а не просто бьёт в один эндпоинт.

Мои первые скрипты были ужасны. Один запрос в цикле, хардкод ID, никакой связности. Я получал зелёные метрики и думал, что всё ок. Потом оказалось, что реальные пользователи ходят по 5-7 страницам, авторизуются, кладут товары в корзину, и в этом потоке приложение падает совсем в другом месте.

Здесь я покажу, как я сейчас собираю сценарии: от простого к сложному, с данными между шагами и с разумным разделением кода.

Простой сценарий: авторизация + действие

Самый распространённый паттерн: пользователь логинится, получает токен, делает что-то полезное. Вот как это выглядит в k6:

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

export const options = {
  vus: 10,
  duration: '1m',
};

const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';

export default function () {
  // Шаг 1: Логин
  const loginRes = http.post(`${BASE_URL}/auth/login`, JSON.stringify({
    email: 'test@example.com',
    password: 'testpass123',
  }), {
    headers: { 'Content-Type': 'application/json' },
  });

  check(loginRes, {
    'login status is 200': (r) => r.status === 200,
    'login returns token': (r) => r.json('token') !== undefined,
  });

  const token = loginRes.json('token');
  sleep(1);

  // Шаг 2: Получить профиль
  const profileRes = http.get(`${BASE_URL}/profile`, {
    headers: { Authorization: `Bearer ${token}` },
  });

  check(profileRes, {
    'profile status is 200': (r) => r.status === 200,
  });
  sleep(2);

  // Шаг 3: Создать заказ
  const orderRes = http.post(`${BASE_URL}/orders`, JSON.stringify({
    items: [{ id: 1, qty: 2 }],
  }), {
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
  });

  check(orderRes, {
    'order created': (r) => r.status === 201,
    'order has id': (r) => r.json('id') !== undefined,
  });
  sleep(1);
}

Что здесь важно:

  • Каждый шаг проверяется отдельно. Если логин упал, я сразу вижу это в метриках.
  • Данные передаются между шагами: токен из ответа используется в следующих запросах.

sleep() между шагами — это не пауза «для галочки», а имитация реального пользователя. Без него получается не нагрузочный тест, а DDoS собственного сервиса.

Разделение кода: не пишите всё в один файл

Когда сценарий разрастается, я разбиваю его на части:

// config.js
export const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
export const DEFAULT_HEADERS = {
  'Content-Type': 'application/json',
};

// auth.js
import http from 'k6/http';
import { check } from 'k6';
import { BASE_URL, DEFAULT_HEADERS } from './config.js';

export function login(email, password) {
  const res = http.post(`${BASE_URL}/auth/login`, JSON.stringify({ email, password }), {
    headers: DEFAULT_HEADERS,
  });
  check(res, {
    'login ok': (r) => r.status === 200,
  });
  return res.json('token');
}

// orders.js
import http from 'k6/http';
import { check } from 'k6';
import { BASE_URL, DEFAULT_HEADERS } from './config.js';

export function createOrder(token, items) {
  const res = http.post(`${BASE_URL}/orders`, JSON.stringify({ items }), {
    headers: { ...DEFAULT_HEADERS, Authorization: `Bearer ${token}` },
  });
  check(res, {
    'order created': (r) => r.status === 201,
  });
  return res.json('id');
}

// main.js
import { sleep } from 'k6';
import { login } from './auth.js';
import { createOrder } from './orders.js';

export default function () {
  const token = login('test@example.com', 'testpass123');
  sleep(1);
  createOrder(token, [{ id: 1, qty: 2 }]);
  sleep(2);
}

Так проще поддерживать: логика авторизации в одном месте, заказы — в другом. Если API меняется, правлю в одном файле, а не ищу по всем скриптам.

Параметризация данных: не хардкодьте ID

Моя типичная ошибка — использовать один и тот же ID товара для всех VU. В реальности разные пользователи смотрят разные товары. в k6 есть встроенный механизм под это:

// data.json
[
  { "id": 1, "name": "Product A" },
  { "id": 2, "name": "Product B" },
  { "id": 3, "name": "Product C" }
]

// script.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';
import { BASE_URL } from './config.js';

const products = new SharedArray('products', function () {
  return JSON.parse(open('./data.json'));
});

export default function () {
  const product = products[Math.floor(Math.random() * products.length)];
  const res = http.get(`${BASE_URL}/products/${product.id}`);
  check(res, { 'product loaded': (r) => r.status === 200 });
  sleep(1);
}

SharedArray важен: он загружает данные один раз и раздаёт всем VU. Обычный open() внутри default function будет читать файл на каждую итерацию — и быстро станет bottleneck.

Scenarios: когда один поток — мало

Реальные пользователи ведут себя по-разному. Кто-то быстро листает каталог, кто-то долго сидит в корзине, кто-то вообще только читает статьи. В k6 это моделируется через scenarios:

// script.js — login и createOrder берём из примера с разделением кода выше
import http from 'k6/http';
import { sleep } from 'k6';
import { login } from './auth.js';
import { createOrder } from './orders.js';
import { BASE_URL } from './config.js';

export const options = {
  scenarios: {
    browsers: {
      executor: 'constant-vus',
      vus: 50,
      duration: '5m',
      exec: 'browseCatalog',
    },
    buyers: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '2m', target: 20 },
        { duration: '3m', target: 20 },
        { duration: '1m', target: 0 },
      ],
      exec: 'buyFlow',
    },
  },
};

export function browseCatalog() {
  http.get(`${BASE_URL}/products`);
  sleep(Math.random() * 3 + 1);
}

export function buyFlow() {
  const token = login('buyer@example.com', 'pass');
  createOrder(token, [{ id: 1, qty: 1 }]);
  sleep(2);
}

Здесь два независимых потока: «браузеры» постоянно листают каталог, «покупатели» заходят волнами и делают заказы. Метрики собираются отдельно для каждого сценария.

Что я проверяю перед «боевым» прогоном

  1. Скрипт проходит дымовой прогон без ошибок.
  2. Все checks зелёные.
  3. Данные не заканчиваются на 100-й итерации.
  4. Токены не протухают раньше, чем закончится сценарий.
  5. Логи приложения не показывают необработанных ошибок.

К следующей части

Со сценариями разобрались. Но метрики в терминале — это только половина дела. В следующей части я покажу, как складывать результаты в InfluxDB и смотреть на них в Grafana, чтобы видеть динамику, а не только итоговые цифры.

Previous

Discussion

No comments yet - start the thread.

No comments yet - start the thread.

Leave a comment

Comments are published immediately after submission.