15
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Azure Functions C# での Unit Testing のポイント (1)

Last updated at Posted at 2018-09-01

私はまだまだ C# を初めて歴が浅いので、特にテストのはまだまだうまく書けない。Java や Ruby を使っていた時に TDD 野郎だった自分からするとこれはストレスなので、学んだことから、C#の UnitTesting についてまとめていこうと思う。まだまだこれで正解とも思っていないので、よりよい方法があれば是非コメントをお願いいたします。

StrikesArchitecture.jpg

Unit Testing の基本方針

上記の図がヒントなのですが、Hexagonal Architectureで考えていくと、Unit Testの戦略が整理しやすいです。その前に、よいユニットテストの条件を書いておきます。

よいユニットテストの条件

  • リグレッションエラーを高確率で見つけ出す
  • False Positive を生み出す可能性が低い
  • 素早いフィードバック
  • メンテナンスコストが低い

False Positive というのは、本当は、問題があるのに、「正しい」と報告されることです。確かに Mock とかしまくっていたらいかにも起こりそうです。良いテストスイートの条件は次の通り

  • よいユニットテストの条件の最初の3つのバランスをとる
  • シンプルで理解しやすい

ユニットテストの種類

ユニットテストの種類には3種類あります。

  • Output Verfication
  • State Verfication
  • Collaboration Valification

アウトプットを検査する種類のもの。これは、ステートレスです。次にステートの状態が変わるのを検査するもの。最後に、システムやオブジェクト間のコラボレーションを検査するものです。ユニットテストは、できるだけ、最初のものに寄せるとよいです。これは関数型のテクニックも使えて、イミュータブルなので、テストが簡単になります。その次にステート、その次にコラボレーションになるにつれて複雑になっていくので、できるだけ簡単に書けるように倒しましょう。

インターフェイスデザインの条件

実装するにあたり、クラスや関数のインターフェイスの良しあしをチェックする。

1つのゴールに対して、あなたがいくつのオペレーションをする必要があるかを計算する。

  • 1より上 -> 実装が露出している可能性が高い
  • 1 -> よいデザインの API

ここではどういうことが言いたいか?というと、たとえば

var model = body.Value;
var isValid = model.Validate();

if (isValid) {
   :

みたいなコードは一つのゴール(ここでは、model を検証する)に対して、複数のステップを必要としています。ところが、もしこれが


if(body.IsValid()) {
  :

で済むとしてら、ゴールに対して一つの操作で完了しています。これが良いAPIの例です。先の例では、内部の実装が外に露出しています。

何を Mock するべきか?

最初に上げたHexagonal architecture をテスト観点で見直してみます。この図の中心には、ドメインオブジェクトがあり、その外側に、Application Layer というのがあり、そこが、外部のシステムと接続しています。

StrikesArchitecture.jpg

ここで、テストのアーキテクチャを考えるときの基本的な考え方があります。それは、Domainは、ApplicationLayerとしか接しないし、外のシステムは、Application Layer としか接しないということです。私が外部接続が多いシステムのユニットテストを書くと、モックやスタブだらけになるのが悩みでした。これをどう解決するかをこの図が助けてくれます。

ドメインロジックをできるだけ活用する

Mockをする箇所としない箇所を明確にします。まず、ドメインの部分はモックは必要ありません。どこに必要かというと、Application Layer つまり、Domain の部分をコーディネイトする部分もしくは、外部との接続の部分です。つまり、Application Layer か、外部接続のところにしか、Mock は必要ありません。
どのロジックが本来、どのレイヤーに属するのかを常に意識しましょう。

だから、どうするかというと、あるロジックがあって、Mock箇所と、モデルに押し込むべきコードが混在していると、とっても理解しにくいコードになります。ドメインロジックは、モデルに寄せて、モック無しでテストを書けるようにして、コントローラや、外部との接点のみモックを活用します。これは、テストコードだけの考慮ではなく、本体側のコードもそうなるようにするとテストが書きやすくなります。CosmosDBにデータを登録するときに、バリデーションをしたいとします。すると、普通のコードはこんな感じでしょうか。 それぞれ、このレイヤーに置くべきものか書いてみましょう。


[FunctionName("CreatePackage")]
public static async Task<IActionResult> CreatePackage(
  [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "package")]HttpRequest req,
  [CosmosDB(DATABASE_NAME, COLLECTION_NAME, ConnectionStringSetting = "CosmosDBConnection")] IAsyncCollector<Package> packages,
  ILogger log)
{
    var content = req.ReadAsString(); // Application Layer (Controller)
    var model = JsonConvert.DeserializeObject<Package>(content);  // Domain
    var results = new List<ValidationResult>();  // Domain
    var isValid = Validator.TryValidateObject(model, new ValidationContext(model, null, null), results, true); // Domain
    if (isValid) {  // Domain
      model.Id = Guid.NewGuid();  // Domain
      await packages.AddAsync(model);  // Application Layer (外部インターフェイス)
      return new CreatedResult($"package/{model.Id}", model); // Application Layer (Controller)
    } else {
      return new BadRequestObjectResult(JsonConvert.SerializeObject(body.results));  // Application Layer (Controller)
    }
}

これは、Azure Functions 等のサーバーレスアーキテクチャの特有の問題かもしれませんが、バインディングがあり、ちょろちょろっと本体にコードが書けてしまいます。そしてそれで動きます。このコードを全体をモックしてテストするとなると、相当ややこしいことになります。ところが、Model は、モックなしのユニットテストで書けます。ここでいうと//Domain というので書いたところのロジックは、ここに書くのではなくモデルに書いてしまいましょう。するとどうなるかというと、ずいぶんシンプルになりましたね。後はパラメータは、外部システムとの境界ですので、そこをモックします。

        [FunctionName("CreatePackage")]
        public static async Task<IActionResult> CreatePackage(
        [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "package")]HttpRequest req,
        [CosmosDB(DATABASE_NAME, COLLECTION_NAME, ConnectionStringSetting = "CosmosDBConnection")] IAsyncCollector<Package> packages,
        ILogger log)
        {
            var body = await req.GetBodyAsync<Package>();
            
            if (body.IsValid)
            {
                var model = body.Value;
                model.GenerateId();
                await packages.AddAsync(model);             
                return new CreatedResult($"package/{model.Id}", model);
            }
            else
            {
                return new BadRequestObjectResult(JsonConvert.SerializeObject(body.ValidationResults));
            }
        }

外部境界のギリギリをモックする

Mockする場所、特に外部システムの境界はどこをモックすればいいでしょうか?例えば、HttpClient とかを使っているとすると、HttpClient を使っているクラスにインターフェイスを持たせて、そのクラスをモックする方法があります。それだと、インターフェイスだらけになってしまいます。どうするのがよいかというと、最も外部の境界に近い部分をモックするというのが教科書的には正解です。HttpClient なら、HttpClient そのもの、をモックして、使っているメソッドをモックします。Extension Method でそれが無理ならその内部で使っているメソッドを見て Mock するのが良いでしょう。そうすると、False positive を最小にすることができます。

Application Layer は、コーディネーション、コントローラ、そして、Domain は、POCO の集まりです。

よくないモジュールの条件

  • 実装の詳細が外に漏れている
  • ドメインの情報やロジックが外に漏れている
  • コードが汚染されている
  • 単一責務の法則が破られている
  • テストが Non-determinism である

最後のものは、テストを実行するたびに結果が変わるということです。たとえば、日付によって依存してしまう、Guid を生成するとかだとそうなります。そいうときはどうしたらいいでしょう?

Fixture Object パターン

これは私がネットで見つけたのですが、Fixture Object パターンというのがあります。テストを書くときに、Fixture オブジェクトというクラスを作って、セットアップで使うオブジェクトや、Expected のオブジェクト、そして、その生成ロジックを中に閉じ込めます。

これは、サンプルではないので、若干複雑ですが、ポイントとしては、Mockのセットアップも内部でやっていますし、例えば、期待値みたいなものもメソッドでとれるようになっていますし、

_collectorMock.Setup(c => c.AddAsync(It.IsAny<Package>(), It.IsAny<CancellationToken>())).Returns(Task.CompletedTask)
                    .Callback<Package, CancellationToken>((p, c) =>
                    {
                        _expected = p;
                    }); 

みたいにして、渡ってきた値を期待値として渡しています。この渡ってくるmodel は、Id を Guid で生成しているので、普通だと、アサーションするのが難しいですが、こういう風にすると、生成されたGuid をもったオブジェクトを、FixtureObuject にステートとしてもたせているので、簡単に扱えます。

       private class ParameterFixture
        {
            private Mock<HttpRequest> _requestMock;
            private Mock<ILogger> _loggerMock;
            private Mock<IAsyncCollector<Package>> _collectorMock;

            public HttpRequest Request => _requestMock.Object;

            public ILogger Logger => _loggerMock.Object;

            public IAsyncCollector<Package> Collector => _collectorMock.Object;

            public ParameterFixture()
            {
                _requestMock = new Mock<HttpRequest>();
                _loggerMock = new Mock<ILogger>();
                _collectorMock = new Mock<IAsyncCollector<Package>>();
            }

            private Package _input;
            private Package _expected;

            public Package Input => _input;
            public Package Expected => _expected;

            private Stream _stream;

            public void SetUpCreated(Func<Package> createPackage)
            {
                var _input = createPackage();
                // Setup HttpRequest 
                var document = JsonConvert.SerializeObject(_input);
                _stream = GenerateStreamFromString(document); // try not to use using. 
            
                // ReadAsStreamAsync is extention method. we can't mock it. 
                _requestMock.Setup(r => r.Body).Returns(_stream);
                _collectorMock.Setup(c => c.AddAsync(It.IsAny<Package>(), It.IsAny<CancellationToken>())).Returns(Task.CompletedTask)
                    .Callback<Package, CancellationToken>((p, c) =>
                    {
                        _expected = p;
                    }); 
            }

            public void Cleanup()
            {
                _stream.Close();
            }

            private static Stream GenerateStreamFromString(string s)
            {
                var stream = new MemoryStream();
                var writer = new StreamWriter(stream);
                writer.Write(s);
                writer.Flush();
                stream.Position = 0;
                return stream;
            }

            public void VerifyCreated()
            {
                _collectorMock.Verify(p => p.AddAsync(_expected, It.IsAny<CancellationToken>()));
            }

            internal Package CreatePackageSuccess()
            {
                return new Package()
                {
                    Name = "hello"
                };
            }

            internal Package CreatePackageFail()
            {
                return new Package()
                {
                    // No Name (required)
                    ProjectPage = "abc" // this should be URL
                };
            }

        }


    }

ちなみに、本体はこんな感じ。テストコードがシンプルで意図が読み取りやすくなっていると思います。テスト用のExtension メソッドも一部作成してテストコードを書きやすくしています。

        [Fact]
        public async Task Create_package_success()
        {
            var fixture = new ParameterFixture();
            fixture.SetUpCreated(fixture.CreatePackageSuccess);

            var result = await StrikesRepository.CreatePackage(fixture.Request, fixture.Collector, fixture.Logger);
            Assert.Equal("CreatedResult", result.GetTypeName());

            fixture.VerifyCreated();
            var createdResult = (CreatedResult) result;
            Assert.Equal($"package/{fixture.Expected.Id}", (string)createdResult.Location);
            Assert.Equal(fixture.Expected.Id, ((Package)createdResult.Value).Id);
            fixture.Cleanup(); // Only in case you use Stream. 
        }

まとめ

今日は第一弾として学んだテストのテクニックを書いています。この多くのテクニックは、下記の Pluralshight のコースで学んだものです。Azure Functions 用ではありませんが、大変勉強になりました。次はコーディングしていくにあたって発生した問題と対処について書いてみたいと思います。

15
15
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
15
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?