読者です 読者をやめる 読者になる 読者になる

VS2015 の IntelliTest を調べた 前編

先日勤め先での勉強会で IntelliTest の概要についてさらっと発表したのですが、時間が足りなくて全然調査結果をスライドに反映できなかったのでブログにアウトプットします。

※VS2015 RC での情報です

IntelliTest の概要

はるか昔 Microsoft Research が Pex というテストケース自動生成ツールを作っていたのですが (なくなった訳じゃなくて今もあります)、それがとうとう IntelliTest という名前で VS に組み込まれたものです。ちなみに Preview 版では Smart Unit Test と呼ばれていました。

IntelliTest は、Pex と同じく VS 上でユニットテストのテストケースを自動生成してくれます。

例えばこんなコードがあるとして

public string SomeMethod(string s1, string s2)
{
    if (s1.Length < s2.Length)
        return $"{s1} < {s2}";
    else if (s1.Length == s2.Length)
        return $"{s1} = {s2}";
    else
        return $"{s1} > {s2}";
}

このコード上でコンテキストメニューから [Run IntelliTest] を実行することにより、以下のようなテストケースを生成、実行までしてくれます。

f:id:kendik:20150719022042p:plain

二つほど Red 出てますね。引数に null が渡されたときの考慮が足りていないようです。IntelliTest は、例外はコード中で明示的にスローされたもの以外 Red と判定します。というわけで先ほどのコードを以下のように修正します。

public string SomeMethod(string s1, string s2)
{
    if (s1 == null)
        throw new ArgumentNullException();
    if(s2 == null)
        throw new ArgumentNullException();

    if (s1.Length < s2.Length)
        return $"{s1} < {s2}";
    else if (s1.Length == s2.Length)
        return $"{s1} = {s2}";
    else
        return $"{s1} > {s2}";
}

f:id:kendik:20150719022053p:plain

All Green になりました。
こんな感じにテストを動かしながら開発を行うことが出来ます。
また、自動生成されたテストケースは保存が可能で、テストエクスプローラなどからの回帰テストに利用できます。

テストを保存するとこんな感じにテストプロジェクトが作られて

f:id:kendik:20150719022101p:plain

こんなテストコードができあがります。

public partial class Class1Test
    {
[TestMethod]
[PexGeneratedBy(typeof(Class1Test))]
[ExpectedException(typeof(ArgumentNullException))]
public void SomeMethodThrowsArgumentNullException362()
{
    string s;
    Class1 s0 = new Class1();
    s = this.SomeMethod(s0, (string)null, (string)null);
}
[TestMethod]
[PexGeneratedBy(typeof(Class1Test))]
[ExpectedException(typeof(ArgumentNullException))]
public void SomeMethodThrowsArgumentNullException68()
{
    string s;
    Class1 s0 = new Class1();
    s = this.SomeMethod(s0, "", (string)null);
}
[TestMethod]
[PexGeneratedBy(typeof(Class1Test))]
public void SomeMethod996()
{
    string s;
    Class1 s0 = new Class1();
    s = this.SomeMethod(s0, "", "");
    Assert.IsNotNull((object)s0);
}
[TestMethod]
[PexGeneratedBy(typeof(Class1Test))]
public void SomeMethod226()
{
    string s;
    Class1 s0 = new Class1();
    s = this.SomeMethod(s0, "\0", "");
    Assert.IsNotNull((object)s0);
}
[TestMethod]
[PexGeneratedBy(typeof(Class1Test))]
public void SomeMethod394()
{
    string s;
    Class1 s0 = new Class1();
    s = this.SomeMethod(s0, "", "\0");
    Assert.IsNotNull((object)s0);
}
    }

インデントがおかしいですが、原文ママです。メソッド名の数字もなんかアレですが、まぁここらへんは自動生成ということでいいでしょう。
なお、テストコードが書かれたファイルには .g.cs という拡張子が付くので、自動生成されたことが一目で分かるようになっています。

さらに、テスト対象メソッドが変更された場合でも再度 IntelliTest を走らせれば保存されたテストコードを作り直してくれます。例えばさっきのテスト対象コードをちょっと書き換えてみます。

public string SomeMethod(string s1, string s2)
{
    if (s1 == null)
        throw new ArgumentNullException();
    if (s2 == null)
        throw new ArgumentNullException();

    if (s1.Length <= s2.Length)
        return $"{s1} <= {s2}";
    else
        return $"{s1} > {s2}";
}

再実行すると、テストコードが以下のようになりました。

public partial class Class1Test
    {
[TestMethod]
[PexGeneratedBy(typeof(Class1Test))]
[ExpectedException(typeof(ArgumentNullException))]
public void SomeMethodThrowsArgumentNullException362()
{
    string s;
    Class1 s0 = new Class1();
    s = this.SomeMethod(s0, (string)null, (string)null);
}
[TestMethod]
[PexGeneratedBy(typeof(Class1Test))]
[ExpectedException(typeof(ArgumentNullException))]
public void SomeMethodThrowsArgumentNullException68()
{
    string s;
    Class1 s0 = new Class1();
    s = this.SomeMethod(s0, "", (string)null);
}
[TestMethod]
[PexGeneratedBy(typeof(Class1Test))]
public void SomeMethod996()
{
    string s;
    Class1 s0 = new Class1();
    s = this.SomeMethod(s0, "", "");
    Assert.IsNotNull((object)s0);
}
[TestMethod]
[PexGeneratedBy(typeof(Class1Test))]
public void SomeMethod226()
{
    string s;
    Class1 s0 = new Class1();
    s = this.SomeMethod(s0, "\0", "");
    Assert.IsNotNull((object)s0);
}
    }

書き換えたコードに追従してテストコードも変わっていますね :)
ただこれはまぁ悪い面もあって、自動生成したユニットテストを手動で修正しても上書きされてしまいます。自動生成は自動生成に任せましょうということですね。

じゃあユニットテストは IntelliTest で全部いいじゃんって話になるかと実はそうではなくて、先ほどのテストコードを見ると分かるんですが、実績値の評価はしていません。例外がスローされるかどうかと、戻り値が null でないことを確認しているだけですね。
じゃあどうするかというと、どうしようもないです。戻り値が期待通りであるかは依然として開発者が確認する必要があります (厳密にいうとある程度は自動でやれますが、それは後述)。

自動生成されたユニットテストについて

実績値が期待値通りかは自分で評価する必要があると書きましたが、多少自動で確認させることは出来ます。

PexAssert

PexAssert Class (Microsoft.Pex.Framework)

PexAssert というクラスを使ってアサーションを記述できます。先ほど .g.cs ファイルにテストコードが保存されると書きましたが、実は普通に以下のような .cs クラスも作られています。

public partial class Class1Test
{
    [PexMethod]
    public string SomeMethod(
        [PexAssumeUnderTest]Class1 target,
        string s1,
        string s2
    )
    {
        string result = target.SomeMethod(s1, s2);
        return result;
        // TODO: アサーションを メソッド Class1Test.SomeMethod(Class1, String, String) に追加します
    }
}

コメントにあるとおり、アサーションはここに追加していきます。
例えば SomeMethod メソッドの仕様を変えて、引数には同じ長さが決して入らない仕様にしたとします。そうすると、結果は必ず s1 < s2s2 > s1 になることを期待しますね。

private static readonly Regex SomeMethodResultAssert = new Regex("^.* [<>] .*$");

[PexMethod]
public string SomeMethod(
    [PexAssumeUnderTest]Class1 target,
    string s1,
    string s2
)
{
    string result = target.SomeMethod(s1, s2);
    PexAssert.IsTrue(SomeMethodResultRegex.IsMatch(result));
    return result;
}

IntelliTest を実行してみます。

f:id:kendik:20150719022113p:plain

まだテスト対象コードを修正していないので、 Red になりました。適切にアサーションが働いています。テストコードも書き換わっています。

// ↓ のテストメソッドが増えた。他は省略。
[TestMethod]
[PexGeneratedBy(typeof(Class1Test))]
[PexRaisedException(typeof(PexAssertFailedException))]
public void SomeMethodThrowsPexAssertFailedException591()
{
    string s;
    Class1 s0 = new Class1();
    s = this.SomeMethod(s0, "", "");
}

テスト対象をテストが通るように書き換えます。

public string SomeMethod(string s1, string s2)
{
    if (s1 == null)
        throw new ArgumentNullException();
    if (s2 == null)
        throw new ArgumentNullException();

    if (s1.Length < s2.Length) // ここの条件が変わった
        return $"{s1} < {s2}";
    else
        return $"{s1} > {s2}";
}

f:id:kendik:20150719022126p:plain

はい、改めて All Green になりました。
ただ、引数に同じ長さの文字列が渡されないという仕様はコードからもテストからも読み取れませんね。同じというか普通に今のままだと考慮漏れによるバグがあるようにしか見えないです。

PexAssume

PexAssume Class (Microsoft.Pex.Framework)

前提となる仕様が見えないなら、前提条件も追加しましょう。 PexAssume というクラスを使って前提条件を記述できます。

private static readonly Regex SomeMethodResultAssert = new Regex("^.* [<>] .*$");

[PexMethod]
public string SomeMethod(
    [PexAssumeUnderTest]Class1 target,
    string s1,
    string s2
)
{
    PexAssume.IsTrue(s1.Length != s2.Length); // New!
    string result = target.SomeMethod(s1, s2);
    PexAssert.IsTrue(SomeMethodResultRegex.IsMatch(result));
    return result;
}

(ちなみにこういうとき普通は AreNotEqual メソッドを使うもんでしょうし、実際 MSDN でも AreNotEqual を使ったサンプルが記載されているんですが、 PexAssume クラスにはそのようなメソッドは定義されていません (MSDNメソッド一覧的にも)。謎です。 RC だから実装追いついてないとかなんでしょうか?でも Pex リリースされて何年たってると。。。)

このまま実行してしまうと、引数に null が渡されたときにヌルポになってしまいます。そもそも長さを見るなら null が渡されては困りますので、 null が渡されないようさらに前提条件を追加します。あとテスト対象も null を考慮した実装はいりませんね。

private static readonly Regex SomeMethodResultAssert = new Regex("^.* [<>] .*$");

[PexMethod]
public string SomeMethod(
    [PexAssumeUnderTest]Class1 target,
    string s1,
    string s2
)
{
    PexAssume.IsNotNull(s1);
    PexAssume.IsNotNull(s2);
    PexAssume.IsTrue(s1.Length != s2.Length); // New!
    string result = target.SomeMethod(s1, s2);
    PexAssert.IsTrue(SomeMethodResultRegex.IsMatch(result));
    return result;
}
public string SomeMethod(string s1, string s2)
{
    if (s1.Length < s2.Length)
        return $"{s1} < {s2}";
    else
        return $"{s1} > {s2}";
}

f:id:kendik:20150719022136p:plain

再実行して All Green になりました。テスト対象コードが小さくなったのと仕様がテストコードに追加されたので、テストケースもずいぶんシンプルになりましたね。

コードやら画像やらでちょっと長くなったので続きは次回。