概要
つい最近正式版がリリースされた、Blazor WebAssemblyですが、
そのBlazorのコンポーネント向けのテストフレームワークの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が表示されているはずです。
追加したテストPJ側の依存関係にプロジェクトの参照追加で、テスト対象となるBlazorのプロジェクトを追加します。
不要ファイルの削除
テンプレートで作成したテストPJには、単体でも動くようにテスト用のコンポーネントが同じPJ内に含まれています。(Counter.razor)
同じコンポーネントがBlazorアプリ側の初期状態にも含まれていますので、そちらを参照するように変更します。
※下記の操作はすべて、テストPJ側の操作です。
- Counter.razorの削除
- _Imports.razorに参照の追加
@using BlazorアプリのPJ名.Pages;
- CounterCSharpTest.csに参照の追加
@usingBlazorアプリのPJ名.Pages;
テストの実行
まずは、この状態でテストが動くか確認してみましょう。
ツールバーから テスト → 全てのテストを実行 を選択します。
下記のようにテストが実行されてパスすれば成功です。
以上で、セットアップは完了です。
次からはテストの内容を確認していきます。
テストの内容確認
bUnitのテンプレートではCounterコンポーネントのテストがサンプルとして実装されています。
Counterコンポーネントは下記のような、ボタンを押下すると数値がインクリメントされる機能が実装されているコンポーネントです。
C#コードベースのテスト
早速ですが、テストコードを見ていきます。
publicclassCounterCSharpTests:TestContext{[Fact]publicvoidCounterStartsAtZero(){// Arrangevarcut=RenderComponent<Counter>();// Assert that content of the paragraph shows counter at zerocut.Find("p").MarkupMatches("<p>Current count: 0</p>");}[Fact]publicvoidClickingButtonIncrementsCounter(){// Arrangevarcut=RenderComponent<Counter>();// Act - click button to increment countercut.Find("button").Click();// Assert that the counter was incrementedcut.Find("p").MarkupMatches("<p>Current count: 1</p>");}}
見ればすぐわかる内容ですが、RenderComponentメソッドで対象のコンポーネントを描画し、
その後、UIを操作や表示内容の確認を実施しています。
サンプルでは下記の2パターンのテストが実装されています。
- 初期状態は、pタグに"Current count: 0"といった要素が記述されていること
- ボタンクリック後に、pタグに"Current count: 1"といった要素が記述されていること
2.にテストケースを追加してみます。
再度ボタンを押下した場合に、カウンタの数が2になっていることを確認することとします。
[Fact]publicvoidClickingButtonIncrementsCounter(){// Arrangevarcut=RenderComponent<Counter>();// Act - click button to increment countercut.Find("button").Click();// Assert that the counter was incrementedcut.Find("p").MarkupMatches("<p>Current count: 1</p>");// ここからテストケース追加// Act - click button to increment counter againcut.Find("button").Click();// Assert that the counter was incrementedcut.Find("p").MarkupMatches("<p>Current count: 2</p>");}
その後、テストを再度実行してパスする事を確認しましょう。
razorベースのテスト
先ほどはC#コードベースのテストを確認しましたが、次はrazorコードベースのテスト手法を確認します。
(CounterRazorTest.razor)
このファイルには2種類のテストが実装されいるので順に説明します。
スナップショットテスト
SnapshotTestタグで囲まれた要素が1つのテストケースになります。
TestInputタグ内に、テスト対象のコンポーネントを定義し、ExpectedOutputタグ内に実際に出力されるhtmlタグを記載してアサーションするといった形になります。
Setup,SetupAsyncでラムダ呼び出し可能なようなので、でDIのモックも可能なようですが、ボタン操作などはできなさそうに見えるのでどちらかというと、コンポーネントに渡すパラメータを色々と変えた場合のテスト用途として使えそうです。
<SnapshotTestDescription="Counter starts at zero"><TestInput><Counter/></TestInput><ExpectedOutput><h1>Counter</h1><p>Currentcount:0</p><buttonclass="btnbtn-primary">Click me</button>
</ExpectedOutput></SnapshotTest>// 略
Razorコンポーネントテスト
こちらは最初に紹介したC#コードベースのテストとスナップショットテストを合わせたようなテストです。
Fixtureタグが1つのテストケースとなり、ComponentUnderTestにテスト対象のタグを定義し、
Fragmentに期待されるHTMLの部分要素を定義します。
C#コードとしてはTest属性でメソッドを紐づけることでrazorタグ内の要素を操作することができます。
やっていること自体は,C#コードのサンプルと同様にボタンを1度クリック後にpタグの文言がインクリメントされているかをチェックしているだけです。
<FixtureDescription="Clicking button increments counter"Test="Test"><ComponentUnderTest><Counter></Counter></ComponentUnderTest><Fragment><p>Currentcount:1</p></Fragment></Fixture>@code{publicvoidTest(Fixturefixture){// Arrangevarcut=fixture.GetComponentUnderTest<Counter>();// Act - click button to increment countercut.Find("button").Click();// Assert that the counter was incrementedvarexpected=fixture.GetFragment();cut.Find("p").MarkupMatches(expected);}}
Fragmentタグにはidを付与することで何個も定義してケースごとに使い分けることができます。
下記は2つのFragmentを定義して、ボタンを1度押したケースとその後、再度押したケースで結果がインクリメントされていることを検証しています。
<FixtureDescription="Clicking button increments counter"Test="Test"><ComponentUnderTest><Counter></Counter></ComponentUnderTest><Fragmentid="first"><p>Currentcount:1</p></Fragment><Fragmentid="second"><p>Currentcount:2</p></Fragment></Fixture>@code{publicvoidTest(Fixturefixture){// Arrangevarcut=fixture.GetComponentUnderTest<Counter>();// Act - click button to increment countercut.Find("button").Click();// Assert that the counter was incrementedvarexpected=fixture.GetFragment("first");cut.Find("p").MarkupMatches(expected);// ここから追加(再度ボタンを押下)// Act - click button to increment counter againcut.Find("button").Click();// Assert that the counter was incrementedexpected=fixture.GetFragment("second");cut.Find("p").MarkupMatches(expected);}}
ちなみに敢えてテストケースを失敗させた場合には、下図のように表示されます。
実際の値と期待値のタグが表示されるのでわかりやすいかと思います。
コードの追加及びテストケースの追加
これまではCounterコンポーネントのテストケースを確認しましたが、次はより実践的なAPI等によるデータ取得が絡むコンポーネントのケースを考えてみます。
初期状態で作成されているFetch dataの天気読み込み部分を書き換えたうえでテストケースを追加してみます。
処理のサービス化
元のコードはコンポーネント内でHTTPクライアントを使ってサーバ側に設置されたjosnを読み込む形となっていますが、
下記のようなインターフェス及びダミーデータを返す形にサービス化します。
publicinterfaceIWeatherService{Task<IEnumerable<WeatherForecast>>GetWeatherForecastAsync();}publicclassWeatherService:IWeatherService{publicasyncTask<IEnumerable<WeatherForecast>>GetWeatherForecastAsync(){// ダミーデータを作成して返すawaitTask.Delay(1500);varforecasts=newList<WeatherForecast>();forecasts.Add(newWeatherForecast(){Date=newDateTime(2020,5,1),TemperatureC=20,Summary="Sunny"});forecasts.Add(newWeatherForecast(){Date=newDateTime(2020,5,2),TemperatureC=10,Summary="Rainy"});forecasts.Add(newWeatherForecast(){Date=newDateTime(2020,5,3),TemperatureC=14,Summary="Cloudy"});returnforecasts;}}
作成したサービスをDIできるように登録します。
publicclassProgram{publicstaticasyncTaskMain(string[]args){// 略builder.Services.AddSingleton<IWeatherService,WeatherService>();// 略}}
コンポーネント内でサービスを呼び出してデータを取得するように変更します。
@page"/fetchdata"// サービスをInject@injectIWeatherServiceWeatherService<h1>Weatherforecast</h1>// 略@code{privateWeatherForecast[]forecasts;protectedoverrideasyncTaskOnInitializedAsync(){forecasts=(awaitWeatherService.GetWeatherForecastAsync()).ToArray();}}
以上で実装は終了です。
実際に動かしてデータ取得ができていることを確認しましょう。
テストの作成
次に実装した機能に対するテストを作成していきます。
モックの作成
テスト時には実際にAPI等にアクセスせずに、任意の応答が取得できるように下記のようなモッククラスを作成します。
これを使用することで、各テストケースで任意のテストデータを設定ができます。
internalclassMockWeatherService:IWeatherService{publicTaskCompletionSource<IEnumerable<WeatherForecast>>Task{get;}=newTaskCompletionSource<IEnumerable<WeatherForecast>>();publicTask<IEnumerable<WeatherForecast>>GetWeatherForecastAsync(){returnTask.Task;}}
モックを使用して、取得データが0件の場合と3件の場合のケースを実装します。
各テストケースでモックにデータを準備して設定しています。
データの取得処理は非同期になるので、取得が完了するまで待機しないと、結果をアサーションできません。
そのための仕組みとして、WaitForStateメソッドが用意されています。
下記の例では、データの取得が完了してtableタグが描画されてるまで待っています。
publicclassFeatchDataCSharpTest:TestContext{[Fact]publicvoidZeroDataCase(){// set empty record mockvarforecasts=newList<WeatherForecast>();varmockService=newMockWeatherService();mockService.Task.SetResult(forecasts);Services.AddSingleton<IWeatherService>(mockService);varfetchData=RenderComponent<FetchData>();// wait until table renderedfetchData.WaitForState(()=>fetchData.FindAll(".table").Count>0);varexpectedHtml=@"<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]publicvoidExistsDataCase(){// set dummy record mockvarforecasts=newList<WeatherForecast>();forecasts.Add(newWeatherForecast(){Date=newDateTime(2020,5,1),TemperatureC=20,Summary="Sunny"});forecasts.Add(newWeatherForecast(){Date=newDateTime(2020,5,2),TemperatureC=10,Summary="Rainy"});forecasts.Add(newWeatherForecast(){Date=newDateTime(2020,5,3),TemperatureC=14,Summary="Cloudy"});varmockService=newMockWeatherService();mockService.Task.SetResult(forecasts);Services.AddSingleton<IWeatherService>(mockService);varfetchData=RenderComponent<FetchData>();// wait until table renderedfetchData.WaitForState(()=>fetchData.FindAll(".table").Count>0);varexpectedHtml=@"<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);}}
まとめ
簡単なサンプルパターンだけにはなりますが、Blazorのコンポーネント向けテストフレームワークのbUnitの紹介をしました。
紹介したもの以外にもJS側のモック化や様々なアサーション機能など、色々と機能が提供されいますので、
興味のある方は公式ページを参照してみください。
bUnit自体はまだbetaということで、破壊的な変更が加わる可能性等がありますが、従来のC#ロジックとは別にGUIに近いrazorコンポーネントのテスト自動化が可能なことが確認できました。
HTMLの出力内容が隠蔽されるようなUIライブラリを使う場合には使いづらいですが、自分で制御できる場合には、有用かもしれません。