[Mac][Zsh] JUnit 形式の XML ファイルから実行に時間のかかっているテストケーストップ5を抽出する

1.はじめに

CIでテスト実行にかかる時間の割合がとても大きく、
どんなテストケースが遅いのか気になったのでシェルで抽出してみました。

長くなってしまったので前後編に分けます。

本ポスト(前編)では、本タイトルの要旨、つまり
「JUnit の実行結果から実行時間の遅いテストケーストップ5を抽出するまで」
を書きます。

そこで抽出した文字列は十分伝わりますが不恰好なので
どうせなら XMLとして整形しちゃおうということで、
後編ではそのあたりを書こうと思います。

2.前提条件

  • Mac: macOS Big Sur 11.5.1
  • Zsh: zsh 5.8 (x86_64-apple-darwin20.0)
  • AWK: awk version 20200816

3.最終的なコマンド

前後編合わせた最終的なコマンドです。

僕が試した JUnit ファイルだと、
以下のzshコマンドを実行して JUnit の XML ライクなフォーマットで出力できました。
(シェル芸人ではないのでスマートさはないと思います)

$ echo \<testsuites\> \<testsuite\> `cat YourProject.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3 }' | sed -e "s/time=\'//" | sort -rn | head -5 | sed -e 's@/>@@' -e 's@>@@' -e "s/^/<testcase time='/" -e 's@$@/>@'` \<\/testsuite\> \<\/testsuites\> | tidy --input-xml true -utf8 --wrap 0 --indent true --add-xml-decl true -o output.xml

ちょっと分かりづらすぎるので、2つに分割すると以下のコマンドになります。

$ echo \<testsuites\> \<testsuite\> `cat YourProject.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3 }' | sed -e "s/time=\'//" | sort -rn | head -5 | sed -e 's@/>@@' -e 's@>@@' -e "s/^/<testcase time='/" -e 's@$@/>@'` \<\/testsuite\> \<\/testsuites\> > wip.xml

$ tidy --input-xml true -utf8 --wrap 0 --indent true --add-xml-decl true -o output.xml wip.xml

1つ目のコマンドで、テスト実行時間の降順でソートし、遅い順に5個ピックアップし、
その結果を wip.xml という名前で保存しています。

これだけでも問題なく xml ファイルになっているんですが、
改行やインデントがなくて可読性が悪い状態になっています。

ですので、2つ目のコマンドで改行やインデントをきれいにし、
output.xml という名前で出力しています。


ちなみに冒頭のワンライナー、echo の処理も sed で代用できるけど、
こちらの方がスマートかも知れない? 🤔

$  cat YourProject.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3 }' | sed -e "s/time=\'//" | sort -rn | head -5 | sed -e 's@/>@@' -e 's@>@@' -e "s@^@<testcase time=\'@g" -e 's@$@/>@g' -e '1s@^@<testsuites><testsuite>@' -e '$s@$@</testsuite></testsuites>@'| tidy --input-xml true -utf8 --wrap 0 --indent true --add-xml-decl true -o output.xml

4.JUnit 実行結果の XML の形式について

今回僕が試したのは以下のようなフォーマットでした。

<?xml version='1.0' encoding='UTF-8'?>
<testsuites name='All' tests='4' failures='0' >
  <testsuite name='Tests/AAATests' tests='2' failures='0'>
    <testcase classname='AAATests' name='テストケースA()' time='0.008349895477294922'/>
    <testcase classname='AAATests' name='テストケースB()' time='0.002708911895751953'/>
  </testsuite>
  <testsuite name='Tests/BBBTests' tests='2' failures='0'>
    <testcase classname='BBBTests' name='テストケースA()' time='0.004773974418640137'/>
    <testcase classname='BBBTests' name='テストケースB()' time='0.003311038017272949'/>
  </testsuite>
</testsuites>

検索したところ、JUnit の公式のフォーマットはなさそうという記述が多かったので、
各環境によって実行結果のフォーマットは微妙に異なってくるのかなと思います。

ただ、GitHub には JUnit のリポジトリ があり、JUnit5 のフォーマットが定義されています
ですので JUnit5 に関してはこれが公式なのかなと思います。


これ以降、冒頭に書いたコマンドについて順を追って説明します!

5.例として使用する XML ファイル

以下のサンプルを My.junit というファイル名で利用します。

<?xml version='1.0' encoding='UTF-8'?>
<testsuites name='All' tests='5' failures='0' >
  <testsuite name='Tests/AAATests' tests='5' failures='0'>
    <testcase classname='AAATests' name='テストケースA()' time='0.008349895477294922'/>
    <testcase classname='AAATests' name='テストケースB()' time='0.002708911895751953'/>
    <testcase classname='AAATests' name='テストケースC()' time='5.103403783017138107'/>
    <testcase classname='AAATests' name='テストケースD()' time='2.847301841843703481'/>
    <testcase classname='AAATests' name='テストケースE()' time='0.902874301873418053'/>
  </testsuite>
  <testsuite name='Tests/BBBTests' tests='5' failures='0'>
    <testcase classname='BBBTests' name='テストケースA()' time='3.004773974418640137'/>
    <testcase classname='BBBTests' name='テストケースB()' time='0.003311038017272942'/>
    <testcase classname='BBBTests' name='テストケースC()' time='0.003813487107181401'/>
    <testcase classname='BBBTests' name='テストケースD()' time='11.384701784318703814'>
      <failure>Assertion Failure at BBBTests</failure>
    </testcase>
    <testcase classname='BBBTests' name='テストケースE()' time='0.841047132138008907'/>
  </testsuite>
</testsuites>

6.testcase という文字列のある行のみを抜き出す

以下のコマンドを打ちます。

$ cat My.junit | grep 'testcase.*'

ターミナルに以下のように出力されます。

    <testcase classname='AAATests' name='テストケースA()' time='0.008349895477294922'/>
    <testcase classname='AAATests' name='テストケースB()' time='0.002708911895751953'/>
    <testcase classname='AAATests' name='テストケースC()' time='5.103403783017138107'/>
    <testcase classname='AAATests' name='テストケースD()' time='2.847301841843703481'/>
    <testcase classname='AAATests' name='テストケースE()' time='0.902874301873418053'/>
    <testcase classname='BBBTests' name='テストケースA()' time='3.004773974418640137'/>
    <testcase classname='BBBTests' name='テストケースB()' time='0.003311038017272942'/>
    <testcase classname='BBBTests' name='テストケースC()' time='0.003813487107181401'/>
    <testcase classname='BBBTests' name='テストケースD()' time='11.384701784318703814'>
    <testcase classname='BBBTests' name='テストケースE()' time='0.841047132138008907'/>

7.実行時間の遅い順にソートする

いよいよここから本題です。

実行時間でソートするため、まずは秒数を各行の文頭に持ってきます。

その前準備として、各 testcase 要素内の属性を並べ替えます。

いまは classname, name, time の順番になっていますが、
これをtime, classname, name の順番にします。

ここでは AWK を使って並べ替えることにします。

7-1.AWK とは

AWK とは、基本的には

  1. 文頭から、指定したレコード分離文字(デフォルトは改行コード)までが現れるまでを1レコードとする
  2. 各レコード毎に、指定したフィールド分離文字(デフォルトはスペース)でレコード内の文字列を分割し、指定したアクションを行う

ということをするプログラミング言語です。
と言いつつ、機能拡張により最近のプログラミング言語のように複雑なこともできるようです。


AWKに関しては丁寧な解説ページがたくさんありますが、
僕は主に以下のサイトを参考にさせていただきました。

  1. Qiita: awkコマンドの基本
  2. hydroculのメモ: awk コマンド
  3. とほほのWWW入門: とほほのAWK入門

7-2. AWK を使ってtime=を先頭に持ってくる

ソートをするため、

<testcase classname='AAATests' name='テストケースA()' time='0.008349895477294922'/>

のような行にて、time 属性の 0.008349895477294922 を文頭に持ってきたいです。
前準備として time=’0.008349895477294922′ を文頭に持ってこようと思います。

そのためには半角スペースを区切り文字とし、
分割された文字列の塊たちを並べ替えて、time= を先頭に持ってこればよさそうです。

まずは以下のようなコマンドを打ちます。

$ cat My.junit | grep 'testcase.*' | awk -F ' ' '{print $1, $2, $3, $4}'

すると、以下のように出力されます。

<testcase classname='AAATests' name='テストケースA()' time='0.008349895477294922'/>
<testcase classname='AAATests' name='テストケースB()' time='0.002708911895751953'/>
<testcase classname='AAATests' name='テストケースC()' time='5.103403783017138107'/>
... 略

あまり変わったようには見えないです🤔

ちなみにここの awk では、「フィールド分離文字は半角スペースである」と
-F ' ' オプションで指定しています。
デフォルトなのでこのオプションは省略しても挙動は変わりません。

次に以下のコマンドを打ちます。
上のコマンドと比べると、print の引数から $1 を消しています。

$ cat My.junit | grep 'testcase.*' | awk -F ' ' '{print $2, $3, $4}'

すると、以下のような表示になりました。

classname='AAATests' name='テストケースA()' time='0.008349895477294922'/>
classname='AAATests' name='テストケースB()' time='0.002708911895751953'/>
classname='AAATests' name='テストケースC()' time='5.103403783017138107'/>
... 略

各行から <testcase という文字列が消えてますね!

つまり、元の各行は半角スペースで分割されており、
$0 には <testcase という文字列が入っていることになります。

$4 には time='0.008...'/> という文字列が入っているので、$4 を先頭に持ってきます。

$ cat My.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3}' 

すると、以下のように表示されます。

time='0.008349895477294922'/> classname='AAATests' name='テストケースA()'
time='0.002708911895751953'/> classname='AAATests' name='テストケースB()'
time='5.103403783017138107'/> classname='AAATests' name='テストケースC()'
... 略

簡単に文字列操作できました 🙌

次に、文頭の time=' という文字列を除去します。
そのために sed コマンドを使います。

7-3. sed とは

sed とは、基本的には

  1. ある文字列に対して、命令に沿って文字列の追加や削除などを行う
  2. 命令は「1行目に指定した文字列を追加しろ」などが使えるが、最も使われるのは正規表現による文字列置換
  3. awk は各行内での文字列の抽出が得意だが、sed は文全体での文字列の編集が得意

のような感じかと思います。

sed に関しても丁寧な解説ページがたくさんありますが、
僕は主に以下のサイトを参考にさせていただきました。

  1. TECHNOSCORE BLOG: あえていうほどでもないsed入門
  2. hydroculのメモ: sed コマンド
  3. Qiita: sedでこういう時はどう書く?

7-4. sed を使って行頭のtime=’ を除去する

ソートをするため、

time='0.008349895477294922'/> classname='AAATests' name='テストケースA()'

のような文字列から time=' を除去します。
time=' を空文字列に置換することにします。

sed で正規表現を用いて置換する場合は

sed -e 's/置換前の文字列/置換後の文字列/'

というコマンドになります。
スラッシュ 「/」で区切っていくのが一般的です。

ですので、今回は time=' という文字列を空文字に置換したいので、
何も考えずに当てはめると以下のようなコマンドになります。

sed -e 's/time='//'

ですがこれはダメです。
実行すると以下のような表示になると思います(環境によって違うかも知れません)。

pipe pipe pipe quote>

なぜかと言うと、スクリプト、つまり s/time='// の記述を
シングルクォートで開始しているため、
time=' でスクリプトの末尾だと解釈されてしまうからだと思われます。

それを回避するために、スクリプトの前後をダブルクォートで囲みます。
(シングルクォートのエスケープ方法もあるかも知れませんが、今回は追求しません)

sed -e "s/time='//"

ということで、以下のコマンドを打ちます。

$ cat My.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3 }' | sed -e "s/time=\'//"

すると、以下のように表示されます。

0.008349895477294922'/> classname='AAATests' name='テストケースA()'
0.002708911895751953'/> classname='AAATests' name='テストケースB()'
5.103403783017138107'/> classname='AAATests' name='テストケースC()'
...略

簡単に文字列操作できました 🙌
あとはソートして上位何件かを取得するだけです。

まずはソートします!

7-5. sort を使ってソートする

@IT: 【 sort 】コマンド――テキストファイルを行単位で並べ替える を参考にしました。
Linux の記事ですが、今回の用途的には違いはありませんでした。

降順でソートするので、r オプションを使えばよさそうです。

以下のコマンドを打ちます。

$ cat My.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3 }' | sed -e "s/time=\'//" | sort -r 

すると、以下のような表示になります。

5.103403783017138107'/> classname='AAATests' name='テストケースC()'
3.004773974418640137'/> classname='BBBTests' name='テストケースA()'
2.847301841843703481'/> classname='AAATests' name='テストケースD()'
11.384701784318703814'> classname='BBBTests' name='テストケースD()'
0.902874301873418053'/> classname='AAATests' name='テストケースE()'
...略

何かがおかしいです。

ソートはされていますが、実行時間の遅い順ではありません。
各行の1文字目だけに着目すればソートはされていると言えますが、
どうも数値として扱われていないようです。

文字列を数値として扱うよう n オプションを追加してみます。

$ cat My.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3 }' | sed -e "s/time=\'//" | sort -rn

すると以下のような表示になります。

11.384701784318703814'> classname='BBBTests' name='テストケースD()'
5.103403783017138107'/> classname='AAATests' name='テストケースC()'
3.004773974418640137'/> classname='BBBTests' name='テストケースA()'
...略

実行時間順にソートすることができました!🙌

トップ5を抽出してみます。

7-6. head を使って先頭N行を抽出する

これは簡単。

以下のコマンドで実現できます。

head -数値

というわけで、以下のコマンドを実行します。

$ cat My.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3 }' | sed -e "s/time=\'//" | sort -rn | head -5

先頭5行、つまり実行時間の遅い順に上位5件が抽出できたかと思います!

11.384701784318703814'> classname='BBBTests' name='テストケースD()'
5.103403783017138107'/> classname='AAATests' name='テストケースC()'
3.004773974418640137'/> classname='BBBTests' name='テストケースA()'
2.847301841843703481'/> classname='AAATests' name='テストケースD()'
0.902874301873418053'/> classname='AAATests' name='テストケースE()'

文字列の整形はされていませんが、テキストで共有する場合はこれで十分伝わりますね!

これで最低限やりたいことはできました!

長くなったので一旦ここで切って、本ポストは後編に続けます。

後編は趣向が変わって、
抽出したデータを XML 文書として扱えるよう文字列を追加・削除する、
XML のインデントなどを整形をするという内容になります。

8.さいごに

sed や awk は難しそうなイメージがあったんですが、
やってみると思ったよりとっつきやすかったです。

数万行程度のテキストなら秒で処理できますし、とてもパワフルですね!

勉強して損はなさそうです!

後編に続きます。
お疲れ様でした!

[Mac][Zsh] JUnit 形式の XML ファイルから実行に時間のかかっているテストケーストップ5を抽出する」への1件のフィードバック

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中

%d人のブロガーが「いいね」をつけました。