テスト自動化における速度測定と結果のグラフ化

こんにちは。自動化エンジニアの水谷です。

Racineを使った自動化案件で立て続けに2件続けて、テストの自動化と同時に(検索やダウンロードなどの)機能の速度測定を行ってほしいとの要望がありました。
そこで、これを実現するために自分が作成したコードを、今後のプロジェクトでも使用していただける、あるいは参考にしていただけるよう共有しておきたいと思います。

時間測定とその保存方法についての方針

時間を測定する項目は20数件程度で、過去の測定結果は消さずに保存し、比較できるようにしたいとの要望がであったため、CSVでテストを実行するたびに新しい行を追加していく方法としました(CSV形式で保存することにメリットもデメリットもありますが、ここでは言及しません)。
CSVはデフォルトで"time.csv"というファイル名とし、config.properiesで変更できるようにします。そして、CSVはまた、以下のような内容を保持することと想定します。なお、ここで数字の単位はミリ秒です。
テスト開始時間,台帳検索,発番,新規図書登録,PDF変換,図書検索,・・・
2020/2/28 8:39,12088,3822,7981,21908,995,・・・
2020/2/28 10:26,2565,3794,8936,18548,1729,・・・
2020/3/2 13:52,2184,3759,8160,19356,1523,・・・
2020/3/3 14:04,2287,3654,7698,20156,1326,・・・

TimeRecorder.java

time.csvをアップデートするクラスを以下のように作成しました。

package jp.co.abc.automation.core;

import java.io.*;
import java.sql.Timestamp;
import java.text.SimpleDateFormat;
import jp.shiftinc.automation.util.Configuration;
import jp.co.ihi.automation.constant.ConfigurationKey;

public class TimeRecorder {
   private static long startTime;
   private static long endTime;
   public static int numberOfTimeMeasurementsDone;
   private static String filePath = "time.csv";
   
   public TimeRecorder() {
       Configuration configuration = Configuration.getInstance();
       String filePathTmp = configuration.get(ConfigurationKey.TIME_FILE);
       if (filePathTmp != null) filePath = filePathTmp;
   }

   public static void WriteToTimeCSV(String s) {
       try {
           if (filePath.length() != 0) {
               File file = new File(filePath);
               FileWriter fileWriter = new FileWriter(file, true);
               fileWriter.write(s);
               fileWriter.flush();
               fileWriter.close();
           }
       }
       catch (IOException e) {
           System.out.println(e.getMessage());
       }
   }
   
   public static void WriteToTimeCSVComma(String s) {
       WriteToTimeCSV(s + ",");
   }
   
   public static void TimerCSVForceNewLine() {
       try {
           if (filePath.length() != 0) {
               FileInputStream fs = new FileInputStream(filePath);
               InputStreamReader isr = new InputStreamReader(fs, "UTF-8");
               int data, lastdata = '\n';
               
               while ((data = isr.read()) != -1)
               {
                   lastdata = data;
               }
               isr.close();
               if (lastdata != '\n') {
                   System.out.println("The last char of time.csv is not \\n. force add return char to time.csv.");
                   WriteToTimeCSV("\r\n");
               }
           }
       }
       catch (Exception e) {
           System.out.println(e.getMessage());
       }
   }
   
   public void timerStart() {
       startTime = System.currentTimeMillis();
       Timestamp timestamp = new Timestamp(startTime);
       SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss.SSS");
       System.out.println("開始時間: " + sdf.format(timestamp));
   }

   public void timerStop() {
       endTime = System.currentTimeMillis();
       Timestamp timestamp = new Timestamp(endTime);
       SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss.SSS");
       System.out.println("終了時間: " + sdf.format(timestamp));
       long executionTime = endTime - startTime;
       System.out.println("実行時間: " + executionTime + "ms");
       WriteToTimeCSVComma("" + executionTime);
       numberOfTimeMeasurementsDone++;
   }
   
   public void resetTimer() {
       startTime = 0;
       endTime = 0;
   }
}

いくつかメソッドがありますが、ポイントはtimerStart()メソッドを呼び出してからtimerStop()メソッドを呼び出すまでの時間がCSVファイルに記録されることです。主にこれら2つのメソッドがテストケースから呼び出されることになります。
なお、TimerCSVForceNewLine()メソッドは、前回のテスト実行が不完全に終了し、想定した項目数以下の時間測定結果しか書き込めなかった場合、強制的に新しい行からスタートさせるためのものです。

TimeRecorderクラスの使用方法

当初このクラスをBaseTest内でインスタンス化して、@BeforeAllのメソッドでCSVファイルの行頭の日時を書き込み、テストケース中に適宜timerStart()とtimerStop()を呼び出すような使い方を想定していましたが、場合によっては(というよりむしろ)Page Objectからこれらのメソッドを呼び出したい場合もありますので、思い切ってこのクラスをBaseTestおよびBasePageの継承元にしてしまっています。あまり良い方法ではないかもしれませんが、これでとても簡略化できました。

BaseTestは以下のようにしました。

public class BaseTest extends TimeRecorder {
   protected static Configuration configuration;
   private static BrowserMobProxy bmp = null;
   public static int numberOfTimeMeasurementsExpected;
   
   @BeforeAll
   public static void setupClass() {
       configuration = Configuration.getInstance();
       SelenideLogger.addListener("AllureSelenide", new AllureSelenide());
       bmp = setHttpProxy(configuration);

       Calendar cal = Calendar.getInstance();
       TimerCSVForceNewLine();
       WriteToTimeCSVComma(new SimpleDateFormat("yyyy/MM/dd HH:mm:ss").format(cal.getTime()));
  }
   
   @AfterAll
   public static void tearDownClass() {
       SelenideLogger.removeListener("AllureSelenide");
       if (bmp != null) {
           bmp.stop();
       }
      WriteToTimeCSV("\r\n");
  }
   
   @BeforeEach
   public void initializeTest() {
       resetTimer();
       numberOfTimeMeasurementsExpected = 0;
       numberOfTimeMeasurementsDone = 0;
  }
   
   @AfterEach
   public void tearDown() {
       WebDriverRunner.webdriverContainer.closeWebDriver();
      System.out.println("numberOfTimeMeasurementsDone = "+ numberOfTimeMeasurementsDone + " numberOfTimeMeasurementsExpected = " + numberOfTimeMeasurementsExpected);
       for (int i = numberOfTimeMeasurementsDone; i < numberOfTimeMeasurementsExpected; i++)
       {
           WriteToTimeCSVComma("---");
       }
  }

@BeforeAllでは、TimerCSVForceNewLine()をコールして、CSVファイルを必ず新しい行の先頭から書き込めるようにし、その後テスト実行日付を記録します。
@AfterAllではCSVファイルに改行コードを書き込んでいます。
@BeforeEachでは、念のためタイマーをリセットしておき(本来は不要)、そのテストで書き込まれる予定の計測結果個数(numberOfTimeMeasurementExpected)と、実際に書き込んだ個数(numberOfTimeMeasurementDone)を初期化します。これは、そのテストが複数か所個所で時間計測をする可能性があり、逆に時間計測を行わないテストケースである可能性もあるため、いったんここでnumberOfTimeMeasurementExpectedを0にセットしておき、テストメソッド内でこれを正しい値にセットしなおしてもらいます。なお、numberOfTimeMeasurementDoneはtimerStop()を呼び出すたびにインクリメントされます。
@AfterEachでは、これら(numberOfTimeMeasurementExpectedとnumberOfTimeMeasurementDone)を比較し、差があればその差の分だけ"---"をCSVに書き込んでいます。これは、テストが途中でFailし、例えば3か所で測定する予定が1か所測定した後でFailしてしまい、残り2か所の測定ができなかった場合などで、その測定できなかった2か所に"---"を記録して、帳尻を合わせています。このあたりは別の実装方法も考えられますが、限られた時間の中で一番短時間で実装できる方法を選択した結果となります。

Scenarioクラスからの使用方法

以下はテストメソッド内からstartTimer()およびstopTimer()メソッドをコールする例です。このテストでは3か所の時間測定を行いますので、numberOfTimeMeasurementsExpected = 3;としています。

@Feature("検索")
class Test_x_x_SearchSomething extends BaseTest {
   @Test
   void SearchSomething() {
       // このテストケース内で行う時間測定か箇所の数を指定する。
       numberOfTimeMeasurementsExpected = 3;
       
       // ログインしてホームページを開く
       startTimer();
       HomePage homePage = Login();
       stopTimer();

       // 検索ページを開く
       startTimer();
       SearchPage searchPage = homePage.SelectSearchMenu();
       stopTimer();
       // 検索ワードを入力する
       searchPage.SetSearchWord();

       // 検索ボタンを押し結果が表示されるまで待つ
       startTimer();
       searchPage.ClickSearchButton();
       stopTimer();
   }
}

PageObjectからの使用方法

より厳密に時間測定が必要な場合はPageObjectからtimerStart();とtimerStop();をコールします。

 @Step("「登録」ボタンを押す")
   public boolean ClickRegistButton(boolean recordTime) {
       $("input#regist-btn").click();
       if (recordTime) timerStart();
       boolean result = waitElementLocated(By.id("imui-messagebox"), 10);
       if (recordTime) timerStop();
       return result;
   }

この例では、$("input#regist-btn")をクリックしてから$("#imui-messagebox")が現れるまでの時間を計測しています。
なお、このメソッドでは引数がtrueの時のみ時間計測するようにしています。テスト全体でこのメソッドが複数回呼び出されるが、測定は1回だけで充分という状況のため、このようにしています。

CSVのグラフ化

テストで作成(追記)されたCSVをグラフ化し、ブラウザーから見れるようにします。
グラフの作成はchart.jsを使用していますが、使いやすく十分にきれいだと思います。

graph.html
<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <script type="text/javascript" src="Chart.min.js"></script>
   <script type="text/javascript" src="chartjs-plugin-colorschemes.min.js"></script>
   <script src="mychart.js"></script>
   <style>
       #myChart  {
             width: 300px;
           }
          
           canvas {
             width: 300px;
           }
   </style>
   <title>chart of results</title>
</head>
<body>
<h1>sampletime</h1>
<!--ここにグラフが挿入されます-->
<div style="position:absolute:padding:50px; top:60px; left:10px;">
   <table><tbody>
       <tr>
           <td><canvas id="myChart1" style="width: 600px; height: 240px"></canvas></td>
           <td><canvas id="myChart2" style="width: 600px; height: 240px"></canvas></td>
           <td><canvas id="myChart3" style="width: 600px; height: 240px"></canvas></td>
       </tr>
       <tr>
           <td><canvas id="myChart4" style="width: 600px; height: 240px"></canvas></td>
           <td><canvas id="myChart5" style="width: 600px; height: 240px"></canvas></td>
           <td><canvas id="myChart6" style="width: 600px; height: 240px"></canvas></td>
       </tr>
       <tr>
           <td><canvas id="myChart7" style="width: 600px; height: 240px"></canvas></td>
           <td><canvas id="myChart8" style="width: 600px; height: 240px"></canvas></td>
           <td><canvas id="myChart9" style="width: 600px; height: 240px"></canvas></td>
       </tr>
       <tr>
           <td><canvas id="myChart10" style="width: 600px; height: 240px"></canvas></td>
           <td><canvas id="myChart11" style="width: 600px; height: 240px"></canvas></td>
           <td><canvas id="myChart12" style="width: 600px; height: 240px"></canvas></td>
       </tr>
       <tr>
           <td><canvas id="myChart13" style="width: 600px; height: 240px"></canvas></td>
           <td><canvas id="myChart14" style="width: 600px; height: 240px"></canvas></td>
           <td><canvas id="myChart15" style="width: 600px; height: 240px"></canvas></td>
       </tr>
       <tr>
           <td><canvas id="myChart16" style="width: 600px; height: 240px"></canvas></td>
           <td><canvas id="myChart17" style="width: 600px; height: 240px"></canvas></td>
           <td><canvas id="myChart18" style="width: 600px; height: 240px"></canvas></td>
       </tr>
 </tbody></table>
</div>
</body>
</html>

mychart.js
// CSVtoArray
function csv2Array(str) {
   var csvData = [];
   {
     var lines = str.split("\n");
     var cells = lines[0].split(",");
   }
   csvData.push(cells);
   var startline = 1;
   if (lines.length > 12) startline = lines.length - 11;
   for (var i = startline; i < lines.length - 1; ++i) {
     var cells = lines[i].split(",");
     cells[0] = cells[0].substr(0, cells[0].indexOf(" "));  // 日付のみ
     csvData.push(cells);
   }
   convertData = [];
   
   // 縦横変換
   for(var c = 0;c < csvData[0].length;c++) {
       columnData = [];
       for(var r = 0;r < csvData.length;r++) {
         columnData.push(csvData[r][c]);
       }
       convertData.push(columnData);
   }
   return convertData;
 }
 
 // グラフ描画
 function drawBarChart(data) {
   var tmpLabels = [], tmpData = [];
   var label = data[0];
   for (var row in data) {
     if (row > 0) tmpLabels.push(data[row][0]);
     var rowData = [];
     rowShiftData = data[row].shift();
     for (var column in data[row]) {
       rowData.push(data[row][column]);
     }
     tmpData.push(rowData);
   };

   var options = { 
       "responsive": true,
       "maintainAspectRatio": false,
        showScale: false,
            scales: { yAxes: [ { ticks: { beginAtZero: true, min: 0 } } ] },
            animation: false
   }
   
   console.log(tmpLabels[0] + "," + tmpData[0][0]);
 

   // chart.jsで描画
   for (var chartNumber = 1; chartNumber < 18; chartNumber++)
   {
     var ctx = document.getElementById("myChart" + chartNumber).getContext("2d");
     var dataFinal = [ { label: tmpLabels[chartNumber - 1], data: tmpData[chartNumber], fill:false,lineTension:0, pointBorderWidth:5, pointStyle:"rect", borderWidth:1 } ];

     // 過去4回の平均より50%以上長かった場合は赤色でグラフを表示する
     if (tmpData[chartNumber].length >= 5) {
       var sum = 0;
       for (var i = tmpData[chartNumber].length - 5; i < tmpData[chartNumber].length - 1; i++) sum += parseInt(tmpData[chartNumber][i]);
       var ave = sum / 4;

       if (tmpData[chartNumber][4] >= ave * 1.5) { 
         dataFinal = [ { label: tmpLabels[chartNumber - 1], data: tmpData[chartNumber], fill:false,lineTension:0, pointBorderWidth:10, pointStyle:"rect", borderWidth:2, backgroundColor: "red", borderColor: "red", pointBorderColor: "red" } ];
       }
     }
     var myChart = new Chart(ctx, {
       type: 'line',
       data: {
         labels: label,
         datasets: dataFinal
       },
       options: options
     });
   }
 }
 
 function main() {
   // 1) ajaxでCSVファイルをロード
   var req = new XMLHttpRequest();
   var filePath = 'time.csv';
   req.open("GET", filePath, true);
   req.onload = function() {
     // 2) CSVデータ変換の呼び出し
     data = csv2Array(req.responseText);
     // 3) chart.jsデータ準備、4) chart.js描画の呼び出し
     drawBarChart(data);

   setTimeout("location.reload()",600000);

 }
   req.send(null);
 }
 
 main();

グラフは、顧客からの要望で直近7回分を折れ線グラフにして表示しています。また、最新の計測値が直近4回の平均より50%以上大きかった(遅かった)場合は赤色でグラフを表示するようにしています。
なお、グラフは10分に1度リロードされます。

以下は出力のサンプルです。

グラフ1

また、全測定項目を1つにまとめて表示するページも用意しました。

グラフ2

20項目以上が1つのグラフに入っており詳細が見にくいですが、顧客は「まずこのグラフを見て、跳ね上がったグラフがあったら個別のグラフを見る」、とのことでしたので、このようなグラフのまま提示しました。納品しております。
こちらのグラフのコードもほぼ同様ですので、掲載は省略します。

もし、お困りのことがありましたら、お気軽にご相談下さい。

――――――――――――――――――――――――――――――――――

執筆者プロフィール:水谷裕一
大手外資系IT企業で15年間テストエンジニアとして、多数のプロジェクトでテストの自動化作業を経験。その後画像処理系ベンチャーを経てSHIFTに入社。
SHIFTでは、テストの自動化案件を2件こなした後、株式会社リアルグローブ・オートメーティッド(RGA)にPMとして出向中。RGAでは主にAnsibleに関する案件をプレーイングマネジャーとして担当している。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/

みんなにも読んでほしいですか?

オススメした記事はフォロワーのタイムラインに表示されます!