きなこもち.net

.NET Framework × UiPath,Orchestrator × Azure × AWS × Angularなどの忘備録

外部WEB API呼び出し用のService× Unit Test × 実装~テストまでの流れを考えてみた(模索中)

Purpose

外部のWEB APIを呼び出すServiceに対するUnit Testを作成するときの指針について考える。
また、使いまわしができそうなUnit Testのテストテンプレートを作る。

前提

ソースコード

Github

テストフレームワーク

XUnitTest
Moq

テスト対象のWeb API

Weather

テスト対象のサービスの実装方針

  • Weatherの説明では、Responsesとして6つ定義されている。そのため、6つのResponseに対応できるようにする。
  • 200:OKが返ってきた場合、ResponseのBody部をObjectにDesirializeしたものを呼び出し元に返す。
  • 400:Bad Requestが返ってきた場合。これは送信したリクエストパラメータが不正である場合であるため、ログにリクエストパラメータと、レスポンス情報を出力する。呼び出し元には、空のインスタンスを返す。=情報取得できたが、結果が0件の場合と同じ挙動。
  • 401:Unauthorizedが返ってきた場合。これは、Web APIを実行するためのトークンの認証が切れている可能性があるため、ErrorLogを出力する。ただ、失敗時の後処理などが続く可能性もあるため、例外をスローすることはせず、呼び出し元には、空のインスタンスを返す。
  • 403:Forbiddenが返ってきた場合。401と同様の対処を行う。
  • 404: Not Foundが返ってきた場合。検索結果が0件ということなので、呼び出し元には空のインスタンスを返す。
  • 500: Internal Server Errorが返ってきた場合。Error Logに記録を行う。401の理由と同じで、呼び出し元には空のインスタンスを返却する。
  • まとめると、200:OK以外の場合は、ログにResponse情報を出力し、空のインスタンスを返す。
  • このPublicメソッド内では、Exceptionを発生させないようにする。

テスト方針

概要

LSST DM Developer Guideを参考に、UnitTestについて考える。

実装の流れ

こうしたほうが効率化良いのではないかと思う流れのメモ。
1. Interfaceの定義。(今回の場合、IAzureMapsClientServiceクラスとIFを定義) 2. 実装クラスの定義。この時、中身は実装しない。 3. 実装方針(実際には、設計書があるはず・・・)からブラックボックステストを実装する。 4. 実装方針に従い、実際の処理を実装。 5. Step3. で定義したブラックボックステストがすべて成功することを確認する。 6. カバレッジ分析を行い、ブランチカバレッジが100%になっていることを確認。足りていないケースがある場合は、追加でホワイトボックステストを実装する。 7. ホワイトボックステストがすべて通り、ブランチカバレッジが100%になっていることを確認する。 8. Done

ブラックボックステスト

方針

UnitTestでは、実際のHTTP Requestを送信せずに済むように、Moqを使い、httpClientのモックを使って行う。実際にリクエストを飛ばすわけではないため、入力パラメータは、Web APIのモックで設定するResponse情報とする。もちろん、テスト対象のメソッドの引数によっても挙動が変わることもあるため、それも入力パラメータとして考える。

呼び出し元   service    WEB API
|---------->|           |  ブラックボックステスト:入力1
|           |---------->|  ブラックボックステスト:出力1
|           |<----------| ブラックボックステスト:入力2
|<----------|           | ブラックボックステスト:出力2

このテスト対象のサービス.メソッドへ入力するものは、テストケースの入力値として、
逆に出力されるものは、テストケースの検証項目として利用するという方針。

テストケース1

入力パラメータ:期待するURLが構築できるかのテスト

※必要に応じて入力値と期待値の組み合わせを増やす。 ※テスト実行のため、とりあえず200:OKが返るように設定する。 入力1: query string = string.emptty. 入力2: any

期待する出力1:URL:WebAPIに送信するHttpRequestのURL 期待する出力2:any

実装イメージ

public void GetWeatherInfo_StatusCode200_ReturnSuccess()
{
    #region Arange
    var loggerStub = new Mock<ILogger<AzureMaps>>();
    var logger = loggerStub.Object;
    var iOptionsStub = new AzureMaps();
    var iOptions = Options.Create<AzureMaps>(iOptionsStub);
    //The best way to create a mock HTTPClient instance is by mocking HttpMessageHandler. 
    //Mocking of HttpMessageHandler will also ensure the client calling actual endpoints are faked by intercepting it.
    var httpMessageHandlerStub = this.GetHttpResponseMessageStub<CurrentConditionsModel>(new CurrentConditionsModel()
    {
        results = new Result[] {
        (new Result()
        {
            dateTime=DateTime.Now,
            cloudCover="cloudCover",
        })
    }
    }, HttpStatusCode.OK);
    var httpClientStub = new HttpClient(httpMessageHandlerStub.Object);
    var httpClientFactoryStub = new Mock<IHttpClientFactory>();
    var actualHttpRequestUrl = string.Empty;
    httpClientFactoryStub.Setup(m => m.CreateClient(It.IsAny<string>()))
        .Callback<string>(url => actualHttpRequestUrl = url)
        .Returns(httpClientStub);
    var httpClientFactory = httpClientFactoryStub.Object;
    #endregion

    #region Action
    var targetService = new AzureMapsClientService(logger, iOptions, httpClientFactory);
    var actual = targetService.WeatherGetCurrentConditions(string.Empty);
    #endregion
    
    #region Assert
    var expectedURL="https://atlas.microsoft.com/weather/currentConditions/json?api-version=1.0&subscription-key=プライマリーキー(Configに設定された値)";
    Assert.Equal(expectedURL, actualHttpRequestUrl);
    #endregion
}

テストケース2

入力パラメータ:200:OKが返ってきたとき、呼び出し元に期待通りに結果を返せること

入力1: query string = any 入力2: 200:OK.

期待する出力1: any 期待する出力2:Mockで設定したWebAPIから返される検索結果

実装イメージ

public void GetWeatherInfo_StatusCode200_ReturnSuccess()
{
    #region Arange
    var loggerStub = new Mock<ILogger<AzureMaps>>();
    var logger = loggerStub.Object;
    var iOptionsStub = new AzureMaps();
    var iOptions = Options.Create<AzureMaps>(iOptionsStub);
    //The best way to create a mock HTTPClient instance is by mocking HttpMessageHandler. 
    //Mocking of HttpMessageHandler will also ensure the client calling actual endpoints are faked by intercepting it.
    var httpMessageHandlerStub = this.GetHttpResponseMessageStub<CurrentConditionsModel>(new CurrentConditionsModel()
    {
        results = new Result[] {
        (new Result()
        {
            dateTime=DateTime.Now,
            cloudCover="cloudCover",
        })
    }
    }, HttpStatusCode.OK);
    var httpClientStub = new HttpClient(httpMessageHandlerStub.Object);
    var httpClientFactoryStub = new Mock<IHttpClientFactory>();
    var actualHttpRequestUrl = string.Empty;
    httpClientFactoryStub.Setup(m => m.CreateClient(It.IsAny<string>()))
        .Callback<string>(url => actualHttpRequestUrl = url)
        .Returns(httpClientStub);
    var httpClientFactory = httpClientFactoryStub.Object;
    #endregion

    #region Action
    var targetService = new AzureMapsClientService(logger, iOptions, httpClientFactory);
    var actual = targetService.WeatherGetCurrentConditions(string.Empty);
    #endregion
    
    #region Assert
    var expectedURL="https://atlas.microsoft.com/weather/currentConditions/json?api-version=1.0&subscription-key=プライマリーキー(Configに設定された値)";
    var expectedItem = "cloudCover";
    Assert.Equal(expectedItem, actual.results.Single().cloudCover);
    Assert.Equal(expectedURL, actualHttpRequestUrl);
    #endregion
}

テストケース3-7

入力パラメータ:200:OK以外が返ってきたとき、呼び出し元に期待通りに結果を返せること

入力1: query string = any 入力2: 200:OK以外の5つ.

期待する出力1: any 期待する出力2:空のインスタンス

ホワイトボックステスト

方針

ブラックボックスでカバーできなかった条件分岐を網羅できるように、実装後のコードを見ながら設定を加える。今回の場合、HttpRequestを実行する処理で例外が発生したときの条件がブラックボックスで検証できていないため、それを追加する。

テストケース8

HttpRequest送信で失敗したとき、呼び出し元に空のインスタンスを返せること。

入力1: query string = any 入力2: 200:OK以外の5つ.

期待する出力1: any 期待する出力2:空のインスタンス

さらに、HttpClientのMockで、Exceptionをスローするように設定する。

実装イメージ

[Fact]
public void GetWeatherInfo_ThrowException_ReturnEmptyInstance()
{
    #region Arange
    var loggerStub = new Mock<ILogger<AzureMaps>>();
    var logger = loggerStub.Object;
    var iOptionsStub = new AzureMaps();
    var iOptions = Options.Create<AzureMaps>(iOptionsStub);
    var httpClientFactoryStub = new Mock<IHttpClientFactory>();
    var actualHttpRequestUrl = string.Empty;
    httpClientFactoryStub.Setup(m => m.CreateClient(It.IsAny<string>()))
        .Callback<string>(url => actualHttpRequestUrl = url)
        .Throws(new Exception("Test Case8"));
    var httpClientFactory = httpClientFactoryStub.Object;
    #endregion

    #region Action
    var targetService = new AzureMapsClientService(logger, iOptions, httpClientFactory);
    var actual = targetService.WeatherGetCurrentConditions(string.Empty);
    #endregion

    #region Assert
    Assert.Null( actual.results);
    #endregion
}

まとめ

UnitTestのテストケースのだしかたと、実装方法がまだしっくりこない。
今回考えた方法もまだまとまり切っていない感がある。もっと効率の良い方法を模索しなくては・・・