みかづきメモ

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

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

ここ数ヶ月くらい、ずっと VRChat 向けの Roslyn Analyzer を作って得られた知見を放出していく系私です。
今回は、ユニットテストのお話。

ちなみに宣伝しておくと、作った Analyzer はこれで、 Unity + VRChat Udon 向けに作られています。

もし興味がある人は見てみるとどこか役立つ部分があるかもしれません。

github.com


ということで、 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 コードを使えば、上記と同じ事が出来ます。
が、今回の場合、カスタムしたものを使っているので、自前で実装しています。

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