È consigliabile testare i controller dell'API Web direttamente o tramite un client HTTP?

È consigliabile testare i controller dell'API Web direttamente o tramite un client HTTP?

Modifica:TL;DR

La conclusione dovresti fare entrambe le cose perché ogni test ha uno scopo diverso.

Risposta:

Questa è una buona domanda, una che mi pongo spesso.

Innanzitutto, devi considerare lo scopo di un test unitario e lo scopo di un test di integrazione.

Test unitario:

  • Cose come filtri, routing e associazione del modello non lo faranno lavoro.

Test di integrazione:

  • Cose come filtri, instradamento e associazione del modello lo faranno lavoro.

"Best practice ” dovrebbe essere pensato come “Ha valore e ha senso”.

Dovresti chiederti C'è qualche valore nello scrivere il test o sto solo creando questo test per il gusto di scrivere un test?

Diciamo il tuo GetGroups() il metodo è simile a questo.

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

Non c'è alcun valore nello scrivere uno unit test per questo! perché quello che stai facendo è testare un deriso implementazione di _repository ! Allora, qual è il punto?! Il metodo non ha logica e il repository sarà solo esattamente quello che hai deriso, niente nel metodo suggerisce il contrario.

Il repository avrà una propria serie di unit test separati in cui tratterai l'implementazione dei metodi del repository.

Ora diciamo il tuo GetGroups() il metodo è più di un semplice wrapper per _repository e contiene una logica.

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

Ora vale la pena scrivere uno unit test per il GetGroups() metodo perché il risultato potrebbe cambiare a seconda del deriso HttpContext.User valore.

Attributi come [Authorize] o [ServiceFilter(….)] non lo farà essere attivato in uno unit test.

.

Scrivere test di integrazione vale quasi sempre perché vuoi testare cosa farà il processo quando fa parte di un'applicazione/sistema/processo reale.

Chiediti, viene utilizzato dall'applicazione/sistema?Se , scrivi un test di integrazione perché il risultato dipende da una combinazione di circostanze e criteri.

Ora anche se il tuo GetGroups() il metodo è solo un wrapper come nella prima implementazione, il _repository indicherà un vero e proprio datastore, niente viene preso in giro !

Quindi ora, non solo il test copre il fatto che il datastore ha dati (o meno), ma si basa anche su una connessione effettiva, HttpContext essere impostato correttamente e se la serializzazione delle informazioni funziona come previsto.

Cose come filtri, instradamento e associazione del modello lo faranno funziona anche. Quindi, se avessi un attributo sul tuo GetGroups() metodo, ad esempio [Authorize] o [ServiceFilter(….)] , lo farà essere attivato come previsto.

Uso xUnit per i test, quindi per un test unitario su un controller uso questo.

Test dell'unità di controllo:

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

Il mocker del contesto utilizzato per i test di unità.

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

L'estensione del seme.

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

e per i test di integrazione:(questo è un codice da un tutorial, ma non ricordo dove l'ho visto, su YouTube o su Pluralsight)

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

e usalo in questo modo nel test di integrazione.

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

Modifica aggiunta: In conclusione, dovresti fare entrambe le cose, perché ogni test ha uno scopo diverso.

Guardando le altre risposte vedrai che il consenso è di fare entrambe le cose.


Non mi è mai piaciuto prendere in giro in quanto man mano che le applicazioni maturano, lo sforzo speso per prendere in giro può comportare un sacco di sforzi.

Mi piace esercitare gli endpoint tramite chiamate HTTP dirette. Oggi esistono strumenti fantastici come Cypress che permettono di intercettare e modificare le richieste dei clienti. La potenza di questa funzione insieme alla facile interazione GUI basata su browser offusca le definizioni dei test tradizionali perché un test in Cypress può essere di tutti questi tipi Unit, Functional, Integration ed E2E.

Se un endpoint è a prova di proiettile, l'inserimento dell'errore diventa impossibile dall'esterno. Ma anche gli errori dall'interno sono facili da simulare. Esegui gli stessi test di Cypress con Db giù. Oppure inietta la simulazione di problemi di rete intermittenti da Cypress. Questo è prendere in giro problemi esterni che è più vicino a un ambiente di prod.


TL;DR

Non "o" ma "e" . Se sei seriamente intenzionato a conoscere le migliori pratiche di test, hai bisogno di entrambi i test.

Il primo test è un test unitario. Ma il secondo è un test di integrazione.

C'è un consenso comune (piramide dei test) sulla necessità di più unit test rispetto al numero di test di integrazione. Ma hai bisogno di entrambi.

Ci sono molte ragioni per cui dovresti preferire gli unit test ai test di integrazione, la maggior parte di essi si riduce al fatto che gli unit test sono piccoli (in tutti i sensi) e i test di integrazione non lo sono. Ma i 4 principali sono:

  1. Località

    Quando il tuo unit test fallisce, di solito, solo dal nome puoi capire dove si trova il bug. Quando il test di integrazione diventa rosso, non puoi dire subito dov'è il problema. Forse è nel controller.GetGroups oppure è nel HttpClient o c'è qualche problema con la rete.

    Inoltre, quando introduci un bug nel tuo codice è possibile che solo uno degli unit test diventi rosso, mentre con i test di integrazione ci sono più possibilità che più di uno di essi fallisca.

  2. Stabilità

    Con un piccolo progetto che puoi testare sulla tua scatola locale probabilmente non te ne accorgerai. Ma su un grande progetto con un'infrastruttura distribuita vedrai test lampeggianti tutto il tempo. E questo diventerà un problema. Ad un certo punto puoi ritrovarti a non fidarti più dei risultati dei test.

  3. Velocità

    Con un piccolo progetto con un piccolo numero di test non te ne accorgerai. Ma su un piccolo progetto diventerà un problema. (Ritardi di rete, ritardi IO, inizializzazione, pulizia, ecc., ecc.)

  4. Semplicità

    L'hai notato tu stesso.

    Ma non è sempre vero. Se il codice è strutturato male, è più facile scrivere test di integrazione. E questo è un motivo in più per cui dovresti preferire gli unit test. In qualche modo ti costringono a scrivere codice più modulare (e non mi sto occupando di Iniezione di dipendenza ).

Ma tieni anche presente che le best practice parlano quasi sempre di grandi progetti. Se il tuo progetto è piccolo e rimarrà piccolo, ci sono grandi possibilità che starai meglio con decisioni del tutto opposte.

Scrivi più test. (Di nuovo, ciò significa - entrambi). Diventa più bravo a scrivere i test. Eliminali dopo.

La pratica rende perfetti.