LESSON 40分

「k6 は JavaScript ベースで書けるから、フロントエンドエンジニアにも馴染みやすい」と佐藤CTOは言った。「Gatling は Scala ベースだが DSL が洗練されていて、複雑なシナリオが書きやすい。どちらを選ぶかより、正しいシナリオを書けるかが重要だ。」

1. k6 の基本

シンプルなテストスクリプト

// k6/basic-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';

// カスタムメトリクス
const errorRate = new Rate('errors');
const responseTime = new Trend('response_time');

// テスト設定
export const options = {
  // Load Test: 段階的に負荷を上げる
  stages: [
    { duration: '2m', target: 50 },   // ウォームアップ: 2分で50VUまで
    { duration: '5m', target: 200 },   // ランプアップ: 5分で200VUまで
    { duration: '10m', target: 200 },  // 定常: 200VUを10分維持
    { duration: '3m', target: 0 },     // クールダウン: 3分で0VUまで
  ],

  // 成功基準(Thresholds)
  thresholds: {
    http_req_duration: [
      'p(95)<500',   // p95 が 500ms 以下
      'p(99)<1000',  // p99 が 1000ms 以下
    ],
    errors: ['rate<0.01'],             // エラー率 1% 以下
    http_req_failed: ['rate<0.01'],    // HTTP エラー率 1% 以下
  },
};

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

export default function () {
  // シナリオ: 商品一覧 → 商品詳細 → カートに追加
  const listRes = http.get(`${BASE_URL}/api/products?page=1&limit=20`);
  check(listRes, {
    'product list: status 200': (r) => r.status === 200,
    'product list: has products': (r) => JSON.parse(r.body).length > 0,
  });
  responseTime.add(listRes.timings.duration);
  errorRate.add(listRes.status !== 200);

  sleep(Math.random() * 3 + 2); // 思考時間: 2-5秒

  // 商品詳細
  const productId = Math.floor(Math.random() * 10000) + 1;
  const detailRes = http.get(`${BASE_URL}/api/products/${productId}`);
  check(detailRes, {
    'product detail: status 200': (r) => r.status === 200,
  });
  responseTime.add(detailRes.timings.duration);
  errorRate.add(detailRes.status !== 200);

  sleep(Math.random() * 5 + 3); // 思考時間: 3-8秒

  // カートに追加(15%の確率)
  if (Math.random() < 0.15) {
    const cartRes = http.post(
      `${BASE_URL}/api/cart/items`,
      JSON.stringify({ productId, quantity: 1 }),
      { headers: { 'Content-Type': 'application/json' } }
    );
    check(cartRes, {
      'add to cart: status 201': (r) => r.status === 201,
    });
    errorRate.add(cartRes.status !== 201);
  }

  sleep(Math.random() * 2 + 1); // 思考時間: 1-3秒
}

シナリオベースのテスト

// k6/scenario-test.js
import http from 'k6/http';
import { check, sleep, group } from 'k6';

export const options = {
  scenarios: {
    // シナリオ1: ブラウジングユーザー(60%)
    browsing: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '2m', target: 120 },
        { duration: '10m', target: 120 },
        { duration: '2m', target: 0 },
      ],
      exec: 'browsingScenario',
    },

    // シナリオ2: 検索ユーザー(25%)
    searching: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '2m', target: 50 },
        { duration: '10m', target: 50 },
        { duration: '2m', target: 0 },
      ],
      exec: 'searchingScenario',
    },

    // シナリオ3: 購入ユーザー(15%)
    purchasing: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '2m', target: 30 },
        { duration: '10m', target: 30 },
        { duration: '2m', target: 0 },
      ],
      exec: 'purchasingScenario',
    },

    // シナリオ4: スパイクテスト(別タイミングで実行)
    spike: {
      executor: 'ramping-arrival-rate', // Open Model
      startRate: 0,
      timeUnit: '1s',
      preAllocatedVUs: 500,
      maxVUs: 1000,
      stages: [
        { duration: '5m', target: 100 },   // 通常: 100 req/s
        { duration: '30s', target: 1000 },  // スパイク: 1000 req/s
        { duration: '5m', target: 100 },    // 回復: 100 req/s
      ],
      exec: 'spikeScenario',
      startTime: '15m', // 他のシナリオが安定してから開始
    },
  },
  thresholds: {
    'http_req_duration{scenario:browsing}': ['p(95)<300'],
    'http_req_duration{scenario:searching}': ['p(95)<500'],
    'http_req_duration{scenario:purchasing}': ['p(95)<800'],
    'http_req_duration{scenario:spike}': ['p(95)<2000'],
  },
};

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

export function browsingScenario() {
  group('browsing', function () {
    http.get(`${BASE_URL}/`);
    sleep(3);
    http.get(`${BASE_URL}/api/products?category=electronics&page=1`);
    sleep(5);
    const pid = Math.floor(Math.random() * 10000) + 1;
    http.get(`${BASE_URL}/api/products/${pid}`);
    sleep(8);
  });
}

export function searchingScenario() {
  group('searching', function () {
    const keywords = ['ワイヤレスイヤホン', 'ノートPC', 'キーボード', 'モニター'];
    const keyword = keywords[Math.floor(Math.random() * keywords.length)];
    http.get(`${BASE_URL}/api/search?q=${encodeURIComponent(keyword)}`);
    sleep(4);
    const pid = Math.floor(Math.random() * 10000) + 1;
    http.get(`${BASE_URL}/api/products/${pid}`);
    sleep(5);
  });
}

export function purchasingScenario() {
  group('purchasing', function () {
    // ログイン
    const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({
      email: `user${Math.floor(Math.random() * 10000)}@example.com`,
      password: 'testpassword',
    }), { headers: { 'Content-Type': 'application/json' } });

    const token = loginRes.json('token');
    const authHeaders = {
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`,
      },
    };

    sleep(2);

    // 商品閲覧 → カート追加 → チェックアウト
    const pid = Math.floor(Math.random() * 10000) + 1;
    http.get(`${BASE_URL}/api/products/${pid}`);
    sleep(10);

    http.post(`${BASE_URL}/api/cart/items`,
      JSON.stringify({ productId: pid, quantity: 1 }),
      authHeaders
    );
    sleep(3);

    http.get(`${BASE_URL}/api/cart`, authHeaders);
    sleep(5);

    http.post(`${BASE_URL}/api/orders`, JSON.stringify({
      paymentMethod: 'credit_card',
    }), authHeaders);
  });
}

export function spikeScenario() {
  http.get(`${BASE_URL}/api/products?page=1&limit=20`);
}

2. Gatling の基本

Gatling シナリオ(Scala DSL)

// src/test/scala/simulations/ECSiteSimulation.scala
import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._

class ECSiteSimulation extends Simulation {

  val httpProtocol = http
    .baseUrl("http://localhost:3000")
    .acceptHeader("application/json")
    .contentTypeHeader("application/json")
    .shareConnections // コネクション共有でリアルなブラウザ挙動に

  // テストデータのフィーダー
  val productFeeder = csv("products.csv").random
  val userFeeder = csv("users.csv").circular

  // シナリオ1: ブラウジング
  val browsingScenario = scenario("Browsing")
    .feed(productFeeder)
    .exec(
      http("Product List")
        .get("/api/products?page=1&limit=20")
        .check(status.is(200))
        .check(jsonPath("$[0].id").saveAs("productId"))
    )
    .pause(2, 5) // 2-5秒のランダム思考時間
    .exec(
      http("Product Detail")
        .get("/api/products/${productId}")
        .check(status.is(200))
    )
    .pause(3, 8)

  // シナリオ2: 購入フロー
  val purchaseScenario = scenario("Purchase")
    .feed(userFeeder)
    .feed(productFeeder)
    .exec(
      http("Login")
        .post("/api/auth/login")
        .body(StringBody("""{"email":"${email}","password":"${password}"}"""))
        .check(status.is(200))
        .check(jsonPath("$.token").saveAs("authToken"))
    )
    .pause(1, 3)
    .exec(
      http("Add to Cart")
        .post("/api/cart/items")
        .header("Authorization", "Bearer ${authToken}")
        .body(StringBody("""{"productId":${productId},"quantity":1}"""))
        .check(status.is(201))
    )
    .pause(2, 5)
    .exec(
      http("Checkout")
        .post("/api/orders")
        .header("Authorization", "Bearer ${authToken}")
        .body(StringBody("""{"paymentMethod":"credit_card"}"""))
        .check(status.is(201))
    )

  setUp(
    browsingScenario.inject(
      rampUsers(500).during(5.minutes),  // 5分で500ユーザー
      constantUsersPerSec(20).during(10.minutes), // 毎秒20ユーザー追加
    ),
    purchaseScenario.inject(
      rampUsers(100).during(5.minutes),
      constantUsersPerSec(5).during(10.minutes),
    )
  ).protocols(httpProtocol)
    .assertions(
      global.responseTime.percentile3.lt(500),  // p95 < 500ms
      global.responseTime.percentile4.lt(1000), // p99 < 1000ms
      global.successfulRequests.percent.gt(99),  // 成功率 > 99%
    )
}

3. 分散負荷テスト

// k6 の分散実行(k6 Cloud or 自前クラスタ)

// docker-compose.yml による分散構成
/*
version: '3'
services:
  k6-master:
    image: grafana/k6
    command: run --out influxdb=http://influxdb:8086/k6 /scripts/test.js
    volumes:
      - ./scripts:/scripts
    environment:
      - K6_VUS=200
      - BASE_URL=http://target-app:3000

  k6-worker-1:
    image: grafana/k6
    command: run --out influxdb=http://influxdb:8086/k6 /scripts/test.js
    volumes:
      - ./scripts:/scripts
    environment:
      - K6_VUS=200
      - BASE_URL=http://target-app:3000

  k6-worker-2:
    image: grafana/k6
    command: run --out influxdb=http://influxdb:8086/k6 /scripts/test.js
    volumes:
      - ./scripts:/scripts
    environment:
      - K6_VUS=200
      - BASE_URL=http://target-app:3000

  influxdb:
    image: influxdb:1.8
    ports:
      - "8086:8086"

  grafana:
    image: grafana/grafana
    ports:
      - "3001:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
*/

結果の可視化と分析

// k6 結果を InfluxDB + Grafana で可視化するパイプライン
// 実行コマンド: k6 run --out influxdb=http://localhost:8086/k6 test.js

// k6 の handleSummary でカスタムレポート生成
export function handleSummary(data: any): Record<string, string> {
  const summary = {
    totalRequests: data.metrics.http_reqs.values.count,
    rps: data.metrics.http_reqs.values.rate.toFixed(1),
    p50: data.metrics.http_req_duration.values['p(50)'].toFixed(1),
    p95: data.metrics.http_req_duration.values['p(95)'].toFixed(1),
    p99: data.metrics.http_req_duration.values['p(99)'].toFixed(1),
    errorRate: (data.metrics.http_req_failed.values.rate * 100).toFixed(2),
  };

  const report = `
# Load Test Report
- Date: ${new Date().toISOString()}
- Total Requests: ${summary.totalRequests}
- RPS: ${summary.rps}
- Latency p50: ${summary.p50}ms
- Latency p95: ${summary.p95}ms
- Latency p99: ${summary.p99}ms
- Error Rate: ${summary.errorRate}%
`;

  return {
    'stdout': report,
    'results/summary.json': JSON.stringify(data, null, 2),
    'results/report.md': report,
  };
}

4. k6 vs Gatling の選択基準

観点k6Gatling
言語JavaScript/TypeScriptScala (Java)
学習コスト低(Web 開発者に馴染み)中(Scala DSL の習得が必要)
プロトコルHTTP, WebSocket, gRPCHTTP, WebSocket, JMS
レポートテキスト + 外部ツール連携リッチな HTML レポート内蔵
分散実行k6 Cloud / 自前クラスタEnterprise 版 / 自前
CI/CD 統合CLI ベースで容易Maven/Gradle プラグイン
リソース効率Go ランタイムで軽量JVM で重め
推奨ケースAPI テスト、CI/CD組込み複雑なシナリオ、エンタープライズ
コラム: k6 拡張と xk6

k6 は Go で書かれており、xk6 でカスタム拡張を追加できる。

# SQL データベースへの直接負荷テスト
xk6 build --with github.com/grafana/xk6-sql

# Kafka への負荷テスト
xk6 build --with github.com/mostafa/xk6-kafka

# ブラウザテスト(k6 browser)
# k6 v0.46+ でブラウザベースの負荷テストが可能
# Playwright ベースで Core Web Vitals も測定

k6 browser は Lighthouse とは異なり、並列ブラウザでの負荷テストが可能。

まとめ

トピック要点
k6 基本JavaScript ベース、stages でVU制御、thresholds で合否判定
シナリオ分離複数の executor で異なるユーザー行動を同時再現
GatlingScala DSL、feeder でデータ駆動、リッチレポート
分散実行大規模テストは複数マシンで負荷生成、結果は集約
ツール選択チームスキルとCI/CD要件で判断

チェックリスト

  • k6 のテストスクリプトを書ける(stages、check、thresholds)
  • 複数シナリオの同時実行を設定できる
  • Open Model(arrival-rate)と Closed Model(VU)の違いを理解した
  • Gatling のシナリオ構造を理解した
  • テスト結果を可視化する方法を知っている

次のステップへ

負荷テストツールの使い方を学んだ。次は 容量計画とスケーリング戦略 で、テスト結果を基にしたキャパシティプランニングを学ぼう。

推定読了時間: 40分