单例模式和单元测试

54

《Effective Java》中关于单例模式的单元测试有如下表述:

使一个类成为单例可能会使其客户端的测试变得困难,因为除非实现作为其类型的接口,否则无法为单例替代模拟实现。

有人能解释一下为什么吗?


3
我使用通过接口访问的单例,并尽可能使它们无状态。这避免了许多单例可能存在的问题(但在许多情况下不使用它们,它们经常被使用;) - Peter Lawrey
14个回答

45
你可以使用反射来重置你的单例对象,以防止测试相互影响。
@Before
public void resetSingleton() throws SecurityException, NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
   Field instance = MySingleton.class.getDeclaredField("instance");
   instance.setAccessible(true);
   instance.set(null, null);
}

参考:单元测试单例模式


@TedEd 对我没用 :( 你有什么想法为什么会这样吗? - Jeredriq Demas
@TedEd 单元测试单例模式的链接已失效。 - SaadurRehman

13

问题不在于测试单例本身;本书的意思是,如果您要测试的类 依赖于 单例,则可能会出现问题。

除非您 (1) 使单例实现一个接口,并且 (2) 使用该接口将单例注入到您的类中。

例如,单例通常会像这样直接实例化:

public class MyClass
{
    private MySingleton __s = MySingleton.getInstance() ;

    ...
}

现在,MyClass可能非常难以自动化测试。例如,正如@Boris Pavlović在他的答案中指出的那样,如果单例的行为基于系统时间,那么您的测试现在也依赖于系统时间,而且您可能无法测试依赖于星期几等情况的用例。

但是,如果您的单例“实现了一个充当其类型的接口”,那么只要将其传递进去,您仍然可以使用该接口的单例实现

public class SomeSingleton
    implements SomeInterface
{
    ...
}

public class MyClass
{
    private SomeInterface __s ;

    public MyClass( SomeInterface s )
    {
        __s = s ;
    }

    ...
}

...

MyClass m = new MyClass( SomeSingleton.getInstance() ) ;

从测试的角度来看,你现在不必关心SomeSingleton是否为单例:你可以传递任何其他实现,包括单例实现,但最有可能使用某种类型的模拟对象来进行控制和测试。

顺便说一句,这不是正确的做法:

public class MyClass
{
    private SomeInterface __s = SomeSingleton.getInstance() ;

    public MyClass()
    {
    }

    ...
}

在运行时,这仍然是一样的,但是在测试时,你现在又依赖于SomeSingleton


这种方式看起来非常好。只要类型是“SomeInterface”,我就可以随意更换任何类。但现在,单例中使用的所有方法都必须成为实现覆盖,对吗?例如,如果我创建了一个日志管理器单例,那么现在“SomeInterface”必须具有以下方法- printLog、saveLog、setLogTag、resetLog等等。我的理解是,所有单例方法现在都必须在接口中声明,对吗? - j2emanue

11

模拟需要接口,因为你所做的是用一个模仿测试需要的替代品来替换真实的底层行为。由于客户端只处理接口引用类型,所以不需要知道实现是什么。

如果没有接口,就不能模拟具体类,因为你不能替换行为而不让测试客户端知道它。在这种情况下,这是一个全新的类。

对于所有类,无论是Singleton还是其他类,都是如此。


2
说得好!“Mockit”可以模拟具体类,但它确实进行了非常邪恶的内省操作,可能会产生非常奇怪的行为。单例(在Java中实现)是邪恶的!Guice做得更好... - Jaco Van Niekerk

9

我认为这实际上取决于单例访问模式的实现方式。

例如

MySingleton.getInstance()

在进行测试时可能会非常困难

MySingletonFactory mySingletonFactory = ...
mySingletonFactory.getInstance() //this returns a MySingleton instance or even a subclass

没有提供任何关于使用单例模式的信息。因此,您可以自由地替换工厂。

注意:单例是指应用程序中只有一个该类的实例,但它的获取或存储方式不一定通过静态手段。


1
好观点,但自由取决于getInstance()方法是否是接口的一部分。 - duffymo

6

这很简单。

在单元测试中,您希望隔离您要测试的类(被测系统)。

您不想测试一堆类,因为那样就失去了单元测试的目的。

但并非所有类都可以独立完成所有任务。大多数类使用其他类来完成工作,并在其他类之间进行协调,同时添加一些自己的功能以获得最终结果。

重点是-您不关心SUT所依赖的类如何工作。您关心的是SUT如何与这些类一起工作。这就是为什么您需要存根或模拟 SUT所需的类。您可以将这些模拟作为构造函数参数传递给SUT。

对于单例模式-坏处在于getInstance()方法是全局可访问的。这意味着您通常从内部一个类中调用它,而不是依赖于后面可以模拟的接口。这就是为什么当您想要测试SUT时,无法替换它的原因。

解决方案不是使用巧妙的public static MySingleton getInstance()方法,而是依赖于您的类需要使用的接口。这样做,您就可以在需要时传入测试替身


1
+1,对我来说单例模式是一种反模式,类似于上个世纪的“全局变量”。正确的面向对象编程和模拟行为驱动开发意味着依赖注入,而不是使用单例模式。 - Guillaume
如果我正确理解了这个答案,你是说模拟协作类没问题吗? 这种方法有点棘手,我大部分同意你的回答... 但是,当它的一个协作者的行为发生变化时,模拟协作类是否会让一个类处于尴尬的位置呢?在这种情况下,单元测试将在协作者的行为发生变化后通过... 不确定事情是否像你所说的那样黑白分明。 - KRK Owner
考虑得很周到。这就是为什么仅仅进行单元测试是不够的,你可能需要集成测试和端到端测试。 - user3657103

4

单例对象是在外部没有任何控制的情况下创建的。在同一本书的另一章中,Bloch建议使用enum作为默认的单例实现。让我们看一个例子。

public enum Day {
  MON(2), TUE(3), WED(4), THU(5), FRI(6), SAT(7), SUN(1);

  private final int index;

  private Day(int index) {

    this.index = index;
  }

  public boolean isToday() {

    return index == new GregorianCalendar().get(Calendar.DAY_OF_WEEK);
  }
}

假设我们有一段代码只应在周末执行:

public void leisure() {

  if (Day.SAT.isToday() || Day.SUN.isToday()) {

    haveSomeFun();
    return;
  }

  doSomeWork();
}

测试休闲方法将会非常困难。它的执行将取决于执行时所在的日期。如果在工作日执行,则会调用doSomeWork(),而在周末则会调用haveSomeFun()

对于这种情况,我们需要使用一些重型工具,如PowerMock来拦截GregorianCalendar构造函数,返回一个模拟对象,该模拟对象将在两个测试用例中分别测试leisure方法的两个执行路径,并返回对应于工作日或周末的索引。


似乎这是一个有点笨重的解决方案 - 感谢您提供的示例只是一个例子 - 但是注入时间提供程序将允许更精确的测试,并且实现起来要简单得多 :-) - KRK Owner

4
it’s impossible to substitute a mock implementation for a singleton

这是不正确的。您可以子类化您的单例并设置注入模拟对象。另外,您可以使用PowerMock来模拟静态方法。但是,需要模拟单例可能是设计不良的症状。
真正的问题是当滥用单例时会变成依赖磁铁。由于它们随处可访问,因此将您需要的函数放在其中似乎比委托给适当的类更方便,特别是对于刚接触面向对象编程的程序员而言。
现在的测试问题是您的测试对象访问了一堆单例。即使对象可能只使用单例中的一小部分方法,您仍然需要模拟每个单例并找出依赖哪些方法。具有静态状态的单例(MonoState模式)甚至更糟,因为您必须确定对象之间的哪些交互受到单例状态的影响。
谨慎使用单例和测试性能够同时存在。例如,在缺少DI框架的情况下,您可以使用单例作为您的工厂和服务定位器,您可以设置注入来创建假服务层进行端到端测试。

3

有可能,参见示例

import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.lang.reflect.Field;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;

public class DriverSnapshotHandlerTest {

private static final String MOCKED_URL = "MockedURL";
private FormatterService formatter;

@SuppressWarnings("javadoc")
@Before
public void setUp() {
    formatter = mock(FormatterService.class);
    setMock(formatter);
    when(formatter.formatTachoIcon()).thenReturn(MOCKED_URL);
}

/**
 * Remove the mocked instance from the class. It is important, because other tests will be confused with the mocked instance.
 * @throws Exception if the instance could not be accessible
 */
@After
public void resetSingleton() throws Exception {
   Field instance = FormatterService.class.getDeclaredField("instance");
   instance.setAccessible(true);
   instance.set(null, null);
}

/**
 * Set a mock to the {@link FormatterService} instance
 * Throws {@link RuntimeException} in case if reflection failed, see a {@link Field#set(Object, Object)} method description.
 * @param mock the mock to be inserted to a class
 */
private void setMock(FormatterService mock) {
    Field instance;
    try {
        instance = FormatterService.class.getDeclaredField("instance");
        instance.setAccessible(true);
        instance.set(instance, mock);
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

/**
 * Test method for {@link com.example.DriverSnapshotHandler#getImageURL()}.
 */
@Test
public void testFormatterServiceIsCalled() {
    DriverSnapshotHandler handler = new DriverSnapshotHandler();
    String url = handler.getImageURL();

    verify(formatter, atLeastOnce()).formatTachoIcon();
    assertEquals(MOCKED_URL, url);
}

}

1

1
使用PowerMock来模拟单例类(SingletonClassHelper)实例和非静态方法(nonStaticMethod),该方法在task.execute()中被调用。
    import static org.mockito.Mockito.when;

    import org.junit.Before;
    import org.junit.Test;
    import org.junit.runner.RunWith;
    import org.mockito.InjectMocks;
    import org.mockito.Mock;
    import org.mockito.Mockito;
    import org.powermock.api.mockito.PowerMockito;
    import org.powermock.core.classloader.annotations.PrepareForTest;
    import org.powermock.modules.junit4.PowerMockRunner;

    @PrepareForTest({ SingletonClassHelper.class })
    @RunWith(PowerMockRunner.class)
    public class ClassToTest {

        @InjectMocks
        Task task;

        private static final String TEST_PAYLOAD = "data";
        private SingletonClassHelper singletonClassHelper;


        @Before
        public void setUp() {
            PowerMockito.mockStatic(SingletonClassHelper.class);
            singletonClassHelper = Mockito.mock(SingletonClassHelper.class);
            when(SingletonClassHelper.getInstance()).thenReturn(singletonClassHelper);
        }

        @Test
        public void test() {
            when(singletonClassHelper.nonStaticMethod(parameterA, parameterB, ...)).thenReturn(TEST_PAYLOAD);
            task.execute();
        }
    }

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接