Тестирование приложений MVC ASP.NET Core
Совет
Это фрагмент из книги, архитектор современных веб-приложений с ASP.NET Core и Azure, доступный в документации .NET или в виде бесплатного скачиваемого PDF-файла, который можно читать в автономном режиме.
"Если вы не любите модульное тестирование продукта, скорее всего, ваши клиенты не хотели бы протестировать его, либо". _-Анонимные-
В ответ на изменения в программном обеспечении любой сложности могут возникать самые непредвиденные ошибки. Соответственно, после внесения изменений для любых приложений, за исключением самых простых или наименее важных, необходимо проводить тестирование. Тестирование вручную является самым медленным, наименее надежным и наиболее дорогим способом проверить программное обеспечение. К сожалению, если возможность тестирования не заложена в приложение на этапе проектирования, это может быть единственный доступный способ тестирования. Приложения, написанные с соблюдением принципов архитектуры, изложенных в главе 4, по большей части должны поддерживать модульное тестирование. Приложения ASP.NET Core поддерживают автоматизированную интеграцию и функциональное тестирование.
Виды автоматических тестов
Существует множество различных автоматических тестов приложений. Самым простым низкоуровневым подходом является модульное тестирование. Чуть выше уровнем находятся интеграционные и функциональные тесты. В этом документе не описаны другие виды тестирования, например тесты пользовательского интерфейса, нагрузочные тесты и тесты на принятие сборки.
Модульные тесты
В рамках модульного теста проверяется отдельная часть логики вашего приложения. Проще описать те возможности, которые не реализует модульное тестирование. Модульный тест не позволяет проверить работу кода с зависимостями или инфраструктурой, для чего предназначены интеграционные тесты. Модульный тест не позволяет проверить платформу, на которой написан код. Если возникают ошибки такого рода, следует отправить соответствующий отчет и написать код для обходного решения проблемы. Модульный тест выполняется полностью в памяти и внутри процесса. Он не взаимодействует с файловой системой, сетью или базой данных. Модульные тесты предназначены исключительно для тестирования кода.
Поскольку модульные тесты проверяют только один блок кода без каких-либо внешних зависимостей, они должны выполняться исключительно быстро. Таким образом, набор из нескольких сотен модульных тестов может быть выполнен буквально за пару секунд. Модульные тесты следует выполнять как можно чаще, в идеальном случае перед каждой отправкой в общий репозиторий системы управления версиями и в обязательном порядке перед каждой автоматизированной сборкой на сервере сборки.
Интеграционные тесты
Несмотря на рекомендации по инкапсуляции кода, который взаимодействует с такими инфраструктурами, как базы данных и файловые системы, в некоторых случаях такой код все же используется и требует тестирования. Кроме того, вам необходимо проверить корректность взаимодействия между слоями вашего кода после того, как будут полностью разрешены все зависимости приложения. Для этого используются интеграционные тесты. Интеграционные тесты выполняются дольше и требуют более тщательной настройки по сравнению с модульными, поскольку они часто полагаются на внешние зависимости и инфраструктуры. В связи с этим по возможности не следует проверять с помощью интеграционных тестов то, что может быть проверено посредством модульных тестов. Если конкретный сценарий можно проверить с помощью модульного теста, необходимо сделать именно так. Если это невозможно, тогда попробуйте прибегнуть к интеграционному тестированию.
Процедуры настройки и уничтожения интеграционных тестов обычно гораздо более сложны по сравнению с модульными тестами. Например, после выполнения интеграционного теста в отношении реальной базы данных необходимо предусмотреть способ возврата базы в известное состояние, которое было до начала тестирования. С развитием новых тестов, добавляемых в схему рабочей базы данных, возрастает размер и степень сложности таких тестовых скриптов. Во многих крупных системах практически нецелесообразно выполнять полный набор интеграционных тестов на рабочих станциях разработчиков перед возвратом изменений в общую систему управления версиями. В таких случаях интеграционные тесты могут выполняться на сервере сборки.
Функциональные тесты
Интеграционные тесты позволяют проверить корректность совместной работы нескольких компонентов системы с точки зрения разработчика. В отличие от них, функциональные тесты позволяют проверить соответствие системы требованиям с точки зрения пользователя. В следующей цитате приводится наглядная аналогия, позволяющая сравнить функциональные и модульные тесты:
"Разработку системы уже не раз сравнивали с возведением дома. И хотя это не совсем корректно, на базе этой аналогии мы можем понять разницу между модульным и функциональным тестированием. Модульное тестирование похоже на посещение строительной площадки инспектором. Он проверяет различные внутренние системы дома, его фундамент, несущие конструкции, электрические и сантехнические сети, а также множество других компонентов. Таким образом, инспектор проверяет (тестирует) безопасность всех частей дома и их соответствие строительным нормам. В этом контексте функциональные тесты можно сравнить с визитом владельца дома. Он справедливо полагает, что все внутренние системы были проверены инспектором и, соответственно, функционируют в соответствии с предназначением. Сам же владелец при этом будет сосредоточен на том, чтобы проверить, насколько комфортно ему будет жить в новом доме. Он будет проверять внешний вид дома, размер его комнат, соответствие дома потребностям семьи и даже то, не будет ли его в дальнейшем беспокоить утренний свет, попадающий в окна. Таким образом, владелец дома проводит функциональное тестирование своего жилища. Важно понимать, что он делает это с точки зрения пользователя. Инспектор осуществляет модульное тестирование. Иными словами, он проверяет дом с точки зрения строителя".
Источник: Сравнение модульного и функционального тестирования
Я очень люблю говорить: "Как разработчики, мы не сможем двумя способами: мы создадим вещь неправильно, или мы создадим не такую вещь". Модульные тесты гарантируют, что вы создаете все правильно; функциональные тесты гарантируют, что вы создаете правильные вещи.
Поскольку функциональные тесты выполняются на уровне системы, для них может потребоваться определенная степень автоматизации пользовательского интерфейса. Как и интеграционные тесты, они обычно имеют дело с определенного вида тестовой инфраструктурой. Это делает их еще более медленными и машинно-зависимыми по сравнению с модульными и интеграционными тестами. Поэтому необходимо использовать ровно столько функциональных тестов, сколько нужно для того, чтобы гарантировать функционирование системы в соответствии с требованиями пользователя.
Пирамида тестирования
Мартин Фаулер (Martin Fowler) описал пирамиду тестирования, пример которой показан на рис. 9-1.
Рис. 9-1. Пирамида тестирования
Уровни пирамиды и их относительный размер описывают различные виды тестов и их количество, необходимое для проверки приложения. Как видно из рисунка, в качестве фундамента рекомендуется брать большое количество модульных тестов, поверх которого проводится меньшее число интеграционных тестов. Венчает же пирамиду малочисленный уровень функциональных тестов. В идеале каждый уровень должен содержать только те тесты, которые невозможно выполнить надлежащим образом на более низком уровне. При выборе теста, который будет выполняться в каждом конкретном сценарии, помните о пирамиде тестирования.
Что следует проверить
Разработчики, не имеющие достаточного опыта в написании автоматических тестов, часто сталкиваются с проблемой выбора того, что следует проверять. Для начала рекомендуется проверить условную логику. Во всех случаях, когда используется метод, поведение которого изменяется в зависимости от значения условного выражения (if-else, switch и т. д.), следует проводить хотя бы пару тестов, проверяющих корректность поведения в разных условиях. Если в коде используются условия ошибки, следует написать как минимум один тест для безошибочного прохождения кода и хотя бы один для выполнения кода с ошибками или нетипичными результатами. Это позволит проверить корректность работы приложения при возникновении ошибок. Наконец, попробуйте сосредоточиться на том, что может пойти не по сценарию, и не зацикливайтесь на таких показателях, как объем протестированного кода. Тем не менее в большинстве случаев рекомендуется протестировать как можно больший объем кода. Но обычно лучше потратить больше времени на написание нескольких тестов для сложного и критически важного метода, чем многократно проверять автоматические свойства, только чтобы увеличить объем протестированного кода.
Упорядочение тестовых проектов
Вы можете упорядочивать тестовые проекты так, как это удобно вам. В качестве общей рекомендации можно посоветовать разделять тесты по виду (модульные, интеграционные) и по тому, что они проверяют (проект, пространство имен). Будут ли для этого применяться папки в рамках одного тестового проекта или потребуется несколько тестовых проектов, зависит от структуры решения. Работать с одним проектом проще, однако для крупных проектов с множеством тестов или для удобства выполнения различных наборов тестов вы можете использовать несколько тестовых проектов. Многие команды упорядочивают тестовые проекты на основе проверяемых проектов. В этом случае для приложений с достаточно большим числом проектов может получиться слишком много тестовых проектов, особенно если для каждого из них осуществляется разбиение по виду теста. В качестве компромисса можно использовать подход с одним проектом для каждого вида теста для каждого приложения. В этом случае проверяемые проекты и классы будут определяться с помощью папок внутри тестовых проектов.
Общепринятый подход предполагает размещение проектов приложения в папке "src", а тестовых проектов — в параллельной папке "tests". Если это покажется вам более удобным, вы можете создать соответствующие папки решений в Visual Studio.
Рис. 9-2. Организация теста в решении
Вы можете использовать любую предпочитаемую платформу тестирования. Эффективным решением является платформа xUnit, на которой пишутся все тесты для ASP.NET Core и EF Core. Вы можете добавить тестовый проект xUnit в Visual Studio, используя показанный на рис. 9-3 шаблон или через интерфейс командной строки с помощью dotnet new xunit
.
Рис. 9-3. Добавление тестового проекта xUnit в Visual Studio
Присвоение имен тестам
Присваивайте тестам согласованные имена, которые указывают на выполняемые ими задачи. Я могу порекомендовать успешно проверенный на практике подход, при котором имена тестовых классов задаются на основе проверяемого ими класса и метода. В результате такого подхода получается много небольших тестовых классов, но при этом предельно ясно, за что отвечает каждый тест. Таким образом, имена тестовых классов определяют проверяемый класс и метод. С помощью имени тестового метода можно указать, какое поведение он проверяет. Это имя должно определять ожидаемое поведение, а также все входные данные и допущения, которые должны приводить к такому поведению. Примеры имен тестов:
CatalogControllerGetImage.CallsImageServiceWithId
CatalogControllerGetImage.LogsWarningGivenImageMissingException
CatalogControllerGetImage.ReturnsFileResultWithBytesGivenSuccess
CatalogControllerGetImage.ReturnsNotFoundResultGivenImageMissingException
В качестве варианта можно добавить в конец имени тестового класса слово "Should" (Должен) и слегка изменить формулировку:
CatalogControllerGetImage
Should.
CallImageServiceWithId
CatalogControllerGetImage
Should.
LogWarningGivenImageMissingException
Некоторые разработчики считают второй подход более понятным, несмотря на более длинные имена. В любом случае постарайтесь выбрать соглашение об именовании, которое позволит легко определять поведение теста. Благодаря этому в случае неудачного завершения одного или нескольких тестов вы сможете легко определить, что именно не удалось сделать. Не рекомендуется присваивать тестам слишком расплывчатые имена, например ControllerTests.Test1, так как по ним вы не сможете определить, на что указывают результаты теста.
Если вы используете одно из описываемых выше соглашений об именовании, в результате чего получаете множество небольших тестовых классов, рекомендуется упорядочить тесты по папкам и пространствам имен. На рис. 9-4 показан возможный подход к упорядочению тестов по папкам в рамках нескольких тестовых проектов.
Рис. 9-4. Упорядочение тестовых классов по папкам на основе проверяемых классов.
Если конкретный класс приложения содержит много тестируемых методов (и, соответственно, много тестовых классов), будет удобнее поместить эти классы в папку, соответствующую классу приложения. Такой подход аналогичен упорядочению обычных файлов в папках. Если в папке, содержащей множество файлов, присутствует больше трех или четырех связанных файлов, их зачастую рекомендуется перенести в отдельную вложенную папку.
Модульное тестирование приложений ASP.NET Core
В грамотно спроектированном приложении ASP.NET Core большая часть сложных функций и бизнес-логики инкапсулируется в бизнес-сущностях и различных службах. Само приложение MVC ASP.NET Core со всеми его контроллерами, фильтрами, моделями представлений и представлениями должно требовать всего нескольких модульных тестов. Большая часть функционала конкретного действия выполняется за пределами самого метода действия. Модульные тесты не позволяют эффективно проверить правильность выполнения маршрутизации или глобальной обработки ошибок. Кроме того, модульное тестирование не позволяет проверить какие-либо фильтры, в том числе применяемые для подтверждения, проверки подлинности и авторизации, в ходе теста, предназначенного для метода действия контроллера. Без этих источников реакции на события большинство методов действия будут иметь небольшой размер, поскольку они делегируют основную часть выполняемых задач службам, которые могут быть протестированы независимо от использующего их контроллера.
В некоторых случаях, чтобы провести модульное тестирование кода, требуется выполнить его рефакторинг. Зачастую для этого требуется определить абстракции и использовать внедрение зависимостей для доступа к абстракции в коде, который требуется протестировать, вместо того, чтобы писать код непосредственно для работы с инфраструктурой. Например, рассмотрим следующий простой метод действия для показа изображений:
[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");
}
Модульное тестирование этого метода затруднено из-за наличия прямой зависимости от System.IO.File
, который используется для чтения из файловой системы. Вы можете проверить корректность поведения этого метода, однако для тестирования работы с реальными файлами вам потребуются интеграционные тесты. Стоит отметить, что вы не можете модульный тест этот метод маршрут— вы увидите, как это сделать с помощью функционального теста в ближайшее время.
Что следует проверять, если вы не можете напрямую протестировать поведение файловой системы или маршрут с помощью модульного теста? После рефакторинга, который позволит провести модульное тестирование, вы можете заметить отсутствие некоторых тестовых случаев и функционального поведения, например обработки ошибок. Что делает метод в том случае, если файл не найден? Что он должен делать в такой ситуации? В этом примере после рефакторинга метод будет выглядеть следующим образом:
[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
и _imageService
. После этого вы можете проверить, что в _imageService
передается тот же идентификатор, который был передан в метод действия, а полученные байты возвращаются в FileResult. Вы также можете проверить правильность ведения журнала ошибок и что в случае отсутствия изображения возвращается результат NotFound
, так как это поведение можно отнести к важным функциям приложения (т. е. это не просто временный код, который разработчик добавляет для диагностики проблем). Реальная логика файла вынесена в отдельную службу реализации и дополнена таким образом, чтобы возвращать специфичное для приложения исключение в том случае, если файл не найден. Эту реализацию можно проверить отдельно с помощью интеграционного теста.
В большинстве случаев стоит использовать обработчики глобальных исключений в контроллерах, чтобы объем логики в них был минимальным и модульное тестирование не требовалось. Выполните большую часть тестирования действий контроллера с помощью функциональных тестов и класса TestServer
, как описано ниже.
Интеграционное тестирование приложений ASP.NET Core
Большая часть интеграционных тестов в приложениях ASP.NET Core должны работать со службами и другими типами реализации, определенными в проекте инфраструктуры. Например, вы могли бы проверить, что EF Core успешно обновился и получил ожидаемые данные из ваших классов доступа к данным, находящихся в проекте инфраструктуры. Лучший способ проверить, правильно ли работает проект ASP.NET Core MVC, — с помощью функциональных тестов, выполняемых при работе приложения на тестовом узле.
Функциональное тестирование приложений ASP.NET Core
Для удобства функционального тестирования приложений ASP.NET Core используется класс TestServer
. Для настройки TestServer
напрямую используется WebHostBuilder
или HostBuilder
(как обычно для приложения) или тип WebApplicationFactory
(начиная с версии 2.1). Постарайтесь, чтобы тестовый узел максимально соответствовал рабочему узлу. Тогда при тестировании приложение ведет себя так же, как в рабочей среде. Класс WebApplicationFactory
помогает настраивать каталог ContentRoot на сервере TestServer, который используется платформой ASP.NET Core для поиска статических ресурсов, таких как представления.
Можно создать простые функциональные тесты, создав тестовый класс, который реализует IClassFixture<WebApplicationFactory<TEntryPoint>>
, где TEntryPoint
— это класс Startup
веб-приложения. При наличии этого интерфейса ваше средство тестирования может создать клиент с помощью метода фабрики CreateClient
:
public class BasicWebTests : IClassFixture<WebApplicationFactory<Program>>
{
protected readonly HttpClient _client;
public BasicWebTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
// write tests that use _client
}
Совет
Если вы используете в файле Program.cs минимальную конфигурацию API, по умолчанию класс будет объявлен внутренним и не будет доступен из тестового проекта. Вместо этого можно выбрать в веб-проекте любой другой класс экземпляра или добавить его в файл Program.cs.
// Make the implicit Program class public so test projects can access it
public partial class Program { }
Как правило, необходимо выполнять дополнительную настройку сайта перед запуском каждого теста; например, настроить приложение, чтобы оно использовало выполняющееся в памяти хранилище данных, а затем заполнилось тестовыми данными. Для этого создайте собственный подкласс WebApplicationFactory<TEntryPoint>
и переопределите его метод ConfigureWebHost
. Приведенный ниже пример взят из проекта eShopOnWeb FunctionalTests и используется как часть тестов в главном веб-приложении.
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}");
}
}
});
}
}
Тесты могут использовать эту пользовательскую фабрику WebApplicationFactory, чтобы с ее помощью создать клиент, а затем делать запросы к приложению, используя этот экземпляр клиента. Приложение заполнится данными, которые можно использовать в утверждениях теста. Следующий тест проверяет, что домашняя страница приложения eShopOnWeb правильно загружается и содержит список продуктов, который был добавлен в приложение при заполнении данными.
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);
}
}
Этот функциональный тест выполняет полный стек приложения MVC ASP.NET Core/Razor Pages, включая все ПО промежуточного слоя, фильтры, модули привязки и т. д. Он проверяет, что заданный маршрут ("/") возвращает ожидаемый код состояния успеха и выходные данные HTML. Реальный веб-сервер для этого не настраивается, что позволяет избежать большинства проблем, связанных с тестированием на реальном веб-сервере (например, с настройкой брандмауэра). Функциональные тесты, выполняемые в отношении TestServer, как правило, медленнее интеграционных и модульных тестов, однако значительно быстрее тестов, которые проводятся по сети с использованием тестового веб-сервера. Используйте функциональные тесты, чтобы убедиться, что стек интерфейса приложения работает надлежащим образом. Эти тесты особенно полезны в случаях, когда вы обнаруживаете дублирование в контроллерах или страницах и решаете эту проблему путем добавления фильтров. В идеале этот рефакторинг не изменит поведение приложения, и набор функциональных тестов проверит это.
Справочники — тестирование приложений MVC ASP.NET Core
- Тестирование на платформе ASP.NET Core
https://learn.microsoft.com/aspnet/core/testing/- Соглашение об именовании модульных тестов
https://ardalis.com/unit-test-naming-convention- Тестирование EF Core
https://learn.microsoft.com/ef/core/miscellaneous/testing/- Интеграционные тесты на платформе ASP.NET Core
https://learn.microsoft.com/aspnet/core/test/integration-tests