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.

Давайте рассмотрим, что для этого нужно:

  1. Иметь возможность при загрузке класса выполнить его модификаци. А именно подсунуть ему свою реализацию метода getInstance.
  2. Создать наследника 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.

blog comments powered by Disqus