なつねこメモ

主にプログラミング関連のメモ帳 ♪(✿╹ヮ╹)ノ 書いてあるコードは自己責任でご自由にどうぞ。記事本文の無断転載は禁止です。

Roslyn Analyzer で良い感じにテストしたい

ここ数ヶ月くらい、ずっと VRChat 向けの Roslyn Analyzer を作って得られた知見を放出していく系私です。
今回は、ユニットテストのお話。
Roslyn Analyzer のテストは、多くの場合、ソースコードをインプットとして、どの Diagnostic が、どういった場所に、どのようなメッセージで報告されるのか、についてテストを行います。
そのときに個人的に面倒だと思うのが、「どの場所に」レポートが報告されるのかです。

例えば、以下のような入力コードがあった場合。

using UdonSharp;

namespace UdonRabbit
{
    public class TestBehaviour : UdonSharpBehaviour
    {
        private int? _i;

        private bool? TestMethod(bool? b)
        {
            return null;
        }
    }
}

この状態で、 WithSpan(10, 34, 10, 39) みたいに期待値を書かれても、いったいどこだといった感じになります。 私はなりました。 また、コード上と内部情報とメッセージとで、なんか +1 されてたりそのままだったりしてわけわからん、ってなるので、テストを修正しようにもちょっとわからん、ってなります。 ということで、私は以下のようにテストコードを入力するようにしました。

using UdonSharp;

namespace UdonRabbit
{
    public class TestBehaviour : UdonSharpBehaviour
    {
        private [|int?|] _i;

        private [|bool?|] TestMethod([|bool?|] b)
        {
            return null;
        }
    }
}

診断レポートが表示されるべき場所を、 [|...|] で囲い、テスト用のプロジェクト実行時に該当部分を WithSpan で渡すようにします。 また、このままでは C# の文法的に Valid ではないので、[||] についても取り除きます。

上で貼ったリポジトリの場合には、以下のようにして Analyzer のテストを書くことが出来ます。

[Fact]
public async Task UdonSharpBehaviourNullableTypeHasDiagnosticsReport()
{
    var diagnostic = ExpectDiagnostic(NotSupportNullableTypes.ComponentId)
        .WithSeverity(DiagnosticSeverity.Error);

    const string source = @"
using UdonSharp;

namespace UdonRabbit
{
    public class TestBehaviour : UdonSharpBehaviour
    {
        private [|int?|] _i;

        private [|bool?|] TestMethod([|bool?|] b)
        {
            return null;
        }
    }
}
";

    await VerifyAnalyzerAsync(source, Enumerable.Repeat(3).Select(_ => diagnostic));
}

VerifyAnalyzerAsync の内部では、以下のような処理を行っています。

protected async Task VerifyAnalyzerAsync(string source, param DiagnosticResult[] expected)
{
    var testProject = new TestProject(...); // テスト用 Unity プロジェクトの生成

    ParseInputSource(testProject, source, expected);

    await testProject.RunAsync(CancellationToken.None); // 各種 Assertion
}

private void ParseInputSource(TestProject testProject, string source, DiagnosticResult[] expected)
{
    var sb = new StringBuilder();
    var diagnostics = expected.ToList();

    var line = 1;
    var column = 1;
    var expectedLine = 0;
    var expectedColumn = 0;
    var isReading = false;
    var i = 0;

    using var sr = new StringReader(source);
    while (sr.Peek() > -1)
    {
        var c = sr.Read();
        switch (c)
        {
            case '\n':
                sb.Append((char) c);
                line++;
                column = 1;
                break;

            case '[' when sr.Peek() == '|':
                sr.Read();

                expectedLine = line;
                expectedColumn = column;
                isReading = true;
                break;

            case '|' when isReading && sr.Peek() == ']':
                sr.Read();

                diagnostics[i] = diagnostics[i].WithSpan(expectedLine, expectedColumn, line, column);
                i++;
                isReading = false;
                break;

            default:
                sb.Append((char) c);
                column++;
                break;
        }
    }

    testProject.ExpectedDiagnostics.AddRange(diagnostics);
    testProject.SourceCode = sb.ToString();
}

あとは、通常通り、位置が正しいかどうかを Assert するコードを書いてあげれば OK です。 個人的には、これでどこにレポートが報告されるべきか、書くのも見るのもわかりやすくなったかな、と思っています。 ちなみに、おとなしく自動生成された VerifyCS コードを使えば、上記と同じ事が出来ます。 が、今回の場合、カスタムしたものを使っているので、自前で実装しています。

ということで、メモでした。