Hudson の NUnit Plugin を使うとテストケースの数が減る問題とその解決方法

仕事で Hudson を使い始めた頃 (去年の 10 月だか 11 月だか) から、NUnit のテストケースが実際よりも少なくなってしまう問題は認識していましたが、あまり気にしていませんでした。
しかし今回、かなりの数のテストケースが削られていたため、ちょっと調べて直してみました。

始める前に

ここで紹介する方法は、プラグインのクラスファイルの一部を入れ替える方法です。
あくまでその場しのぎの解決方法であることを理解したうえで、この方法を実行する場合は自己責任でお願いします。

調査

まず、どこでテストケースが減っているのかを調べていきました。


NUnit が出力する XML ファイルでは、当然ながらテストケースの数は正しいものが出力されていました。
どうやら NUnit Plugin が NUnit の出力する XML ファイルを JUnit の形式に XSL ファイルで変換しているらしいので、この XSL ファイルに NUnit から出力された XML ファイルを与え、変換してみました。しかし、ここではテストケースの数は正しいままでした。それに、XSL ファイルの内容を見ても、テストケースの数は減りそうにありません。


次に、ソース *1 をチェックアウトして、変換部分を探してみると、NUnitReportTransformer クラスの splitJUnitFile メソッドが怪しい感じでした。
このメソッドは XSL 変換した結果を複数ファイルに分割しているのですが、なぜ分割しているのかは分かりません。
しかし、ここで分割にミスってテストケースを減らしている可能性は大いにあり得ますので、実際にこのクラスを動かしてみると、やはりテストケースの数が減っていました。

原因

どのテストケースが減っているのかを調べたところ、NUnit のパラメタライズドテストを使い、テストメソッドをオーバーロードしていた部分が減っていました。

[TestCase(10, "hoge")]
[TestCase(20, "piyo")]
public void Hoge(int input, string expected) { ... }

[TestCase(10, true, "hoge")]
public void Hoge(int input, bool isXxx, string expected) { ... }

NUnit のパラメタライズドテストの詳細については、

を参照してください。


上記のテストコードが、HogePiyo 名前空間の FooBarTest クラスに記述されていた場合、全体としては 3 つのテストケースが出力されて欲しいにも関わらず、NUnit Plugin ではテストメソッド名しか考慮しておらず、1 つのテストケースしか出力されていませんでした (後のテストケースで上書きされる)。
さらに、NUnit が出力する XML で、パラメタライズドテストは test-suite 扱いだったので、splitJUnitFile の動作と合わさって、テストケースが削られていました。


例えば、上の例で出力される XML

<test-suite name="HogePiyo" ...>
 <test-suite name="FooBarTest" ...>
  <test-suite name="Hoge" ...>
   <results>
    <test-case name="HogePiyo.FooBar.Hoge(10, &quot;hoge&quot;)" .../>
    <test-case name="HogePiyo.FooBar.Hoge(20, &quot;piyo&quot;)" .../>
   </results>
  </test-suite>
  <test-suite name="Hoge" ...>
   <results>
    <test-case name="HogePiyo.FooBar.Hoge(10, True, &quot;hoge&quot;)" .../>
   </results>
  </test-suite>
 </test-suite>
</test-suite>

のようなものになりますが、

[Test]
public void HogeTest1() { ... }

[Test]
public void HogeTest2() { ... }

[Test]
public void HogeTest3() { ... }

の場合、

<test-suite name="HogePiyo" ...>
 <test-suite name="FooBarTest" ...>
  <results>
   <test-case name="HogeTest1" .../>
   <test-case name="HogeTest2" .../>
   <test-case name="HogeTest3" .../>
  </results>
 </test-suite>
</test-suite>

のような XML になります。

XSL の修正

上の XML をそれぞれ XSL 変換すると、

<!-- パラメタライズドテストの場合 -->
<testsuite name="HogePiyo.FooBarTest.Hoge" ...>
 <testcase classname="HogePiyo.FooBarTest.Hoge" name="" .../>
 <testcase classname="HogePiyo.FooBarTest.Hoge" name="" .../>
</testsuite>
<testsuite name="HogePiyo.FooBarTest.Hoge" ...>
 <testcase classname="HogePiyo.FooBarTest.Hoge" name="" .../>
</testsuite>
<!-- 普通のテストの場合 -->
<testsuite name="HogePiyo.FooBarTest" ...>
 <testcase classname="HogePiyo.FooBarTest" name="HogeTest1" .../>
 <testcase classname="HogePiyo.FooBarTest" name="HogeTest2" .../>
 <testcase classname="HogePiyo.FooBarTest" name="HogeTest3" .../>
</testsuite>

となります。
パラメタライズドテストでは classname にメソッド名まで含んでしまっている上、name が空になってしまっています。


この問題を修正するために、main/resources/hudson/plugins/nunit/nunit-to-junit.xsl の 13 行目の XSL 変数 assembly と 18 行目の testcaseName で直接値を決定していた部分を、仮の testcaseName の内容を見てから決定するようにしました。

<!-- この下が13行目 -->
<xsl:variable name="tmpAssembly"
 select="concat(substring-before($firstTestName, @name), @name)" />     <!-- assemblyからtmpAssemblyに変更 -->

<!--  <redirect:write file="{$outputpath}/TEST-{$tmpAssembly}.xml">-->

 <testsuite name="{$tmpAssembly}"
  tests="{count(*/test-case)}" time="{@time}"
  failures="{count(*/test-case/failure)}" errors="0"
  skipped="{count(*/test-case[@executed='False'])}">
  <xsl:for-each select="*/test-case[@time!='']">
   <xsl:variable name="tmpTestcaseName">    <!-- testcaseNameからtmpTestcaseNameに変更 -->
    <xsl:choose>
     <xsl:when test="contains(./@name, $tmpAssembly)">
      <xsl:value-of select="substring-after(./@name, concat($tmpAssembly,'.'))"/>
     </xsl:when>
     <xsl:otherwise>
      <xsl:value-of select="./@name"/>
     </xsl:otherwise>
    </xsl:choose>
   </xsl:variable>
   <!-- 以下新規追加するコード -->
   <xsl:variable name="assembly">           <!-- ここでassemblyを決定 -->
    <xsl:choose>
     <xsl:when test="string-length($tmpTestcaseName)=0">
      <xsl:value-of select="substring-before($tmpAssembly, concat('.', ../../@name))"/>
     </xsl:when>
     <xsl:otherwise>
      <xsl:value-of select="$tmpAssembly"/>
     </xsl:otherwise>
    </xsl:choose>
   </xsl:variable>
   <xsl:variable name="testcaseName">       <!-- ここでtestcaseNameを決定 -->
    <xsl:choose>
     <xsl:when test="string-length($tmpTestcaseName)=0">
      <xsl:value-of select="concat(../../@name, substring-after(./@name, ../../@name))"/>
     </xsl:when>
     <xsl:otherwise>
      <xsl:value-of select="$tmpTestcaseName"/>
     </xsl:otherwise>
    </xsl:choose>
   </xsl:variable>

仮の testcaseName (tmpTestcaseName) が空の場合、パラメタライズドテストとみなして、assembly と testcaseName を組み直し、空ではない場合、tmpAssembly と tmpTestcaseName を assembly と testcaseName として使用するようにしました。


しかし、これだけでは XSL 変数 assembly の名前がかぶった場合、つまりパラメタライズドテストでオーバーロードしていた場合に、テストケースが消えてしまいます。
なぜなら、main/java/hudson/plugins/nunit/NUnitReportTransformer.java の splitJUnitFile メソッドが

String filename = JUNIT_FILE_PREFIX + element.getAttribute("name").replaceAll(ILLEGAL_FILE_CHARS_REGEX, "_") + JUNIT_FILE_POSTFIX;
File junitOutputFile = new File(junitOutputPath, filename);
FileOutputStream fileOutputStream = new FileOutputStream(junitOutputFile);

となっているからです。
element は testsuite 要素が格納されているので、その name 属性、つまり XSL 変数 assembly の名前がかぶっていた場合、上記プログラムの 1 行目で生成されるファイル名は同一のものになります。
そして、上記プログラムの 3 行目で FileOutputStream のコンストラクタを呼び出していますが、ここで同じ名前のファイルが上書きされてしまい、その分のテストコードが消えることになります。

NUnitReportTransformer.java の修正

これを避けるために、NUnitReportTransformer.java にも修正が必要となります*2
要は、作成しようとしているファイル名がすでに存在するなら、違うファイル名を使えばいいだけです。


そこで、ファイル名の末尾に連番を付与することにしました。
上記 3 行は、以下のように変更します。

File junitOutputFile = outputFile(element.getAttribute("name").replaceAll(ILLEGAL_FILE_CHARS_REGEX, "_"), junitOutputPath);
FileOutputStream fileOutputStream = new FileOutputStream(junitOutputFile);

そして、フィールドとメソッドを追加します。

private static int seq = 2;
private static File outputFile(String tmp, File parent) {
    File f = new File(parent, filename(tmp));
    return f.exists() ? outputFileImpl(tmp, parent)
                      : f;
}
private static File outputFileImpl(String tmp, File parent) {
    File f = new File(parent, filename(tmp, seq));
    if (f.exists()) {
        seq++;
        return outputFileImpl(tmp, parent);
    }
    seq = 2;
    return f;
}
private static String filename(String name) {
    return JUNIT_FILE_PREFIX + name + JUNIT_FILE_POSTFIX;
}
private static String filename(String name, int n) {
    return JUNIT_FILE_PREFIX + name + "_" + n + JUNIT_FILE_POSTFIX;
}

後はこれをコンパイルして、Hudson をインストールした場所にある plugins/nunit/WEB-INF/classes/hudson/plugins/nunit に .xsl ファイルと生成された .class ファイルをコピーします。
これで、ファイル名がかぶることがなくなり、テストケースの数が減ることがなくなりました。
さらに、今まで空だったテスト名や、メソッド名まで含んでしまっていたクラス名も直りました。

別の問題

動作には支障がないので、問題ないと言えば問題ないのですが、ひとつ気になるところを見つけてしまいました。
NUnitReportTransformer.java の 34 行目の正規表現なのですが、

[\\*/:<>\\?\\|\\\\\";]+

となっています。文字クラスの中では * も ? も | も特別な意味を持ちませんので、

[*/:<>?|\\\\\";]+

で大丈夫です。\ が続いているのがちょっと読みにくいので、

[\"*/:<>?|\\\\;]+

と、離してしまうとより読みやすくなると思います。

*1:https://hudson.dev.java.net/svn/hudson/trunk/hudson/plugins/nunit/

*2:XSL だけでどうにかできそうですが、考えるのが面倒だったので・・・