После использования Spring 2.5 я переключился с контекста приложения на основе XML на аннотации. Хотя я нахожу эти очень полезные и огромные средства для экономии времени, у меня всегда было чувство, что я теряю что-то с точки зрения гибкости. В частности, аннотация @Autowired — или стандарт @Inject — показалась мне новой «новой», усиливающей связь между моими классами и усложняющей изменение реализации при необходимости.
Я все еще чувствую это немного, но я выучил интересный шаблон, чтобы ограничить проблему, когда дело доходит до тестирования моего кода, то есть, когда я хочу заменить реальную реализацию компонента на макет.
Давайте проиллюстрируем на примере. Я хочу создать приложение, чтобы найти интересные вещи в Интернете для меня. Я начну со службы, которая берет URL-адрес и добавляет его в закладки, если он новый, интересный.
До недавнего времени я мог кодировать что-то вроде этого:
@Named public class AwesomenessFinder { @Inject private BlogAnalyzer blogAnalyzer; @Inject private BookmarkService bookmarkService; public void checkBlog(String url) { if (!bookmarkService.contains(url) && blogAnalyzer.isInteresting(url)) { bookmarkService.bookmark(url); } } }
Это плохо, понимаешь почему? Если нет, продолжайте читать, я надеюсь, вы узнаете что-то полезное сегодня.
Поскольку я добросовестный, я хочу создать модульные тесты для этого кода. Надеюсь, мой алгоритм в порядке, но я хочу убедиться, что он не будет создавать закладки для скучных блогов или один и тот же URL дважды.
Вот где возникают проблемы, я хочу изолировать AwesomenessFinder от его зависимостей. Если бы я использовал конфигурацию XML, я мог бы просто внедрить фиктивную реализацию в моем тестовом контексте, могу ли я сделать это с аннотациями? Ну да! Есть способ с аннотацией @Primary. Давайте попробуем создать фиктивные реализации для BlogAnalyzer и BookmarkService.
@Named @Primary public class BlogAnalyzerMock implements BlogAnalyzer { public boolean isInteresting(String url) { return true; } } @Named @Primary public class BookmarkServiceMock implements BookmarkService { Set bookmarks = new HashSet(); public boolean contains(String url) { return bookmarks.contains(url); } public void bookmark(String url) { bookmarks.add(url); } }
Поскольку я использую Maven и помещаю эти макеты в каталог test / java, основное приложение не увидит их и внедрит реальные реализации. С другой стороны, модульные тесты увидят 2 реализации. @Primary требуется для предотвращения исключения, такого как:
org.springframework.beans.factory.NoSuchBeanDefinitionException: No unique bean of type [service.BlogAnalyzer] is defined: expected single matching bean but found 2: [blogAnalyzerMock, blogAnalyzerImpl]
Теперь я могу проверить свой алгоритм:
@RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(locations = "classpath:application-context.xml") public class AwesomenessFinderTest { @Inject private AwesomenessFinder awesomenessFinder; @Inject private BookmarkService bookmarkService; @Test public void checkInterestingBlog_bookmarked() { String url = "http://www.javaspecialists.eu"; assertFalse(bookmarkService.contains(url)); awesomenessFinder.checkBlog(url); assertTrue(bookmarkService.contains(url)); } }
Не плохо, я проверил счастливый путь, интересный блог добавлен в закладки. Теперь, как мне пройти тестирование других случаев. Конечно, я могу добавить некоторую логику в мои макеты, чтобы найти определенные URL-адреса, уже добавленные в закладки или не интересные, но это может стать неуклюжим. И это очень простой алгоритм, представьте, как плохо будет тестировать что-то более сложное.
Есть лучший способ, который требует перепроектирования моего класса и способа внедрения зависимостей. Вот как:
@Named public class AwesomenessFinder { private BlogAnalyzer blogAnalyzer; private BookmarkService bookmarkService; @Inject public AwesomenessFinder(BlogAnalyzer blogAnalyzer, BookmarkService bookmarkService) { this.blogAnalyzer = blogAnalyzer; this.bookmarkService = bookmarkService; } public void checkBlog(String url) { if (!bookmarkService.contains(url) && blogAnalyzer.isInteresting(url)) { bookmarkService.bookmark(url); } } }
Обратите внимание, что я по-прежнему автоматически связываю свои зависимости с аннотацией @Inject, поэтому на вызывающих абоненты моего AwesomenessFinder это не повлияет. Например, следующее в клиентском классе все еще будет работать:
@Inject private AwesomenessFinder awesomenessFinder;
Тем не менее, большая разница в том, что я автоматически подключаюсь на уровне конструктора, что дает мне простой способ внедрить ложные реализации. И, так как мы издеваемся, давайте использовать библиотеку насмешек. В прошлом году я написал пост о mockito, в котором я использовал уродливые сеттеры для инъекций своих издевательств. С техникой, упомянутой здесь, мне больше не нужно раскрывать свои зависимости, я получаю намного лучшую инкапсуляцию.
Вот как выглядит обновленный тестовый пример:
public class AwesomenessFinderTest { @Test public void checkInterestingBlog_bookmarked() { BookmarkService bookmarkService = mock(BookmarkService.class); when(bookmarkService.contains(anyString())).thenReturn(false); BlogAnalyzer blogAnalyzer = mock(BlogAnalyzer.class); when(blogAnalyzer.isInteresting(anyString())).thenReturn(true); AwesomenessFinder awesomenessFinder = new AwesomenessFinder(blogAnalyzer, bookmarkService); String url = "http://www.javaspecialists.eu"; awesomenessFinder.checkBlog(url); verify(bookmarkService).bookmark(url); } }
Обратите внимание, что теперь это просто Java, нет необходимости использовать Spring для вставки макетов. Кроме того, определение этих макетов находится в том же месте, что и их использование, что облегчает обслуживание.
Чтобы сделать шаг вперед, давайте реализуем другие тестовые случаи. Чтобы избежать дублирования кода, мы проведем рефакторинг тестового класса и введем некоторые перечисления, чтобы сделать тестовые примеры максимально выразительными.
public class AwesomenessFinderTest { private enum Knowledge {KNOWN, UNKNOWN}; private enum Quality {INTERESTING, BORING}; private enum ExpectedBookmark {STORED, IGNORED} private enum ExpectedAnalysis {ANALYZED, SKIPPED} @Test public void checkInterestingBlog_bookmarked() { checkCase(Knowledge.UNKNOWN, Quality.INTERESTING, ExpectedBookmark.STORED, ExpectedAnalysis.ANALYZED); } @Test public void checkBoringBlog_ignored() { checkCase(Knowledge.UNKNOWN, Quality.BORING, ExpectedBookmark.IGNORED, ExpectedAnalysis.ANALYZED); } @Test public void checkKnownBlog_ignored() { checkCase(Knowledge.KNOWN, Quality.INTERESTING, ExpectedBookmark.IGNORED, ExpectedAnalysis.SKIPPED); } private void checkCase(Knowledge knowledge, Quality quality, ExpectedBookmark expectedBookmark, ExpectedAnalysis expectedAnalysis) { BookmarkService bookmarkService = mock(BookmarkService.class); boolean alreadyBookmarked = (knowledge == Knowledge.KNOWN) ? true : false; when(bookmarkService.contains(anyString())).thenReturn(alreadyBookmarked); BlogAnalyzer blogAnalyzer = mock(BlogAnalyzer.class); boolean interesting = (quality == Quality.INTERESTING) ? true : false; when(blogAnalyzer.isInteresting(anyString())).thenReturn(interesting); AwesomenessFinder awesomenessFinder = new AwesomenessFinder(blogAnalyzer, bookmarkService); String url = "whatever"; awesomenessFinder.checkBlog(url); if (expectedBookmark == ExpectedBookmark.STORED) { verify(bookmarkService).bookmark(url); } else { verify(bookmarkService, never()).bookmark(url); } if (expectedAnalysis == ExpectedAnalysis.ANALYZED) { verify(blogAnalyzer).isInteresting(url); } else { verify(blogAnalyzer, never()).isInteresting(url); } } }
Наконец, что не менее важно, приятным бонусом к внедрению конструктора является возможность иметь все зависимости класса в одном месте (конструкторе). Если список зависимостей выходит за рамки контроля, вы получаете очень явный запах кода с размером конструктора. Это признак того, что у вас, конечно, есть несколько обязанностей в вашем классе, и вы должны разделить их на несколько классов, чтобы их было легче изолировать для модульного тестирования.