Поделиться через


Тестирование приложений MVC ASP.NET Core

Подсказка

Это фрагмент из книги, архитектор современных веб-приложений с ASP.NET Core и Azure, доступный в .NET Docs или в виде бесплатного скачиваемого PDF-файла, который можно прочитать в автономном режиме.

Архитектура современных веб-приложений с ASP.NET Core и Azure, миниатюра обложки электронной книги.

"Если вы не любите модульное тестирование вашего продукта, скорее всего, ваши клиенты тоже не захотят его тестировать." _- Анонимный -

Программное обеспечение любой сложности может потерпеть сбой неожиданными способами в ответ на изменения. Таким образом, тестирование после внесения изменений требуется для всех, кроме наиболее тривиальных (или наименее критических) приложений. Тестирование вручную является самым медленным, наименее надежным, самым дорогим способом тестирования программного обеспечения. К сожалению, если приложения не предназначены для тестирования, это может быть единственным средством тестирования. Приложения, написанные для выполнения архитектурных принципов, описанных в главе 4 , должны быть в основном модульными тестируемыми. ASP.NET Приложения Core поддерживают автоматическую интеграцию и функциональное тестирование.

Типы автоматизированных тестов

Существует множество автоматизированных тестов для программных приложений. Самый простой тест на самом низком уровне — это модульный тест. На более высоком уровне существуют тесты интеграции и функциональные тесты. Другие виды тестов, таких как тесты пользовательского интерфейса, нагрузочные тесты, стресс-тесты и тесты дыма, выходят за рамки этого документа.

Модульные тесты

Модульное тестирование проверяет одну часть логики приложения. Можно дальше описать это, перечислив некоторые вещи, которыми оно не является. Модульный тест не проверяет, как работает код с зависимостями или инфраструктурой— это то, что такое тесты интеграции. Модульный тест не тестирует платформу, на которой написан код — вы должны предполагать, что она работает или, если вы обнаружите, что это не так, сообщите о проблеме и создайте обходное решение. Модульный тест выполняется полностью в памяти и в процессе выполнения. Он не взаимодействует с файловой системой, сетью или базой данных. Модульные тесты должны тестировать только код.

Модульные тесты, из-за того, что они проверяют только одну единицу кода без внешних зависимостей, должны выполняться очень быстро. Таким образом, вы сможете запускать наборы тестов сотен модульных тестов в течение нескольких секунд. Запустите их часто, в идеале перед каждой отправкой в общий репозиторий системы управления версиями, и, конечно, с каждой автоматической сборкой на сервере сборки.

Интеграционные тесты

Хотя рекомендуется инкапсулировать код, взаимодействующий с инфраструктурой, такой как базы данных и файловые системы, вы все равно будете иметь некоторые из этого кода, и вы, вероятно, хотите протестировать его. Кроме того, необходимо убедиться, что слои кода взаимодействуют в соответствии с вашими ожиданиями, когда зависимости приложения полностью удовлетворены. Эта функция отвечает за тесты интеграции. Тесты интеграции, как правило, медленнее и сложнее настроить, чем модульные тесты, так как они часто зависят от внешних зависимостей и инфраструктуры. Таким образом, следует избежать тестирования вещей, которые можно протестировать с помощью модульных тестов в тестах интеграции. Если вы можете протестировать заданный сценарий с помощью модульного теста, необходимо протестировать его с помощью модульного теста. Если вы не можете, попробуйте использовать тест интеграции.

Интеграционные тесты часто имеют более сложные процедуры настройки и завершения, чем модульные тесты. Например, интеграционный тест, который производится с фактической базой данных, потребует метода для возврата базы данных в известное состояние перед каждым запуском теста. По мере добавления новых тестов и развития схемы рабочей базы данных эти скрипты тестирования, как правило, будут увеличиваться в размерах и сложности. Во многих крупных системах непрактично выполнять полные наборы тестов интеграции на рабочих станциях разработчиков, прежде чем проверять изменения общего управления версиями. В этих случаях тесты интеграции могут выполняться на сервере сборки.

Функциональные тесты

Тесты интеграции написаны с точки зрения разработчика, чтобы убедиться, что некоторые компоненты системы работают правильно. Функциональные тесты записываются с точки зрения пользователя и проверяют правильность системы на основе его требований. Следующий фрагмент содержит полезные аналогии по поводу того, как думать о функциональных тестах по сравнению с модульными тестами:

Часто развитие системы уподобляют строительству дома. Хотя эта аналогия не совсем правильна, мы можем расширить ее в целях понимания разницы между модульными и функциональными тестами. Модульное тестирование аналогично строительному инспектору, посещающему строительную площадку дома. Он сосредоточен на различных внутренних системах дома, фундамента, обрамления, электротехники, сантехники и т. д. Он проверяет, что части дома будут работать правильно и безопасно, то есть соответствовать строительным нормам. Функциональные тесты в этом сценарии аналогичны домовладельцу, который посещает эту же строительную площадку. Он предполагает, что внутренние системы будут вести себя соответствующим образом, что инспектор здания выполняет свою задачу. Домовладелец сосредоточен на том, как это будет жить в этом доме. Он обеспокоен тем, как выглядит дом, удобного ли размера различные комнаты, соответствует ли дом потребностям семьи, удачно ли расположены окна, чтобы ловить утреннее солнце. Домовладельц выполняет функциональные тесты в доме. Он понимает точку зрения пользователя. Инспектор по строительству проводит проверки в доме. У него есть перспектива строителя".

Источник: модульное тестирование против функциональных тестов

Я очень люблю говорить: "Как разработчики, мы терпим неудачу двумя способами: мы создаем вещь неправильно, или мы создаем не ту вещь". Модульные тесты гарантируют, что вы создаете вещь правильно; функциональные тесты гарантируют, что вы создаете правильную вещь.

Так как функциональные тесты работают на уровне системы, им может потребоваться некоторая степень автоматизации пользовательского интерфейса. Как и тесты интеграции, они обычно работают с какой-то тестовой инфраструктурой. Эта деятельность делает их медленнее и более хрупкими, чем модульные и интеграционные тесты. Вы должны иметь только столько функциональных тестов, сколько вам нужно убедиться, что система ведет себя так, как пользователи ожидают.

Тестирование пирамиды

Мартин Фаулер написал о пирамиде тестирования, пример которого показан на рис. 9-1.

Тестирование пирамиды

Рис. 9-1. Тестирование пирамиды

Различные слои пирамиды и их относительные размеры представляют различные виды тестов и сколько необходимо написать для приложения. Как видите, рекомендация состоит в том, чтобы иметь большую базу модульных тестов, поддерживаемую меньшим уровнем тестов интеграции, с еще меньшим уровнем функциональных тестов. Каждый слой должен в идеале иметь только тесты в нем, которые не могут быть выполнены должным образом на нижнем слое. Имейте в виду пирамиду тестирования при попытке решить, какой тест требуется для конкретного сценария.

Что протестировать

Распространенная проблема для разработчиков, которые имеют ограниченный опыт в написании автоматизированных тестов, заключается в том, чтобы решить, что именно нужно тестировать. Хорошая отправная точка — проверить условную логику. Повсюду, где у вас есть метод, поведение которого изменяется в зависимости от условного оператора (if-else, switch и т. д.), вы должны иметь возможность придумать как минимум пару тестов, подтверждающих правильное поведение при определённых условиях. Если в коде есть условия ошибки, рекомендуется написать по крайней мере один тест на "счастливый путь" через код (без ошибок) и по крайней мере один тест для "грустного пути" (с ошибками или нетипическими результатами), чтобы подтвердить поведение приложения, как ожидалось в случае ошибок. Наконец, попробуйте сосредоточиться на тестировании вещей, которые могут завершиться ошибкой, а не сосредоточиться на метриках, таких как покрытие кода. Как правило, большее покрытие кода лучше, чем меньшее. Однако обычно лучше потратить время на написание нескольких тестов для сложного и бизнес-критического метода, чем на тесты для автосвойств только ради улучшения показателей покрытия тестов.

Организация тестовых проектов

Тестовые проекты можно организовать так, как вам удобнее всего. Рекомендуется разделить тесты по типам (модульный тест, тест интеграции) и по тому, что они тестируют (по проекту, по пространству имен). Независимо от того, состоит ли это разделение из папок в одном тестовом проекте или нескольких тестовых проектах, это вопрос проектирования. Один проект прост, но для больших проектов с большим количеством тестов или для более простого выполнения различных наборов тестов может потребоваться несколько различных тестовых проектов. Многие команды упорядочивают тестовые проекты на основе тестового проекта, который для приложений с более чем несколькими проектами может привести к большому количеству тестовых проектов, особенно если вы по-прежнему разбиваете эти тесты в соответствии с тем, какие тесты находятся в каждом проекте. Компромиссный подход заключается в осуществлении одного проекта на каждый вид тестирования для каждого приложения, с папками внутри тестовых проектов, показывающими тестируемый проект (и класс).

Распространенный подход заключается в организации проектов приложений в папке src и тестовых проектах приложения в параллельной папке test. Вы можете создать соответствующие папки решения в Visual Studio, если вы найдете эту организацию полезной.

Организация тестов в вашем решении

Рис. 9-2. Организация тестирования в вашем решении

Вы можете использовать любую тестовую платформу, которую вы предпочитаете. Платформа xUnit хорошо работает и является тем, в чем написаны все тесты ASP.NET Core и EF Core. Тестовый проект xUnit можно добавить в Visual Studio с помощью шаблона, показанного на рис. 9-3, или из интерфейса командной строки.dotnet new xunit

Добавление тестового проекта xUnit в Visual Studio

Рис. 9-3. Добавление тестового проекта xUnit в Visual Studio

Тестирование названий

Давайте тестам согласованные имена, которые ясно указывают, что делает каждый тест. Один из подходов, с которыми у меня был большой успех, заключается в том, чтобы назвать тестовые классы в соответствии с классом и методом, с которыми они тестируются. Этот подход приводит к множеству небольших тестовых классов, но чрезвычайно ясно показывает, за что отвечает каждый тест. При настройке имени тестового класса для идентификации класса и метода для проверки можно использовать имя метода теста для указания проверяемого поведения. Это имя должно включать ожидаемое поведение и любые входные данные или предположения, которые должны дать это поведение. Примеры имен тестов:

  • CatalogControllerGetImage.CallsImageServiceWithId

  • CatalogControllerGetImage.LogsWarningGivenImageMissingException

  • CatalogControllerGetImage.ReturnsFileResultWithBytesGivenSuccess

  • CatalogControllerGetImage.ReturnsNotFoundResultGivenImageMissingException

Вариант этого подхода заканчивает каждое имя тестового класса на "Should" и немного изменяет грамматическое время:

  • CatalogControllerGetImage Следует.ЗватьImageServiceWithId

  • CatalogControllerGetImage Следует.РегистрироватьWarningGivenImageMissingException

Некоторые команды находят второй подход именования более четким, хотя немного более подробным. В любом случае попробуйте использовать соглашение об именовании, которое предоставляет представление о поведении теста, так что при сбое одного или нескольких тестов очевидно из их имен, какие случаи завершились сбоем. Избегайте именования тестов расплывчатым образом, например ControllerTests.Test1, так как эти имена не имеют значения, когда они отображаются в результатах теста.

Если вы следуете соглашению об именовании, например приведенному выше, которое создает множество небольших классов тестов, рекомендуется дополнительно упорядочить тесты с помощью папок и пространств имен. На рисунке 9-4 показан один подход к организации тестов по папкам в нескольких тестовых проектах.

Организация тестового класса по папке на основе тестового класса

Рис. 9-4. Организация тестовых классов по папкам на основе тестируемого класса.

Если определенный класс приложения имеет множество методов тестирования (и, следовательно, многие тестовые классы), это может быть целесообразно разместить эти классы в папке, соответствующей классу приложения. Эта организация не отличается от того, как вы можете упорядочить файлы в папках в другом месте. Если у вас более трех или четырех связанных файлов в папке, содержащей много других файлов, их часто полезно переместить в собственную вложенную папку.

Модульное тестирование приложений ASP.NET Core

В хорошо разработанном приложении ASP.NET Core большая часть сложности и бизнес-логики будет инкапсулирована в бизнес-сущностях и различных службах. Самому приложению ASP.NET Core MVC, с его контроллерами, фильтрами, моделями представлений и представлениями, должно требоваться немного модульных тестов. Большая часть функциональных возможностей данного действия лежит за пределами самого метода действия. Проверка правильности работы маршрутизации или глобальной обработки ошибок не может быть эффективно выполнена с помощью модульного теста. Аналогичным образом, любые фильтры, включая проверку модели и фильтры проверки подлинности и авторизации, нельзя модульно тестировать с помощью метода действия контроллера. Без этих источников поведения большинство методов действий должны быть тривиально небольшими, делегируя большую часть своей работы службам, которые можно протестировать независимо от контроллера, использующего их.

Иногда необходимо рефакторинг кода для модульного тестирования. Часто это действие включает определение абстракций и внедрение зависимостей для доступа к абстракции в коде, который вы хотите проверить, а не кодировать непосредственно в инфраструктуре. Например, рассмотрим этот простой метод действия для отображения изображений:

[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 успешно обновлял и извлекал данные, ожидаемые из классов доступа к данным, размещенных в проекте инфраструктуры. Лучший способ проверить правильность работы проекта MVC ASP.NET Core — это функциональные тесты, которые выполняются в приложении, работающем на тестовом узле.

Функциональное тестирование приложений 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
}

Подсказка

Если вы используете минимальную конфигурацию API в файле Program.cs , по умолчанию класс будет объявлен внутренним и не будет доступен из тестового проекта. Вместо этого можно выбрать любой другой класс экземпляра в веб-проекте или добавить его в файл 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);
  }
}

Этот функциональный тест проверяет полный стек приложений ASP.NET Core MVC / Razor Pages, включая все промежуточные слои, фильтры и привязки, которые могут находиться на месте. Он проверяет, возвращает ли заданный маршрут ("/") ожидаемый код состояния успешности и выходные данные HTML. Он делает это без настройки реального веб-сервера и избегает значительных проблем, которые могут возникнуть при использовании реального веб-сервера для тестирования (например, проблемы с настройками брандмауэра). Функциональные тесты, выполняемые с помощью TestServer, обычно медленнее интеграции и модульных тестов, но гораздо быстрее, чем тесты, которые будут выполняться по сети на тестовый веб-сервер. Используйте функциональные тесты, чтобы убедиться, что интерфейсный стек приложения работает должным образом. Эти тесты особенно полезны при поиске дублирования в контроллерах или страницах, а также для устранения дублирования путем добавления фильтров. В идеале это рефакторинг не изменит поведение приложения, и набор функциональных тестов будет проверять, что это так.

Ссылки по тестированию приложений на ASP.NET Core MVC