LoginSignup
22
19

More than 3 years have passed since last update.

Blazorコンポーネント向けのテストフレームワークのbUnitを使ってみる

Posted at

概要

つい最近正式版がリリースされた、Blazor WebAssemblyですが、
そのBlazorのコンポーネント向けのテストフレームワークのbUnitを試した際のメモです。

bUnit公式
サンプルコード

前提

  • Windows 10 (64bit)
  • Visual Studio 2019 16.6
  • .NET Core SDK (3.1.300)

※今回はBlazor WebAssemblyを使用しています。
(Server-sideは未検証ですが恐らく動くと思います。)

手順

Blazorプロジェクトの作成

Visual Studioもしくはdotnetコマンドから、Blazor WebAssemblyの新規プロジェクトを作成してください。

bUnit用のテンプレートをインストール

下記のコマンドでテンプレートをインストールします。
バージョンは2020/5/22時点で1.0.0-beta-7ですが、下記を参照して適宜最新バージョン等をインストールしてください。
https://www.nuget.org/packages/bunit.template/

dotnet new --install bunit.template::1.0.0-beta-7

なお、上記のテンプレートを使用せずに、既存のNUnitやMSTest用のプロジェクトにも導入可能なようです。
詳細は公式HPの下記を参照ください。
https://bunit.egilhansen.com/docs/create-test-project.html?tabs=xunit#create-a-test-project-with-bunit-template

プロジェクトの生成

現時点ではVisual StudioからGUIでプロジェクトテンプレートとして選択ができないようなので、
最初に作成したBlazorのプロジェクトにカレントディレクトリを移動して、下記のコマンドを実行します。

dotnet new bunit -o <テストPJ名>

テストPJ名は任意ですが、よく見かけるUnitTestの例ですと、
<テスト対象のPJ名.Test>
とする事が多いかと思います。

プロジェクトの参照

BlazorのプロジェクトをVisual Studioから立ち上げて、
ソリューションエクスプローラから 追加 → 既存のプロジェクト で作成したテストPJを追加します。
追加すれば、下記のようにBlazorのPJとUnitTest用のPJが表示されているはずです。

tpj.PNG

追加したテストPJ側の依存関係にプロジェクトの参照追加で、テスト対象となるBlazorのプロジェクトを追加します。

tp2j.PNG

不要ファイルの削除

テンプレートで作成したテストPJには、単体でも動くようにテスト用のコンポーネントが同じPJ内に含まれています。(Counter.razor)
同じコンポーネントがBlazorアプリ側の初期状態にも含まれていますので、そちらを参照するように変更します。
※下記の操作はすべて、テストPJ側の操作です。

  • Counter.razorの削除
  • _Imports.razorに参照の追加
  @using BlazorアプリのPJ名.Pages;
  • CounterCSharpTest.csに参照の追加
  @using BlazorアプリのPJ.Pages;

テストの実行

まずは、この状態でテストが動くか確認してみましょう。
ツールバーから テスト → 全てのテストを実行 を選択します。
下記のようにテストが実行されてパスすれば成功です。

test_res.PNG

以上で、セットアップは完了です。
次からはテストの内容を確認していきます。

テストの内容確認

bUnitのテンプレートではCounterコンポーネントのテストがサンプルとして実装されています。
Counterコンポーネントは下記のような、ボタンを押下すると数値がインクリメントされる機能が実装されているコンポーネントです。

co.gif

C#コードベースのテスト

早速ですが、テストコードを見ていきます。

CounterCSharpTest.cs

    public class CounterCSharpTests : TestContext
    {
        [Fact]
        public void CounterStartsAtZero()
        {
            // Arrange
            var cut = RenderComponent<Counter>();

            // Assert that content of the paragraph shows counter at zero
            cut.Find("p").MarkupMatches("<p>Current count: 0</p>"); 
        }

        [Fact]
        public void ClickingButtonIncrementsCounter()
        {
            // Arrange
            var cut = RenderComponent<Counter>();

            // Act - click button to increment counter
            cut.Find("button").Click();

            // Assert that the counter was incremented
            cut.Find("p").MarkupMatches("<p>Current count: 1</p>");
        }
    }

見ればすぐわかる内容ですが、RenderComponentメソッドで対象のコンポーネントを描画し、
その後、UIを操作や表示内容の確認を実施しています。
サンプルでは下記の2パターンのテストが実装されています。

  1. 初期状態は、pタグに"Current count: 0"といった要素が記述されていること
  2. ボタンクリック後に、pタグに"Current count: 1"といった要素が記述されていること

2.にテストケースを追加してみます。
再度ボタンを押下した場合に、カウンタの数が2になっていることを確認することとします。


        [Fact]
        public void ClickingButtonIncrementsCounter()
        {
            // Arrange
            var cut = RenderComponent<Counter>();

            // Act - click button to increment counter
            cut.Find("button").Click();

            // Assert that the counter was incremented
            cut.Find("p").MarkupMatches("<p>Current count: 1</p>");

            // ここからテストケース追加
            // Act - click button to increment counter again
            cut.Find("button").Click();

            // Assert that the counter was incremented
            cut.Find("p").MarkupMatches("<p>Current count: 2</p>");
        }

その後、テストを再度実行してパスする事を確認しましょう。

razorベースのテスト

先ほどはC#コードベースのテストを確認しましたが、次はrazorコードベースのテスト手法を確認します。
(CounterRazorTest.razor)
このファイルには2種類のテストが実装されいるので順に説明します。

スナップショットテスト

SnapshotTestタグで囲まれた要素が1つのテストケースになります。
TestInputタグ内に、テスト対象のコンポーネントを定義し、ExpectedOutputタグ内に実際に出力されるhtmlタグを記載してアサーションするといった形になります。
Setup,SetupAsyncでラムダ呼び出し可能なようなので、でDIのモックも可能なようですが、ボタン操作などはできなさそうに見えるのでどちらかというと、コンポーネントに渡すパラメータを色々と変えた場合のテスト用途として使えそうです。

CounterRazorTest.razor

<SnapshotTest Description="Counter starts at zero">
    <TestInput>
        <Counter />
    </TestInput>
    <ExpectedOutput>
        <h1>Counter</h1>
        <p>Current count: 0</p>
        <button class="btn btn-primary">Click me</button>
    </ExpectedOutput>
</SnapshotTest>

// 略

Razorコンポーネントテスト

こちらは最初に紹介したC#コードベースのテストとスナップショットテストを合わせたようなテストです。
Fixtureタグが1つのテストケースとなり、ComponentUnderTestにテスト対象のタグを定義し、
Fragmentに期待されるHTMLの部分要素を定義します。

C#コードとしてはTest属性でメソッドを紐づけることでrazorタグ内の要素を操作することができます。
やっていること自体は,C#コードのサンプルと同様にボタンを1度クリック後にpタグの文言がインクリメントされているかをチェックしているだけです。


<Fixture Description="Clicking button increments counter" Test="Test">
    <ComponentUnderTest>
        <Counter></Counter>
    </ComponentUnderTest>
    <Fragment>
        <p>Current count: 1</p>
    </Fragment>
</Fixture>

@code
{
    public void Test(Fixture fixture)
    {
        // Arrange
        var cut = fixture.GetComponentUnderTest<Counter>();

        // Act - click button to increment counter
        cut.Find("button").Click();

        // Assert that the counter was incremented
        var expected = fixture.GetFragment();
        cut.Find("p").MarkupMatches(expected);
    }
}

Fragmentタグにはidを付与することで何個も定義してケースごとに使い分けることができます。
下記は2つのFragmentを定義して、ボタンを1度押したケースとその後、再度押したケースで結果がインクリメントされていることを検証しています。


<Fixture Description="Clicking button increments counter" Test="Test">
    <ComponentUnderTest>
        <Counter></Counter>
    </ComponentUnderTest>
    <Fragment id="first">
        <p>Current count: 1</p>
    </Fragment>
    <Fragment id="second">
        <p>Current count: 2</p>
    </Fragment>
</Fixture>

@code
{
    public void Test(Fixture fixture)
    {
        // Arrange
        var cut = fixture.GetComponentUnderTest<Counter>();

        // Act - click button to increment counter
        cut.Find("button").Click();

        // Assert that the counter was incremented
        var expected = fixture.GetFragment("first");
        cut.Find("p").MarkupMatches(expected);

        // ここから追加(再度ボタンを押下)
        // Act - click button to increment counter again
        cut.Find("button").Click();

        // Assert that the counter was incremented
        expected = fixture.GetFragment("second");
        cut.Find("p").MarkupMatches(expected);
    }
}

ちなみに敢えてテストケースを失敗させた場合には、下図のように表示されます。
実際の値と期待値のタグが表示されるのでわかりやすいかと思います。

res.PNG

コードの追加及びテストケースの追加

これまではCounterコンポーネントのテストケースを確認しましたが、次はより実践的なAPI等によるデータ取得が絡むコンポーネントのケースを考えてみます。

初期状態で作成されているFetch dataの天気読み込み部分を書き換えたうえでテストケースを追加してみます。

wea.gif

処理のサービス化

元のコードはコンポーネント内でHTTPクライアントを使ってサーバ側に設置されたjosnを読み込む形となっていますが、
下記のようなインターフェス及びダミーデータを返す形にサービス化します。

WeatherService.cs

    public interface IWeatherService
    {
        Task<IEnumerable<WeatherForecast>> GetWeatherForecastAsync();
    }
    public class WeatherService : IWeatherService
    {
        public async Task<IEnumerable<WeatherForecast>> GetWeatherForecastAsync()
        {
            // ダミーデータを作成して返す
            await Task.Delay(1500);
            var forecasts = new List<WeatherForecast>();
            forecasts.Add(new WeatherForecast() { Date = new DateTime(2020, 5, 1), TemperatureC = 20, Summary = "Sunny" });
            forecasts.Add(new WeatherForecast() { Date = new DateTime(2020, 5, 2), TemperatureC = 10, Summary = "Rainy" });
            forecasts.Add(new WeatherForecast() { Date = new DateTime(2020, 5, 3), TemperatureC = 14, Summary = "Cloudy" });
            return forecasts;
        }
    }

作成したサービスをDIできるように登録します。

Program.cs

    public class Program
    {
        public static async Task Main(string[] args)
        {
            // 略
            builder.Services.AddSingleton<IWeatherService, WeatherService>();
            // 略
        }
    }

コンポーネント内でサービスを呼び出してデータを取得するように変更します。

FeatchData.razor

@page "/fetchdata"
// サービスをInject
@inject IWeatherService WeatherService

<h1>Weather forecast</h1>

// 略

@code {
    private WeatherForecast[] forecasts;

    protected override async Task OnInitializedAsync()
    {
        forecasts = (await WeatherService.GetWeatherForecastAsync()).ToArray();
    }
}

以上で実装は終了です。
実際に動かしてデータ取得ができていることを確認しましょう。

テストの作成

次に実装した機能に対するテストを作成していきます。

モックの作成

テスト時には実際にAPI等にアクセスせずに、任意の応答が取得できるように下記のようなモッククラスを作成します。
これを使用することで、各テストケースで任意のテストデータを設定ができます。


    internal class MockWeatherService : IWeatherService
    {
        public TaskCompletionSource<IEnumerable<WeatherForecast>> Task { get; } = new TaskCompletionSource<IEnumerable<WeatherForecast>>();

        public Task<IEnumerable<WeatherForecast>> GetWeatherForecastAsync()
        {
            return Task.Task;
        }
    }

モックを使用して、取得データが0件の場合と3件の場合のケースを実装します。
各テストケースでモックにデータを準備して設定しています。

データの取得処理は非同期になるので、取得が完了するまで待機しないと、結果をアサーションできません。
そのための仕組みとして、WaitForStateメソッドが用意されています。
下記の例では、データの取得が完了してtableタグが描画されてるまで待っています。


    public class FeatchDataCSharpTest : TestContext
    {
        [Fact]
        public void ZeroDataCase()
        {
            // set empty record mock
            var forecasts = new List<WeatherForecast>();
            var mockService = new MockWeatherService();
            mockService.Task.SetResult(forecasts);

            Services.AddSingleton<IWeatherService>(mockService);

            var fetchData = RenderComponent<FetchData>();
            // wait until table rendered
            fetchData.WaitForState(() => fetchData.FindAll(".table").Count > 0);

            var expectedHtml = @"<table class=""table"">
                                    <thead>
                                        <tr>
                                            <th>Date</th>
                                            <th>Temp. (C) </th>
                                            <th>Temp. (F) </th>
                                            <th>Summary </th>
                                        </tr>
                                    </thead>
                                <tbody>
                                </tbody>";
            fetchData.Find("table").MarkupMatches(expectedHtml);
        }

        [Fact]
        public void ExistsDataCase()
        {
            // set dummy record mock
            var forecasts = new List<WeatherForecast>();
            forecasts.Add(new WeatherForecast() { Date = new DateTime(2020, 5, 1), TemperatureC = 20, Summary = "Sunny" });
            forecasts.Add(new WeatherForecast() { Date = new DateTime(2020, 5, 2), TemperatureC = 10, Summary = "Rainy" });
            forecasts.Add(new WeatherForecast() { Date = new DateTime(2020, 5, 3), TemperatureC = 14, Summary = "Cloudy" });

            var mockService = new MockWeatherService();
            mockService.Task.SetResult(forecasts);

            Services.AddSingleton<IWeatherService>(mockService);

            var fetchData = RenderComponent<FetchData>();
            // wait until table rendered
            fetchData.WaitForState(() => fetchData.FindAll(".table").Count > 0);

            var expectedHtml = @"<table class=""table"">
                                    <thead>
                                        <tr>
                                            <th>Date</th>
                                            <th>Temp. (C) </th>
                                            <th>Temp. (F) </th>
                                            <th>Summary </th>
                                        </tr>
                                    </thead>
                                    <tbody>
                                    <tr>
                                        <td>2020/05/01</td>
                                        <td>20</td>
                                        <td>67</td>
                                        <td>Sunny</td>
                                    </tr>
                                    <tr>
                                        <td>2020/05/02</td>
                                        <td>10</td>
                                        <td>49</td>
                                        <td>Rainy</td>
                                    </tr>
                                    <tr>
                                        <td>2020/05/03</td>
                                        <td>14</td>
                                        <td>57</td>
                                        <td>Cloudy</td>
                                    </tr>
                            </tbody>";
            fetchData.Find("table").MarkupMatches(expectedHtml);
        }
    }

テストを流してパスする事を確認します。
ast2.PNG

まとめ

簡単なサンプルパターンだけにはなりますが、Blazorのコンポーネント向けテストフレームワークのbUnitの紹介をしました。
紹介したもの以外にもJS側のモック化や様々なアサーション機能など、色々と機能が提供されいますので、
興味のある方は公式ページを参照してみください。

bUnit自体はまだbetaということで、破壊的な変更が加わる可能性等がありますが、従来のC#ロジックとは別にGUIに近いrazorコンポーネントのテスト自動化が可能なことが確認できました。
HTMLの出力内容が隠蔽されるようなUIライブラリを使う場合には使いづらいですが、自分で制御できる場合には、有用かもしれません。

22
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
19