Teste ASP.NET aplicativos MVC principais
Gorjeta
Este conteúdo é um excerto do eBook, Architect Modern Web Applications with ASP.NET Core e Azure, disponível no .NET Docs ou como um PDF transferível gratuito que pode ser lido offline.
"Se você não gosta de testar seu produto, muito provavelmente seus clientes também não vão gostar de testá-lo." _- Anónimo-
Software de qualquer complexidade pode falhar de maneiras inesperadas em resposta a mudanças. Assim, o teste após fazer alterações é necessário para todos, exceto os aplicativos mais triviais (ou menos críticos). O teste manual é a maneira mais lenta, menos confiável e mais cara de testar software. Infelizmente, se os aplicativos não forem projetados para serem testáveis, ele pode ser o único meio de teste disponível. Os aplicativos escritos para seguir os princípios de arquitetura estabelecidos no capítulo 4 devem ser amplamente testáveis por unidade. ASP.NET aplicativos principais suportam integração automatizada e testes funcionais.
Tipos de testes automatizados
Existem muitos tipos de testes automatizados para aplicações de software. O teste mais simples e de nível mais baixo é o teste de unidade. Em um nível um pouco mais alto, há testes de integração e testes funcionais. Outros tipos de testes, como testes de interface do usuário, testes de carga, testes de estresse e testes de fumaça, estão além do escopo deste documento.
Testes de unidade
Um teste de unidade testa uma única parte da lógica do seu aplicativo. Pode-se descrevê-lo listando algumas das coisas que não é. Um teste de unidade não testa como seu código funciona com dependências ou infraestrutura – é para isso que os testes de integração servem. Um teste de unidade não testa a estrutura em que seu código está escrito – você deve assumir que ele funciona ou, se achar que não funciona, arquivar um bug e codificar uma solução alternativa. Um teste de unidade é executado completamente na memória e em processo. Ele não se comunica com o sistema de arquivos, a rede ou um banco de dados. Os testes de unidade devem testar apenas o seu código.
Os testes de unidade, em virtude do fato de que eles testam apenas uma única unidade do seu código, sem dependências externas, devem ser executados extremamente rápido. Assim, você deve ser capaz de executar conjuntos de testes de centenas de testes de unidade em poucos segundos. Execute-os com frequência, idealmente antes de cada push para um repositório de controle de código-fonte compartilhado e, certamente, com cada compilação automatizada em seu servidor de compilação.
Testes de integração
Embora seja uma boa ideia encapsular seu código que interage com a infraestrutura, como bancos de dados e sistemas de arquivos, você ainda terá parte desse código e provavelmente desejará testá-lo. Além disso, você deve verificar se as camadas do código interagem como esperado quando as dependências do aplicativo são totalmente resolvidas. Esta funcionalidade é da responsabilidade dos testes de integração. Os testes de integração tendem a ser mais lentos e difíceis de configurar do que os testes de unidade, porque muitas vezes dependem de dependências externas e de infraestrutura. Assim, você deve evitar testar coisas que poderiam ser testadas com testes de unidade em testes de integração. Se você pode testar um determinado cenário com um teste de unidade, você deve testá-lo com um teste de unidade. Se não puder, considere usar um teste de integração.
Os testes de integração geralmente têm procedimentos de configuração e desmontagem mais complexos do que os testes de unidade. Por exemplo, um teste de integração que vai contra um banco de dados real precisará de uma maneira de retornar o banco de dados a um estado conhecido antes de cada execução de teste. À medida que novos testes são adicionados e o esquema do banco de dados de produção evolui, esses scripts de teste tenderão a crescer em tamanho e complexidade. Em muitos sistemas grandes, é impraticável executar pacotes completos de testes de integração em estações de trabalho de desenvolvedores antes de verificar as alterações no controle de código-fonte compartilhado. Nesses casos, os testes de integração podem ser executados em um servidor de compilação.
Testes funcionais
Os testes de integração são escritos a partir da perspetiva do desenvolvedor, para verificar se alguns componentes do sistema funcionam corretamente juntos. Os testes funcionais são escritos a partir da perspetiva do usuário e verificam a correção do sistema com base em seus requisitos. O trecho a seguir oferece uma analogia útil sobre como pensar em testes funcionais, em comparação com testes de unidade:
"Muitas vezes o desenvolvimento de um sistema é comparado à construção de uma casa. Embora essa analogia não esteja correta, podemos estendê-la para fins de compreensão da diferença entre testes unitários e funcionais. O teste de unidade é análogo a um inspetor de construção que visita o canteiro de obras de uma casa. Ele está focado nos vários sistemas internos da casa, a fundação, enquadramento, elétrico, encanamento, e assim por diante. Ele garante (testa) que as partes da casa funcionarão corretamente e com segurança, ou seja, cumprirão o código de construção. Os testes funcionais neste cenário são análogos ao proprietário que visita este mesmo canteiro de obras. Ele parte do princípio de que os sistemas internos se comportarão adequadamente, que o inspetor de construção está executando sua tarefa. O proprietário está focado em como será viver nesta casa. Ele está preocupado com a aparência da casa, são os vários quartos de um tamanho confortável, se a casa se encaixa nas necessidades da família, são as janelas em um bom local para pegar o sol da manhã. O proprietário está realizando testes funcionais na casa. Ele tem a perspetiva do usuário. O inspetor de construção está realizando testes de unidade na casa. Ele tem a perspetiva do construtor."
Fonte: Testes Unitários versus Testes Funcionais
Gosto de dizer: "Como desenvolvedores, falhamos de duas maneiras: construímos a coisa errada ou construímos a coisa errada". Os testes de unidade garantem que você está construindo a coisa certa; Os testes funcionais garantem que você está construindo a coisa certa.
Como os testes funcionais operam no nível do sistema, eles podem exigir algum grau de automação da interface do usuário. Como os testes de integração, eles geralmente trabalham com algum tipo de infraestrutura de teste também. Esta atividade torna-os mais lentos e frágeis do que os testes unitários e de integração. Você deve ter apenas tantos testes funcionais quanto você precisa para ter certeza de que o sistema está se comportando como os usuários esperam.
Pirâmide de teste
Martin Fowler escreveu sobre a pirâmide de teste, um exemplo do qual é mostrado na Figura 9-1.
Figura 9-1. Pirâmide de teste
As diferentes camadas da pirâmide, e seus tamanhos relativos, representam diferentes tipos de testes e quantos você deve escrever para sua aplicação. Como você pode ver, a recomendação é ter uma grande base de testes de unidade, suportada por uma camada menor de testes de integração, com uma camada ainda menor de testes funcionais. Idealmente, cada camada deve ter apenas testes que não possam ser realizados adequadamente em uma camada inferior. Tenha em mente a pirâmide de testes quando estiver tentando decidir que tipo de teste você precisa para um cenário específico.
O que testar
Um problema comum para desenvolvedores que não têm experiência em escrever testes automatizados é descobrir o que testar. Um bom ponto de partida é testar a lógica condicional. Em qualquer lugar que você tenha um método com comportamento que muda com base em uma instrução condicional (if-else, switch e assim por diante), você deve ser capaz de criar pelo menos alguns testes que confirmam o comportamento correto para determinadas condições. Se o seu código tiver condições de erro, é bom escrever pelo menos um teste para o "caminho feliz" através do código (sem erros), e pelo menos um teste para o "caminho triste" (com erros ou resultados atípicos) para confirmar que seu aplicativo se comporta como esperado diante de erros. Por fim, tente se concentrar em testar coisas que podem falhar, em vez de se concentrar em métricas como cobertura de código. Mais cobertura de código é melhor do que menos, geralmente. No entanto, escrever mais alguns testes de um método complexo e crítico para os negócios geralmente é um melhor uso do tempo do que escrever testes para propriedades automáticas apenas para melhorar as métricas de cobertura do código de teste.
Organização de projetos de teste
Os projetos de teste podem ser organizados, mas funciona melhor para você. É uma boa ideia separar os testes por tipo (teste de unidade, teste de integração) e pelo que eles estão testando (por projeto, por namespace). Se essa separação consiste em pastas dentro de um único projeto de teste, ou vários projetos de teste, é uma decisão de design. Um projeto é mais simples, mas para projetos grandes com muitos testes, ou para executar mais facilmente diferentes conjuntos de testes, você pode querer ter vários projetos de teste diferentes. Muitas equipes organizam projetos de teste com base no projeto que estão testando, o que para aplicativos com mais do que alguns projetos pode resultar em um grande número de projetos de teste, especialmente se você ainda os dividir de acordo com o tipo de testes em cada projeto. Uma abordagem de compromisso é ter um projeto por tipo de teste, por aplicativo, com pastas dentro dos projetos de teste para indicar o projeto (e classe) que está sendo testado.
Uma abordagem comum é organizar os projetos de aplicação sob uma pasta 'src', e os projetos de teste do aplicativo em uma pasta paralela 'testes'. Você pode criar pastas de solução correspondentes no Visual Studio, se achar essa organização útil.
Figura 9-2. Testar a organização na sua solução
Você pode usar qualquer estrutura de teste que preferir. A estrutura xUnit funciona bem e é no que todos os testes ASP.NET Core e EF Core estão escritos. Você pode adicionar um projeto de teste xUnit no Visual Studio usando o modelo mostrado na Figura 9-3 ou da CLI usando dotnet new xunit
.
Figura 9-3. Adicionar um projeto de teste xUnit no Visual Studio
Nomenclatura de teste
Nomeie seus testes de forma consistente, com nomes que indiquem o que cada teste faz. Uma abordagem com a qual tive grande sucesso é nomear as classes de teste de acordo com a classe e o método que estão testando. Esta abordagem resulta em muitas classes de teste pequenas, mas deixa extremamente claro pelo que cada teste é responsável. Com o nome da classe de teste configurado, para identificar a classe e o método a ser testado, o nome do método de teste pode ser usado para especificar o comportamento que está sendo testado. Esse nome deve incluir o comportamento esperado e quaisquer entradas ou suposições que devem produzir esse comportamento. Alguns exemplos de nomes de teste:
CatalogControllerGetImage.CallsImageServiceWithId
CatalogControllerGetImage.LogsWarningGivenImageMissingException
CatalogControllerGetImage.ReturnsFileResultWithBytesGivenSuccess
CatalogControllerGetImage.ReturnsNotFoundResultGivenImageMissingException
Uma variação desta abordagem termina cada nome de classe de teste com "Should" e modifica ligeiramente o tempo:
CatalogControllerGetImage
Deve.
ligarImageServiceWithId
CatalogControllerGetImage
Deve.
registrarWarningGivenImageMissingException
Algumas equipes acham a segunda abordagem de nomeação mais clara, embora um pouco mais detalhada. Em qualquer caso, tente usar uma convenção de nomenclatura que forneça informações sobre o comportamento do teste, de modo que, quando um ou mais testes falharem, seja óbvio a partir de seus nomes quais casos falharam. Evite nomear seus testes vagamente, como ControllerTests.Test1, pois esses nomes não oferecem valor quando você os vê nos resultados do teste.
Se você seguir uma convenção de nomenclatura como a acima, que produz muitas classes de teste pequenas, é uma boa ideia organizar ainda mais seus testes usando pastas e namespaces. A Figura 9-4 mostra uma abordagem para organizar testes por pasta dentro de vários projetos de teste.
Figura 9-4. Organização de classes de teste por pasta com base na classe que está sendo testada.
Se uma classe de aplicativo específica tiver muitos métodos sendo testados (e, portanto, muitas classes de teste), pode fazer sentido colocar essas classes em uma pasta correspondente à classe de aplicativo. Essa organização não é diferente de como você pode organizar arquivos em pastas em outro lugar. Se você tiver mais de três ou quatro arquivos relacionados em uma pasta contendo muitos outros arquivos, geralmente é útil movê-los para sua própria subpasta.
Teste de unidade ASP.NET aplicativos principais
Em um aplicativo ASP.NET Core bem projetado, a maior parte da complexidade e da lógica de negócios será encapsulada em entidades de negócios e uma variedade de serviços. O próprio aplicativo ASP.NET Core MVC, com seus controladores, filtros, modelos de visualização e visualizações, deve exigir poucos testes de unidade. Grande parte da funcionalidade de uma determinada ação está fora do próprio método de ação. Testar se o roteamento ou o tratamento de erros globais funcionam corretamente não pode ser feito de forma eficaz com um teste de unidade. Da mesma forma, quaisquer filtros, incluindo validação de modelo e filtros de autenticação e autorização, não podem ser testados por unidade com um teste direcionado ao método de ação de um controlador. Sem essas fontes de comportamento, a maioria dos métodos de ação deve ser trivialmente pequena, delegando a maior parte de seu trabalho a serviços que podem ser testados independentemente do controlador que os usa.
Às vezes, você precisará refatorar seu código para testá-lo em unidade. Frequentemente, essa atividade envolve a identificação de abstrações e o uso da injeção de dependência para acessar a abstração no código que você gostaria de testar, em vez de codificar diretamente na infraestrutura. Por exemplo, considere este método de ação fácil para exibir imagens:
[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");
}
O teste de unidade desse método é dificultado por sua dependência direta do System.IO.File
, que ele usa para ler do sistema de arquivos. Você pode testar esse comportamento para garantir que ele funcione conforme o esperado, mas fazer isso com arquivos reais é um teste de integração. Vale a pena notar que você não pode testar a rota desse método — você verá como fazer esse teste com um teste funcional em breve.
Se você não pode testar o comportamento do sistema de arquivos diretamente e não pode testar a rota, o que há para testar? Bem, depois de refatoração para tornar o teste de unidade possível, você pode descobrir alguns casos de teste e comportamento ausente, como manipulação de erros. O que o método faz quando um arquivo não é encontrado? O que deve fazer? Neste exemplo, o método refatorado tem esta aparência:
[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
e _imageService
ambos são injetados como dependências. Agora você pode testar se a mesma ID que é passada para o método de ação é passada para _imageService
, e que os bytes resultantes são retornados como parte do FileResult. Você também pode testar se o log de erros está acontecendo conforme o esperado e que um NotFound
resultado é retornado se a imagem estiver faltando, supondo que esse comportamento seja um comportamento importante do aplicativo (ou seja, não apenas um código temporário que o desenvolvedor adicionou para diagnosticar um problema). A lógica de arquivo real foi movida para um serviço de implementação separado e foi aumentada para retornar uma exceção específica do aplicativo para o caso de um arquivo ausente. Você pode testar essa implementação independentemente, usando um teste de integração.
Na maioria dos casos, você desejará usar manipuladores de exceção globais em seus controladores, portanto, a quantidade de lógica neles deve ser mínima e provavelmente não vale a pena testar a unidade. Faça a maioria dos seus testes de ações do controlador usando testes funcionais e a TestServer
classe descrita abaixo.
Testes de integração ASP.NET aplicativos principais
A maioria dos testes de integração em seus aplicativos ASP.NET Core deve testar serviços e outros tipos de implementação definidos em seu projeto de infraestrutura. Por exemplo, você pode testar se o EF Core estava atualizando e recuperando com êxito os dados esperados de suas classes de acesso a dados residentes no projeto de infraestrutura. A melhor maneira de testar se seu projeto ASP.NET Core MVC está se comportando corretamente é com testes funcionais executados em seu aplicativo em execução em um host de teste.
Testes funcionais ASP.NET aplicativos principais
Para aplicativos ASP.NET Core, a classe torna os TestServer
testes funcionais bastante fáceis de escrever. Você configura um TestServer
usando um WebHostBuilder
(ou HostBuilder
) diretamente (como normalmente faz para seu aplicativo), ou com o tipo (disponível desde a WebApplicationFactory
versão 2.1). Tente fazer a correspondência entre seu host de teste e seu host de produção o mais próximo possível, para que seus testes tenham um comportamento semelhante ao que o aplicativo fará na produção. A WebApplicationFactory
classe é útil para configurar o ContentRoot do TestServer, que é usado pelo ASP.NET Core para localizar recursos estáticos como Views.
Você pode criar testes funcionais simples criando uma classe de teste que implementa IClassFixture<WebApplicationFactory<TEntryPoint>>
, onde TEntryPoint
é a classe do Startup
seu aplicativo Web. Com essa interface instalada, seu dispositivo de teste pode criar um cliente usando o método de CreateClient
fábrica:
public class BasicWebTests : IClassFixture<WebApplicationFactory<Program>>
{
protected readonly HttpClient _client;
public BasicWebTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
// write tests that use _client
}
Gorjeta
Se você estiver usando uma configuração mínima de API em seu arquivo de Program.cs , por padrão, a classe será declarada interna e não estará acessível a partir do projeto de teste. Em vez disso, você pode escolher qualquer outra classe de instância em seu projeto da Web ou adicioná-la ao seu arquivo Program.cs :
// Make the implicit Program class public so test projects can access it
public partial class Program { }
Freqüentemente, você desejará executar alguma configuração adicional do seu site antes de cada execução de teste, como configurar o aplicativo para usar um armazenamento de dados na memória e, em seguida, semear o aplicativo com dados de teste. Para obter essa funcionalidade, crie sua própria subclasse e WebApplicationFactory<TEntryPoint>
substitua seu ConfigureWebHost
método. O exemplo abaixo é do projeto eShopOnWeb FunctionalTests e é usado como parte dos testes na aplicação web principal.
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}");
}
}
});
}
}
Os testes podem fazer uso desse WebApplicationFactory personalizado usando-o para criar um cliente e, em seguida, fazendo solicitações para o aplicativo usando essa instância de cliente. O aplicativo terá dados semeados que podem ser usados como parte das afirmações do teste. O teste a seguir verifica se a página inicial do aplicativo eShopOnWeb é carregada corretamente e inclui uma listagem de produtos que foi adicionada ao aplicativo como parte dos dados semente.
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);
}
}
Este teste funcional exercita toda a pilha de aplicativos Core MVC / Razor Pages do ASP.NET, incluindo todos os middlewares, filtros e fichários que possam estar no lugar. Ele verifica se uma determinada rota ("/") retorna o código de status de sucesso esperado e a saída HTML. Ele faz isso sem configurar um servidor web real, e evita grande parte da fragilidade que o uso de um servidor web real para testes pode experimentar (por exemplo, problemas com configurações de firewall). Os testes funcionais executados no TestServer geralmente são mais lentos do que os testes de integração e de unidade, mas são muito mais rápidos do que os testes que seriam executados pela rede para um servidor Web de teste. Use testes funcionais para garantir que a pilha de front-end do seu aplicativo esteja funcionando conforme o esperado. Esses testes são especialmente úteis quando você encontra duplicação em seus controladores ou páginas e resolve a duplicação adicionando filtros. Idealmente, essa refatoração não mudará o comportamento do aplicativo, e um conjunto de testes funcionais verificará esse é o caso.
Referências – Teste ASP.NET aplicativos MVC principais
- Testes no ASP.NET Core
https://learn.microsoft.com/aspnet/core/testing/- Convenção de nomenclatura de teste de unidade
https://ardalis.com/unit-test-naming-convention- Testando o EF Core
https://learn.microsoft.com/ef/core/miscellaneous/testing/- Testes de integração no ASP.NET Core
https://learn.microsoft.com/aspnet/core/test/integration-tests