Web API コントローラーを直接テストするか、HTTP クライアントを介してテストするのがベスト プラクティスですか?

編集:TL;DR

各テストは異なる目的を果たすため、両方を行う必要があるという結論。

答え:

これは良い質問です。私はよく自問します。

まず、単体テストの目的と統合テストの目的を確認する必要があります。

単体テスト:

  • フィルタ、ルーティング、モデル バインディングなどはしません 仕事。

統合テスト:

  • フィルタ、ルーティング、モデル バインディングなどはします 仕事。

ベスト プラクティス 」は「価値があり、理にかなっている」と考えるべきです。

テストを書く価値はあるのか、それともテストを書くためだけにこのテストを作成しているのか?

あなたの GetGroups() としましょう メソッドは次のようになります。

[HttpGet]
[Authorize]
public async Task<ActionResult<Group>> GetGroups()
{            
    var groups  = await _repository.ListAllAsync();
    return Ok(groups);
}

単体テストを書いても意味がありません!あなたがしているのはモックのテストだからです _repository の実装 !では、そのポイントは何ですか?!メソッドにはロジックがなく、リポジトリは、モックしたものとまったく同じになるだけです。メソッドには、それ以外のことは何も示唆されていません.

リポジトリには、リポジトリ メソッドの実装をカバーする個別のユニット テストの独自のセットがあります。

では、あなたの GetGroups() としましょう メソッドは _repository の単なるラッパーではありません

[HttpGet]
[Authorize]
public async Task<ActionResult<Group>> GetGroups()
{            
   List<Group> groups;
   if (HttpContext.User.IsInRole("Admin"))
      groups = await _repository.FindByExpressionAsync(g => g.IsAdminGroup == true);
   else
      groups = await _repository.FindByExpressionAsync(g => g.IsAdminGroup == false);

    //maybe some other logic that could determine a response with a different outcome...
    
    return Ok(groups);
}

GetGroups() の単体テストを書く価値がある モックによって結果が変わる可能性があるため、メソッド HttpContext.User

[Authorize] などの属性 または [ServiceFilter(….)] しません 単体テストでトリガーされます。

.

統合テストを書くことはほとんど常に価値がある プロセスが実際のアプリケーション/システム/プロセスの一部を形成するときに、そのプロセスが何をするかをテストしたいからです。

これはアプリケーション/システムによって使用されていますか?はいの場合は、自問してください。 、結果は状況と基準の組み合わせに依存するため、統合テストを作成します。

GetGroups() でも メソッドは、最初の実装である _repository のような単なるラッパーです 実際のデータストアを指し、何もモックされていません !

したがって、テストはデータストアにデータがある (またはない) という事実をカバーするだけでなく、実際に行われている接続 HttpContext にも依存しています。 適切に設定されているかどうか、および情報のシリアル化が期待どおりに機能しているかどうか。

フィルタ、ルーティング、モデル バインディングなどはします も機能します。 GetGroups() に属性がある場合 メソッド、たとえば [Authorize] または [ServiceFilter(….)] 、それはします

テストには xUnit を使用するため、コントローラーの単体テストにはこれを使用します。

コントローラ単体テスト:

public class MyEntityControllerShould
{
    private MyEntityController InitializeController(AppDbContext appDbContext)
    {
        var _controller = new MyEntityController (null, new MyEntityRepository(appDbContext));            
        var httpContext = new DefaultHttpContext();
        var context = new ControllerContext(new ActionContext(httpContext, new RouteData(), new ActionDescriptor()));
        _controller.ControllerContext = context;
        return _controller;
    }

    [Fact]
    public async Task Get_All_MyEntity_Records()
    {
      // Arrange
      var _AppDbContext = AppDbContextMocker.GetAppDbContext(nameof(Get_All_MeetUp_Records));
      var _controller = InitializeController(_AppDbContext);
    
     //Act
     var all = await _controller.GetAllValidEntities();
     
     //Assert
     Assert.True(all.Value.Count() > 0);
    
     //clean up otherwise the other test will complain about key tracking.
     await _AppDbContext.DisposeAsync();
    }
}

単体テストに使用されるコンテキスト モッカー。

public class AppDbContextMocker
{
    /// <summary>
    /// Get an In memory version of the app db context with some seeded data
    /// </summary>
    /// <param name="dbName"></param>
    /// <returns></returns>
    public static AppDbContext GetAppDbContext(string dbName)
    {
        //set up the options to use for this dbcontext
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(dbName)                
            .Options;
        var dbContext = new AppDbContext(options);
        dbContext.SeedAppDbContext();
        return dbContext;
    }
}

シード拡張。

public static class AppDbContextExtensions
{
   public static void SeedAppDbContext(this AppDbContext appDbContext)
   {
       var myEnt = new MyEntity()
       {
          Id = 1,
          SomeValue = "ABCD",
       }
       appDbContext.MyENtities.Add(myEnt);
       //add more seed records etc....

        appDbContext.SaveChanges();
        //detach everything
        foreach (var entity in appDbContext.ChangeTracker.Entries())
        {
           entity.State = EntityState.Detached;
        }
    }
}

統合テストの場合:(これはチュートリアルのコードですが、どこで見たか覚えていません。YouTube か Pluralsight のどちらかです)

TestFixture のセットアップ

public class TestFixture<TStatup> : IDisposable
{
    /// <summary>
    /// Get the application project path where the startup assembly lives
    /// </summary>    
    string GetProjectPath(string projectRelativePath, Assembly startupAssembly)
    {
        var projectName = startupAssembly.GetName().Name;

        var applicationBaseBath = AppContext.BaseDirectory;

        var directoryInfo = new DirectoryInfo(applicationBaseBath);

        do
        {
            directoryInfo = directoryInfo.Parent;
            var projectDirectoryInfo = new DirectoryInfo(Path.Combine(directoryInfo.FullName, projectRelativePath));
            if (projectDirectoryInfo.Exists)
            {
                if (new FileInfo(Path.Combine(projectDirectoryInfo.FullName, projectName, $"{projectName}.csproj")).Exists)
                    return Path.Combine(projectDirectoryInfo.FullName, projectName);
            }
        } while (directoryInfo.Parent != null);

        throw new Exception($"Project root could not be located using application root {applicationBaseBath}");
    }

    /// <summary>
    /// The temporary test server that will be used to host the controllers
    /// </summary>
    private TestServer _server;

    /// <summary>
    /// The client used to send information to the service host server
    /// </summary>
    public HttpClient HttpClient { get; }

    public TestFixture() : this(Path.Combine(""))
    { }

    protected TestFixture(string relativeTargetProjectParentDirectory)
    {
        var startupAssembly = typeof(TStatup).GetTypeInfo().Assembly;
        var contentRoot = GetProjectPath(relativeTargetProjectParentDirectory, startupAssembly);

        var configurationBuilder = new ConfigurationBuilder()
            .SetBasePath(contentRoot)
            .AddJsonFile("appsettings.json")
            .AddJsonFile("appsettings.Development.json");


        var webHostBuilder = new WebHostBuilder()
            .UseContentRoot(contentRoot)
            .ConfigureServices(InitializeServices)
            .UseConfiguration(configurationBuilder.Build())
            .UseEnvironment("Development")
            .UseStartup(typeof(TStatup));

        //create test instance of the server
        _server = new TestServer(webHostBuilder);

        //configure client
        HttpClient = _server.CreateClient();
        HttpClient.BaseAddress = new Uri("http://localhost:5005");
        HttpClient.DefaultRequestHeaders.Accept.Clear();
        HttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

    }

    /// <summary>
    /// Initialize the services so that it matches the services used in the main API project
    /// </summary>
    protected virtual void InitializeServices(IServiceCollection services)
    {
        var startupAsembly = typeof(TStatup).GetTypeInfo().Assembly;
        var manager = new ApplicationPartManager
        {
            ApplicationParts = {
                new AssemblyPart(startupAsembly)
            },
            FeatureProviders = {
                new ControllerFeatureProvider()
            }
        };
        services.AddSingleton(manager);
    }

    /// <summary>
    /// Dispose the Client and the Server
    /// </summary>
    public void Dispose()
    {
        HttpClient.Dispose();
        _server.Dispose();
        _ctx.Dispose();
    }

    AppDbContext _ctx = null;
    public void SeedDataToContext()
    {
        if (_ctx == null)
        {
            _ctx = _server.Services.GetService<AppDbContext>();
            if (_ctx != null)
                _ctx.SeedAppDbContext();
        }
    }
}

統合テストでこのように使用します。

public class MyEntityControllerShould : IClassFixture<TestFixture<MyEntityApp.Api.Startup>>
{
    private HttpClient _HttpClient;
    private const string _BaseRequestUri = "/api/myentities";

    public MyEntityControllerShould(TestFixture<MyEntityApp.Api.Startup> fixture)
    {
        _HttpClient = fixture.HttpClient;
        fixture.SeedDataToContext();
    }

    [Fact]
    public async Task Get_GetAllValidEntities()
    {
        //arrange
        var request = _BaseRequestUri;

        //act
        var response = await _HttpClient.GetAsync(request);

        //assert
        response.EnsureSuccessStatusCode(); //if exception is not thrown all is good

        //convert the response content to expected result and test response
        var result = await ContentHelper.ContentTo<IEnumerable<MyEntities>>(response.Content);
        Assert.NotNull(result);
    }
}

追加編集: 結論として、各テストは異なる目的を果たすため、両方を行う必要があります。

他の回答を見ると、コンセンサスは両方を行うことであることがわかります。


アプリケーションが成熟するにつれて、モックに費やす労力が膨大な労力になる可能性があるという点で、私はモックを好きではありませんでした.

私は、直接 Http 呼び出しによってエンドポイントを実行するのが好きです。今日、クライアントの要求を傍受して変更できるサイプレスのような素晴らしいツールがあります。サイプレスの 1 つのテストは、ユニット、機能、統合、および E2E のすべてのタイプになる可能性があるため、この機能とブラウザ ベースの GUI による簡単な対話により、従来のテスト定義がぼやけます。

エンドポイントが防弾の場合、外部からのエラー注入は不可能になります。しかし、内部からのエラーでさえ、簡単にシミュレートできます。 Db をダウンさせて同じ Cypress テストを実行します。または、Cypress から断続的なネットワーク問題のシミュレーションを挿入します。これは、prod 環境に近い、問題を外部から嘲笑するものです。


TL;DR

「または」ではない しかし「そして」 .テストのベスト プラクティスに真剣に取り組んでいる場合は、両方のテストが必要です。

最初のテストはです 単体テスト。しかし、2 つ目は統合テストです。

統合テストの数と比較して、より多くの単体テストが必要であるという共通のコンセンサス (テスト ピラミッド) があります。でも両方必要です。

統合テストよりも単体テストを優先すべき理由はたくさんありますが、それらのほとんどは、単体テストは (あらゆる意味で) 小さく、統合テストはそうではないという事実に要約されます。ただし、主な 4 つは次のとおりです。

<オール> <リ>

地域

単体テストが失敗した場合、通常、名前だけでバグの場所を特定できます。統合テストが赤くなると、どこに問題があるのか​​すぐにはわかりません。たぶん controller.GetGroups にあります または HttpClient にあります 、またはネットワークに問題があります。

また、コードにバグを導入すると、単体テストの 1 つだけが赤くなる可能性が十分にありますが、統合テストでは、複数の単体テストが失敗する可能性が高くなります。

<リ>

安定性

ローカルボックスでテストできる小さなプロジェクトでは、おそらく気付かないでしょう。しかし、分散インフラストラクチャを使用する大規模なプロジェクトでは、常に点滅するテストが表示されます。そして、それが問題になります。ある時点で、テスト結果が信頼できなくなっていることに気付くことがあります。

<リ>

速度

テストの数が少ない小さなプロジェクトでは、それに気付かないでしょう。しかし、ビットプロジェクトでは、それが問題になります。 (ネットワーク遅延、IO 遅延、初期化、クリーンアップなど)

<リ>

シンプルさ

ご自分で気づいたはずです。

しかし、それは必ずしも真実ではありません。コードの構造が不十分であれば、統合テストを作成する方が簡単です。これが、単体テストを好むもう 1 つの理由です。ある意味で、よりモジュール化されたコードを書くことを強いられます (そして、依存性注入 については取り上げていません) ).

ただし、ベスト プラクティスにも注意してください。 ほとんどの場合、大きなプロジェクトに関するものです。プロジェクトが小規模であり、今後も小規模なままである場合は、厳密に反対の決定を下したほうがよい可能性が高くなります。

もっとテストを書いてください。 (繰り返しますが、それは両方を意味します)。テストを書くのが上手になります。後で削除してください。

練習は完璧です。