Привет! Давайте сегодня поговорим о качестве модульных тестов у вас на проекте.
При написании unit-тестов часто приходится прибегать к инициализации тестового окружения с помощью тестовых дублеров (моков и/или стабов). Допустим, у нас мы тестируем проект, которые имеет следующую схематическую структуру.
У нас есть интерфейс ISessionContext, который по сути является корнем композиции, из него мы по цепочке можем получить хранилище отчетов IReportsRepository. Нам необходимо написать модульные тесты на класс ReportAccessValidator, который проверяет, имеет ли выбранный отчет право на переданные аргументы. Этот конструктор принимает ISessionContext, с которым он работает. Примерный модульный тест будет выглядеть так (для создания дублеров используется Rhino Mock. Не имею ничего против Moq, но так исторически сложилось, что у нас на проекте Носорог).
При написании unit-тестов часто приходится прибегать к инициализации тестового окружения с помощью тестовых дублеров (моков и/или стабов). Допустим, у нас мы тестируем проект, которые имеет следующую схематическую структуру.
У нас есть интерфейс ISessionContext, который по сути является корнем композиции, из него мы по цепочке можем получить хранилище отчетов IReportsRepository. Нам необходимо написать модульные тесты на класс ReportAccessValidator, который проверяет, имеет ли выбранный отчет право на переданные аргументы. Этот конструктор принимает ISessionContext, с которым он работает. Примерный модульный тест будет выглядеть так (для создания дублеров используется Rhino Mock. Не имею ничего против Moq, но так исторически сложилось, что у нас на проекте Носорог).
[TestMethod] public void TestValidateInaccessibleParams() { var mock = new MockRepository(); var sesContext = mock.StrictMock<ISessionContext>(); var layer = mock.StrictMock<IBusinessLayer>(); var data = mock.StrictMock<IDataManager>(); var reportRepo = mock.StrictMock<IReportRepository>(); // Дальше куча строк с объявлением зависимостей в цепочке // и описанием какие методы должны быть дернуты... // Нам это не важно. // Важно то, что таких строк может быть много // (На проекте достигало 150 - 200) mock.ReplayAll(); var validator = new ReportAccessValidator(sesContext, reportId, parameters); validator.Validate(); mock.VerifyAll(); }Если мы продолжим писать тесты для проверки различной логики (а используя TDD, мы обязательно напишем еще кучу тестов), на придется повторять инициализацию цепочки от теста к тесту, обычно незначительно модифицируя ее. В итоге получаем дублирование кода. Такие тесты тяжело читать и сопровождать. Непосвященному в них тяжело разобраться. В таких тестах много вспомогательных переменных, за которыми нужно следить.
Первое, что приходит в голову, - это вынести все эти вспомогательные инициализации в метод(ы) и вызывать их из тестов. Мне такой подход не нравится, так как мы все портянки кода переносим в другое место, тесты становится более читаемые, но в итоге получается куча методов, за порядком вызовов которых нужно внимательно следить.
Я хочу предложить вынести весь вспомогательный код в отдельный класс, реализуемый по принципу Object Builder. Применение такого подхода дает нам повышение читаемости и сопровождаемости тестов. Тесты становятся описательными. Естественно, такой подход будет полезен для сложных тестов с большим количеством настроек. Применения такого подхода соответствует современной сейчас концепции среди наших заграничных коллег, которой называется DRY and DAMP (Don't repeat youself and Descriptove And Meaningful Phrases).
[TestMethod] public void TestValidateInaccessibleParams() { // Создаем builder для SessionContext var builder = new SessionBuilder(); using(builder) { // Определяем контекс с заданным уровнем доступа, // параметрами по умолчанию и пользователем, от которого создана сессия var sessContext = builder.WithAccess(AccessConstants.Confidential) .WithDefaultParams() .WithUser("DefaultUser") .Build(); var validator = new ReportAccessValidator(sesContext, reportId, parameters); validator.Validate(); } }
В отличие от предыдущего теста, этот тест написан полностью. Это достигается за счет создания класса - построителя окружения. Он берет на себя всю ответственность по созданию тестовых дублеров. У такого класса есть набор методов With..., которые опционально добавляют необходимые параметры к окружения. В нашем примере таким образом добавляется уровень доступа, параметры сессии по-умолчанию и пользователь. В идеале эти методы должны быть написаны так, чтобы не был важен порядок их вызова. Но это не всегда возможно. Не забывайте, что в тестах важней простота, чем сложный дизайн вспомогательного тестового окружения.
Наш построитель реализует интерфейс IDisposable, где в Dispose вызывается метод mock.VerifyAll. Это не обязательно делать так, но мне кажется. что благодаря этому тест становится еще проще читать и сопровождать.
Ниже приведен каркас используемого построителя. Класс показан не полностью, на самом деле он может быть довольно большим, но код пишется один раз и не дублируется по тестам. Возвращение this в методах позволяет строить удобочитаемые цепочки, а метод Dispose делает проверку дублей прозрачной в теле теста.
class SessionBuilder : IDisposable { public SessionBuilder() { Mock = new MockRepository(); _sesContext = mock.StrictMock<ISessionContext>(); _layer = mock.StrictMock<IBusinessLayer>(); _data = mock.StrictMock<IDataManager>(); _reportRepo = mock.StrictMock<IReportRepository>(); ... } // MockRepository делаем открытым на случай, // если понадобится детализировать моки внутри теста... internal MockRepository Mock { get; private set; } public SessionBuilder WithAccess(long access) { _access = access; return this; } public SessionBuilder WithDefaultParams() { // Устанавливаем параметры return this; } public SessionBuilder WithUser(string userName) { _user = Mock.StrictMock<IUser>(); _user.Except(u => u.Name).Return(userName).Repeat.Any(); // Еще настраиваем, если нужно return this; } public IDocument BuidDocument() { // СТРОИМ SessionContext ПО ВСЕМ ПАРАМЕТРАМ return _sesContext } public void Dispose() { Mock.VerifyAll(); } }
Данный подход значительно упрощает сопровождения сложный тестов с богатым окружением. Надеюсь, что в ваших проектах Object Builder также окажется очень полезным.
UPD. Архитектор на нашем проекте дал одну очень полезную рекомендацию для улучшения работы построителя.
В метод Dispose стоит включить код, проверяющий обрабатывается ли сейчас исключение, и если да, то не выполнять проверку моков, так как она только скроет реальные проблемы в коде.
public void Dispose() { if (Marshal.GetExceptionCode() != 0 || Marshal.GetExceptionPointers() != IntPtr.Zero) return; Mock.VerifyAll(); }
В промышленном коде (не в тестах!) такой подход не допускается, так как эти методы предназначены для компиляторов, а не для приложений, и также это нарушает семантику шаблона IDisposable. В тестах это не важно, а в основном коде этот подход крайне не желателен.
Комментариев нет:
Отправить комментарий