понедельник, 6 июня 2011 г.

Стабить или не стабить репозитории?

Для тестировании кода, который использует репозитории, есть два основных подхода:

1. Стабить и мокать вызовы репозиториев
2. Использовать реальные реализации репозиториев (а значит работать с базой)

Какой из вариантов выбрать - большой вопрос и не меньший повод для холивара.

Возьмем для примера следующий сервис:

public class ParametersService : IParametersService
{
private readonly IParametersRepository _parametersRepository;

public ParametersService(IParametersRepository parametersRepository)
{
_parametersRepository = parametersRepository;
}

public Parameter GetParameterOrAddNew(string parameterName)
{
var parameter = _parametersRepository.GetParameterOrDefault(parameterName);
if (parameter == null)
parameter = _parametersRepository.Add(parameterName);
return parameter;
}
}


А теперь напишем тест (возьмем для примера NUnit и RhinoMocks) для первого:


private IParametersService _parametersService;

public void SetUp(IParametersRepository parametersRepository)
{
_parametersService = new ParametersService(parametersRepository);
}

[Test]
public void GetParameterOrDefault_NoParameter_ParameterIsAdded()
{
//создаем Stub + Mock
var parametersRepository = MocksRepository.GenerateMock<IParametersRepository>();
//Эмулируем отсутствие параметра
parametersRepository.Stub(pr => pr.GetParameterOrDefault("testParameterName")).Return(null);

SetUp(parametersRepository);

var parameter = _parametersService.GetParameterOrAddNew("testParameterName");

//Проверяем, что метод Add был вызван
parametersRepository.AssertWasCalled(pr => pr.Add("testParameterName"));
}

и второго:


private IParametersService _parametersService = new ParametersService(new ParametersRepository);

[Test]
public void GetParameterOrDefault_NoParameter_ParameterIsAdded()
{
var parameter = _parametersRepository.GetParameterOrDefault("testParameterName");
//Проверяем, что такого параметра нет
Assert.IsNull(parameter);

var parameter = _parametersService.GetParameterOrAddNew("testParameterName");

Assert.IsNotNull(parameter);
Assert.AreEqual("testParameterName", parameter.Name);
}


Какую из реализаций выбрать?

Первая реализация более "чистая": мы тестируем непосредственно логику сервиса, не отвлекаясь на сторонние зависимости - классический unit-тест.

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

В .NET распространены два подхода к реализации паттерна репозиторий:
1 (классический). Инкапсуляция запроса в рамках метода репозитория. Методы репозитория возвращают сущность/коллекцию сущностей.
2. Легковесный репозиторий, который возвращает IQueryable, который, в свою очередь, уже вне репозитория обрастает фильтрами, сортировками, пейджингом и т. д. посредством паттерна Specification

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

Вторая же реализация больше подходит ко второму подходу к тестированию. Во-первых, раз мы возвращаем IQueryable, то находимся в зоне действия не LINQ To Objects, а LINQ To Entities, LINQ To NHibernate и т. д. Если же мы застабим репозиторий, то будем работать с LINQ To Objects, а значит наш код будет тестировать не то поведение, которое будет в продакшене. Во-вторых, ввиду того, что репозиторий не возвращает конечный результат выполнения запроса (как в первой реализации), такую реализацию репозитория не очень удобно стабить.

Нельзя не отметить, что производительность второго подхода гораздо ниже. Но давайте не будем забывать, что это тесты, а не продакшен. Говоря о производительности, мы должны ответить всего на один вопрос: достаточно ли высока производительность для того, чтобы не тормозить работу программиста при прогонке тестов? Вопреки распространенному мнению, зачастую ответ на этот вопрос будет положительным. Ведь локально мы прогоняем тесты лишь для модуля/модулей, над которым непосредственно в данный момент работаем. А сделать так, чтобы тесты всех модулей не тормозили на build-сервере, - задача решаемая (ведь build-сервер - это мощный сервер с современным железом, а не старый списанный десктоп, да?). Если производительность второго подхода вас не устраивает, но использовать первый подход не хочется, можно попробовать использовать in-memory базы данных: они практически выравнивают производительность двух подходов.

Производительность - не единственная проблема второго подхода: необходимо решить вопрос очистки базы данных после каждого теста. Для этого можно и накатывать после каждого теста базу (медленно решение), и откатываться к некоторому snapshot'у (работает быстрее, но поддерживается не всеми СУБД), и использовать транзакций без их последующего коммита. Я предпочитаю последний вариант за его универсальность и удовлетворяющую моим требованиям произвидительность. К тому же, благодаря TransactionScope реализовать этот подход очень просто. Правда, как я отмечал в своей недавней статье про TransactionScope, этот подход не работает, если в коде используется TransactionScope в режиме RequiresNew или Suppress. Заинтересовавшимся вопросом восстановления состояния базы данных, рекомендую серию постов на английском языке: раз, два, три

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

P. S. Выше я изложил, почему с классическими репозиториями имеет смысл использовать первый подход к тестированию. Но при этом в своем личном проекте я тестирую такие репозитории вторым подходом. Просто мне спится спокойней, когда сервисы работают с реальными репозиториями. Как думаете, это паранойя? :)

PP. S. Добавил кросспост на Хабр: http://habrahabr.ru/blogs/net/120755/

Комментариев нет: