После использования 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);
}
}
}
Наконец, что не менее важно, приятным бонусом к внедрению конструктора является возможность иметь все зависимости класса в одном месте (конструкторе). Если список зависимостей выходит за рамки контроля, вы получаете очень явный запах кода с размером конструктора. Это признак того, что у вас, конечно, есть несколько обязанностей в вашем классе, и вы должны разделить их на несколько классов, чтобы их было легче изолировать для модульного тестирования.