Май 2016

Том 31 номер 5

ASP.NET Core - Написание ясного кода в ASP.NET Core с использованием встраивания зависимостей

Стив Смит | Май 2016

Эта статья основана на ASP.NET Core 1.0 предварительной версии RC1. Некоторая информация может быть изменена при выпуске версии RC2.

Продукты и технологии:

ASP.NET Core 1.0

В статье рассматриваются:

  • примеры жесткого связывания;
  • написание «честных» классов;
  • встраивание зависимостей в ASP.NET Core;
  • код с возможностью модульного тестирования;
  • тестирование обязанностей контроллера.

Исходный код можно скачать по ссылке

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

Жесткое связывание

Жесткое связывание (tight coupling) хорошо подходит для демонстрационного программного обеспечения. Если вы посмотрите на типичное приложение-пример, показывающее, как создавать сайты на основе ASP.NET MVC (версий 3–5), то скорее всего найдете код, подобный следующему (взят из класса DinnersController приложения-примера NerdDinner, использующего MVC 4):

private NerdDinnerContext db = new NerdDinnerContext();
private const int PageSize = 25;

public ActionResult Index(int? page)
{
  int pageIndex = page ?? 1;

  var dinners = db.Dinners
    .Where(d => d.EventDate >=
      DateTime.Now).OrderBy(d => d.EventDate);
  return View(dinners.ToPagedList(pageIndex, PageSize));
}

Глядя на код, чтобы оценить степень его связывания, помните фразу «new является связующим».

Этот тип кода очень сложен в модульном тестировании, потому что NerdDinnerContext создается в процессе конструирования класса и требует подключения к базе данных. Неудивительно, что такие демонстрационные приложения нечасто включают какие-либо модульные тесты. Однако ваше приложение может выиграть от нескольких модульных тестов, даже если вы не занимаетесь разработкой на основе тестов, так что было бы лучше писать код, который можно было бы протестировать. Более того, этот код нарушает принцип Don’t Repeat Yourself (DRY) (принцип «не повторяйся»), поскольку каждый класс контроллера, так или иначе обращающийся к данным, содержит один и тот же код для создания EF-контекста базы данных (Entity Framework). Это делает внесение будущих изменений более дорогостоящим и подверженным ошибкам, особенно по мере развития приложения в течение длительного времени.

Глядя на код, чтобы оценить степень его связывания, помните фразу «new является связующим». То есть везде, где экземпляр класса создается с помощью ключевого слова new, ваша реализация связывается конкретно с этим кодом реализации. Dependency Inversion Principle (принцип инверсии зависимостей) (bit.ly/DI-Principle) утверждает: «Абстракции не должны зависеть от деталей — детали должны зависеть от абстракций». В этом примере детали того, как контроллер извлекает данные для передачи представлению, зависят от деталей того, как эти данные извлекаются, а именно от EF.

Вдобавок к ключевому слову new «статическое сцепление» является еще одним источником жесткого связывания, которое затрудняет тестирование и сопровождение приложений. В предыдущем примере имеется зависимость от системных часов компьютера в виде вызова DateTime.Now. Это связывание усложнило бы создание набора тестовых Dinners для использования в некоторых модульных тестах, так как их свойства EventDate пришлось бы устанавливать относительно текущему показанию часов. Это связывание можно было бы удалить из данного метода несколькими способами, самый простой из которых — переложить заботу об этом на какую-то новую абстракцию, возвращающую Dinners, чтобы это больше не было частью метода. В качестве альтернативы я мог бы сделать значение параметром, чтобы метод возвращал все Dinners после предоставленного параметра DateTime вместо использования только DateTime.Now. Наконец, можно было бы создать абстракцию для текущего времени и ссылаться на текущее время через эту абстракцию. Это может оказаться хорошим подходом, если приложение часто ссылается на DateTime.Now. (Кроме того, заметьте, что, поскольку эти обеды [dinners] предположительно происходят в разных часовых поясах, тип DateTimeOffset может быть более эффективным выбором в реальном приложении.)

Будьте честны

Другая проблема с сопровождением кода вроде этого — он нечестен со взаимодействующими с ним объектами. Вы должны избегать написания классов, экземпляры которых можно создавать в недопустимых состояниях, так как это является частым источником ошибок. Таким образом, все, что необходимо вашему классу для выполнения своих задач, должно предоставляться через его конструктор. Как утверждает Explicit Dependencies Principle (принцип явных зависимостей) (bit.ly/ED-Principle), «методы и классы должны явным образом требовать любые взаимодействующие объекты (collaborating objects), нужные им для корректной работы». Класс DinnersController имеет лишь конструктор по умолчанию, а это подразумевает, что ему не надо взаимодействовать с какими-либо объектами для корректной работы. Но что будет, если вы подвергнете его тесту? Что сделает этот код, если вы запустите его из нового консольного приложения, которое ссылается на MVC-проект?

var controller = new DinnersController();
var result = controller.Index(1);

Первое, что не удастся в этом случае, — попытка создать экземпляр EF-контекста. Код сгенерирует исключение InvalidOperationException: «No connection string named ‘NerdDinnerContext’ could be found in the application config file.» (в конфигурационном файле приложения не найдена строка подключения с именем ‘NerdDinnerContext’). Меня обманули! Для работы этому классу нужно больше, чем заявляет его конструктор! Если классу необходим какой-то способ доступа к наборам экземпляров Dinner, он должен запрашивать их через свой конструктор (или как параметры в своих методах).

Встраивание зависимостей

Встраивание зависимостей (dependency injection, DI) относится к передаче зависимостей класса или метода в виде параметров вместо «зашивания» в код этих связей через new или статические вызовы. Это набирающий популярность метод в .NET-разработке из-за обеспечения им разъединения приложений, в которых он применяется. В ранних версиях ASP.NET преимущества DI не использовались, и, хотя в ASP.NET MVC и Web API наблюдается прогресс в отношении его поддержки, ни одна из инфраструктур до сих пор не предоставляет полной поддержки, в том числе контейнер для управления зависимостями и жизненными циклами их объектов. В ASP.NET Core 1.0 DI не просто полностью поддерживается — оно повсеместно применяется в самом продукте.

В ASP.NET Core 1.0 DI не просто полностью поддерживается — оно повсеместно применяется в самом продукте.

ASP.NET Core не только поддерживает DI, но и включает DI-контейнер, также называемый контейнером Inversion of Control (IoC) или контейнером сервисов. Каждое приложение ASP.NET Core конфигурирует свои зависимости, используя этот контейнер, в методе ConfigureServices класса Startup. Данный контейнер обеспечивает необходимую базовую поддержку, но может быть заменен пользовательской реализацией, если в этом есть потребность. Более того, EF Core также имеет встроенную поддержку DI, поэтому ее конфигурирование в приложении ASP.NET Core сводится к простому вызову метода расширения. Для этой статьи я создал ответвление NerdDinner с именем GeekDinner. EF Core конфигурируется, как показано ниже:

public void ConfigureServices(IServiceCollection services)
{
  services.AddEntityFramework()
    .AddSqlServer()
    .AddDbContext<GeekDinnerDbContext>(options =>
      options.UseSqlServer(ConnectionString));

  services.AddMvc();
}

После этого довольно легко запросить через DI экземпляр GeekDinnerDbContext от класса контроллера вроде DinnersController:

public class DinnersController : Controller
{
  private readonly GeekDinnerDbContext _dbContext;

  public DinnersController(GeekDinnerDbContext dbContext)
  {
    _dbContext = dbContext;
  }

  public IActionResult Index()
  {
    return View(_dbContext.Dinners.ToList());
  }
}

Заметьте, что нет ни одного экземпляра ключевого слова new; все зависимости, нужные контроллеру, передаются через его контроллер, и заботится об этом за меня DI-контейнер ASP.NET. В процессе написания приложения мне незачем беспокоиться об инфраструктуре, участвующей в разрешении зависимостей, запрашиваемых моими классами через свои конструкторы. Конечно, при желании можно изменить это поведение, даже заменить контейнер по умолчанию другой реализацией. Поскольку теперь мой класс контроллера следует принципу явных зависимостей, я знаю, что для его работы нужно предоставить экземпляр GeekDinnerDbContext. Выполнив небольшую подготовку для DbContext, я могу создать экземпляр контроллера сам по себе, как демонстрирует это консольное приложение:

var optionsBuilder = new DbContextOptionsBuilder();
optionsBuilder.UseSqlServer(Startup.ConnectionString);
var dbContext = new GeekDinnerDbContext(
  optionsBuilder.Options);
var controller = new DinnersController(dbContext);
var result = (ViewResult) controller.Index();

При конструировании DbContext в EF Core требуется несколько больше работы, чем в EF6, где просто принималась строка подключения. Дело в том, что, как и ASP.NET Core, EF Core спроектирован в расчете на большую модульность. Обычно вам не понадобится иметь дело напрямую с DbContextOptionsBuilder, так как он используется «за кулисами», когда вы конфигурируете EF через методы расширения вроде AddEntityFramework и AddSqlServer.

А можно ли это протестировать?

Тестирование приложения вручную является важным — вам нужна возможность запустить его и убедиться, что оно действительно запускается и дает ожидаемый вывод. Но поступать так всякий раз, когда вносится какое-то изменение, — пустая трата времени. Одно из значимых преимуществ свободно связанных приложений в том, что они, как правило, лучше поддаются модульному тестированию, чем жестко связанные. Еще важнее, что ASP.NET Core и EF Core гораздо проще в тестировании, чем их предшественники. Для начала я напишу простой тест, который напрямую передает в контроллер какой-то DbContext, который был сконфигурирован на использование хранилища в памяти. Я настрою GeekDinnerDbContext, используя параметр DbContextOptions, который предоставляется этим контекстом через конструктор:

var optionsBuilder = new DbContextOptionsBuilder<
  GeekDinnerDbContext>();
optionsBuilder.UseInMemoryDatabase();
_dbContext = new GeekDinnerDbContext(optionsBuilder.Options);

// Добавляем образцы данных
_dbContext.Dinners.Add(new Dinner() { Title = "Title 1" });
_dbContext.Dinners.Add(new Dinner() { Title = "Title 2" });
_dbContext.Dinners.Add(new Dinner() { Title = "Title 3" });
_dbContext.SaveChanges();

Сконфигурировав это в тестовом классе, легко написать тест, показывающий, что Model в ViewResult возвращает корректные данные:

[Fact]
public void ReturnsDinnersInViewModel()
{
  var controller = new OriginalDinnersController(_dbContext);

  var result = controller.Index();

  var viewResult = Assert.IsType<ViewResult>(result);
  var viewModel = Assert.IsType<IEnumerable<Dinner>>(
    viewResult.ViewData.Model).ToList();
  Assert.Equal(1, viewModel.Count(d => d.Title == "Title 1"));
  Assert.Equal(3, viewModel.Count);
}

Конечно, здесь пока что мало логики для тестирования, поэтому данный тест ничего особенного и не проверяет. Критики возразили бы, что этот тест малозначим, и я согласился бы с ними. Однако это отправная точка для будущего теста, когда появится больше логики, что и будет сделано в самом ближайшем будущем. Но сначала, хоть EF Core и поддерживает модульное тестирование в памяти, я все же позабочусь о предотвращении прямого связывания с EF в своем контроллере. Нет никаких причин для связывания обязанностей UI с обязанностями инфраструктуры доступа к данным — по сути, это нарушило бы другой принцип, Separation of Concerns (принцип разделения обязанностей).

Избегайте зависимости от того, что не используется

Принцип отделения интерфейса (Interface Segregation Principle) (bit.ly/LS-Principle) утверждает, что классы должны зависеть только от той функциональности, которую они действительно используют. В случае нового DinnersController с поддержкой DI тот по-прежнему зависит от всего DbContext. Вместо склеивания реализации контроллера с EF можно было бы задействовать некую абстракцию, которая предоставляла бы необходимую функциональность.

Что на самом деле нужно этому методу действия (action method) для должного функционирования? Определенно не весь DbContext. Ему даже не требуется доступ к полному свойству Dinners контекста. Ему достаточно возможности отображать экземпляры Dinner соответствующей страницы. Простейшая .NET-абстракция, представляющая это, — IEnumerable<Dinner>. Поэтому я определю интерфейс, который просто возвращает IEnumerable<Dinner>, и это удовлетворит (большую часть) требований метода Index:

public interface IDinnerRepository
{
  IEnumerable<Dinner> List();
}

Я называю это репозитарием, поскольку он следует такому шаблону: он абстрагирует доступ к данным интерфейсом, подобным набору. Если по какой-то причине вам не нравится шаблон репозитария или имя, вы можете назвать его IGetDinners, IDinnerService или как угодно иначе (мой рецензент предложил ICanHasDinner). Независимо от того, как вы назовете этот тип, он будет служить той же цели.

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

Покончив с этим, теперь подстроим DinnersController для приема IDinnerRepository в качестве параметра конструктора вместо GeekDinnerDbContext и вызова метода List вместо прямого обращения к Dinners DbSet:

private readonly IDinnerRepository _dinnerRepository;
public DinnersController(IDinnerRepository dinnerRepository)
{
  _dinnerRepository = dinnerRepository;
}

public IActionResult Index()
{
  return View(_dinnerRepository.List());
}

К этому моменту можно скомпилировать и запустить ваше веб-приложение, но вы столкнетесь с исключением, если перейдете к /Dinners: InvalidOperationException («Unable to resolve service for type ‘GeekDinner.Core.Interfaces.IdinnerRepository’ while attempting to activate GeekDinner.Controllers.DinnersController.») («Не удалось разрешить сервис для типа ‘GeekDinner.Core.Interfaces.IdinnerRepository’ при попытке активировать GeekDinner.Controllers.DinnersController.»). Я пока что не реализовал интерфейс, и, как только это будет сделано, мне также понадобится сконфигурировать свою реализацию для использования, когда DI будет выполнять запросы к IDinnerRepository. Реализация этого интерфейса тривиальна:

public class DinnerRepository : IDinnerRepository
{
  private readonly GeekDinnerDbContext _dbContext;
  public DinnerRepository(GeekDinnerDbContext dbContext)
  {
    _dbContext = dbContext;
  }

  public IEnumerable<Dinner> List()
  {
    return _dbContext.Dinners;
  }
}

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

Чтобы сконфигурировать ASP.NET Core на встраивание правильной реализации, когда классы запрашивают IDinnerRepository, нужно добавить следующую строку кода в конец ранее показанного метода ConfigureServices:

services.AddScoped<IDinnerRepository, DinnerRepository>();

Это выражение инструктирует DI-контейнер ASP.NET Core использовать экземпляр DinnerRepository всякий раз, когда требуется разрешить тип, зависимый от экземпляра IDinnerRepository. Scoped означает, что для каждого веб-запроса, обрабатываемого ASP.NET, будет использоваться один экземпляр. Также можно добавлять сервисы, указывая жизненные циклы Transient или Singleton. В данном случае подходит Scoped, поскольку мой DinnerRepository зависит от DbContext, который тоже использует жизненный цикл Scoped. Вот краткое описание доступных сроков жизни объектов.

  • Transient Используется новый экземпляр типа всякий раз, когда запрашивается этот тип.
  • Scoped При первом запросе в рамках данного HTTP-запроса создается новый экземпляр типа, а затем он повторно используется для всех последующих типов, разрешаемых при этом HTTP-запросе.
  • Singleton Единственный экземпляр типа создается один раз и используется всеми последующими запросами для этого типа.

Встроенный контейнер поддерживает несколько способов конструирования типов, которые он будет предоставлять. Самый типичный случай — простая передача типа контейнеру, после чего тот попытается создать экземпляр этого типа, предоставляя ему любые зависимости, которые тип запрашивает по мере выполнения. Вы также можете указать лямбда-выражение для конструирования типа или — в случае жизненного цикла Singleton — передать полностью сконструированный экземпляр в ConfigureServices при его регистрации.

После подключения DI приложение выполняется, как и раньше. Теперь, как показано на рис. 1, я могу тестировать его с применением новой абстракции, используя имитацию или заглушку реализации интерфейса IDinnerRepository вместо того, чтобы напрямую полагаться на EF в коде теста.

Рис. 1. Тестирование DinnersController, используя имитирующий объект

public class DinnersControllerIndex
{
  private List<Dinner> GetTestDinnerCollection()
  {
    return new List<Dinner>()
    {
      new Dinner() {Title = "Test Dinner 1" },
      new Dinner() {Title = "Test Dinner 2" },
    };
  }

  [Fact]
  public void ReturnsDinnersInViewModel()
  {
    var mockRepository = new Mock<IDinnerRepository>();
    mockRepository.Setup(r =>
      r.List()).Returns(GetTestDinnerCollection());
    var controller = new DinnersController(
      mockRepository.Object, null);

    var result = controller.Index();

    var viewResult = Assert.IsType<ViewResult>(result);
    var viewModel = Assert.IsType<IEnumerable<Dinner>>(
      viewResult.ViewData.Model).ToList();
    Assert.Equal("Test Dinner 1", viewModel.First().Title);
    Assert.Equal(2, viewModel.Count);
  }
}

Этот тест работает независимо от того, откуда берется список экземпляров Dinner. Вы могли бы переписать код для доступа к данным, чтобы использовать другую базу данных, Azure Table Storage или XML-файлы, а контроллер все равно работал бы точно так же. Конечно, в данном случае он не делает ничего особенного, поэтому вам, возможно, интересно…

А как насчет реальной логики?

До сих пор я не реализовал никакой реальной бизнес-логики — у меня были лишь простые методы, возвращающие несложные наборы данных. Истинная ценность тестирования проявляется только при наличии логики и особых случаев, в которых вы должны убедиться, что приложение будет вести себя ожидаемым образом. Чтобы продемонстрировать это, я добавлю некоторые требования к своему сайту GeekDinner. Сайт будет предоставлять API, который позволит кому угодно отвечать на приглашение на обед. Однако для числа приглашаемых будет дополнительно задан максимум, и количество ответов на приглашение (RSVP) не должно превышать этот максимум. Пользователи, запрашивающие RSVP, когда максимум уже достигнут, должны добавляться в список ожидания. Наконец, желающие пообедать могут указывать крайний срок относительно их начального времени, по истечении которого они не станут принимать приглашения.

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

Лучшее место для размещения бизнес-логики — модель предметной области приложения, которая не должна зависеть от обязанностей инфраструктуры (вроде работы с базами данных или UI). Класс Dinner подходит для управления RSVP, описанными в требованиях, поскольку он будет хранить максимальное количество участников мероприятия и знать, сколько RSVP было выдано на данный момент. Однако часть логики также зависит от того, когда появляется RSVP (до или после крайнего срока), поэтому методу понадобится доступ к текущему времени.

Я мог бы просто использовать DateTime.Now, но это затруднило бы тестирование моей логики и связало бы модель предметной области с системными часами. Другой вариант — задействовать абстракцию IDateTime и встроить ее в сущность Dinner. Однако, как показывает мой опыт, лучше всего сохранять сущности вроде Dinner свободными от зависимостей, особенно если вы планируете применять какое-то O/RM-средство наподобие EF для извлечения этих сущностей с уровня хранения. В связи с этим я не хочу заполнять сущности зависимостями, да и EF определенно не смогла бы иметь с этим дело без дополнительного кода с моей стороны. Распространенный подход — изъятие логики из сущности Dinner и перенос ее в какой-либо сервис (вроде DinnerService или RsvpService), в который можно легко встраивать зависимости. Однако это обычно приводит к антишаблону анемичной модели предметной области (anemic domain model antipattern) (bit.ly/anemic-model), в которой сущности имеют очень мало логики (или вообще не имеют ее) и являются просто контейнерами состояния. Нет, в данном случае решение прямолинейное: метод может просто принимать текущее время как параметр и позволять вызывающему коду передавать его.

При таком подходе логика добавления RSVP проста (рис. 2). Для этого метода есть ряд тестов, которые демонстрируют, что он ведет себя, как ожидалось; тесты доступны в проекте-примере, сопутствующем этой статье.

Рис. 2. Бизнес-логика в модели предметной области

public RsvpResult AddRsvp(string name, string email, DateTime currentDateTime)
{
  if (currentDateTime > RsvpDeadlineDateTime())
  {
    return new RsvpResult("Failed - Past deadline.");
  }
  var rsvp = new Rsvp()
  {
    DateCreated = currentDateTime,
    EmailAddress = email,
    Name = name
  };
  if (MaxAttendees.HasValue)
  {
    if (Rsvps.Count(r => !r.IsWaitlist) >= MaxAttendees.Value)
    {
      rsvp.IsWaitlist = true;
      Rsvps.Add(rsvp);
      return new RsvpResult("Waitlist");
    }
  }
  Rsvps.Add(rsvp);
  return new RsvpResult("Success");
}

Переместив эту логику в модель предметной области, я добился того, что API-метод моего контроллера останется компактным и сконцентрированным на собственных обязанностях. В итоге можно легко протестировать, что контроллер делает то, что ему положено, поскольку через этот метод проходит сравнительно немного путей выполнения.

Обязанности контроллера

Часть обязанностей контроллера — проверка ModelState и обеспечение его допустимости. Для ясности я делаю это в методе действия, но в более крупном приложении я исключил бы этот повторяющийся код в каждом методе действия, используя Action Filter:

[HttpPost]
public IActionResult AddRsvp([FromBody]RsvpRequest rsvpRequest)
{
  if (!ModelState.IsValid)
  {
    return HttpBadRequest(ModelState);
  }

Предполагая, что ModelState допустим, метод действия должен извлечь соответствующий экземпляр Dinner по идентификатору, переданному в запросе. Если метод не может найти экземпляр Dinner с совпадающим идентификатором, он должен вернуть результат Not Found:

var dinner = _dinnerRepository.GetById(rsvpRequest.DinnerId);
if (dinner == null)
{
  return HttpNotFound("Dinner not found.");
}

По завершении этих проверок метод действия может делегировать бизнес-операцию, представленную запросом, модели предметной области, вызвав метод AddRsvp класса Dinner, который вы видели ранее, и сохранив обновленное состояние модели предметной области (в данном случае экземпляр dinner и его набор RSVP); после этого он возвращает ответ OK:

var result = dinner.AddRsvp(rsvpRequest.Name,
    rsvpRequest.Email,
    _systemClock.Now);

  _dinnerRepository.Update(dinner);
  return Ok(result);
}

Вспомните: я решил, что у класса Dinner не должно быть зависимости от системных часов, и предпочел передавать текущее время в его метод. В контроллере я передаю _systemClock.Now как параметр currentDateTime. Это локальное поле, заполняемое через DI, что избавляет и контроллер от жесткого связывания с системными часами. Использовать DI в контроллере вполне разумно в противоположность сущности предметной области, так как контроллеры всегда создаются контейнерами сервисов ASP.NET; это подключает любые зависимости, объявленные контроллером в его конструкторе. Поле _systemClock имеет тип IDateTime, что определяется и реализуется всего несколькими строками кода:

public interface IDateTime
{
  DateTime Now { get; }
}
public class MachineClockDateTime : IDateTime
{
  public DateTime Now { get { return System.DateTime.Now; } }
}

Конечно, мне также нужно сконфигурировать контейнер ASP.NET так, чтобы он использовал MachineClockDateTime всякий раз, когда классу требуется экземпляр IDateTime. Это делается в ConfigureServices класса Startup, и, хотя подойдет любой жизненный цикл объекта, в данном случае я предпочел использовать Singleton, так как один экземпляр MachineClockDateTime будет обслуживать все приложение:

services.AddSingleton<IDateTime, MachineClockDateTime>();

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

Следующие шаги

Скачайте сопутствующий этой статье проект-пример и посмотрите модульные тесты для Dinner и DinnersController. Помните, что свободно связанный код, как правило, гораздо проще в модульном тестировании, чем жестко связанный код, напичканный ключевыми словами new или вызовами статических методов, зависящих от обязанностей инфраструктуры. «New является связующим», так что ключевое слово new следует использовать в приложении осознанно, а не по воле случая. Узнать больше о ASP.NET Core и ее поддержке встраивания зависимостей можно на docs.asp.net.


Стив Смит (Steve Smith) — независимый тренер, преподаватель и консультант, а также обладатель звания ASP.NET MVP. Написал десятки статей для официальной документации ASP.NET Core (docs.asp.net) и работает с группами, осваивающими эту технологию. С ним можно связаться через сайт ardalis.com, также следите за его заметками в Twitter (@ardalis).

Выражаю благодарность за рецензирование статьи эксперту Microsoft Дугу Бантингу (Doug Bunting).


Discuss this article in the MSDN Magazine forum