Ist es Best Practice, meine Web-API-Controller direkt oder über einen HTTP-Client zu testen?

Ist es Best Practice, meine Web-API-Controller direkt oder über einen HTTP-Client zu testen?

Bearbeiten:TL;DR

Abschließend sollten Sie beides tun, da jeder Test einem anderen Zweck dient.

Antwort:

Das ist eine gute Frage, die ich mir oft stelle.

Zunächst müssen Sie sich mit dem Zweck eines Komponententests und dem Zweck eines Integrationstests befassen.

Einheitentest :

  • Dinge wie Filter, Routing und Modellbindung werden nicht Arbeit.

Integrationstest :

  • Dinge wie Filter, Routing und Modellbindung werden Arbeit.

Best Practice “ sollte als „Hat Wert und Sinn“ verstanden werden.

Sie sollten sich fragen:Hat es einen Wert, den Test zu schreiben, oder erstelle ich diesen Test nur, um einen Test zu schreiben?

Nehmen wir an, Ihr GetGroups() Methode sieht so aus.

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

Es macht keinen Sinn, dafür einen Unit-Test zu schreiben! denn Sie testen einen mocked Implementierung von _repository ! Also, was soll das?! Die Methode hat keine Logik und das Repository wird nur genau das sein, was Sie verspottet haben, nichts in der Methode deutet auf etwas anderes hin.

Das Repository verfügt über einen eigenen Satz separater Komponententests, in denen Sie die Implementierung der Repository-Methoden abdecken.

Sagen wir jetzt Ihre GetGroups() -Methode ist mehr als nur ein Wrapper für _repository und hat eine gewisse Logik darin.

[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);
}

Jetzt ist es sinnvoll, einen Komponententest für GetGroups() zu schreiben Methode, da sich das Ergebnis je nach mocked ändern kann HttpContext.User Wert.

Attribute wie [Authorize] oder [ServiceFilter(….)] nicht in einem Unit-Test ausgelöst werden.

.

Das Schreiben von Integrationstests lohnt sich fast immer weil Sie testen möchten, was der Prozess tun wird, wenn er Teil einer tatsächlichen Anwendung/eines Systems/Prozesses ist.

Fragen Sie sich, wird dies von der Anwendung/dem System verwendet? Wenn ja , einen Integrationstest schreiben, da das Ergebnis von einer Kombination aus Umständen und Kriterien abhängt.

Jetzt auch wenn Ihr GetGroups() Methode ist nur ein Wrapper wie in der ersten Implementierung, dem _repository zeigt auf einen tatsächlichen Datenspeicher, nichts wird verspottet !

Der Test deckt jetzt also nicht nur die Tatsache ab, ob der Datenspeicher Daten enthält (oder nicht), sondern beruht auch darauf, dass eine tatsächliche Verbindung hergestellt wird, HttpContext ordnungsgemäß eingerichtet ist und ob die Serialisierung der Informationen wie erwartet funktioniert.

Dinge wie Filter, Routing und Modellbindung werden funktioniert auch. Wenn Sie also ein Attribut auf Ihrem GetGroups() hatten Methode, zum Beispiel [Authorize] oder [ServiceFilter(….)] , es wird wie erwartet ausgelöst werden.

Ich verwende xUnit zum Testen, also verwende ich dies für einen Komponententest auf einem Controller.

Controller-Einheitentest:

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();
    }
}

Der Kontext-Mocker, der für Komponententests verwendet wird.

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;
    }
}

Die Seed-Erweiterung.

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;
        }
    }
}

und für Integrationstests:(dies ist Code aus einem Tutorial, aber ich kann mich nicht erinnern, wo ich ihn gesehen habe, entweder auf YouTube oder Pluralsight)

Setup für die 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();
        }
    }
}

und verwenden Sie es so im Integrationstest.

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);
    }
}

Hinzugefügte Bearbeitung: Zusammenfassend sollten Sie beides tun, da jeder Test einem anderen Zweck dient.

Wenn Sie sich die anderen Antworten ansehen, werden Sie feststellen, dass der Konsens darin besteht, beides zu tun.


Ich habe es noch nie gemocht, mich zu verspotten, da der Aufwand, der für das Verspotten aufgewendet wird, eine Menge Aufwand bedeuten kann, wenn Anwendungen ausgereift sind.

Ich mag es, Endpunkte durch direkte HTTP-Anrufe zu trainieren. Heute gibt es fantastische Tools wie Cypress, mit denen Kundenanfragen abgefangen und geändert werden können. Die Leistungsfähigkeit dieser Funktion zusammen mit der einfachen browserbasierten GUI-Interaktion verwischt traditionelle Testdefinitionen, da ein Test in Cypress alle diese Typen Unit, Functional, Integration und E2E umfassen kann.

Wenn ein Endpunkt kugelsicher ist, wird eine Fehlerinjektion von außen unmöglich. Aber auch Fehler von innen lassen sich leicht simulieren. Führen Sie die gleichen Cypress-Tests mit Db down aus. Oder fügen Sie eine intermittierende Netzwerkproblemsimulation von Cypress ein. Dies verspottet Probleme extern, was eher einer Produktionsumgebung entspricht.


TL;DR

Nicht "oder" sondern "und" . Wenn Sie es mit Best Practices beim Testen ernst meinen, brauchen Sie beide Tests.

Der erste Test ist ein Unit-Test. Aber der zweite ist ein Integrationstest.

Es besteht ein allgemeiner Konsens (Testpyramide), dass Sie im Vergleich zur Anzahl der Integrationstests mehr Unit-Tests benötigen. Aber Sie brauchen beides.

Es gibt viele Gründe, warum Sie Unit-Tests Integrationstests vorziehen sollten, die meisten davon laufen darauf hinaus, dass Unit-Tests (in jeder Hinsicht) klein sind und Integrationstests nicht. Aber die wichtigsten 4 sind:

  1. Ort

    Wenn Ihr Komponententest fehlschlägt, können Sie normalerweise nur anhand seines Namens herausfinden, wo sich der Fehler befindet. Wenn der Integrationstest rot wird, können Sie nicht sofort sagen, wo das Problem liegt. Vielleicht ist es in controller.GetGroups oder es ist im HttpClient , oder es liegt ein Problem mit dem Netzwerk vor.

    Auch wenn Sie einen Fehler in Ihren Code einführen, ist es durchaus möglich, dass nur einer der Komponententests rot wird, während bei Integrationstests die Wahrscheinlichkeit größer ist, dass mehr als einer von ihnen fehlschlägt.

  2. Stabilität

    Bei einem kleinen Projekt, das Sie auf Ihrer lokalen Box testen können, werden Sie es wahrscheinlich nicht bemerken. Aber bei einem großen Projekt mit verteilter Infrastruktur werden Sie ständig blinkende Tests sehen. Und das wird zum Problem. Irgendwann kann es vorkommen, dass Sie Testergebnissen nicht mehr vertrauen.

  3. Geschwindigkeit

    Bei einem kleinen Projekt mit wenigen Tests merkt man das nicht. Aber bei einem kleinen Projekt wird es zum Problem. (Netzwerkverzögerungen, E/A-Verzögerungen, Initialisierung, Bereinigung usw. usw.)

  4. Einfachheit

    Sie haben es selbst bemerkt.

    Aber das stimmt nicht immer. Wenn Ihr Code schlecht strukturiert ist, ist es einfacher, Integrationstests zu schreiben. Und das ist ein weiterer Grund, warum Sie Unit-Tests bevorzugen sollten. In gewisser Weise zwingen sie Sie dazu, mehr modularen Code zu schreiben (und ich spreche nicht von Dependency Injection). ).

Aber denken Sie auch an die Best Practices geht es fast immer um große Projekte. Wenn Ihr Projekt klein ist und klein bleiben wird, besteht eine große Chance, dass Sie mit genau entgegengesetzten Entscheidungen besser dran sind.

Schreibe mehr Tests. (Das bedeutet wiederum - beides). Beim Schreiben von Tests besser werden. Löschen Sie sie später.

Übung macht den Meister.