概要
C# でバックエンド開発を行う場合、近年では、やはりWeb APIによる開発事例が多いかと思います。
本記事では、C# における Web API 開発フレームワークの代表格である ASP.NET Core、及び ASP.NET API 2 Owin を題材とし、Azure DevOpsを使って自動テスト環境を整えるまでの流れを紹介します。
お品書き
- ユニットテストを実装する
- Azure DevOpsでビルドパイプラインを作成する
- Azure DevOpsのテスト結果をSlackへ通知する
環境
- Windows10
- Visual Studio 2019
- ASP.NET Core
- ASP.NET Web API 2 Owin
- Azure DevOps
サンプルコード
サンプルコードを Github にアップしています。
一部、今回の記事と無関係な実装も含まれていますが、ご了承ください。
ASP.NET Core
https://github.com/tYoshiyuki/dotnet-core-mediatr-sample
ASP.NET Web API 2 Owin
https://github.com/tYoshiyuki/dotnet-owin-webapi-sample
ユニットテストを実装する
ASP.NET で Web API を作成した場合、APIのエンドポイントとなる 「コントローラ」 と、業務ロジックを担当する 「ロジック」 のレイヤーを分ける事が多いかと思います。
今回は、上記レイヤー分けに従って、ユニットテストを MsTest 及び xUnit を使って実装していきます。
テストのカテゴリ分けについて
C# のテストフレームワークでユニットテストを実装する場合、テストの内容や種別によってカテゴリ分けをすることをお勧めします。
カテゴリ分けをすることにより、Visual Studio のテストエクスプローラーで表示をフィルタリングしたり、テスト結果の分析を行うことが出来るようになります。ユニットテストの総ケース数が増えてきた場合でも、柔軟なテスト運用が可能となります。テストケース総数を勘案した上で、カテゴリ分けを設計すると良いかと思います。
以下、カテゴリ分けの例になります。
-
権限の名称
- 一般ユーザ, 管理ユーザ など
-
テスト種別名
- ロジックのテスト, Web APIサーバを用いたインテグレーションテスト など
-
処理時間
- 通常のテスト, 処理時間の長いテスト
-
業務ドメインの名称
1. ロジックテストを実装する
MsTest
まずは、ASP.NET Web API 2 Owin / MsTest の実装例です。
テストカテゴリは、アノテーションで指定します。MsTest v1 ではメソッド単位にしかカテゴリを付与できませんでしたが、
MsTest v2 からクラス単位にカテゴリを付与出来るようになっています。
[TestClass]
[TestCategory("Todo"), TestCategory("Logic")]
public class TodoServiceTest
{
// ・・・一部省略
前述した通りですが、テストエクスプローラーでカテゴリに従い、表示項目をフィルタリングすることが出来ます。
テストケース数が膨大になった場合は、有効な機能になるため活用しましょう。
テストパターンが類似しているテストケースについては、パラメタライズドテストを検討すると良いです。
MsTest v2 はパラメタライズドテストに対応しています。実装例を以下に示します。
インプットデータや期待値を纏めてパラメータとすることで、データパターンを網羅したテストを効率よく実装出来ます。
[DataTestMethod]
[DynamicData(nameof(TestData), DynamicDataSourceType.Method)]
public void Update_正常系(Todo todo)
{
// Arrange
_mock.Setup(_ => _.Get())
.Returns(_data);
_service = new TodoService(_mock.Object);
// Act
_service.Update(todo);
// Assert
var expect = _service.Get(todo.Id);
Assert.AreEqual(todo.Id, expect.Id);
Assert.AreEqual(todo.Description, expect.Description);
}
public static IEnumerable<object[]> TestData()
{
yield return new object[] { new Todo { Id = 1, Description = "Test 991", CreatedDate = DateTime.Now } };
yield return new object[] { new Todo { Id = 2, Description = "Test 992", CreatedDate = DateTime.Now } };
yield return new object[] { new Todo { Id = 3, Description = "Test 993", CreatedDate = DateTime.Now } };
}
上記は、DynamicData のパターンを紹介していますが、それ以外にも DataRow を利用したり、
データソースをカスタマイズしたりと様々な例がありますので、興味のある方は公式ドキュメントを確認することをお勧めします。
https://github.com/Microsoft/testfx-docs
xUnit
次は、ASP.NET Core / xUnit の実装例です。
テストカテゴリは、Traitアノテーションで指定します。MsTestと異なり、キー(name)・バリュー(value)のような形で設定します。
[Trait("Category", "Logic")]
public class InMemoryUserRepositoryTests
{
// ・・・一部省略
テストメソッドは Fact アノテーション で実装します。
[Fact]
public void FindAll()
{
// Arrange
PrepareUsers();
var expect = _users;
// Act
var result = _userRepository.FindAll().ToList();
// Assert
result.Count.Is(3);
foreach (var user in expect)
{
var target = result.First(_ => _.UserId.Equals(user.UserId));
target.UserId.Is(user.UserId);
target.UserName.Is(user.UserName);
target.FullName.Is(user.FullName);
}
}
続いて、パラメタライズドテストの実装例です。パラメタライズドテストには Theory アノテーションを使用します。
MsTest v2 とほぼ同じように記載が可能です。
[Theory]
[MemberData(nameof(TestData))]
public void RemoveTest(User user)
{
// Arrange
PrepareUsers();
var expect = user;
// Act
_userRepository.Remove(expect);
// Assert
_userRepository.Find(expect.UserId).IsNull();
}
public static IEnumerable<object[]> TestData()
{
yield return new object[] { new User(new UserId("1"), new UserName("Taro"), new FullName("Taro", "Yamada")) };
yield return new object[] { new User(new UserId("2"), new UserName("Jiro"), new FullName("Jiro", "Suzuki")) };
yield return new object[] { new User(new UserId("3"), new UserName("Saburo"), new FullName("Saburo", "Tanaka"))};
}
インテグレーションテストを実装する
続いて、コントローラから一気通貫でテストを行うインテグレーションテストを実装します。
Web API を呼び出すためには HTTPサーバ が必要になりますが、.NETのユニットテストでは専用の TestServer があるため、これを利用すると、いい感じにテストの実装が出来ます。
以下、ASP.NET Web API 2 Owin の実装例です。
[ClassInitialize]
public static void Setup(TestContext context)
{
Server = TestServer.Create<Startup>();
HttpClient = Server.HttpClient;
}
[TestMethod]
public async Task Get_正常系()
{
// Arrange・Act
var response = await HttpClient.GetAsync(_url);
// Assert
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadAsAsync<List<Todo>>();
Assert.IsTrue(result.Any());
}
ASP.NET Core の場合は、WebApplicationFactoryを利用します。
WebApplicationFactory を継承したクラスを準備します。(尚、サンプルソースでは初期データの投入も行っています。)
public class IntegrationTestWebApplicationFactory<TStartup>
: WebApplicationFactory<TStartup> where TStartup : class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureServices(services =>
{
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IUserRepository>();
repository.Save(new User(new UserId("1"), new UserName("Taro"), new FullName("Tanaka", "Tanaka")));
repository.Save(new User(new UserId("2"), new UserName("Jiro"), new FullName("Suzuki", "Suzuki")));
repository.Save(new User(new UserId("3"), new UserName("Saburo"), new FullName("Sato", "Sato")));
});
}
}
次に、WebApplicationFactory をユニットテストで利用した場合の実装例です。
WebApplicationFactory を継承したクラスを、コンストラクタインジェクションで受け取り、そこから HTTP Client を取得し、HTTPリクエストを実行するような感じです。
public UsersControllerTest(IntegrationTestWebApplicationFactory<Startup> webApplicationFactory)
{
_client = webApplicationFactory.CreateClient();
}
[Fact]
public async Task Get()
{
// Arrange
const string url = "/api/users/1";
// Act
var response = await _client.GetAsync(url);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<UserViewModel>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
Assert.Equal("1", result.Id);
Assert.Equal("Tanaka", result.FirstName);
Assert.Equal("Taro", result.UserName);
}
Azure DevOpsでビルドパイプラインを作成する
続いて、Azure DevOpsでビルドパイプラインを作成します。パイプラインの作成方法は、旧形式では GUI で作成する必要がありましたが、現在では YAML での作成が可能となっています。今回は、ビルドパイプラインでユニットテストの実行とカバレッジレポートを取得する方法を紹介します。
まずは、GUIでの作成例です。ASP.NET Web API 2 Owin のプロジェクトをサンプルに作成しています。
ポイントとしては、テストの実行・カバレッジの取得 (Test and output coverage) と レポートの出力 (Generate coverage report)です。
カバレッジの取得には OpenCover を使用します。Nugetより取得しましょう。
OpenCover より MsTest をコマンドラインで実行し、カバレッジの取得を行います。
テスト対象となる DLL の指定や、カバレッジ取得対象とする 名前空間 をコマンドラインのパラメータで指定します。
また、カバレッジの取得は デバッグビルド で実行する必要があるため注意が必要です。
以下、設定内容の詳細 YAML になります。
steps:
- script: |
%OPEN_COVER_PATH% -register -target:%MSTEST_PATH% -targetargs:%TARGET_FILE_AND_ARGS% -targetdir:%TARGET_DIR% -filter:%FILTER% -output:%OUTPUT_FILE%
displayName: 'Test and output coverage'
env:
MSTEST_PATH: "C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe"
TARGET_DIR: ".\DotNetOwinWebApiSample.Api.Test\bin\Debug"
TARGET_FILE_AND_ARGS: "DotNetOwinWebApiSample.Api.Test.dll /Logger:trx;LogFileName=DotNetOwinWebApiSample.Api.Test.trx"
FILTER: "+[DotNetOwinWebApiSample*]* -[*.Test.*]*"
OUTPUT_FILE: "coverage.xml"
OPEN_COVER_PATH: ".\packages\OpenCover.4.7.922\tools\OpenCover.Console.exe"
OpenCover の設定 (特にfilter) に関しては、若干クセがあるので、公式のドキュメントを読んでおくと良いです。
https://github.com/opencover/opencover/wiki/Usage
続いてカバレッジレポートの取得です。
ReportGenerator を Visual Studio Marketplace から取得し、Azure DevOps に追加しましょう。
上記拡張を追加すると、ReportGenerator のタスクを作成出来るようになります。
steps:
- task: Palmmedia.reportgenerator.reportgenerator-build-release-task.reportgenerator@4
displayName: 'Generate coverage report'
パイプラインの実行後、Code Coverage のタブが追加され、カバレッジレポートが HTML で閲覧出来るようになります。
Tests のタブと合わせて確認することで、効率的にテスト結果を可視化することが出来ます。
次に、YAMLでの作成例です。
ASP.NET Core のプロジェクトをサンプルに実装します。
trigger:
- master
pool:
vmImage: 'windows-latest'
variables:
buildConfiguration: 'Debug'
steps:
- script: dotnet restore
displayName: 'dotnet restore'
- script: dotnet build --configuration $(buildConfiguration)
displayName: 'dotnet build $(buildConfiguration)'
- task: DotNetCoreCLI@2
inputs:
command: test
projects: '*.Test/*.Test.csproj'
arguments: -c $(BuildConfiguration) --collect:"XPlat Code Coverage" -- RunConfiguration.DisableAppDomain=true
displayName: Run Tests
- task: DotNetCoreCLI@2
inputs:
command: custom
custom: tool
arguments: install --tool-path . dotnet-reportgenerator-globaltool
displayName: Install ReportGenerator tool
- script: reportgenerator -reports:$(Agent.TempDirectory)/**/coverage.cobertura.xml -targetdir:$(Build.SourcesDirectory)/coverlet/reports -reporttypes:"Cobertura"
displayName: Create reports
- task: PublishCodeCoverageResults@1
displayName: 'Publish code coverage'
inputs:
codeCoverageTool: Cobertura
summaryFileLocation: $(Build.SourcesDirectory)/coverlet/reports/Cobertura.xml
- task: DotNetCoreCLI@2
displayName: 'dotnet publish $(buildConfiguration)'
inputs:
command: publish
publishWebProjects: True
arguments: '--configuration $(BuildConfiguration) --output $(Build.ArtifactStagingDirectory)'
zipAfterPublish: True
- task: PublishBuildArtifacts@1
displayName: 'publish artifacts'
ASP.NET Core の場合、dotnet test のコマンドでカバレッジの取得が可能です。
また、reportgeneratorもコマンドでインストール出来るため、ASP.NET Web API 2 Owinに比べてシンプルにパイプラインが作成出来ます。
.NET Core のパイプラインに関しては、公式にもドキュメントがあります。導入の際には、合わせて確認いただくと良いかと思います。
https://docs.microsoft.com/en-us/azure/devops/pipelines/ecosystems/dotnet-core?view=azure-devops
Azure DevOpsのテスト結果をSlackへ通知する
最後に、Azure DevOps と Slack 連携し、ビルドパイプラインによるテスト結果を通知してみます。
Azure DevOps と Slack は専用の連携アプリがあるため、これを利用します。
詳細な手順は公式のドキュメントがあるため、本記事では割愛します。
https://docs.microsoft.com/en-us/azure/devops/pipelines/integrations/slack?view=azure-devops
設定が完了すると、画像の通りにビルド完了時にSlackへ通知が送付されます。
しかし、上記通知内容からではテスト成功件数、失敗件数といった詳細な結果を知ることが出来ません。
勿論、メッセージのリンクから Azure DevOps へ遷移する事は可能ですが、今回は通知内容を Incoming Webhooks を利用してカスタマイズしてみます。
上記、ASP.NET Web API 2 Owinのパイプラインを用いて、Incoming Webhooksの送信を作成します。
PowerShellを使って、ビルドパイプライン内でSlackへの通知を行いましょう。
# ---------------------------------------------------
# テスト実行結果のメッセージを構築します
# ---------------------------------------------------
function CreateTestResultMessage($file) {
$filename = $file.name
$xml = [XML](Get-Content $file)
$start = $xml.TestRun.Times.start
$finish = $xml.TestRun.Times.finish
$executed = $xml.TestRun.ResultSummary.Counters.executed
$passed = $xml.TestRun.ResultSummary.Counters.passed
$failed = $xml.TestRun.ResultSummary.Counters.failed
return "テスト[" + $filename + "]の実行結果だよー" + `
"`n" + "> 開始時刻: " + ([DateTime]$start).ToString("yyyy/MM/dd HH:mm:ss") + `
"`n" + "> 終了時刻: " + ([DateTime]$finish).ToString("yyyy/MM/dd HH:mm:ss") + `
"`n" + "> 実行件数: " + $executed + `
"`n" + "> 成功件数: " + $passed + `
"`n" + "> 失敗件数: " + $failed
}
# ---------------------------------------------------
# カバレッジ取得結果のメッセージを構築します
# ---------------------------------------------------
function CreateCoverageMessage($file) {
$filename = $file.name
$xml = [XML](Get-Content $file)
$sequenceCoverage = $XML.CoverageSession.Summary.sequenceCoverage
$branchCoverage = $XML.CoverageSession.Summary.branchCoverage
$numSequencePoints = $XML.CoverageSession.Summary.numSequencePoints
$visitedSequencePoints = $XML.CoverageSession.Summary.visitedSequencePoints
$numBranchPoints = $XML.CoverageSession.Summary.numBranchPoints
$visitedBranchPoints = $XML.CoverageSession.Summary.visitedBranchPoints
return "テストカバレッジ[" + $filename + "]の実行結果だよー" + `
"`n" + "> Sequence Coverage: " + $sequenceCoverage + "% " + "(" + $visitedSequencePoints + "/" + $numSequencePoints + ")" + `
"`n" + "> Branch Coverage: " + $branchCoverage + "% " + "(" + $visitedBranchPoints + "/" + $numBranchPoints + ")"
}
# ---------------------------------------------------
# Slackへ通知します
# ---------------------------------------------------
function PostSlack($message) {
$encode = [System.Text.Encoding]::GetEncoding('ISO-8859-1')
$utf8Bytes = [System.Text.Encoding]::UTF8.GetBytes($message)
$notificationPayload = @{
text = $encode.GetString($utf8Bytes);
username = "Azure DevOps Test Report";
icon_url = "https://4.bp.blogspot.com/-CtY5GzX0imo/VCIixcXx6PI/AAAAAAAAmfY/AzH9OmbuHZQ/s800/animal_penguin.png"
}
$postUri = "xxx" # Incoming WebhooksのエンドポイントURLを設定します
Invoke-RestMethod -Method POST -Uri $postUri -Body (ConvertTo-Json $notificationPayload) -ContentType application/json
}
# テスト実行結果の送信
$files = Get-ChildItem -Recurse -File -Include *.trx
Foreach ($file in $files) {
$message = CreateTestResultMessage($file)
PostSlack($message)
}
# カバレッジ取得結果の送信
$files = Get-ChildItem -Recurse -File -Include coverage.xml
Foreach ($file in $files) {
$message = CreateCoverageMessage($file)
PostSlack($message)
}
ポイントとして、テスト実行結果は .trx ファイル、カバレッジ取得結果は coverage.xml にそれぞれ存在するため、
XMLパーサーを使用して値の読み取りを行っています。カバレッジ取得結果は、利用したツールによってフォーマットが異なっており、OpenCover以外の別のツールを用いた場合は適宜調整が必要なため、ご注意ください。
また、同様に Microsoft Teams に対してもメッセージの送信が可能です。
# ---------------------------------------------------
# Teamsへ通知します
# ---------------------------------------------------
function PostTeams($message) {
$encode = [System.Text.Encoding]::GetEncoding('ISO-8859-1')
$utf8Bytes = [System.Text.Encoding]::UTF8.GetBytes($message)
$notificationPayload = @{
text = $encode.GetString($utf8Bytes);
}
$postUri = 'xxx' # Incoming WebhooksのエンドポイントURLを設定します
Invoke-RestMethod -Method POST -Uri $postUri -Body (ConvertTo-Json $notificationPayload) -ContentType application/json
}
まとめ
後半、C# というよりも Azure DevOps の記事が中心になってしまいました。。。
C#でモダンな継続的開発を行う場合は、ユニットテストの実装とAzure DevOpsの運用がポイントになってくると思います。
本記事が、少しでも皆様の参考情報となれば幸いです。