Help us understand the problem. What is going on with this article?

ASP.NET Core / ASP.NET Web API 2 Owin で Web API の自動テスト環境を整える

概要

C# でバックエンド開発を行う場合、近年では、やはりWeb APIによる開発事例が多いかと思います。
本記事では、C# における Web API 開発フレームワークの代表格である ASP.NET Core、及び ASP.NET API 2 Owin を題材とし、Azure DevOpsを使って自動テスト環境を整えるまでの流れを紹介します。

お品書き

  1. ユニットテストを実装する
  2. Azure DevOpsでビルドパイプラインを作成する
  3. 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 からクラス単位にカテゴリを付与出来るようになっています。

TodoServiceTest.cs
    [TestClass]
    [TestCategory("Todo"), TestCategory("Logic")]
    public class TodoServiceTest
    {
        // ・・・一部省略

前述した通りですが、テストエクスプローラーでカテゴリに従い、表示項目をフィルタリングすることが出来ます。
テストケース数が膨大になった場合は、有効な機能になるため活用しましょう。
image.png

テストパターンが類似しているテストケースについては、パラメタライズドテストを検討すると良いです。
MsTest v2 はパラメタライズドテストに対応しています。実装例を以下に示します。
インプットデータや期待値を纏めてパラメータとすることで、データパターンを網羅したテストを効率よく実装出来ます。

TodoServiceTest.cs
        [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)のような形で設定します。

InMemoryUserRepositoryTests.cs
    [Trait("Category", "Logic")]
    public class InMemoryUserRepositoryTests
    {
        // ・・・一部省略

テストメソッドは Fact アノテーション で実装します。

InMemoryUserRepositoryTests.cs
        [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 とほぼ同じように記載が可能です。

InMemoryUserRepositoryTests.cs
        [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 があるため、これを利用すると、いい感じにテストの実装が出来ます。
image.png

以下、ASP.NET Web API 2 Owin の実装例です。

TodoControllerTest.cs
        [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 を継承したクラスを準備します。(尚、サンプルソースでは初期データの投入も行っています。)

IntegrationTestWebApplicationFactory.cs
    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リクエストを実行するような感じです。

UsersControllerTest.cs
        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 のプロジェクトをサンプルに作成しています。

image.png

ポイントとしては、テストの実行・カバレッジの取得 (Test and output coverage) と レポートの出力 (Generate coverage report)です。

カバレッジの取得には OpenCover を使用します。Nugetより取得しましょう。

image.png

OpenCover より MsTest をコマンドラインで実行し、カバレッジの取得を行います。
テスト対象となる DLL の指定や、カバレッジ取得対象とする 名前空間 をコマンドラインのパラメータで指定します。
また、カバレッジの取得は デバッグビルド で実行する必要があるため注意が必要です。

image.png

以下、設定内容の詳細 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 に追加しましょう。

image.png

上記拡張を追加すると、ReportGenerator のタスクを作成出来るようになります。

image.png

steps:
- task: Palmmedia.reportgenerator.reportgenerator-build-release-task.reportgenerator@4
  displayName: 'Generate coverage report'

パイプラインの実行後、Code Coverage のタブが追加され、カバレッジレポートが HTML で閲覧出来るようになります。
Tests のタブと合わせて確認することで、効率的にテスト結果を可視化することが出来ます。

image.png

次に、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へ通知が送付されます。

image.png

しかし、上記通知内容からではテスト成功件数、失敗件数といった詳細な結果を知ることが出来ません。
勿論、メッセージのリンクから 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以外の別のツールを用いた場合は適宜調整が必要なため、ご注意ください。

image.png

また、同様に 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
}

image.png

まとめ

後半、C# というよりも Azure DevOps の記事が中心になってしまいました。。。
C#でモダンな継続的開発を行う場合は、ユニットテストの実装とAzure DevOpsの運用がポイントになってくると思います。
本記事が、少しでも皆様の参考情報となれば幸いです。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした