В отличие от ассёртов Playwright, упавшая проверка k6 не останавливает тест: все виртуальные пользователи продолжают работать, а k6 считает процент прошедших и упавших проверок по тысячам запросов и показывает итоговый сигнал здоровья в сводке. Останавливает запуск нарушение порога: если p95 времени ответа превышает заданный лимит, k6 завершается с кодом 99 и шаг CI падает точно так же как упавший тест. Гайд охватывает установку k6, написание первого скрипта с реалистичными паузами sleep(), чтение сводной таблицы вывода, настройку стадий наращивания нагрузки и пороги которые делают регрессии производительности видимыми в CI.

Что такое k6 и зачем QA-инженеру его знать

k6: инструмент нагрузочного тестирования с открытым исходным кодом от Grafana Labs. Скрипты пишутся на JavaScript или TypeScript, запускаются из CLI, на выходе: подробная информация о поведении системы под нагрузкой. Репозиторий на GitHub набрал больше 24 000 звёзд и используется в компаниях любого масштаба.

Для QA-инженеров k6 удобен моделью написания скриптов. Если уже пишешь тесты на Playwright или Jest, синтаксис знакомый: импорт модулей, функции, HTTP-запросы, ассёрты. Никакого тяжёлого GUI, никакого проприетарного формата записи, никаких XML-конфигов. Только скрипт.

k6 чисто встраивается в GitHub Actions. Устанавливаешь бинарник, запускаешь скрипт, и если тест падает (подробнее о том что значит «падает» ниже), шаг CI завершается с ненулевым кодом. Тот же контракт что у любого другого тест-инструмента.

k6 не замена Playwright. Playwright проверяет поведение, k6 измеряет производительность под нагрузкой. Разные вопросы, оба нужны в зрелой QA-стратегии.

Установка k6 и первый тест

k6 поставляется как самодостаточный бинарник. На macOS:

brew install k6

На Linux (Debian/Ubuntu):

sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update
sudo apt-get install k6

На Windows: официальный установщик с k6.io/docs/get-started/installation или через Chocolatey:

choco install k6

Проверяем установку:

k6 version

Создаём файл load-test.js с минимальным скриптом:

import http from 'k6/http';

export default function () {
  http.get('https://test-api.k6.io/public/crocodiles/');
}

Запускаем:

k6 run load-test.js

k6 использует test-api.k6.io как публичную песочницу. Можно запускать без предварительной настройки. В терминале появится вывод: количество запросов, время ответа и сводная таблица. Первый нагрузочный тест запущен.

Анатомия k6-скрипта

Полноценный k6-скрипт состоит из трёх частей: экспорт options который управляет поведением теста, функция default с тестовой логикой, и любая подготовка нужная запросам. Структура:

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

export const options = {
  vus: 10,         // виртуальных пользователей параллельно
  duration: '30s', // сколько длится тест
};

export default function () {
  http.get('https://your-api.example.com/api/products');
  sleep(1); // пауза между запросами
}

vus означает виртуальных пользователей. Каждый VU запускает функцию default в цикле на всё время теста, независимо от остальных. vus: 10 и duration: '30s' означают 10 симулированных пользователей которые непрерывно нагружают эндпоинт 30 секунд. sleep(1) не опционален: он моделирует время которое реальный пользователь тратит между действиями. Без него каждый VU шлёт запросы так быстро как сервер успевает отвечать: нереалистично и даёт завышенные цифры пропускной способности. Секундная пауза: разумный дефолт, корректируй под реальное поведение пользователей.
Виртуальные пользователи k6 представляют собой не потоки и не процессы, а лёгкие горутины. Один инстанс k6 реально запускает тысячи VU без пропорционального потребления памяти и CPU. Кластер для полезного нагрузочного теста не нужен.

Для POST-запросов с JSON-телом (нужны для большинства API-тестирования):

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

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

export default function () {
  const url = 'https://your-api.example.com/api/login';
  const payload = JSON.stringify({
    username: 'testuser@example.com',
    password: 'testpassword',
  });
  const params = {
    headers: { 'Content-Type': 'application/json' },
  };

  http.post(url, payload, params);
  sleep(1);
}

Чтение вывода k6

После прогона k6 печатает сводную таблицу в терминал. Умение быстро её читать превращает сырые числа в понятную информацию.

Типичный вывод:

✓ checks.........................: 100.00% ✓ 1800 ✗ 0
  data_received..................: 4.3 MB  143 kB/s
  data_sent......................: 523 kB  17 kB/s
  http_req_blocked...............: avg=1.2ms    min=1µs    med=4µs    max=312ms  p(90)=7µs    p(95)=11µs
  http_req_connecting............: avg=743µs    min=0s     med=0s     max=181ms  p(90)=0s     p(95)=0s
  http_req_duration..............: avg=312ms    min=98ms   med=280ms  max=2.1s   p(90)=520ms  p(95)=640ms
    { expected_response:true }...: avg=312ms    min=98ms   med=280ms  max=2.1s   p(90)=520ms  p(95)=640ms
  http_req_failed................: 0.00%   ✓ 0 ✗ 1800
  http_req_receiving.............: avg=2.1ms    min=45µs   med=1.1ms  max=74ms   p(90)=4.2ms  p(95)=5.8ms
  http_req_sending...............: avg=178µs    min=27µs   med=136µs  max=3.2ms  p(90)=312µs  p(95)=389µs
  http_req_tls_handshaking.......: avg=412µs    min=0s     med=0s     max=168ms  p(90)=0s     p(95)=0s
  http_req_waiting...............: avg=310ms    min=97ms   med=278ms  max=2.1s   p(90)=518ms  p(95)=637ms
  http_reqs......................: 1800    60.0/s
  iteration_duration.............: avg=1.31s    min=1.1s   med=1.28s  max=3.1s   p(90)=1.52s  p(95)=1.64s
  iterations.....................: 1800    60.0/s
  vus............................: 10      min=10     max=10
  vus_max........................: 10      min=10     max=10

Три метрики которые смотришь первыми:

http_req_duration: полное время round-trip каждого запроса. Колонка p(95) показывает 95-й перцентиль: 95% запросов завершились за это время. Это число важно для SLA, не среднее. Среднее скрывает выбросы, p95 нет. http_reqs: общее количество запросов и частота в секунду. Показывает сколько нагрузки реально создано. http_req_failed: доля запросов получивших ответ с ошибкой (4xx или 5xx). Ноль процентов норма, всё что выше требует расследования.

Проверки (checks)

Функциональная корректность под нагрузкой не менее важна чем скорость. В k6 есть встроенный механизм проверок похожий на expect в других тест-фреймворках:

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

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

export default function () {
  const res = http.get('https://your-api.example.com/api/products');

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
    'body contains products': (r) => r.body.includes('"id"'),
  });

  sleep(1);
}

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

Используй проверки для всего что обычно ассёртишь в функциональном тесте: код статуса, время ответа, наличие ожидаемых полей в теле ответа. Процент прошедших проверок в сводке даёт агрегированный сигнал здоровья по тысячам запросов.

Для эндпоинта логина возвращающего токен:

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

export default function () {
  const payload = JSON.stringify({
    username: 'user@example.com',
    password: 'password123',
  });

  const res = http.post('https://your-api.example.com/auth/login', payload, {
    headers: { 'Content-Type': 'application/json' },
  });

  check(res, {
    'login succeeds': (r) => r.status === 200,
    'token present': (r) => JSON.parse(r.body).token !== undefined,
    'login fast enough': (r) => r.timings.duration < 1000,
  });

  sleep(1);
}

Стадии: наращивание, плато, снижение

Постоянная плоская нагрузка редко нужна. Реальный трафик наращивается по мере прихода пользователей, держится на пике и падает. k6 моделирует это через стадии:

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

export const options = {
  stages: [
    { duration: '2m', target: 50 },   // нагружаем до 50 VU за 2 минуты
    { duration: '5m', target: 50 },   // держим 50 VU 5 минут
    { duration: '2m', target: 100 },  // нагружаем до 100 VU за 2 минуты
    { duration: '5m', target: 100 },  // держим 100 VU 5 минут
    { duration: '2m', target: 0 },    // снижаем до 0
  ],
};

export default function () {
  const res = http.get('https://your-api.example.com/api/products');

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time OK': (r) => r.timings.duration < 1000,
  });

  sleep(1);
}

Этот паттерн (наращивание, плато, ещё наращивание, плато, снижение) называется стресс-профилем. Он показывает при каком количестве пользователей система начинает деградировать. В результатах p95 будет планомерно расти по мере увеличения VU. Точка резкого скачка: предел системы.

Для большинства API-тестирования достаточно простого двухфазного теста: наращивание и плато.

export const options = {
  stages: [
    { duration: '1m', target: 50 },  // прогрев
    { duration: '5m', target: 50 },  // устойчивая нагрузка
    { duration: '30s', target: 0 },  // снижение
  ],
};

Фаза снижения важна. Резкое отключение нагрузки может скрыть проблемы с пулом соединений и даёт чище метрики в конце теста.

Пороги: прохождение и провал теста

Проверки говорят что произошло. Пороги определяют прошёл ли тест. Порог: условие прохождения/провала для любой метрики. Если условие нарушено, k6 завершается с ненулевым кодом. Это и делает нагрузочные тесты полезными в CI.

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

export const options = {
  stages: [
    { duration: '1m', target: 50 },
    { duration: '5m', target: 50 },
    { duration: '30s', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<2000'],  // 95-й перцентиль должен быть меньше 2 секунд
    http_req_failed: ['rate<0.01'],     // менее 1% запросов могут завершиться с ошибкой
    checks: ['rate>0.99'],              // больше 99% проверок должны пройти
  },
};

export default function () {
  const res = http.get('https://your-api.example.com/api/products');

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 2s': (r) => r.timings.duration < 2000,
  });

  sleep(1);
}

Когда p95 превышает 2000 мс, запуск завершается с кодом 99. Шаг CI падает. Это правильное поведение. Сборка которая делает API на 40% медленнее должна падать.

Пороги можно задавать для конкретных эндпоинтов через теги:

export default function () {
  const loginRes = http.post(
    'https://your-api.example.com/auth/login',
    JSON.stringify({ username: 'user@example.com', password: 'pass' }),
    { headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } }
  );

  const productsRes = http.get(
    'https://your-api.example.com/api/products',
    { tags: { name: 'products' } }
  );
}

export const options = {
  thresholds: {
    'http_req_duration{name:login}': ['p(95)<1000'],
    'http_req_duration{name:products}': ['p(95)<500'],
  },
};

Логин ожидаемо медленнее чем закешированный список продуктов, поэтому у каждого эндпоинта свой SLA.

Пороги оцениваются непрерывно во время теста, не только в конце. Если p95 превысил лимит в начале прогона и потом восстановился, порог всё равно помечается как нарушенный. Система которая деградирует и восстанавливается может скрывать проблему с ёмкостью которую нужно расследовать.

k6 в GitHub Actions

k6 чисто работает в GitHub Actions. Команда Grafana публикует официальный экшен который устанавливает бинарник и фиксирует версию:

# .github/workflows/load-test.yml
name: Load Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

jobs:
  load-test:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install k6
        uses: grafana/setup-k6-action@v1
        with:
          k6-version: '0.55.0'

      - name: Run load test
        run: k6 run load-test.js
        env:
          BASE_URL: ${{ vars.BASE_URL }}
          API_KEY: ${{ secrets.API_KEY }}

      - name: Upload k6 results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: k6-results
          path: k6-results.json
          retention-days: 14

Для доступа к переменным окружения внутри скрипта используй __ENV:

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

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

export const options = {
  stages: [
    { duration: '1m', target: 20 },
    { duration: '3m', target: 20 },
    { duration: '30s', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<2000'],
    http_req_failed: ['rate<0.01'],
  },
};

export default function () {
  const res = http.get(`${BASE_URL}/api/products`);

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time acceptable': (r) => r.timings.duration < 2000,
  });

  sleep(1);
}

Для генерации JSON-файла результатов используй флаг --out:

k6 run --out json=k6-results.json load-test.js

Практическая рекомендация: не запускай нагрузочные тесты на каждый пул-реквест против продакшна или общего стейджинга. Нагрузочный тест должен запускаться на мерджах в main или вручную через workflow_dispatch. Тяжёлые нагрузочные тесты на каждой ветке PR создают интерференцию между параллельными запусками. Smoke-тест (5 VU, 30 секунд) на PR приемлем, полный нагрузочный тест нет.

Какие эндпоинты тестировать

Выбор того что тестировать с k6 не менее важен чем сам тест. Не каждому эндпоинту нужен нагрузочный тест. Фокус на тех где производительность важна.

Эндпоинт логина пишешь первым. С него начинается каждая пользовательская сессия. Медленный логин умноженный на 500 параллельных пользователей разрушает опыт ещё до того как кто-то дошёл до функционала. Тестируй полный флоу логина: POST-запрос с учётными данными, получение токена, один аутентифицированный запрос.

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

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

export const options = {
  stages: [
    { duration: '1m', target: 50 },
    { duration: '5m', target: 50 },
    { duration: '1m', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<1500'],
    http_req_failed: ['rate<0.01'],
  },
};

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

  check(loginRes, {
    'login status 200': (r) => r.status === 200,
    'token returned': (r) => JSON.parse(r.body).token !== undefined,
  });

  const token = JSON.parse(loginRes.body).token;

  // Шаг 2: Аутентифицированный запрос
  const profileRes = http.get(`${BASE_URL}/api/me`, {
    headers: { Authorization: `Bearer ${token}` },
  });

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

  sleep(2);
}

Ключевые read-эндпоинты идут следующими. Листинг продуктов, результаты поиска, данные дашборда: всё к чему пользователи обращаются многократно и что попадает в базу данных. Именно эти эндпоинты чаще всего деградируют при масштабировании потому что агрегируют данные из множества строк или таблиц. Флоу оформления заказа или отправки замыкает тройку. Здесь медленная производительность имеет прямой финансовый эффект. Тестируй при меньшем количестве VU чем read-эндпоинты (реальная параллельность при оформлении заказа ниже чем при просмотре) но держи более жёсткий порог времени ответа.

Разумная точка старта для большинства приложений: три нагрузочных скрипта. Один для аутентификации, один для трёх главных read-эндпоинтов в одном скрипте, один для критически важной конверсионной write-операции. Этого покрытия достаточно чтобы ловить значимые регрессии производительности без построения полноценного нагрузочного сьюта до получения базовых метрик.

FAQ

Нужна ли отдельная среда для запуска k6?

Нужна среда которая выдержит нагрузку без влияния на реальных пользователей и порчи продакшн-данных. Отдельная среда для нагрузочных тестов идеальна. Если только стейджинг: запускай тесты в нерабочее время и убедись что тестовые данные изолированы. На продакшн с большим количеством VU никогда не направляй.

Сколько VU использовать?

Начни с числа которое представляет реалистичный пиковый трафик, а не теоретический максимум. Если в приложении 50 параллельных пользователей в напряжённый день, тестируй на 50–100. Знать что происходит при 1000 VU менее важно чем убедиться что система комфортно справляется с нормальной пиковой нагрузкой.

k6-тест проходит, но приложение ощущается медленным. Почему?

k6 измеряет время HTTP-ответа: серверная обработка включена, клиентский рендеринг нет. API-эндпоинт который отвечает за 200 мс может всё равно ощущаться медленным если фронтенд делает 20 последовательных вызовов для заполнения страницы. k6 для производительности на уровне API, браузерные профилировщики для воспринимаемой производительности страницы.

Поддерживает ли k6 WebSocket или gRPC?

Да. k6 поддерживает WebSocket через модуль k6/ws и gRPC через k6/net/grpc. Модель написания скриптов одинаковая, отличается только протокол-специфичный API.

Как поделиться результатами k6 с командой?

JSON-файл из --out json можно импортировать в Grafana для визуализации. Если команда использует Grafana Cloud, k6 имеет нативную интеграцию которая стримит результаты в реальном времени и хранит их для сравнения между запусками. Для команд без Grafana сводная таблица из терминала в комментарий PR или в Slack достаточна для передачи статуса прохождения и ключевых перцентилей.

В чём разница между нагрузочным и стресс-тестом?

Нагрузочный тест проверяет что система выполняет требования к производительности при ожидаемом трафике. Стресс-тест выходит за ожидаемые пределы чтобы найти точку отказа. Конфигурация стадий обрабатывает оба: нагрузочный тест держится на целевом количестве VU, стресс-тест продолжает наращивать пока проверки не начнут падать.

→ See also: Основы нагрузочного тестирования: нагрузочное, стресс и спайк-тестирование | API-тестирование 101: всё, что нужно знать QA-инженеру в 2026 году | CI/CD для QA: сравнение GitHub Actions, Jenkins и GitLab