Должен ли я использовать фиктивные объекты при модульном тестировании? - PullRequest
2 голосов
/ 05 ноября 2010

В моем приложении ASP.Net MVC я использую IoC для облегчения модульного тестирования. Структура моего приложения - структура типа Controller -> Service Class -> Repository. Чтобы выполнить модульное тестирование, у меня есть класс InMemoryRepository, который наследует мой IRepository, в котором вместо выхода в базу данных используется внутренний член List<T>. Когда я создаю свои модульные тесты, я просто передаю экземпляр внутреннего репозитория вместо своего EF-репозитория.

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

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

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

Тем не менее, я не вижу, как полностью решить эту проблему с помощью макетов, и у меня все еще есть действительные тесты. Например, один из моих модульных тестов состоит в том, что если я вызываю _service.GetDocumentById(5), он получает правильный документ из хранилища. Единственный способ, которым это действительный модульный тест (насколько я понимаю), - это если у меня хранится 2 или 3 документа, и мой метод GetdocumentById() правильно извлекает тот, у которого Id равен 5.

Как бы у меня был смоделированный репозиторий с вызовом AsQueryable, и как я мог бы убедиться, что я не замаскирую любые проблемы, возникающие у меня с моими операторами Linq, жестко закодировав операторы return при настройке смоделированного репозитория? Что лучше, чтобы сохранить мой тестовый модуль класса обслуживания, используя InMemoryRepository, но изменить мои тестовые модули контроллера для использования поддельных сервисных объектов?


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

A Repository - это хранилище данных для одного типа объектов, поэтому, если мой класс обслуживания документов нуждается в сущностях документов, он создает IRepository<Document>.

Контроллеры передаются IRepositoryFactory. IRepositoryFactory - это класс, который должен облегчать создание репозиториев без необходимости встраивать репозитории непосредственно в контроллер или заставлять контроллер беспокоиться о том, какие классы обслуживания требуют каких репозиториев. У меня есть InMemoryRepositoryFactory, который дает классы обслуживания InMemoryRepository<Entity> экземпляров, и та же идея относится и к моим EFRepositoryFactory.

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

Так например

public class DocumentController : Controller
{
    private DocumentService _documentService;

    public DocumentController(IRepositoryFactory factory)
    {
        _documentService = new DocumentService(factory);
    }
    ...
}

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

Ответы [ 3 ]

3 голосов
/ 05 ноября 2010

Одним из решений вашей проблемы является изменение ваших контроллеров для запроса IDocumentService экземпляров вместо создания самих служб:

public class DocumentController : Controller
{
    private IDocumentService _documentService;

    // The controller doesn't construct the service itself
    public DocumentController(IDocumentService documentService)
    {
        _documentService = documentService;
    }
    ...
}

В вашем реальном приложении позвольте вашему контейнеру IoC внедрить IRepositoryFactory экземпляров вваши услуги.В своих тестах модулей контроллера просто смоделируйте службы по мере необходимости.

(и см. статью Миско Хеври о конструкторах, выполняющих реальную работу , для расширенного обсуждения преимуществ реструктуризации вашего кода, подобных этой.) * +1010 *

2 голосов
/ 10 ноября 2010

Лично я бы разработал систему на основе шаблона Unit of Work, который ссылается на репозитории. Это может упростить задачу и позволить вам выполнять более сложные операции атомарно. Обычно у вас есть IUnitOfWorkFactory, который предоставляется как зависимость в классах Service. Сервисный класс создаст новую единицу работы, и эта единица работы ссылается на репозитории. Вы можете увидеть пример этого здесь .

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

Хотя ваше беспокойство действительно, я лично не буду беспокоиться о неудаче InMemoryRepository. Это тестовые объекты, и вы должны сохранять эти тестовые объекты как можно более простыми. Это избавляет вас от необходимости писать тесты для ваших тестовых объектов. Большую часть времени я предполагаю, что они правильные (однако я иногда использую самопроверки в таком классе, написав операторы Assert). Тест не пройдёт, если такой объект будет плохо себя вести. Это не оптимально, но вы, как правило, достаточно быстро выясните, в чем проблема по моему опыту. Чтобы быть продуктивным, вам нужно где-то нарисовать линию.

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

<ч />

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

Прежде всего, я стараюсь свести к минимуму инверсию зависимостей только до того, что мне нужно для запуска модульных тестов. Вызовы системных часов, базы данных, SMTP-сервера и файловой системы должны быть сфальсифицированы, чтобы сделать модульные тесты быстрыми и надежными. Другие вещи, которые я стараюсь не переворачивать, потому что чем больше вы издеваетесь, тем менее надежными становятся тесты. Вы тестируете меньше. Минимизация инверсии зависимостей (к тому, что вам нужно для хороших RTM модульных тестов) помогает упростить настройку теста.

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

FakeDocumentService CreateValidService()
{
    return CreateValidService(CreateInitializedContext());
}

FakeDocumentService CreateValidService(InMemoryUnitOfWork context)
{
    return new FakeDocumentSerice(context);
}

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

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

[TestMethod]
public void Operation_WithValidArguments_Succeeds()
{
    // Arrange
    var validArgs = CreateValidArgs();

    var service = BuildNewService(validArgs);

    // Act
    service.Operation();
}

[TestMethod]
[ExpectedException(typeof(InvalidOperationException))]
public void Operation_NegativeAge_ThrowsException()
{
    // Arrange
    var invalidArgs = CreateValidArgs();

    invalidArgs.Age = -1;

    var service = BuildNewService(invalidArgs);

    // Act
    service.Operation();
}

Это позволяет позволить тесту только указать, что имеет значение!Это очень важно, чтобы сделать тесты читабельными!Метод CreateValidArgs() может создать контейнер с более чем 100 аргументами, которые сделают правильное SUT (тестируемая система).Теперь вы централизовали в одном месте действительную конфигурацию по умолчанию.Я надеюсь, что это имеет смысл.

Ваше третье беспокойство касалось невозможности проверить, работает ли LINQ-запрос ожидаемым образом с данным поставщиком LINQ.Это допустимая проблема, потому что довольно легко писать запросы LINQ (в дерево выражений), которые отлично работают при использовании над объектами в памяти, но не выполняют при запросах к базе данных.Иногда невозможно перевести запрос (потому что вы вызываете метод .NET, который не имеет аналога в базе данных), или у поставщика LINQ есть ограничения (или ошибки).Особенно провайдер LINQ Entity Framework 3.5 сильно отстой.

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

Тем не менее, это серьезная проблема.Помимо модульного тестирования вы можете провести интеграционное тестирование.В этом случае вы запускаете свой код с реальным провайдером и (выделенной) тестовой базой данных.Запустите каждый тест в транзакции базы данных и откатите транзакцию в конце теста ( TransactionScope прекрасно работает с этим!).Однако обратите внимание, что написание поддерживаемых интеграционных тестов еще сложнее, чем написание обслуживаемых модульных тестов.Вы должны убедиться, что модель вашей тестовой базы данных синхронизирована.Каждый интеграционный тест должен вставлять данные, необходимые для этого теста, в базу данных, что часто требует много работы для написания и поддержки.Лучше всего сводить количество интеграционных тестов к минимуму.Иметь достаточно интеграционных тестов, чтобы вы чувствовали себя уверенно при внесении изменений в систему.Например, необходимости вызывать метод сервиса со сложным оператором LINQ в одном тесте часто бывает достаточно, чтобы проверить, способен ли ваш поставщик LINQ создать из него действительный SQL.В большинстве случаев я просто предполагаю, что поставщик LINQ будет вести себя так же, как и поставщик LINQ to Objects (.AsQueryable()).Опять же, вам придется где-то нарисовать линию.

Надеюсь, это поможет.

1 голос
/ 05 ноября 2010

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

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...