Статьи

К Autowire или не к Autowire, вот в чем вопрос

После использования 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);
    }
  }
}

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