✔️

k6導入 ~ k6 browserでE2Eテストまでにやってきたことのまとめ

2024/06/09に公開

はじめに

zenn初めてみました✋

スカイウイルでインフラエンジニアをしております。
案件でk6の調査/実装をする機会があったのでまとめてみました。

中でもk6 browserは実験的なモジュールということもあってか関連する記事が少ないため、今回の記事が役に立てればと思います。

k6とは

パフォーマンステストおよび負荷テストのためのオープンソースツールであり、Webアプリのパフォーマンスを評価するために利用できます。
https://k6.io/docs/

以下のような特徴があります

  • 並列実行が可能
  • JavaScriptでテストシナリオを記述
  • CLI
  • 外部統合の容易さ
  • グラフィカルなレポート生成
  • Goで作られている

仕組みとしては、webアプリにAPIテストをしてボトルネックを計測することができます。
これにより、バージョン間で遅くなったAPIの特定やテストに失敗した場合は、不具合やデグレに気づくことができます。つまり、CIに組み込んで使うとよりメリット享受できるかと思います。
これだけであればJmeterあたりでも実現そうですが、特に並列実行できることが強み🔥です。

環境

  1. docker 26.0.1 (docker Engine)
  2. docker-compose v2.4.1

今回のテスト対象サービス

私の自宅鯖で運用している過疎Misskeyです。
https://pekoraskey.melanmeg.com/

k6をコンテナで

私はdocker-compose使わないと気が済まない性なので、まずは以下ファイルを用意します。

dockerfile
FROM grafana/k6:0.51.0
WORKDIR /home/k6
USER root
docker-compose.yml
version: "3"
services:
  k6:
    build:
      context: k6
    image: k6:latest
    container_name: k6
    hostname: k6
    volumes:
      - ./k6/scenarios:/home/k6/
    tty: true
    entrypoint: ash

サンプルのテストシナリオを用意

test1.js
import http from "k6/http";
export default function () {
  http.get("https://pekoraskey.melanmeg.com/");
}

ディレクトリ構成

.
├── docker-compose.yml
└── k6
    ├── Dockerfile
    └── scenarios
        └── test1.js

テスト実行

$ docker-compose build
$ docker-compose up -d
$ docker-compose exec k6 k6 run test1.js
実行結果

          /\      |‾‾| /‾‾/   /‾‾/   
     /\  /  \     |  |/  /   /  /    
    /  \/    \    |     (   /   ‾‾\  
   /          \   |  |\  \ |  ()  | 
  / __________ \  |__| \__\ \_____/ .io

     execution: local
        script: test1.js
        output: -

     scenarios: (100.00%) 1 scenario, 1 max VUs, 10m30s max duration (incl. graceful stop):
              * default: 1 iterations for each of 1 VUs (maxDuration: 10m0s, gracefulStop: 30s)


     data_received..................: 6.3 kB 11 kB/s
     data_sent......................: 468 B  846 B/s
     http_req_blocked...............: avg=365.59ms min=365.59ms med=365.59ms max=365.59ms p(90)=365.59ms p(95)=365.59ms
     http_req_connecting............: avg=169.51ms min=169.51ms med=169.51ms max=169.51ms p(90)=169.51ms p(95)=169.51ms
     http_req_duration..............: avg=187ms    min=187ms    med=187ms    max=187ms    p(90)=187ms    p(95)=187ms   
       { expected_response:true }...: avg=187ms    min=187ms    med=187ms    max=187ms    p(90)=187ms    p(95)=187ms   
     http_req_failed................: 0.00%  ✓ 01
     http_req_receiving.............: avg=135.28µs min=135.28µs med=135.28µs max=135.28µs p(90)=135.28µs p(95)=135.28µs
     http_req_sending...............: avg=37.43µs  min=37.43µs  med=37.43µs  max=37.43µs  p(90)=37.43µs  p(95)=37.43µs 
     http_req_tls_handshaking.......: avg=168.84ms min=168.84ms med=168.84ms max=168.84ms p(90)=168.84ms p(95)=168.84ms
     http_req_waiting...............: avg=186.83ms min=186.83ms med=186.83ms max=186.83ms p(90)=186.83ms p(95)=186.83ms
     http_reqs......................: 1      1.807854/s
     iteration_duration.............: avg=552.99ms min=552.99ms med=552.99ms max=552.99ms p(90)=552.99ms p(95)=552.99ms
     iterations.....................: 1      1.807854/s


running (00m00.6s), 0/1 VUs, 1 complete and 0 interrupted iterations
default ✓ [======================================] 1 VUs  00m00.6s/10m0s  1/1 iters, 1 per VU

k6はCLIなのでサクッと試せるところが良いと思います。
出力されたレポートも見やすい。

特にみるのはhttp_req_duration(リクエストの合計時間)

k6の並列実行について

k6にはオプションがたくさんあります。
少しこんがらがったもの以下2つについて紹介です。

  -u, --vus int                             number of virtual users (default 1)
  -i, --iterations int                      script total iteration limit (among all VUs)

vusが並列実行数、iterationsが反復数、つまりk6 run test1.js -u 3 -i 5とすれば、3ユーザー同時実行を5回繰り返しますといった意味です。まあそのまんまと言えばそうですね、はい😐

結果はこんな感じ

実行結果

          /\      |‾‾| /‾‾/   /‾‾/   
     /\  /  \     |  |/  /   /  /    
    /  \/    \    |     (   /   ‾‾\  
   /          \   |  |\  \ |  ()  | 
  / __________ \  |__| \__\ \_____/ .io

     execution: local
        script: test1.js
        output: -

     scenarios: (100.00%) 1 scenario, 3 max VUs, 10m30s max duration (incl. graceful stop):
              * default: 5 iterations shared among 3 VUs (maxDuration: 10m0s, gracefulStop: 30s)


     data_received..................: 64 kB  301 kB/s
     data_sent......................: 2.1 kB 9.7 kB/s
     http_req_blocked...............: avg=67.63ms  min=321ns    med=97.44ms  max=123.4ms  p(90)=120.96ms p(95)=122.18ms
     http_req_connecting............: avg=29.64ms  min=0s       med=17.73ms  max=65.27ms  p(90)=65.24ms  p(95)=65.26ms 
     http_req_duration..............: avg=42.62ms  min=33.54ms  med=41.87ms  max=49.37ms  p(90)=49.14ms  p(95)=49.26ms 
       { expected_response:true }...: avg=42.62ms  min=33.54ms  med=41.87ms  max=49.37ms  p(90)=49.14ms  p(95)=49.26ms 
     http_req_failed................: 0.00%  ✓ 05
     http_req_receiving.............: avg=4.94ms   min=108.39µs med=233.16µs max=23.24ms  p(90)=14.35ms  p(95)=18.79ms 
     http_req_sending...............: avg=113.34µs min=61.79µs  med=93.31µs  max=236.34µs p(90)=186.16µs p(95)=211.25µs
     http_req_tls_handshaking.......: avg=25.18ms  min=0s       med=30.85ms  max=58.35ms  p(90)=49.7ms   p(95)=54.02ms 
     http_req_waiting...............: avg=37.57ms  min=16.22ms  med=41.4ms   max=49.2ms   p(90)=48.58ms  p(95)=48.89ms 
     http_reqs......................: 5      23.486128/s
     iteration_duration.............: avg=110.5ms  min=33.81ms  med=139.59ms max=166.38ms p(90)=165.1ms  p(95)=165.74ms
     iterations.....................: 5      23.486128/s


running (00m00.2s), 0/3 VUs, 5 complete and 0 interrupted iterations
default ✓ [======================================] 3 VUs  00m00.2s/10m0s  5/5 shared iters

k6で色々試す

POSTリクエストして、ステータスをチェックする

ログインするテストシナリオ

test2.js
import http from "k6/http";
import { check } from "k6";

export default function () {
  const url = "https://pekoraskey.melanmeg.com/api/signin";
  const payload = JSON.stringify({
    username: "test",
    password: "xxxxxxxx",
    "hcaptcha-response": null,
    "g-recaptcha-response": null,
  });
  const params = {
    headers: {
      "Content-Type": "application/json",
    },
  };

  const res = http.post(url, payload, params);
  const statusCheckMessage = `is status 200 for URL: ${url}`;
  check(res, { [statusCheckMessage]: (r) => r.status === 200 });
}
実行結果

          /\      |‾‾| /‾‾/   /‾‾/   
     /\  /  \     |  |/  /   /  /    
    /  \/    \    |     (   /   ‾‾\  
   /          \   |  |\  \ |  ()  | 
  / __________ \  |__| \__\ \_____/ .io

     execution: local
        script: test2.js
        output: -

     scenarios: (100.00%) 1 scenario, 1 max VUs, 10m30s max duration (incl. graceful stop):
              * default: 1 iterations for each of 1 VUs (maxDuration: 10m0s, gracefulStop: 30s)


     ✓ is status 200 for URL: https://pekoraskey.melanmeg.com/api/signin

     checks.........................: 100.00% ✓ 10
     data_received..................: 4.2 kB  15 kB/s
     data_sent......................: 707 B   2.5 kB/s
     http_req_blocked...............: avg=143.51ms min=143.51ms med=143.51ms max=143.51ms p(90)=143.51ms p(95)=143.51ms
     http_req_connecting............: avg=18.17ms  min=18.17ms  med=18.17ms  max=18.17ms  p(90)=18.17ms  p(95)=18.17ms 
     http_req_duration..............: avg=140.02ms min=140.02ms med=140.02ms max=140.02ms p(90)=140.02ms p(95)=140.02ms
       { expected_response:true }...: avg=140.02ms min=140.02ms med=140.02ms max=140.02ms p(90)=140.02ms p(95)=140.02ms
     http_req_failed................: 0.00%   ✓ 01
     http_req_receiving.............: avg=84.93µs  min=84.93µs  med=84.93µs  max=84.93µs  p(90)=84.93µs  p(95)=84.93µs 
     http_req_sending...............: avg=626.93µs min=626.93µs med=626.93µs max=626.93µs p(90)=626.93µs p(95)=626.93µs
     http_req_tls_handshaking.......: avg=84.88ms  min=84.88ms  med=84.88ms  max=84.88ms  p(90)=84.88ms  p(95)=84.88ms 
     http_req_waiting...............: avg=139.31ms min=139.31ms med=139.31ms max=139.31ms p(90)=139.31ms p(95)=139.31ms
     http_reqs......................: 1       3.523799/s
     iteration_duration.............: avg=283.67ms min=283.67ms med=283.67ms max=283.67ms p(90)=283.67ms p(95)=283.67ms
     iterations.....................: 1       3.523799/s


running (00m00.3s), 0/1 VUs, 1 complete and 0 interrupted iterations
default ✓ [======================================] 1 VUs  00m00.3s/10m0s  1/1 iters, 1 per VU

結果をファイル出力する

k6には、カスタムメトリクスやカスタムサマリというものがあります。
単純にファイル出力するだけであれば、オプションでcsvまたはjsonで出力ができますが、
今回はスクリプト内にてjson形式で、さらに欲しいメトリクスだけを出力させてみます。

test2.js
// import文追加
import { textSummary } from "https://jslib.k6.io/k6-summary/0.0.2/index.js";

// ...

// 末尾に以下を追加
export function handleSummary(data) {
  const result = {
    http_req_duration: data.metrics.http_req_duration,
    checks: data.metrics.checks,
  };

  const outputDir = "/home/k6/result.json";
  return {
    stdout: textSummary(data, { indent: " ", enableColors: true }),
    [outputDir]: JSON.stringify(result),
  };
}
resuslt.json
{
  "http_req_duration": {
    "type": "trend",
    "contains": "time",
    "values": {
      "max": 227.32455,
      "p(90)": 227.32455,
      "p(95)": 227.32455,
      "avg": 227.32455,
      "min": 227.32455,
      "med": 227.32455
    }
  },
  "checks": {
    "contains": "default",
    "values": {
      "rate": 1,
      "passes": 1,
      "fails": 0
    },
    "type": "rate"
  }
}

このように、ファイル出力できます。
標準出力にも結果を出したい場合は上記のようにtextSummaryを使う必要がありました。

テストライフサイクル、カスタムメトリクスについて

コメントを連投する

  1. setup()でログイン
  2. default function()でコメント投稿する
test3.js
import http from "k6/http";
import { check } from "k6";
import { textSummary } from "https://jslib.k6.io/k6-summary/0.0.2/index.js";

const baseUrl = "https://pekoraskey.melanmeg.com";

let date = new Date();
let now = date.toISOString().replace("T", " ").substring(0, 19);

export const options = {
  scenarios: {
    constant_arrival_rate: {
      executor: "constant-arrival-rate",
      rate: 1, // 1 iteration per timeUnit
      timeUnit: "3s", // 3 seconds per iteration
      duration: "10s", // test duration
      preAllocatedVUs: 1, // to allocate VUs in advance
      maxVUs: 1, // maximum number of VUs
    },
  },
};

export function setup() {
  const url = `${baseUrl}/api/signin`;
  const payload = JSON.stringify({
    username: "test",
    password: "xxxxxxxx",
    "hcaptcha-response": null,
    "g-recaptcha-response": null,
  });
  const params = {
    headers: {
      "Content-Type": "application/json",
    },
  };

  const res = http.post(url, payload, params);
  const statusCheckMessage = `is status 200 for URL: ${url}`;
  check(res, { [statusCheckMessage]: (r) => r.status === 200 });

  const token = JSON.parse(res.body).i;
  return { token };
}

export default function (data) {
  const token = data.token;
  const url = `${baseUrl}/api/notes/create`;
  const payload = JSON.stringify({
    text: `test k6 comment: ${now}`,
    poll: null,
    cw: null,
    localOnly: false,
    visibility: "public",
    reactionAcceptance: "nonSensitiveOnly",
    i: token,
  });
  const params = {
    headers: {
      "Content-Type": "application/json",
    },
  };

  const res = http.post(url, payload, params);
  const statusCheckMessage = `is status 200 for URL: ${url}`;
  check(res, { [statusCheckMessage]: (r) => r.status === 200 });
}

export function handleSummary(data) {
  const result = {
    api_duration: data.metrics.http_req_duration,
    checks: data.metrics.checks,
  };

  const outputDir = "/home/k6/result.json";
  return {
    stdout: textSummary(data, { indent: " ", enableColors: true }),
    [outputDir]: JSON.stringify(result),
  };
}

実行結果

          /\      |‾‾| /‾‾/   /‾‾/   
     /\  /  \     |  |/  /   /  /    
    /  \/    \    |     (   /   ‾‾\  
   /          \   |  |\  \ |  ()  | 
  / __________ \  |__| \__\ \_____/ .io

     execution: local
        script: test3.js
        output: -

     scenarios: (100.00%) 1 scenario, 1 max VUs, 40s max duration (incl. graceful stop):
              * constant_arrival_rate: 0.33 iterations/s for 10s (maxVUs: 1, gracefulStop: 30s)

     ✓ is status 200 for URL: https://pekoraskey.melanmeg.com/api/notes/create

     █ setup

       ✓ is status 200 for URL: https://pekoraskey.melanmeg.com/api/signin

     checks.........................: 100.00% ✓ 50  
     data_received..................: 12 kB   1.2 kB/s
     data_sent......................: 2.2 kB  215 B/s
     http_req_blocked...............: avg=40.43ms  min=285ns    med=449ns    max=136.3ms  p(90)=108.12ms p(95)=122.21ms
     http_req_connecting............: avg=8.05ms   min=0s       med=0s       max=22.53ms  p(90)=20.61ms  p(95)=21.57ms 
     http_req_duration..............: avg=90.2ms   min=40.71ms  med=66.17ms  max=168.34ms p(90)=153.95ms p(95)=161.14ms
       { expected_response:true }...: avg=90.2ms   min=40.71ms  med=66.17ms  max=168.34ms p(90)=153.95ms p(95)=161.14ms
     http_req_failed................: 0.00%   ✓ 05  
     http_req_receiving.............: avg=94.04µs  min=62.88µs  med=72.48µs  max=187.46µs p(90)=142.74µs p(95)=165.1µs 
     http_req_sending...............: avg=326.83µs min=151.55µs med=219.74µs max=644.07µs p(90)=571.73µs p(95)=607.9µs 
     http_req_tls_handshaking.......: avg=24.54ms  min=0s       med=0s       max=79.77ms  p(90)=65.05ms  p(95)=72.41ms 
     http_req_waiting...............: avg=89.78ms  min=40.49ms  med=65.94ms  max=167.5ms  p(90)=153.24ms p(95)=160.37ms
     http_reqs......................: 5       0.485163/s
     iteration_duration.............: avg=131.04ms min=41.02ms  med=66.46ms  max=305.21ms p(90)=262.59ms p(95)=283.9ms 
     iterations.....................: 4       0.388131/s
     vus............................: 0       min=0      max=0

running (10.3s), 0/1 VUs, 4 complete and 0 interrupted iterations
constant_arrival_rate ✓ [======================================] 0/1 VUs  10s  0.33 iters/s

Misskeyでは、短すぎる間隔でコメントすると反映されない?っぽいので、3秒間隔で10秒間繰り返し実行するoption設定を入れました。
setup()は初回の一度のみ実行させることができるので、4 completeとなっており4回コメントしたことになります。

result.json
{
  "api_duration": {
    "values": {
      "p(90)": 153.95769180000002,
      "p(95)": 161.1491394,
      "avg": 90.2026734,
      "min": 40.713239,
      "med": 66.177258,
      "max": 168.340587
    },
    "type": "trend",
    "contains": "time"
  },
  "checks": {
    "type": "rate",
    "contains": "default",
    "values": {
      "rate": 1,
      "passes": 5,
      "fails": 0
    }
  }
}

出力結果はこんな感じです。

この結果だけで分かりにくいのですが、ここで問題なのが集計結果にsetup()の結果も含まれていることです。k6の仕様としてそうなっているようです。
default function()の結果のみで集計したい場合は次のカスタムメトリクスを使うことで解決できます。


MisskeyのTLを確認すると、3秒間隔で4回分コメントされていることが分かります。

カスタムメトリクス(とグループ、タグ)

このようなスクリプトを用意しました。

test4.js
import http from "k6/http";
import { check, group, sleep } from "k6";
import { Trend } from "k6/metrics";
import { textSummary } from "https://jslib.k6.io/k6-summary/0.0.2/index.js";

const api_duration = new Trend("api_duration");

export function setup() {
  const res = http.get("https://pekoraskey.melanmeg.com/");
  check(res, { "is status 200": (r) => r.status === 200 });
}

export default function () {
  group("vu", function () {
    const res = http.get("https://pekoraskey.melanmeg.com/", {
      tags: {
        name: "test",
      },
    });
    check(res, { "is status 200": (r) => r.status === 200 });
    api_duration.add(res.timings.duration, { name: "test" });
  });
  sleep(1); // handleSummary()を使う場合、少し待たないとなぞに結果に反映されないため
}

export function teardown() {
  const res = http.get("https://pekoraskey.melanmeg.com/");
  check(res, { "is status 200": (r) => r.status === 200 });
}

export function handleSummary(data) {
  // なぜか!で否定を使うと結果にでない
  for (const key in data.metrics) {
    if (key.startsWith("iteration")) delete data.metrics[key];
    if (key.startsWith("http")) delete data.metrics[key];
    if (key.startsWith("data")) delete data.metrics[key];
    if (key.startsWith("group")) delete data.metrics[key];
    if (key.startsWith("vus")) delete data.metrics[key];
  }

  const result = data.metrics;

  const outputDir = "/home/k6/result.json";
  return {
    stdout: textSummary(data, { indent: " ", enableColors: true }),
    [outputDir]: JSON.stringify(result),
  };
}
実行結果
     █ setup

       ✓ is status 200

     █ vu

       ✓ is status 200

     █ teardown

       ✓ is status 200

     api_duration...: avg=95.598704 min=95.598704 med=95.598704 max=95.598704 p(90)=95.598704 p(95)=95.598704
     checks.........: 100.00% ✓ 30/

今更に使ったのですが、オプションで--quietつけるとk6のロゴ辺りの出力を消せます。

結果の通り、avg,minなどが全て同じ値でありdefault function()のテストだけで集計されるというわけです。ついでに標準出力もapi_durationchecksだけにしてみました。

また、group化することで「█ vu」のようにイイ感じにできます。

tagの機能についてですが、この使い方は分かってません💦
オプションの--out csv=xxxにて結果出力したときにtags列として出力されましたが、その他の場合出力されずよくわかりません。一応付けましたがなくてもいいかも。

k6 browserとは

k6 browserは、ブラウザベースのE2Eテストができるツールです。
k6と同様、並列にシナリオテストを実行することもできます。

以下のような特徴があります。

  • Playwrightとおおよその互換性がある
  • ブラウザ操作に基づいたパフォーマンステストが可能
    • 操作に基づいて実行される各APIのテスト状況も確認できる

k6 browserもコンテナで

まずdockerfileのイメージをk6 browser兼用のイメージに書き変えます。
alpineに日本語フォントもインストールします。

日本語フォントがないと文字化けする

1c1
< FROM grafana/k6:0.51.0
---
> FROM grafana/k6:0.51.0-with-browser
3a4
> RUN apk add --no-cache fontconfig ttf-freefont font-noto-cjk
\ No newline at end of file

サンプルのテストシナリオを用意

ユーザーは事前に作成して、ユーザー名/パスワードを変更。

test5.js
import { browser } from "k6/experimental/browser";

export const options = {
  scenarios: {
    ui: {
      executor: "shared-iterations",
      vus: 2,
      iterations: 3,
      options: {
        browser: {
          type: "chromium",
        },
      },
    },
  },
};

export default async function () {
  const context = browser.newContext({
    locale: "ja-JP",
  });
  const page = context.newPage();

  try {
    await page.goto("https://pekoraskey.melanmeg.com/");

    page.waitForLoadState("load");
    page.waitForSelector("button[data-cy-signin]");
    page.click("button[data-cy-signin]");

    page.locator('input[placeholder="ユーザー名"]').type("test");
    page.locator('input[placeholder="パスワード"]').type("xxxxxxxx");
    const submitButton = page.locator('button[type="submit"]');
    await Promise.all([page.waitForNavigation(), submitButton.click()]);

    page.waitForLoadState("load");
    page.waitForSelector(
      "#misskey_app > div > div.xFdHz > div:nth-child(2) > div:nth-child(1) > div:nth-child(2) > div > div > div > div > div > div > div > div.xzSZs.xwehs > div > div:nth-child(1) > article"
    );
    page.screenshot({ path: "/screenshot.png" });
  } finally {
    page.close();
    context.close();
  }
}
実行結果

          /\      |‾‾| /‾‾/   /‾‾/   
     /\  /  \     |  |/  /   /  /    
    /  \/    \    |     (   /   ‾‾\  
   /          \   |  |\  \ |  ()  | 
  / __________ \  |__| \__\ \_____/ .io

     execution: local
        script: test5.js
        output: -

     scenarios: (100.00%) 1 scenario, 2 max VUs, 10m30s max duration (incl. graceful stop):
              * ui: 3 iterations shared among 2 VUs (maxDuration: 10m0s, gracefulStop: 30s)

INFO[0001] Misskey v2023.12.0                            browser_source=console-api source=browser
INFO[0001] Misskey v2023.12.0                            browser_source=console-api source=browser
WARN[0001] sid:80810AAA02E43DEB8D4294718C48960A tid:53F58DBAD25846EA2E0201DF8217750E bctxid:DB3A666BECE010AFFAC8407DF5A2A2BA bctx nil:false, unknown target type: "service_worker"  category="Browser:isAttachedPageValid" elapsed="0 ms" source=browser
WARN[0001] sid:E35A5C2757BF9D51E0B4800D4D9C22A1 tid:9C3F59B84EE6F32CB5D87BE13D2FAAC1 bctxid:DAD71533CF995230E334A637986AA607 bctx nil:false, unknown target type: "service_worker"  category="Browser:isAttachedPageValid" elapsed="0 ms" source=browser
ERRO[0002] Failed to load resource: the server responded with a status of 500 ()  browser_source=network line_number=0 source=browser stacktrace="<nil>" url="https://pekoraskey.melanmeg.com/files/0712f571-06c2-4b12-a7b6-afdb051ed357"
ERRO[0003] Failed to load resource: the server responded with a status of 404 ()  browser_source=network line_number=0 source=browser stacktrace="<nil>" url="https://pekoraskey.melanmeg.com/api/users/show"
ERRO[0003] Failed to load resource: the server responded with a status of 404 ()  browser_source=network line_number=0 source=browser stacktrace="<nil>" url="https://pekoraskey.melanmeg.com/api/users/show"
ERRO[0003] Failed to load resource: the server responded with a status of 404 ()  browser_source=network line_number=0 source=browser stacktrace="<nil>" url="https://pekoraskey.melanmeg.com/api/users/show"
ERRO[0003] Failed to load resource: the server responded with a status of 404 ()  browser_source=network line_number=0 source=browser stacktrace="<nil>" url="https://pekoraskey.melanmeg.com/api/users/show"
ERRO[0003] Failed to load resource: the server responded with a status of 404 ()  browser_source=network line_number=0 source=browser stacktrace="<nil>" url="https://pekoraskey.melanmeg.com/api/users/show"
ERRO[0003] Failed to load resource: the server responded with a status of 404 ()  browser_source=network line_number=0 source=browser stacktrace="<nil>" url="https://pekoraskey.melanmeg.com/api/users/show"
ERRO[0005] Failed to load resource: the server responded with a status of 500 ()  browser_source=network line_number=0 source=browser stacktrace="<nil>" url="https://pekoraskey.melanmeg.com/proxy/preview.webp?url=https%3A%2F%2Fi.ytimg.com%2Fvi%2FBCt3E-FJFdA%2Fmaxresdefault.jpg&preview=1"
ERRO[0007] Failed to load resource: the server responded with a status of 429 ()  browser_source=network line_number=0 source=browser stacktrace="<nil>" url="https://pekoraskey.melanmeg.com/api/signin"
INFO[0007] Misskey v2023.12.0                            browser_source=console-api source=browser
ERRO[0007] Failed to load resource: the server responded with a status of 500 ()  browser_source=network line_number=0 source=browser stacktrace="<nil>" url="https://pekoraskey.melanmeg.com/files/0712f571-06c2-4b12-a7b6-afdb051ed357"
INFO[0010] Misskey v2023.12.0                            browser_source=console-api source=browser
WARN[0010] sid:188F982CE9430FD3837132FB1678E19F tid:CDF06521029E4C8A5568A45D3F3180D8 bctxid:EE43FEA1F80361A53863322391B05C44 bctx nil:false, unknown target type: "service_worker"  category="Browser:isAttachedPageValid" elapsed="0 ms" source=browser
ERRO[0011] Failed to load resource: the server responded with a status of 404 ()  browser_source=network line_number=0 source=browser stacktrace="<nil>" url="https://pekoraskey.melanmeg.com/api/users/show"
ERRO[0012] Failed to load resource: the server responded with a status of 404 ()  browser_source=network line_number=0 source=browser stacktrace="<nil>" url="https://pekoraskey.melanmeg.com/api/users/show"
ERRO[0012] Failed to load resource: the server responded with a status of 404 ()  browser_source=network line_number=0 source=browser stacktrace="<nil>" url="https://pekoraskey.melanmeg.com/api/users/show"
ERRO[0012] Failed to load resource: the server responded with a status of 500 ()  browser_source=network line_number=0 source=browser stacktrace="<nil>" url="https://pekoraskey.melanmeg.com/files/thumbnail-8c0fc227-22b9-4f5c-a412-256fb3fda31c"
INFO[0012] object is too large and will be parsed partially  category=parseConsoleRemoteObjectPreview elapsed="2064 ms" source=browser
INFO[0012] Error occurred during decoding image {"Symbol(_vod)":"","alt":"IMG_0134.jpeg","sizes":"","src":"https://pekoraskey.melanmeg.com/files/thumbnail-8c0fc227-22b9-4f5c-a412-256fb3fda31c","srcset":""} {"code":"0","message":"The source image cannot be decoded.","name":"EncodingError"}  browser_source=console-api source=browser
INFO[0016] Misskey v2023.12.0                            browser_source=console-api source=browser
ERRO[0018] Failed to load resource: the server responded with a status of 500 ()  browser_source=network line_number=0 source=browser stacktrace="<nil>" url="https://pekoraskey.melanmeg.com/files/thumbnail-8c0fc227-22b9-4f5c-a412-256fb3fda31c"
INFO[0018] object is too large and will be parsed partially  category=parseConsoleRemoteObjectPreview elapsed="5503 ms" source=browser
INFO[0018] Error occurred during decoding image {"Symbol(_vod)":"","alt":"IMG_0134.jpeg","sizes":"","src":"https://pekoraskey.melanmeg.com/files/thumbnail-8c0fc227-22b9-4f5c-a412-256fb3fda31c","srcset":""} {"code":"0","message":"The source image cannot be decoded.","name":"EncodingError"}  browser_source=console-api source=browser
ERRO[0033] Uncaught (in promise) waiting for navigation: timed out after 30s  executor=shared-iterations scenario=ui

     browser_data_received.......: 13 MB  400 kB/s
     browser_data_sent...........: 202 kB 6.0 kB/s
     browser_http_req_duration...: avg=133.99ms min=75µs     med=80.21ms  max=3.8s    p(90)=153.25ms p(95)=193.38ms
     browser_http_req_failed.....: 2.77%  ✓ 15526
     browser_web_vital_cls.......: avg=0.033081 min=0        med=0.008874 max=0.14368 p(90)=0.089758 p(95)=0.116719
     browser_web_vital_fcp.......: avg=594.67ms min=443ms    med=593.5ms  max=825.4ms p(90)=759.08ms p(95)=792.24ms
     browser_web_vital_fid.......: avg=2.59ms   min=599.99µs med=2.19ms   max=5ms     p(90)=4.43ms   p(95)=4.71ms  
     browser_web_vital_inp.......: avg=885.33ms min=584ms    med=768ms    max=1.3s    p(90)=1.19s    p(95)=1.25s   
     browser_web_vital_lcp.......: avg=1.65s    min=815.59ms med=2.05s    max=2.41s   p(90)=2.3s     p(95)=2.35s   
     browser_web_vital_ttfb......: avg=181.42ms min=29.4ms   med=245.9ms  max=346.3ms p(90)=308.26ms p(95)=327.28ms
     data_received...............: 0 B    0 B/s
     data_sent...................: 0 B    0 B/s
     iteration_duration..........: avg=17.01s   min=8.71s    med=9.04s    max=33.28s  p(90)=28.43s   p(95)=30.85s  
     iterations..................: 3      0.089439/s
     vus.........................: 1      min=1      max=2
     vus_max.....................: 2      min=2      max=2


running (00m33.5s), 0/2 VUs, 3 complete and 0 interrupted iterations
ui   ✓ [======================================] 2 VUs  00m33.5s/10m0s  3/3 shared iters

並列数2, 反復数3で実行。
500, 404 エラーが入るのは元からです。
browser.newContextにパラメータを渡すことでブラウザの設定をすることができ、この場合、ロケールを日本にする設定を入れてます(デフォルトがen-USぽいので)
パラメータ一覧のドキュメントはここにあります↓
https://grafana.com/docs/k6/latest/javascript-api/k6-experimental/browser/newcontext/
screenshot.pngを確認すると、ちゃんとログイン後の画面となっています。

例えばプロキシ環境でのテストがしたい場合に、このbrowser.newContextにhttpCredentialsの設定を入れる必要がありました。これを見つけるのはちょっと大変でした...🤦

おまけ

k6 + Typescript + opneapi-generatorのすすめ

APIテストシナリオを実装する上で、リクエストのスキーマに気を付けることがあると思います。多分...。型を自動生成して利用したおかげで、安全性が高く開発もスムーズに進められました。型はk6との相性がいいと思います。ぜひk6導入する際はこの構成を検討してみてほしいです。

その他やったこととか

案件ではこの他、k6のラッパーをスーパーエンジニアの方がRustで実装し、CI組み込みもされました🙄
cloudbuildの設定から、Teams通知の設定までパパっとできるように自分もなりたいなぁ

運用後に感じたこと

CIで運用が始まってから特に感じたものとして

  • APIに変更があればすぐに気づける
  • どのAPIで失敗したかわかる
  • エラーログが見れる
  • 前バージョンの結果と比較してデグレが分かる

やっぱ便利です。

総評

記事を書いて、改めてk6, k6 browserの可能性を感じました。特にk6 browser
パフォーマンステストツールの中でもデファクトだろうし、性能のテストに使うので性能面で良いGo言語で実装されている点もポイントが高いです。

k6 browserを使いたいとなる場面としては、k6での実装が困難なテストに関してはk6 browserを利用する使い方がいいのではというとこがありました。
またk6オプションは豊富で--traces-output string--profiling-enabledオプションだったり、グループ化機能でのtagWithCurrentStageProfile()などこの辺りも調査してみたいです。

今回取り上げていませんが、k6を使いこなすにはk6/timersなど各種モジュールやテストを中断させるにはちょっと気づきくいtest.abort()を使ったりすると良いかもです。

またAPIドキュメントを読む際の注意点として、https://k6.io/docs/javascript-api/
サイトには最新バージョンが載っていないので https://grafana.com/docs/k6/latest/
こっちを見ないといけないよう。

比較対象があるでもないですが、ざっと評価してみました。

APIテスト E2Eテスト CI組み込み ツールのパフォーマンス 並列実行
  • E2Eテストは実験的モジュールであること、ドキュメントや参考資料が少ないこと、またplaywrightやchromiumの知見もないと実装が難しそうなので△くらいに
  • APIテストは、k6モジュールとnodejs標準モジュールとの相性でスムーズに実装できないときがあったので〇(スキル不足だった可能性が高い💦)
  • CI組み込みは、k6 browserのプロキシ設定に手こずったので〇にした

個人的にplaywrightが好きなので、それを並列実行する仕組みは興味深いと思いました。
こうやって記事にまとめると知識定着になりますね👊
k6面白い!

以上

Discussion