C#

HttpClient のモックを理解する

とあるリポジトリに貢献しているときに、とても不思議な現象に出会った。HttpClient の SendAsync をモックしているテストコードがあった。本体の方で、EventGrid に HttpRequest を投げている箇所があり、それは単なるステータスをログに吐き出していた。リポジトリオーナーが、ログにEventGrid のリクエストの body も出力してほしいというので、そのようにしたとたんテストが一斉に落ちだした。うーむ。なんでだろ。

ちなみに該当の箇所はこんな感じ

本体

HttpResponseMessage result = await httpClient.PostAsync(this.config.EventGridTopicEndpoint, content);
var body =  await result.Content.ReadAsStringAsync(); 

テスト部

                    var mock = new Mock<HttpMessageHandler>();
                    mock.Protected()
                        .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
                        .Returns((HttpRequestMessage request, CancellationToken cancellationToken) =>
                        {
                            Assert.True(request.Headers.Any(x => x.Key == "aeg-sas-key"));
                            var values = request.Headers.GetValues("aeg-sas-key").ToList();
                            Assert.Single(values);
                            Assert.Equal(eventGridKeyValue, values[0]);
                            Assert.Equal(eventGridEndpoint, request.RequestUri.ToString());
                            var json = request.Content.ReadAsStringAsync().GetAwaiter().GetResult();
                            dynamic content = JsonConvert.DeserializeObject(json);
                            foreach (dynamic o in content)
                            {

最初に思ったのは、ReadAsStringAsync は二回読めないのでは?という推測。では試してみよう。面倒なので、Azure Fucntions でちょこっと。

    // parse query parameter
    string name = req.GetQueryNameValuePairs()
        .FirstOrDefault(q => string.Compare(q.Key, "name", true) == 0)
        .Value;
    string name2 ="";
    string data3 ="";
    string data4 = "";
    string data5 = "";
    if (name == null)
    {
        // Get request body
        dynamic data = await req.Content.ReadAsAsync<object>();
        data3 = req.Content.ReadAsStringAsync().GetAwaiter().GetResult();
        data4 = await req.Content.ReadAsStringAsync();
        dynamic data2 = await req.Content.ReadAsAsync<object>();
        var bytes  = await req.Content.ReadAsByteArrayAsync();
        data5 = System.Text.Encoding.UTF8.GetString(bytes);
        name = data?.name;
        name2 = data2?.message;
    }

    return name == null
        ? req.CreateResponse(HttpStatusCode.BadRequest, "Please pass a name on the query string or in the request body")
        : req.CreateResponse(HttpStatusCode.OK, "Hello " + name + "Message " + name2 + "content " + data3 + "content2: " + data4 + "contentbyte:" + data5);
}

実行結果

"Hello ushioMessage content {\"message\": \"hello\", \"name\": \"ushio\"}content2: {\"message\": \"hello\", \"name\": \"ushio\"}contentbyte:{\"message\": \"hello\", \"name\": \"ushio\"}"

なんの問題もなく動いている。インターネットだと、2回はできないから、Stream を使ってカウント戻すべきとか、Byteの方を使うべしとかあったけど、情報が古い、もしくは、Azure Functions の中でLoadIntoBufferAsync 等をつかっているのだろうか、わからない。ここが問題である可能性は少ない。もしくは何らかのロックとかあるのだろうか?

ちいさな Hello World を作ってみる

自分がコードを理解できていなからだと思ったので、ちいなさプログラムを作ってみた。リポジトリのオーナーのコメントもあってわかったのだけど、問題はそこではなく、Mock のクライアントの最後の戻り値で、Contentをセットしていないからと思われる。実際に、Task.FromResult(newHttpResponseMessage(HttpStatusCode.OK);でMockの戻り値を戻すと、null reference exceptionになる。なぜかわからないが、リポジトリの方では null ではなく、本体側の方でデバッグを実行すると、ロックされて次に行かないように見えていた。(もしかすると例外が起こっていた?)いずれにせよ、次の行に行かなかったで、たぶんこれだ。

using Moq;
using Moq.Protected;
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace HttpClientMockSpike
{
    class Program
    {
        public static HttpClient client; 

        public Program()
        {
            var mock = new Mock<HttpMessageHandler>();
            mock.Protected() // add using Moq.Protected;
                  .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>()) // IExpr is for Protected.
                  .Returns((HttpRequestMessage request, CancellationToken cancellation) =>
                  {
                      var json = request.Content.ReadAsStringAsync().GetAwaiter().GetResult();
                      Console.WriteLine($"Mock: Body: {json}");
                      var content = new StringContent("{\"message\":\"ok!\"}");
                      var message = new HttpResponseMessage(HttpStatusCode.OK);
                      message.Content = content;
                      return Task.FromResult(message);   // Task.FromResult(newHttpResponseMessage(HttpStatusCode.OK); cause null reference exception.
                  });

            client = new HttpClient(mock.Object);
        }
        static void Main(string[] args)
        {
            new Program().MockTesting().GetAwaiter().GetResult();
            Console.ReadLine();
        }

        public async Task MockTesting()
        {
            StringContent content = new StringContent("{\"message\": \"hello\"}", Encoding.UTF8, "application/json");
            HttpResponseMessage result = await client.PostAsync("https://abc.com", content);
            var body = await result.Content.ReadAsStringAsync();
            Console.WriteLine($"HttpClient called: {body}");
        }
    }
}

これで、こちらの方のコードは理解できた。ついでに、Moq のプロテクトメソッドでのテスト方法も理解できた。

まとめ

一見複雑なコードで問題が発生したときは、あれこれ推測してはいけない。それは時間の無駄だ。ちいさなプログラムをつくったり、デバッガをつかってりして、本当に起こっている問題は何かを先に調べるほうが結局速そう。あと、よく知っている人に相談すること。さて、このfix を入れると次の問題が起こったぞw これは解決したけど、次の頑張ります。