Статьи

Весна: для автоматической или нет

После использования Spring 2.5 я переключился с контекста приложения на основе XML на аннотации. Хотя я нахожу эти очень полезные и огромные средства для экономии времени, у меня всегда было чувство, что я теряю что-то с точки зрения гибкости. В частности, аннотация @Autowired — или стандарт @Inject — показалась мне новой «новой», усиливающей связь между моими классами и усложняющей изменение реализации при необходимости. Я все еще чувствую это немного, но я выучил интересный шаблон, чтобы ограничить проблему, когда дело доходит до тестирования моего кода, то есть, когда я хочу заменить реальную реализацию компонента на макет. Давайте проиллюстрируем на примере. Я хочу создать приложение, чтобы найти интересные вещи в Интернете для меня. Я начну со службы, которая берет URL-адрес и добавляет его в закладки, если он новый, интересный. До недавнего времени я мог кодировать что-то вроде этого:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
@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.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@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 требуется для предотвращения исключения, такого как:

1
2
3
org.springframework.beans.factory.NoSuchBeanDefinitionException:
No unique bean of type [service.BlogAnalyzer] is defined: expected single matching bean
but found 2: [blogAnalyzerMock, blogAnalyzerImpl]

Теперь я могу проверить свой алгоритм:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
@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-адреса, уже добавленные в закладки или не интересные, но это может стать неуклюжим. И это очень простой алгоритм, представьте, как плохо будет тестировать что-то более сложное. Есть лучший способ, который требует перепроектирования моего класса и способа внедрения зависимостей. Вот как:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
@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 это не влияет. Например, следующее в клиентском классе все еще будет работать:

1
2
@Inject
private AwesomenessFinder awesomenessFinder;

Тем не менее, большая разница в том, что я автоматически подключаюсь на уровне конструктора, что дает мне простой способ внедрить ложные реализации. И, так как мы издеваемся, давайте использовать библиотеку насмешек. В прошлом году я написал пост о mockito, в котором я использовал уродливые сеттеры для инъекций своих издевательств. С техникой, упомянутой здесь, мне больше не нужно раскрывать свои зависимости, я получаю намного лучшую инкапсуляцию. Вот как выглядит обновленный тестовый пример:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
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 для вставки макетов. Кроме того, определение этих макетов находится в том же месте, что и их использование, что облегчает обслуживание. Чтобы сделать шаг вперед, давайте реализуем другие тестовые случаи. Чтобы избежать дублирования кода, мы проведем рефакторинг тестового класса и введем некоторые перечисления, чтобы сделать тестовые примеры максимально выразительными.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
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);
    }
  }
}

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