Отдавайте предпочтение фейкам, а не динамическим мокам.

С некоторых пор я предпочитаю использовать фейки вместо стабов и моков, поскольку использование фейковых объектов вместо других тестовых дублёров делает тестовые наборы более надёжными. Я написал кодовую базу для своей книги Code That Fits in Your Head полностью с использованием фейков и тестового шпиона, и мне редко приходилось исправлять неработающие тесты. Никаких Moq, FakeItEasy, NSubstitute или Rhino Mocks, только написанные вручную тестовые дублёры.

Недавно я понял, что проблема с моками и стабами заключается в том, что они нарушают инкапсуляцию.

Скоро я приведу несколько практических примеров, но сначала важно определиться с терминологией.

Терминология

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

Я использую слова тестовый дублёр, фейк (Fake), мок, стаб так, как они определены в xUnit Test Patterns. Обычно я стараюсь избегать терминов мок и стаб, поскольку они часто используются нечётко и непоследовательно. Термины тестовый дублёр и фейк подходят лучше.

Однако нам нужно название для библиотек, которые генерируют тестовые дублёры на лету. В .NET это такие библиотеки, как Moq, FakeItEasy и так далее, как перечислено выше. В Java есть Mockito, EasyMock, JMockit и другие подобные.

Как мы обычно называем такие библиотеки? Большинство людей называют их mock-библиотеками или динамическими mock-библиотеками. Возможно, «динамическая библиотека тестовых дублёров» лучше бы соответствовала словарю xUnit Test Patterns, но так их никто не называет. Я буду называть их динамическими mock-библиотеками, чтобы хотя бы подчеркнуть динамическую генерацию объектов «на лету», которую они обычно используют.

Наконец, важно дать определение инкапсуляции. Это ещё одна концепция, в случае с которой можно использовать одно и то же слово и при этом подразумевать разные вещи.

Я основываю своё понимание инкапсуляции на объектно-ориентированном построении программного обеспечения. 

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

По мнению Мейера (Meyer) контракты описывают три свойства объектов:

  • Предварительные условия: что должен выполнить клиентский код, чтобы успешно взаимодействовать с объектом.

  • Инварианты: утверждения об объекте, которые всегда верны.

  • Постусловия: утверждения, которые гарантированно будут истинными после успешного взаимодействия клиентского кода с объектом.

Объекты, сгенерированные динамическими mock-библиотеками, часто нарушают свои контракты.

Создание и чтение в обе стороны 

Рассмотрим интерфейс IReservationsRepository из книги Code That Fits in Your Head:

public interface IReservationsRepository
{
    Task Create(int restaurantId, Reservation reservation);
 
    Task<IReadOnlyCollection<Reservation>> ReadReservations(
        int restaurantId, DateTime min, DateTime max);
 
    Task<Reservation?> ReadReservation(int restaurantId, Guid id);
 
    Task Update(int restaurantId, Reservation reservation);
 
    Task Delete(int restaurantId, Guid id);
}

В одной из предыдущих статей я уже рассказывал о некоторых контрактных свойствах этого интерфейса. Здесь же я хочу остановиться на некотором взаимодействии.

Каков контракт метода Create?

Есть несколько предварительных условий:

  • У клиента должен быть правильно инициализированный объект IReservationsRepository.

  • У клиента должен быть валидный restaurantId.

  • У клиента должен быть валидный reservation.

Клиент, выполнивший эти предварительные условия, может успешно вызвать и ожидать метод Create. Что такое предусловия и постусловия?

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

Как это проявляется в рамках контракта объекта?

Это подразумевает, что клиент должен иметь возможность получить reservation либо с помощью ReadReservation, либо ReadReservations. Это наводит на мысль о свойстве, которое Скотт Влащин (Scott Wlaschin) называет «туда и обратно».

Выбрав ReadReservation для шага проверки, мы теперь имеем свойство: если клиентский код успешно вызывает и ожидает Create, он должен иметь возможность использовать ReadReservation для получения резервирования, которое он только что сохранил. Это подразумевается контрактом IReservationsRepository.

Реализация SQL 

«Реальная» реализация IReservationsRepository, используемая в продакшене, — это реализация, которая хранит резервации в SQL Server. Этот класс должен подчиняться контракту.

Хотя можно было бы написать настоящий тест на основе свойств, выполнение сотен случайно сгенерированных тест-кейсов на реальной базе данных займёт много времени. Вместо этого я решил написать только параметризованный тест:

[Theory]
[InlineData(Grandfather.Id, "2022-06-29 12:00", "e@example.gov", "Enigma", 1)]
[InlineData(Grandfather.Id, "2022-07-27 11:40", "c@example.com", "Carlie", 2)]
[InlineData(2, "2021-09-03 14:32", "bon@example.edu", "Jovi", 4)]
public async Task CreateAndReadRoundTrip(
    int restaurantId,
    string at,
    string email,
    string name,
    int quantity)
{
    var expected = new Reservation(
        Guid.NewGuid(),
        DateTime.Parse(at, CultureInfo.InvariantCulture),
        new Email(email),
        new Name(name),
        quantity);
    var connectionString = ConnectionStrings.Reservations;
    var sut = new SqlReservationsRepository(connectionString);
 
    await sut.Create(restaurantId, expected);
    var actual = await sut.ReadReservation(restaurantId, expected.Id);
 
    Assert.Equal(expected, actual);
}

Нам важны три последние строки:

await sut.Create(restaurantId, expected);
var actual = await sut.ReadReservation(restaurantId, expected.Id);
 
Assert.Equal(expected, actual);

Сначала вызовите Create, а затем ReadReservation. Созданное значение должно быть равно полученному, что и происходит. Все тесты пройдены.

Фейк

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

[Theory]
[InlineData(RestApi.Grandfather.Id, "2022-06-29 12:00", "e@example.gov", "Enigma", 1)]
[InlineData(RestApi.Grandfather.Id, "2022-07-27 11:40", "c@example.com", "Carlie", 2)]
[InlineData(2, "2021-09-03 14:32", "bon@example.edu", "Jovi", 4)]
public async Task CreateAndReadRoundTrip(
    int restaurantId,
    string at,
    string email,
    string name,
    int quantity)
{
    var expected = new Reservation(
        Guid.NewGuid(),
        DateTime.Parse(at, CultureInfo.InvariantCulture),
        new Email(email),
        new Name(name),
        quantity);
    var sut = new FakeDatabase();
 
    await sut.Create(restaurantId, expected);
    var actual = await sut.ReadReservation(restaurantId, expected.Id);
 
    Assert.Equal(expected, actual);
}

Единственное отличие заключается в том, что sut — это экземпляр другого класса. Эти тест-кейсы также пройдены.

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

Динамический мок

Если подвергнуть динамический мок тому же тесту, как он себя поведёт? Попробуем на примере библиотеки Moq 4.18.2:

[Theory]
[InlineData(RestApi.Grandfather.Id, "2022-06-29 12:00", "e@example.gov", "Enigma", 1)]
[InlineData(RestApi.Grandfather.Id, "2022-07-27 11:40", "c@example.com", "Carlie", 2)]
[InlineData(2, "2021-09-03 14:32", "bon@example.edu", "Jovi", 4)]
public async Task CreateAndReadRoundTrip(
    int restaurantId,
    string at,
    string email,
    string name,
    int quantity)
{
    var expected = new Reservation(
        Guid.NewGuid(),
        DateTime.Parse(at, CultureInfo.InvariantCulture),
        new Email(email),
        new Name(name),
        quantity);
    var sut = new Mock<IReservationsRepository>().Object;
 
    await sut.Create(restaurantId, expected);
    var actual = await sut.ReadReservation(restaurantId, expected.Id);
 
    Assert.Equal(expected, actual);
}

Если вы хоть немного работали с динамическими mock-библиотеками, вы не удивитесь, узнав, что все три теста не прошли. Вот одно из сообщений о неудаче:

Ploeh.Samples.Restaurants.RestApi.Tests.MoqRepositoryTests.CreateAndReadRoundTrip(↩
    restaurantId: 1, at: "2022-06-29 12:00", email: "e@example.gov", name: "Enigma", quantity: 1)
 Source: MoqRepositoryTests.cs line 17
 Duration: 1 ms
 
Message: 
  Assert.Equal() Failure
  Expected: Reservation↩
            {↩
              At = 2022-06-29T12:00:00.0000000,↩
              Email = e@example.gov,↩
              Id = c9de4f95-3255-4e1f-a1d6-63591b58ff0c,↩
              Name = Enigma,↩
              Quantity = 1↩
            }
  Actual:   (null)
 
Stack Trace: 
  MoqRepositoryTests.CreateAndReadRoundTrip(↩
    Int32 restaurantId, String at, String email, String name, Int32 quantity) line 35
  --- End of stack trace from previous location where exception was thrown ---

(Я ввёл переносы строк и обозначил их символом, чтобы сделать вывод более читабельным).

Неудивительно, что возвращаемое значение Create — null. Обычно для того, чтобы придать динамическому моку какое-либо поведение, его нужно настроить, а я этого не делал. В этом случае динамический мок возвращает значение по умолчанию для возвращаемого типа, которое в данном случае корректно равно null.

Вы можете сказать, что приведённый выше пример несправедлив. Как динамический имитатор может знать, что делать? Он нуждается в настройке. В этом и заключается вся суть.

Извлечение без создания 

Итак, давайте настроим динамический мок:

var dm = new Mock<IReservationsRepository>();
dm.Setup(r => r.ReadReservation(restaurantId, expected.Id)).ReturnsAsync(expected);
var sut = dm.Object;

Это единственные строки, которые я изменил в тесте, и теперь он проходит.

Распространённая критика динамических тестов с большим количеством моков заключается в том, что они в основном «просто тестируют моки», что и происходит в данном случае.

Чтобы сделать это более явным, следует удалить вызов метода Create:

var dm = new Mock<IReservationsRepository>();
dm.Setup(r => r.ReadReservation(restaurantId, expected.Id)).ReturnsAsync(expected);
var sut = dm.Object;
 
var actual = await sut.ReadReservation(restaurantId, expected.Id);
 
Assert.Equal(expected, actual);

Тест по-прежнему проходит. Очевидно, что он тестирует только динамический мок.

Здесь можно возразить, что это вполне ожидаемо и что это не доказывает, что динамические моки нарушают инкапсуляцию. Однако помните о природе контракта: после успешного завершения Create резервирование оказывается «в» репозитории и может быть позже извлечено либо с помощью ReadReservation, либо с помощью ReadReservations.

В этом варианте теста Create больше не вызывается, но ReadReservation по-прежнему возвращает ожидаемое значение.

Ведут ли себя так SqlReservationsRepository или FakeDatabase? Нет, не ведут.

Попробуйте удалить вызов Create из теста, который проверяет SqlReservationsRepository:

var sut = new SqlReservationsRepository(connectionString);
 
var actual = await sut.ReadReservation(restaurantId, expected.Id);
 
Assert.Equal(expected, actual);

Тест теперь не работает, потому что actual равен null. И это неудивительно. То же самое произойдёт, если удалить вызов Create из теста, в котором используется FakeDatabase:

var sut = new FakeDatabase();
 
var actual = await sut.ReadReservation(restaurantId, expected.Id);
 
Assert.Equal(expected, actual);

И снова утверждение не проходит, потому что actual равен null.

Классы SqlReservationsRepository и FakeDatabase ведут себя в соответствии с контрактом, а динамический мок — нет.

Альтернативное извлечение

Есть ещё один способ, с помощью которого динамический мок нарушает инкапсуляцию. Вспомните, что говорится в контракте: После успешного завершения Create резервирование «попадает» в репозиторий и впоследствии может быть извлечено либо с помощью ReadReservation, либо с помощью ReadReservations.

Другими словами, должно быть возможно изменить взаимодействие с Create с последующим ReadReservation на Create с последующим ReadReservations.

Сначала попробуйте сделать это с помощью SqlReservationsRepository:

await sut.Create(restaurantId, expected);
var min = expected.At.Date;
var max = min.AddDays(1);
var actual = await sut.ReadReservations(restaurantId, min, max);
 
Assert.Contains(expected, actual);

Тест по-прежнему проходит, как и ожидалось.

Во-вторых, попробуйте проделать те же изменения с FakeDatabase:

await sut.Create(restaurantId, expected);
var min = expected.At.Date;
var max = min.AddDays(1);
var actual = await sut.ReadReservations(restaurantId, min, max);
 
Assert.Contains(expected, actual);

Обратите внимание, что это точно такой же код, как и в тесте SqlReservationsRepository. Этот тест также проходит, как и ожидалось.

В-третьих, попробуйте сделать это с динамическим моком:

await sut.Create(restaurantId, expected);
var min = expected.At.Date;
var max = min.AddDays(1);
var actual = await sut.ReadReservations(restaurantId, min, max);
 
Assert.Contains(expected, actual);

Тот же код, другой sut, и тест не проходит. Динамический мок нарушает инкапсуляцию. Чтобы тест снова прошёл, придётся исправить Setup. Это не относится к SqlReservationsRepository или FakeDatabase.

Тестируемую систему ломают динамические моки, а не тесты

Возможно, вы всё ещё не уверены в практической ценности. В конце концов, Бертрау Мейеру (Bertrand Meyer) не удалось добиться большого успеха в распространении своих идей о контрактном программировании.

Однако то, что динамические моки нарушают инкапсуляцию, имеет реальные последствия.

Что, если бы при тестировании своей системы онлайн-бронирования ресторанов вместо FakeDatabase я использовал динамические моки? Тест выглядел бы следующим образом:

[Theory]
[InlineData(1049, 19, 00, "juliad@example.net", "Julia Domna", 5)]
[InlineData(1130, 18, 15, "x@example.com", "Xenia Ng", 9)]
[InlineData( 956, 16, 55, "kite@example.edu", null, 2)]
[InlineData( 433, 17, 30, "shli@example.org", "Shanghai Li", 5)]
public async Task PostValidReservationWhenDatabaseIsEmpty(
    int days,
    int hours,
    int minutes,
    string email,
    string name,
    int quantity)
{
    var at = DateTime.Now.Date + new TimeSpan(days, hours, minutes, 0);
    var dm = new Mock<IReservationsRepository>();
    dm.Setup(r => r.ReadReservations(Grandfather.Id, at.Date, at.Date.AddDays(1).AddTicks(-1)))
        .ReturnsAsync(Array.Empty<Reservation>());
    var sut = new ReservationsController(
        new SystemClock(),
        new InMemoryRestaurantDatabase(Grandfather.Restaurant),
        dm.Object);
    var expected = new Reservation(
        new Guid("B50DF5B1-F484-4D99-88F9-1915087AF568"),
        at,
        new Email(email),
        new Name(name ?? ""),
        quantity);
 
    await sut.Post(expected.ToDto());
 
    dm.Verify(r => r.Create(Grandfather.Id, expected));
}

Это ещё одна вариация теста PostValidReservationWhenDatabaseIsEmpty — настоящя находка. Ранее я уже говорил об этом тесте в других статьях:

Здесь я заменил тестовый дублёр FakeDatabase на динамический мок. (Я использую Moq, но имейте в виду, что последствия использования динамического мока не связаны с конкретными библиотеками).

Для «полного динамического мока» мне следовало бы также заменить SystemClock и InMemoryRestaurantDatabase динамическими моками, но это не обязательно для иллюстрации того, что я хочу донести.

Этот и другие тесты описывают желаемый результат вывода через REST API. Это взаимодействие выглядит следующим образом:

POST /restaurants/90125/reservations?sig=aco7VV%2Bh5sA3RBtrN8zI8Y9kLKGC60Gm3SioZGosXVE%3D HTTP/1.1
content-type: application/json
{
  "at": "2022-12-12T20:00",
  "name": "Pearl Yvonne Gates",
  "email": "pearlygates@example.net",
  "quantity": 4
}

HTTP/1.1 201 Created
Content-Length: 151
Content-Type: application/json; charset=utf-8
Location: [...]/restaurants/90125/reservations/82e550b1690742368ea62d76e103b232?sig=fPY1fSr[...]
{
  "id": "82e550b1690742368ea62d76e103b232",
  "at": "2022-12-12T20:00:00.0000000",
  "email": "pearlygates@example.net",
  "name": "Pearl Yvonne Gates",
  "quantity": 4
}

Здесь интересно то, что ответ включает JSON-представление ресурса, который был создан в результате взаимодействия. В основном это копия отправленных данных, но дополненная ID, сгенерированным сервером.

Код, отвечающий за взаимодействие с базой данных, выглядит следующим образом:

private async Task<ActionResult> TryCreate(Restaurant restaurant, Reservation reservation)
{
    using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
 
    var reservations = await Repository
        .ReadReservations(restaurant.Id, reservation.At)
        .ConfigureAwait(false);
    var now = Clock.GetCurrentDateTime();
    if (!restaurant.MaitreD.WillAccept(now, reservations, reservation))
        return NoTables500InternalServerError();
 
    await Repository.Create(restaurant.Id, reservation).ConfigureAwait(false);
 
    scope.Complete();
 
    return Reservation201Created(restaurant.Id, reservation);
}

Последняя строка кода создаёт ответ 201 Created с reservation в качестве содержимого. В этом фрагменте не показано происхождение параметра reservation, но он представляет собой входной JSON-документ, преобразованный в объект Reservation. Каждый объект Reservation имеет идентификатор, который сервер создаёт, если он не предоставлен клиентом.

Приведённый выше helper-метод TryCreate содержит весь код взаимодействия с базой данных, связанный с созданием нового резервирования. Сначала он вызывает ReadReservations, чтобы получить существующие резервирования. Затем он вызывает Create, если решает принять резервирование. Метод ReadReservations на самом деле является internal методом расширения:

internal static Task<IReadOnlyCollection<Reservation>> ReadReservations(
    this IReservationsRepository repository,
    int restaurantId,
    DateTime date)
{
    var min = date.Date;
    var max = min.AddDays(1).AddTicks(-1);
    return repository.ReadReservations(restaurantId, min, max);
}

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

На данный момент метод TryCreate возвращает обратно reservation. А что, если вместо этого вы хотите запросить базу данных и вернуть запись, которую вы получили из базы данных? В данном конкретном случае для этого нет причин, но, возможно, в других случаях в слое данных происходит что-то, что либо обогащает, либо нормализует данные. Поэтому вы вносите безобидное изменение:

private async Task<ActionResult> TryCreate(Restaurant restaurant, Reservation reservation)
{
    using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
 
    var reservations = await Repository
        .ReadReservations(restaurant.Id, reservation.At)
        .ConfigureAwait(false);
    var now = Clock.GetCurrentDateTime();
    if (!restaurant.MaitreD.WillAccept(now, reservations, reservation))
        return NoTables500InternalServerError();
 
    await Repository.Create(restaurant.Id, reservation).ConfigureAwait(false);
    var storedReservation = await Repository
        .ReadReservation(restaurant.Id, reservation.Id)
        .ConfigureAwait(false);
 
    scope.Complete();
 
    return Reservation201Created(restaurant.Id, storedReservation!);
}

Теперь, вместо того чтобы возвращать обратно reservation, метод вызывает ReadReservation, чтобы извлечь (возможно, обогащенное или нормализованное) storedReservation, и возвращает это значение. Поскольку это значение может быть, предположительно, нулевым, метод использует оператор !, чтобы убедиться, что это не так. Возможно, стоит создать новый тест-кейс для сценария, когда запрос возвращает null.

Возможно, это немного менее эффективно, поскольку подразумевает дополнительный поход к базе данных, но это не должно изменить поведение системы!

Но когда вы запускаете набор тестов, тест PostValidReservationWhenDatabaseIsEmpty завершается ошибкой:

Ploeh.Samples.Restaurants.RestApi.Tests.ReservationsTests.PostValidReservationWhenDatabaseIsEmpty(↩
    days: 433, hours: 17, minutes: 30, email: "shli@example.org", name: "Shanghai Li", quantity: 5)↩
    [FAIL]
  System.NullReferenceException : Object reference not set to an instance of an object.
  Stack Trace:
    [...]\Restaurant.RestApi\ReservationsController.cs(94,0): at↩
      [...].RestApi.ReservationsController.Reservation201Created↩
      (Int32 restaurantId, Reservation r)
    [...]\Restaurant.RestApi\ReservationsController.cs(79,0): at↩
      [...].RestApi.ReservationsController.TryCreate↩
      (Restaurant restaurant, Reservation reservation)
    [...]\Restaurant.RestApi\ReservationsController.cs(57,0): at↩
      [...].RestApi.ReservationsController.Post↩
      (Int32 restaurantId, ReservationDto dto)
    [...]\Restaurant.RestApi.Tests\ReservationsTests.cs(73,0): at↩
      [...].RestApi.Tests.ReservationsTests.PostValidReservationWhenDatabaseIsEmpty↩
      (Int32 days, Int32 hours, Int32 minutes, String email, String name, Int32 quantity)
    --- End of stack trace from previous location where exception was thrown ---

О, ужасный NullReferenceException! Это происходит потому, что ReadReservation возвращает null, поскольку динамический мок не настроен.

Типичная реакция большинства людей такова: О нет, тесты сломались!

Однако я думаю, что это неправильная точка зрения. Динамический мок сломал тестируемую систему (SUT), потому что он передал реализацию IReservationsRepository, которая нарушает контракт. Тест не «сломался» — он не был корректным с самого начала.

Антипаттерн Shotgun surgery

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

Как правило, вам придётся пересмотреть и «исправить» все неудачные тесты, чтобы приспособить их к рефакторингу:

[Theory]
[InlineData(1049, 19, 00, "juliad@example.net", "Julia Domna", 5)]
[InlineData(1130, 18, 15, "x@example.com", "Xenia Ng", 9)]
[InlineData( 956, 16, 55, "kite@example.edu", null, 2)]
[InlineData( 433, 17, 30, "shli@example.org", "Shanghai Li", 5)]
public async Task PostValidReservationWhenDatabaseIsEmpty(
    int days,
    int hours,
    int minutes,
    string email,
    string name,
    int quantity)
{
    var at = DateTime.Now.Date + new TimeSpan(days, hours, minutes, 0);
    var expected = new Reservation(
        new Guid("B50DF5B1-F484-4D99-88F9-1915087AF568"),
        at,
        new Email(email),
        new Name(name ?? ""),
        quantity);
    var dm = new Mock<IReservationsRepository>();
    dm.Setup(r => r.ReadReservations(Grandfather.Id, at.Date, at.Date.AddDays(1).AddTicks(-1)))
        .ReturnsAsync(Array.Empty<Reservation>());
    dm.Setup(r => r.ReadReservation(Grandfather.Id, expected.Id)).ReturnsAsync(expected);
    var sut = new ReservationsController(
        new SystemClock(),
        new InMemoryRestaurantDatabase(Grandfather.Restaurant),
        dm.Object);
 
    await sut.Post(expected.ToDto());
 
    dm.Verify(r => r.Create(Grandfather.Id, expected));
}

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

Как правило, у вас много таких тестов, которые нужно редактировать. Название этого антипаттерна — Shotgun Surgery.

Подразумевается, что рефакторинг невозможен по определению:

«Для рефакторинга необходимым предварительным условием является наличие [...] надёжных тестов».

— Мартин Фаулер, Рефакторинг

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

К тому же, каждый раз, когда вы редактируете существующие тесты, они становятся менее надёжными.

Чтобы решить эти проблемы, используйте фейки вместо моков и стабов. С помощью FakeDatabase весь тестовый набор для системы онлайн-бронирования ресторанов изящно справляется с описанным выше изменением. Ни один тест не завершается ошибкой.

Шпионы 

Если порыться в базе тестового кода книги, также можно найти этот тестовый дублёр:

internal sealed class SpyPostOffice :
    Collection<SpyPostOffice.Observation>, IPostOffice
{
    public Task EmailReservationCreated(
        int restaurantId,
        Reservation reservation)
    {
        Add(new Observation(Event.Created, restaurantId, reservation));
        return Task.CompletedTask;
    }
 
    public Task EmailReservationDeleted(
        int restaurantId,
        Reservation reservation)
    {
        Add(new Observation(Event.Deleted, restaurantId, reservation));
        return Task.CompletedTask;
    }
 
    public Task EmailReservationUpdating(
        int restaurantId,
        Reservation reservation)
    {
        Add(new Observation(Event.Updating, restaurantId, reservation));
        return Task.CompletedTask;
    }
 
    public Task EmailReservationUpdated(
        int restaurantId,
        Reservation reservation)
    {
        Add(new Observation(Event.Updated, restaurantId, reservation));
        return Task.CompletedTask;
    }
 
    internal enum Event
    {
        Created = 0,
        Updating,
        Updated,
        Deleted
    }
 
    internal sealed class Observation
    {
        public Observation(
            Event @event,
            int restaurantId,
            Reservation reservation)
        {
            Event = @event;
            RestaurantId = restaurantId;
            Reservation = reservation;
        }
 
        public Event Event { get; }
        public int RestaurantId { get; }
        public Reservation Reservation { get; }
 
        public override bool Equals(object? obj)
        {
            return obj is Observation observation &&
                   Event == observation.Event &&
                   RestaurantId == observation.RestaurantId &&
                   EqualityComparer<Reservation>.Default.Equals(Reservation, observation.Reservation);
        }
 
        public override int GetHashCode()
        {
            return HashCode.Combine(Event, RestaurantId, Reservation);
        }
    }
}

Как видите, я решил назвать этот класс с приставкой Spy, что указывает на то, что это скорее тестовый шпион, а не фейковый объект. Шпион — это тестовый дублёр, основной целью которого является наблюдение и запись взаимодействий. Нарушает ли это инкапсуляцию или реализует её?

Хотя я предпочитаю фейки, когда это возможно, рассмотрим интерфейс, который реализует SpyPostOffice:

public interface IPostOffice
{
    Task EmailReservationCreated(int restaurantId, Reservation reservation);
 
    Task EmailReservationDeleted(int restaurantId, Reservation reservation);
 
    Task EmailReservationUpdating(int restaurantId, Reservation reservation);
 
    Task EmailReservationUpdated(int restaurantId, Reservation reservation);
}

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

В некотором смысле SpyPostOffice можно рассматривать как приёмник сообщений в памяти. Он выполняет контракт.

Конкурентность

Возможно, вы всё ещё не убеждены. Вы можете возразить, например, что (частичный) контракт, который я изложил, наивен. Рассмотрим ещё раз последствия, выраженные в виде кода:

await sut.Create(restaurantId, expected);
var actual = await sut.ReadReservation(restaurantId, expected.Id);
 
Assert.Equal(expected, actual);

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

Согласен.

Конкурентность значительно усложняет ситуацию. Но даже в этом свете я думаю, что вышеприведённая линия рассуждений уместна по двум причинам.

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

«интерфейс, подобный коллекции, для доступа к объектам домена».

— Эдвард Хиетт (Edward Hieatt) и Роб Ми (Rob Mee) в книге Мартина Фаулера (Martin Fowler) «Паттерны архитектуры корпоративных приложений», паттерн «Репозиторий».

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

Если бы я больше беспокоился по поводу конкуренции за данные, то переход к CQRS выглядел бы многообещающим. Это приводит к другой объектной модели, с другими контрактами.

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

Даже если конкурентность является реальной проблемой, вы всё равно будете ожидать, что если только один поток манипулирует объектом Repository, то то, что вы создаёте, вы должны иметь возможность получить обратно. Контракт может быть немного свободнее, но это всё равно было бы нарушением принципа наименьшего удивления, если бы было иначе.

Заключение

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

Полиморфные объекты (такие как интерфейсы и базовые классы) также имеют контракты. Когда вы заменяете «настоящие» реализации тестовыми дублёрами, тестовые дублёры также должны выполнять контракты. Так поступают фейковые объекты; под это описание могут подходить и тестовые шпионы.

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

Однако по умолчанию динамические моки нарушают инкапсуляцию, поскольку не выполняют контракты объектов. Это приводит к хрупкости тестов.

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

Все актуальные методы и инструменты тестирования можно освоить на онлайн-курсах OTUS: в каталоге можно посмотреть список всех программ, а в календаре — записаться на открытые уроки.

Комментарии (1)


  1. MountainGoat
    23.07.2024 10:47
    +1

    Недавно я понял, что проблема с моками и стабами заключается в том, что они нарушают инкапсуляцию.

    Как - то отсутствует объяснение того, почему соблюдение инкапсуляции в тестах - это ценность.