Dela via


Testa ASP.NET Core MVC-appar

Dricks

Det här innehållet är ett utdrag från eBook, Architect Modern Web Applications med ASP.NET Core och Azure, som finns på .NET Docs eller som en kostnadsfri nedladdningsbar PDF som kan läsas offline.

Architect Modern Web Applications with ASP.NET Core and Azure eBook cover thumbnail.

"Om du inte gillar enhetstestning av din produkt kommer dina kunder förmodligen inte heller att vilja testa den." _-Anonym-

Programvara med all komplexitet kan misslyckas på oväntade sätt som svar på ändringar. Därför krävs testning efter att du har gjort ändringar för alla utom de mest triviala (eller minst kritiska) programmen. Manuell testning är det långsammaste, minst tillförlitliga och dyraste sättet att testa programvara. Om program inte är utformade för att vara testbara kan det tyvärr vara det enda tillgängliga sättet att testa. Program som skrivits för att följa arkitekturprinciperna i kapitel 4 bör till stor del vara enhetstestbara. ASP.NET Core-program stöder automatiserad integrering och funktionell testning.

Typer av automatiserade tester

Det finns många typer av automatiserade tester för program. Det enklaste testet på lägsta nivå är enhetstestet. På en något högre nivå finns integreringstester och funktionella tester. Andra typer av tester, till exempel användargränssnittstester, belastningstester, stresstester och röktester, ligger utanför det här dokumentets omfång.

Enhetstest

Ett enhetstest testar en enda del av programmets logik. Man kan ytterligare beskriva det genom att lista några av de saker som det inte är. Ett enhetstest testar inte hur koden fungerar med beroenden eller infrastruktur – det är vad integreringstester är till för. Ett enhetstest testar inte ramverket som koden är skriven på – du bör anta att det fungerar eller, om du upptäcker att det inte gör det, skapa en bugg och koda en lösning. Ett enhetstest körs helt i minnet och i processen. Den kommunicerar inte med filsystemet, nätverket eller en databas. Enhetstester bör endast testa koden.

Enhetstester, på grund av att de bara testar en enda enhet i koden, utan externa beroenden, bör köras extremt snabbt. Därför bör du kunna köra testsviter med hundratals enhetstester på några sekunder. Kör dem ofta, helst före varje push-överföring till en lagringsplats för delad källkontroll, och säkert med varje automatiserad version på byggservern.

Integreringstester

Även om det är en bra idé att kapsla in koden som interagerar med infrastruktur som databaser och filsystem, kommer du fortfarande att ha en del av koden, och du kommer förmodligen att vilja testa den. Dessutom bör du kontrollera att kodens lager interagerar som du förväntar dig när programmets beroenden är helt lösta. Den här funktionen ansvarar för integreringstester. Integreringstester tenderar att vara långsammare och svårare att konfigurera än enhetstester, eftersom de ofta är beroende av externa beroenden och infrastruktur. Därför bör du undvika att testa saker som kan testas med enhetstester i integrationstester. Om du kan testa ett visst scenario med ett enhetstest bör du testa det med ett enhetstest. Om du inte kan kan du överväga att använda ett integreringstest.

Integreringstester har ofta mer komplexa konfigurations- och rivningsprocedurer än enhetstester. Ett integrationstest som går emot en faktisk databas behöver till exempel ett sätt att återställa databasen till ett känt tillstånd innan varje test körs. När nya tester läggs till och produktionsdatabasschemat utvecklas tenderar dessa testskript att växa i storlek och komplexitet. I många stora system är det opraktiskt att köra fullständiga paket med integreringstester på utvecklararbetsstationer innan du checkar in ändringar i den delade källkontrollen. I dessa fall kan integreringstester köras på en byggserver.

Funktionella tester

Integreringstester skrivs ur utvecklarens perspektiv för att verifiera att vissa komponenter i systemet fungerar korrekt tillsammans. Funktionella tester skrivs ur användarens perspektiv och verifierar systemets korrekthet baserat på dess krav. Följande utdrag ger en användbar analogi för hur man tänker på funktionella tester, jämfört med enhetstester:

" Många gånger är utvecklingen av ett system liknad vid byggandet av ett hus. Även om den här analogi inte är helt korrekt kan vi utöka den för att förstå skillnaden mellan enhets- och funktionstester. Enhetstestning liknar en byggnadsinspektör som besöker ett hus byggarbetsplats. Han fokuserar på de olika interna systemen i huset, grunden, inramning, elektriska, VVS och så vidare. Han ser till (testar) att delarna av huset kommer att fungera korrekt och säkert, det vill säga uppfylla byggkoden. Funktionella tester i detta scenario är analoga med husägaren besöker samma byggarbetsplats. Han förutsätter att de interna systemen fungerar korrekt, att byggnadsinspektören utför sin uppgift. Husägaren är fokuserad på hur det kommer att bli att bo i det här huset. Han är bekymrad över hur huset ser ut, är de olika rummen en bekväm storlek, passar huset familjens behov, är fönstren i en bra plats för att fånga morgonsolen. Husägaren utför funktionella tester på huset. Han har användarens perspektiv. Byggnadsinspektören utför enhetstester på huset. Han har byggarens perspektiv."

Källa: Enhetstestning jämfört med funktionella tester

Jag är förtjust i att säga "Som utvecklare misslyckas vi på två sätt: vi bygger saken fel, eller så bygger vi fel sak." Enhetstester säkerställer att du skapar rätt sak; funktionstester ser till att du skapar rätt sak.

Eftersom funktionstester fungerar på systemnivå kan de kräva viss grad av automatisering av användargränssnittet. Precis som integreringstester fungerar de vanligtvis med någon form av testinfrastruktur också. Den här aktiviteten gör dem långsammare och mer spröda än enhets- och integreringstester. Du bör bara ha så många funktionella tester som du behöver vara säker på att systemet beter sig som användarna förväntar sig.

Testpyramid

Martin Fowler skrev om testpyramid, ett exempel som visas i bild 9-1.

Testing Pyramid

Bild 9-1. Testpyramid

De olika skikten i pyramiden och deras relativa storlekar representerar olika typer av tester och hur många du bör skriva för ditt program. Som du ser är rekommendationen att ha en stor bas av enhetstester, som stöds av ett mindre lager integrationstester, med ett ännu mindre lager av funktionella tester. Varje lager bör helst bara ha tester i det som inte kan utföras tillräckligt på ett lägre lager. Tänk på testpyramiderna när du försöker bestämma vilken typ av test du behöver för ett visst scenario.

Vad du ska testa

Ett vanligt problem för utvecklare som är oerfarna med att skriva automatiserade tester är att komma på vad som ska testas. En bra utgångspunkt är att testa villkorsstyrd logik. Var du än har en metod med beteende som ändras baserat på en villkorssats (om annat, växla och så vidare) bör du kunna komma med minst ett par tester som bekräftar rätt beteende för vissa villkor. Om koden har feltillstånd är det bra att skriva minst ett test för den "lyckliga sökvägen" via koden (utan fel) och minst ett test för den "sorgliga sökvägen" (med fel eller atypiska resultat) för att bekräfta att programmet beter sig som förväntat vid fel. Försök slutligen att fokusera på att testa saker som kan misslyckas, i stället för att fokusera på mått som kodtäckning. Mer kodtäckning är bättre än mindre, vanligtvis. Men att skriva några fler tester av en komplex och affärskritisk metod är vanligtvis en bättre användning av tid än att skriva tester för automatiska egenskaper bara för att förbättra testkodens täckningsmått.

Organisera testprojekt

Testprojekt kan organiseras men fungerar bäst för dig. Det är en bra idé att separera tester efter typ (enhetstest, integrationstest) och efter vad de testar (efter projekt, efter namnområde). Om den här separationen består av mappar i ett enskilt testprojekt eller flera testprojekt är ett designbeslut. Ett projekt är enklast, men för stora projekt med många tester, eller för att enklare kunna köra olika uppsättningar med tester, kanske du vill ha flera olika testprojekt. Många team organiserar testprojekt baserat på det projekt de testar, vilket för program med fler än ett fåtal projekt kan resultera i ett stort antal testprojekt, särskilt om du fortfarande delar upp dessa beroende på vilken typ av tester som finns i varje projekt. En kompromissmetod är att ha ett projekt per typ av test, per program, med mappar i testprojekten som anger vilket projekt (och klass) som testas.

En vanlig metod är att organisera programprojekten under en "src"-mapp och programmets testprojekt under en parallell "testmapp". Du kan skapa matchande lösningsmappar i Visual Studio om du tycker att den här organisationen är användbar.

Test organization in your solution

Bild 9-2. Testa organisationen i din lösning

Du kan använda vilket testramverk du vill. xUnit-ramverket fungerar bra och är vad alla ASP.NET Core- och EF Core-tester är skrivna i. Du kan lägga till ett xUnit-testprojekt i Visual Studio med hjälp av mallen som visas i bild 9–3 eller från CLI med hjälp av dotnet new xunit.

Add an xUnit Test Project in Visual Studio

Bild 9-3. Lägga till ett xUnit-testprojekt i Visual Studio

Testa namngivning

Namnge dina tester på ett konsekvent sätt med namn som anger vad varje test gör. En metod som jag har haft stor framgång med är att namnge testklasser enligt den klass och metod som de testar. Den här metoden resulterar i många små testklasser, men det gör det extremt tydligt vad varje test är ansvarigt för. Med testklassnamnet konfigurerat kan testmetodnamnet användas för att ange det beteende som testas för att identifiera vilken klass och metod som ska testas. Det här namnet bör innehålla det förväntade beteendet och eventuella indata eller antaganden som bör ge det här beteendet. Några exempel på testnamn:

  • CatalogControllerGetImage.CallsImageServiceWithId

  • CatalogControllerGetImage.LogsWarningGivenImageMissingException

  • CatalogControllerGetImage.ReturnsFileResultWithBytesGivenSuccess

  • CatalogControllerGetImage.ReturnsNotFoundResultGivenImageMissingException

En variant av den här metoden avslutar varje testklassnamn med "Should" och ändrar tempuset något:

  • CatalogControllerGetImageSka.ringaImageServiceWithId

  • CatalogControllerGetImageBör.loggaWarningGivenImageMissingException

Vissa team tycker att den andra namngivningsmetoden är tydligare, men lite mer utförlig. I vilket fall som helst kan du försöka använda en namngivningskonvention som ger insikt i testbeteendet, så att när ett eller flera tester misslyckas är det uppenbart i deras namn vilka fall som har misslyckats. Undvik att namnge dina tester vagt, till exempel ControllerTests.Test1, eftersom dessa namn inte ger något värde när du ser dem i testresultat.

Om du följer en namngivningskonvention som den ovan som producerar många små testklasser är det en bra idé att ytterligare organisera dina tester med hjälp av mappar och namnområden. Bild 9–4 visar en metod för att organisera tester efter mapp i flera testprojekt.

Organizing test classes by folder based on class being tested

Bild 9-4. Organisera testklasser efter mapp baserat på klassen som testas.

Om en viss programklass har många metoder som testas (och därmed många testklasser) kan det vara klokt att placera dessa klasser i en mapp som motsvarar programklassen. Den här organisationen skiljer sig inte från hur du kan ordna filer i mappar någon annanstans. Om du har fler än tre eller fyra relaterade filer i en mapp som innehåller många andra filer är det ofta bra att flytta dem till en egen undermapp.

Enhetstestning ASP.NET Core-appar

I ett väldesignat ASP.NET Core-program kapslas det mesta av komplexiteten och affärslogik in i affärsentiteter och en mängd olika tjänster. Själva ASP.NET Core MVC-appen, med dess styrenheter, filter, visningsmodeller och vyer, bör kräva få enhetstester. Mycket av funktionerna i en viss åtgärd ligger utanför själva åtgärdsmetoden. Att testa om routning eller global felhantering fungerar korrekt kan inte utföras effektivt med ett enhetstest. På samma sätt kan inga filter, inklusive modellverifierings- och autentiserings- och auktoriseringsfilter, enhetstestas med ett test som riktar sig mot en kontrollants åtgärdsmetod. Utan dessa beteendekällor bör de flesta åtgärdsmetoder vara trivialt små, vilket delegerar huvuddelen av deras arbete till tjänster som kan testas oberoende av den kontrollant som använder dem.

Ibland måste du omstrukturera koden för att kunna enhetstesta den. Ofta handlar den här aktiviteten om att identifiera abstraktioner och använda beroendeinmatning för att komma åt abstraktionen i koden som du vill testa, i stället för att koda direkt mot infrastrukturen. Tänk dig till exempel den här enkla åtgärdsmetoden för att visa bilder:

[HttpGet("[controller]/pic/{id}")]
public IActionResult GetImage(int id)
{
  var contentRoot = _env.ContentRootPath + "//Pics";
  var path = Path.Combine(contentRoot, id + ".png");
  Byte[] b = System.IO.File.ReadAllBytes(path);
  return File(b, "image/png");
}

Enhetstestning av den här metoden försvåras av dess direkta beroende av System.IO.File, som den använder för att läsa från filsystemet. Du kan testa det här beteendet för att säkerställa att det fungerar som förväntat, men att göra det med riktiga filer är ett integrationstest. Det är värt att notera att du inte kan enhetstesta metodens väg – du får snart se hur du gör den här testningen med ett funktionellt test.

Om du inte kan enhetstesta filsystemets beteende direkt och du inte kan testa vägen, vad finns det att testa? Tja, efter refaktorisering för att göra enhetstestning möjligt, kan du upptäcka vissa testfall och saknade beteende, till exempel felhantering. Vad gör metoden när en fil inte hittas? Vad ska den göra? I det här exemplet ser den omstrukturerade metoden ut så här:

[HttpGet("[controller]/pic/{id}")]
public IActionResult GetImage(int id)
{
  byte[] imageBytes;
  try
  {
    imageBytes = _imageService.GetImageBytesById(id);
  }
  catch (CatalogImageMissingException ex)
  {
    _logger.LogWarning($"No image found for id: {id}");
    return NotFound();
  }
  return File(imageBytes, "image/png");
}

_logger och _imageService matas båda in som beroenden. Nu kan du testa att samma ID som skickas till åtgärdsmetoden skickas till _imageServiceoch att resulterande byte returneras som en del av FileResult. Du kan också testa att felloggning sker som förväntat och att ett NotFound resultat returneras om avbildningen saknas, förutsatt att det här beteendet är viktigt programbeteende (dvs. inte bara tillfällig kod som utvecklaren lade till för att diagnostisera ett problem). Den faktiska fillogik har flyttats till en separat implementeringstjänst och har utökats för att returnera ett programspecifikt undantag för fallet med en fil som saknas. Du kan testa den här implementeringen oberoende av varandra med hjälp av ett integreringstest.

I de flesta fall vill du använda globala undantagshanterare i dina kontrollanter, så mängden logik i dem bör vara minimal och förmodligen inte värt enhetstestning. Utför de flesta av dina tester av kontrollantåtgärder med hjälp av funktionella tester och klassen TestServer som beskrivs nedan.

Integreringstestning ASP.NET Core-appar

De flesta integreringstesterna i dina ASP.NET Core-appar bör testa tjänster och andra implementeringstyper som definierats i infrastrukturprojektet. Du kan till exempel testa att EF Core har uppdaterat och hämtat de data som du förväntar dig av dina dataåtkomstklasser som finns i infrastrukturprojektet. Det bästa sättet att testa att ditt ASP.NET Core MVC-projekt fungerar korrekt är med funktionella tester som körs mot din app som körs på en testvärd.

Funktionell testning ASP.NET Core-appar

För ASP.NET Core-program TestServer gör klassen funktionella tester ganska enkla att skriva. Du konfigurerar en TestServer direkt ( WebHostBuilder eller HostBuilder) (som du normalt gör för ditt program) eller med WebApplicationFactory typen (tillgänglig sedan version 2.1). Försök att matcha testvärden med produktionsvärden så nära som möjligt, så att testerna fungerar ungefär som appen kommer att göra i produktion. Klassen WebApplicationFactory är användbar för att konfigurera TestServers ContentRoot, som används av ASP.NET Core för att hitta statiska resurser som Vyer.

Du kan skapa enkla funktionella tester genom att skapa en testklass som implementerar IClassFixture<WebApplicationFactory<TEntryPoint>>, där TEntryPoint är din webbapps Startup klass. Med det här gränssnittet på plats kan testfixturen skapa en klient med hjälp av fabrikens CreateClient metod:

public class BasicWebTests : IClassFixture<WebApplicationFactory<Program>>
{
  protected readonly HttpClient _client;

  public BasicWebTests(WebApplicationFactory<Program> factory)
  {
    _client = factory.CreateClient();
  }

  // write tests that use _client
}

Dricks

Om du använder minimal API-konfiguration i din Program.cs-fil förklaras klassen som standard intern och kommer inte att vara tillgänglig från testprojektet. Du kan välja vilken annan instansklass som helst i webbprojektet i stället eller lägga till den i din Program.cs-fil :

// Make the implicit Program class public so test projects can access it
public partial class Program { }

Ofta vill du utföra ytterligare konfiguration av din plats innan varje test körs, till exempel att konfigurera programmet för att använda ett minnesinternt datalager och sedan så programmet med testdata. För att uppnå den här funktionen skapar du en egen underklass av WebApplicationFactory<TEntryPoint> och åsidosätter dess ConfigureWebHost metod. Exemplet nedan är från projektet eShopOnWeb FunctionalTests och används som en del av testerna i huvudwebbprogrammet.

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.eShopWeb.Infrastructure.Data;
using Microsoft.eShopWeb.Infrastructure.Identity;
using Microsoft.eShopWeb.Web;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;

namespace Microsoft.eShopWeb.FunctionalTests.Web;
public class WebTestFixture : WebApplicationFactory<Startup>
{
  protected override void ConfigureWebHost(IWebHostBuilder builder)
  {
    builder.UseEnvironment("Testing");

    builder.ConfigureServices(services =>
    {
      services.AddEntityFrameworkInMemoryDatabase();

      // Create a new service provider.
      var provider = services
            .AddEntityFrameworkInMemoryDatabase()
            .BuildServiceProvider();

      // Add a database context (ApplicationDbContext) using an in-memory
      // database for testing.
      services.AddDbContext<CatalogContext>(options =>
      {
        options.UseInMemoryDatabase("InMemoryDbForTesting");
        options.UseInternalServiceProvider(provider);
      });

      services.AddDbContext<AppIdentityDbContext>(options =>
      {
        options.UseInMemoryDatabase("Identity");
        options.UseInternalServiceProvider(provider);
      });

      // Build the service provider.
      var sp = services.BuildServiceProvider();

      // Create a scope to obtain a reference to the database
      // context (ApplicationDbContext).
      using (var scope = sp.CreateScope())
      {
        var scopedServices = scope.ServiceProvider;
        var db = scopedServices.GetRequiredService<CatalogContext>();
        var loggerFactory = scopedServices.GetRequiredService<ILoggerFactory>();

        var logger = scopedServices
            .GetRequiredService<ILogger<WebTestFixture>>();

        // Ensure the database is created.
        db.Database.EnsureCreated();

        try
        {
          // Seed the database with test data.
          CatalogContextSeed.SeedAsync(db, loggerFactory).Wait();

          // seed sample user data
          var userManager = scopedServices.GetRequiredService<UserManager<ApplicationUser>>();
          var roleManager = scopedServices.GetRequiredService<RoleManager<IdentityRole>>();
          AppIdentityDbContextSeed.SeedAsync(userManager, roleManager).Wait();
        }
        catch (Exception ex)
        {
          logger.LogError(ex, $"An error occurred seeding the " +
                    "database with test messages. Error: {ex.Message}");
        }
      }
    });
  }
}

Tester kan använda den här anpassade WebApplicationFactory genom att använda den för att skapa en klient och sedan göra begäranden till programmet med hjälp av den här klientinstansen. Programmet kommer att ha data som kan användas som en del av testets försäkran. Följande test verifierar att startsidan för eShopOnWeb-programmet läses in korrekt och innehåller en produktlista som lades till i programmet som en del av startdata.

using Microsoft.eShopWeb.FunctionalTests.Web;
using System.Net.Http;
using System.Threading.Tasks;
using Xunit;

namespace Microsoft.eShopWeb.FunctionalTests.WebRazorPages;
[Collection("Sequential")]
public class HomePageOnGet : IClassFixture<WebTestFixture>
{
  public HomePageOnGet(WebTestFixture factory)
  {
    Client = factory.CreateClient();
  }

  public HttpClient Client { get; }

  [Fact]
  public async Task ReturnsHomePageWithProductListing()
  {
    // Arrange & Act
    var response = await Client.GetAsync("/");
    response.EnsureSuccessStatusCode();
    var stringResponse = await response.Content.ReadAsStringAsync();

    // Assert
    Assert.Contains(".NET Bot Black Sweatshirt", stringResponse);
  }
}

Det här funktionstestet utför hela ASP.NET Core MVC/Razor Pages-programstacken, inklusive alla mellanprogram, filter och pärmar som kan finnas på plats. Den verifierar att en viss väg ("/") returnerar den förväntade statuskoden för lyckade resultat och HTML-utdata. Det gör det utan att konfigurera en riktig webbserver och undviker mycket av den skörhet som det kan uppstå problem med brandväggsinställningar med hjälp av en riktig webbserver för testning. Funktionella tester som körs mot TestServer är vanligtvis långsammare än integrerings- och enhetstester, men är mycket snabbare än tester som skulle köras över nätverket till en testwebbserver. Använd funktionella tester för att säkerställa att programmets klientdelsstack fungerar som förväntat. Dessa tester är särskilt användbara när du hittar duplicering i dina kontrollanter eller sidor och du hanterar dupliceringen genom att lägga till filter. Helst ändrar den här refaktoreringen inte programmets beteende, och en uppsättning funktionella tester verifierar att så är fallet.

Referenser – Testa ASP.NET Core MVC-appar