Tuesday, December 16, 2008
Подмена Синглтонов
Недавно мне в очередной раз пришлось работать с кодом, полученным в наследство. И я, как честный преверженец TDD, решил предже всего написать тесты на уже существующий класс. К своему огорчению сразу же обнаружил в коде вызов следующего вида: IdGenerator.getInstance()
. Да, это он самый, “любимый” нами синглтон.
UserService.java:
package example;
public class UserService {
public User createUser(String name) {
int id = IdGenerator.getInstance().generateId(name);
return new User(id, name);
}
}
User.java:
package example;
public class User {
private final int id;
private final String name;
public User(int id, String name) {
this.id = id;
this.name = name;
}
public int getId() {
return id;
}
public String getName() {
return name;
}
}
Большинство разработчиков не любят синглтоны, как минимум из-за ряда следующих недостатков:
- Путаница в зависимостях между классами. Синглтоны в коде, в основном, появляются неожиданно и при беглом просмотре их заметить сложно.
- Усложняют последующие изменения в системе. При использовании синглтона будет гораздо сложнее вынести его API в интерфейс и иметь несколько реализаций для разных клиентов.
- Препятствуют тестируемости кода.
Дабы не вдаваться в подробности и не ввязываться в религиозные споры замечу сразу советую почитать статью: "Why Singletons Are Controversial".
В связи с тем, что живем мы в далеком от идеала мире и у нас не всегда есть возможность что-то изменить, ингода нам приходится иметь дело с кодом, который нам нельзя сильно рафакторить по определенным причинам. Поэтому, вернемся все-таки к ситуации когда у нас есть синглтон (IdGenerator
) и класс (UserService
), который его использует.
IdGenerator.java:
package example;
import java.util.Random;
public final class IdGenerator {
private static IdGenerator instance = new IdGenerator();
private IdGenerator() {
}
public static IdGenerator getInstance() {
return instance;
}
public int generateId(String name) {
return new Random().nextInt();
}
}
И теперь попробуем написать тест к классу (UserService
), зависящему от синглтона (IdGenerator
).
UserServiceTest:
package example;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
public class UserServiceTest {
private UserService service;
@Before
public void setUp() throws Exception {
service = new UserService();
}
@After
public void tearDown() throws Exception {
service = null;
}
@Test
public void createUser() {
User user = service.createUser("john");
assertNotNull(user);
// assertEquals(???, user.getId());
assertEquals("john", user.getName());
}
}
А вот далее попытаемся проверить значение ожидаемого id пользователя: assertEquals(???, user.getId())
. Согласитесь, ситуация не из простых? К тому же, допустим, что IdGenerator
использует для генерации id не простой рендомайзер, а полноценную БД. В голову приходят только мысли про Mock Objects. Но тут так просто не выкрутиться с любимым EasyMock или JMock, потому как не один из них не умеет подменять статические методы. И тут мы вспоминаем про третий пункт за что мы так не любим синглтоны.
Немного подумав я решил, что все-таки должен быть способ заменить вызов статического метода в процессе тестирования. А уже в подмененном методе getInstance
вернуть mock IdGenerator
.
Давайте рассмотрим, что для этого нужно:
- Иметь возможность при загрузке класса выполнить его модификаци. А именно подсунуть ему свою реализацию метода
getInstance
. - Создать наследника final класса
IdGenerator
.
На первый взгляд может показаться, что это невозможно. После недолгих поисков я наткнулся на JMockit, который позволяет изменять в runtime статические методы классов и даже конструкторы. Но основным его недостатком является необходимость в дополнительных параметрах запуска Java. И так, еще немного поискав я нашел другое средство: PowerMock. Список его возможностей меня сразу же убедил в том, что префикс "Power" в его названии действительно оправдан.
Вот, небольшой перечень того, что PowerMock умеет делать:
- Mocking статических методов
- Mocking final методов и классов
- Mocking private методов
- Обход инкапсуляции
- Mock конструкторов
- Подавлять нежелательно поведение
Больше всего порадовала тесная интеграция с EasyMock.
Вот, собственно, и сам тест для упрямого UserService.
UserServiceTest:
package example;
import static org.easymock.EasyMock.expect;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.powermock.api.easymock.PowerMock.createMock;
import static org.powermock.api.easymock.PowerMock.mockStatic;
import static org.powermock.api.easymock.PowerMock.replayAll;
import static org.powermock.api.easymock.PowerMock.verifyAll;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
@PrepareForTest(IdGenerator.class)
@RunWith(PowerMockRunner.class)
public class UserServiceTest {
private UserService service;
private IdGenerator mockIdGenerator;
@Before
public void setUp() throws Exception {
service = new UserService();
mockIdGenerator = createMock(IdGenerator.class);
mockStatic(IdGenerator.class);
}
@After
public void tearDown() throws Exception {
service = null;
mockIdGenerator = null;
}
@Test
public void createUser() {
expect(IdGenerator.getInstance()).andReturn(mockIdGenerator);
expect(mockIdGenerator.generateId("john")).andReturn(1234);
replayAll();
User user = service.createUser("john");
assertNotNull(user);
assertEquals(1234, user.getId());
assertEquals("john", user.getName());
verifyAll();
}
}
Кстати, это же решение подходит для тестирования кода, который зависит от системных классов. Например, можно подменить вызов метода: System.currentTimeMillis()
.
На этом буду закругляться. Единственное, хочу упомянуть, что в любом случае следует несколько раз подумать, прежде чем оставлять вызов синглтона в коде. Тех же требований можно достичь гораздо меньшей ценой с помощью Dependency Injection и различных фреймворков вроде Spring и Guice.