在JUnit测试的情况下检查类中的调用次数

时间:2016-07-24 18:13:25

标签: java unit-testing junit mocking

我有一个计算某些东西的代码,缓存是,如果已经计算过,那么从缓存中读取;类似于:

public class LengthWithCache {
    private java.util.Map<String, Integer> lengthPlusOneCache = new java.util.HashMap<String, Integer>();

    public int getLenghtPlusOne(String string) {
        Integer cachedStringLenghtPlusOne = lengthPlusOneCache.get(string);
        if (cachedStringLenghtPlusOne != null) {
            return cachedStringLenghtPlusOne;
        }
        int stringLenghtPlusOne = determineLengthPlusOne(string);
        lengthPlusOneCache.put(string, new Integer(stringLenghtPlusOne));
        return stringLenghtPlusOne;
    }

    protected int determineLengthPlusOne(String string) {
        return string.length() + 1;
    }
}

我想测试函数determineLengthPlusOne是否被调用了足够多次,如下所示:

public class LengthWithCacheTest {
    @Test
    public void testGetLenghtPlusOne() {
        LengthWithCache lengthWithCache = new LengthWithCache();

        assertEquals(6, lengthWithCache.getLenghtPlusOne("apple"));
        // here check that determineLengthPlusOne has been called once

        assertEquals(6, lengthWithCache.getLenghtPlusOne("apple"));
        // here check that determineLengthPlusOne has not been called
    }
}

模拟类LengthWithCache似乎不是一个好选择,因为我想测试它们的功能。 (根据我的理解,我们模拟测试类使用的类,而不是测试类本身。)哪个是最优雅的解决方案?

我的第一个想法是创建另一个包含函数LengthPlusOneDeterminer的类determineLengthPlusOne,将其作为参数添加到函数getLenghtPlusOne,并在单元测试时模拟LengthPlusOneDeterminer,但这似乎有点奇怪,因为它对工作代码(类LengthWithCache的真实客户端)产生了不必要的影响。

基本上我使用Mockito,但欢迎任何模拟框架(或其他解决方案)!谢谢!

5 个答案:

答案 0 :(得分:2)

最优雅的方法是创建一个单独的类来执行缓存并使用它来装饰当前类(在删除缓存之后),这样您就可以安全地对缓存本身进行单元测试而不会干扰基类的功能

public class Length {
    public int getLenghtPlusOne(String string) {
        int stringLenghtPlusOne = determineLengthPlusOne(string);
        lengthPlusOneCache.put(string, new Integer(stringLenghtPlusOne));
        return stringLenghtPlusOne;
    }

    protected int determineLengthPlusOne(String string) {
        return string.length() + 1;
    }
}

public class CachedLength extends Length {
    private java.util.Map<String, Integer> lengthPlusOneCache = new java.util.HashMap<String, Integer>();

    public CachedLength(Length length) {
        this.length = length; 
    }

    public int getLenghtPlusOne(String string) {
        Integer cachedStringLenghtPlusOne = lengthPlusOneCache.get(string);
        if (cachedStringLenghtPlusOne != null) {
            return cachedStringLenghtPlusOne;
        }
        return length.getLenghtPlusOne(string);
    }
}

然后你可以轻松测试我注入一个模拟的Length

的缓存
Length length = Mockito.mock(Length.class);
CachedLength cached = new CachedLength(length);
....
Mockito.verify(length, Mockito.times(5)).getLenghtPlusOne(Mockito.anyInt());

答案 1 :(得分:2)

您不需要模拟来满足您的需求。 要测试内部行为(调用getLenghtPlusOne()是否已调用),您需要有一个方法来访问LengthWithCache中的缓存。
但是在你的设计层面,我们想象你不想在公共方法中打开缓存。这是正常的。
尽管存在这种约束,仍存在多种解决方案来对缓存行为进行测试。 我将介绍我的做法。也许,还有更好的。 但我认为在大多数情况下,您将被迫使用一些技巧或将您的设计复杂化以进行单元测试。

它依赖于通过扩展类来扩充您的类,以便为您的测试添加所需的信息和行为。
这是您在单元测试中使用的子类 此类扩展中最重要的一点是不要破坏或修改要测试的对象的行为。
它必须添加新信息并添加新行为,而不是修改原始类的信息和行为,否则测试会失去其值,因为它不再测试原始类中的行为。

关键点:
- 如果调用方法lengthPlusOneWasCalledForCurrentCall,则拥有一个私有字段lengthPlusOneWasCalled,用于注册当前调用
- 使用公共方法知道用作参数的字符串的lengthPlusOneWasCalledForCurrentCall的值。它启用了断言。
- 使用公共方法来清除lengthPlusOneWasCalledForCurrentCall的状态。它可以在断言后保持干净状态。

package cache;

import java.util.HashSet;
import java.util.Set;

import org.junit.Assert;
import org.junit.Test;

public class LengthWithCacheTest {

    private class LengthWithCacheAugmentedForTest extends LengthWithCache {

        private Set<String> lengthPlusOneWasCalledForCurrentCall = new HashSet<>();

        @Override
        protected int determineLengthPlusOne(String string) {
            // start : info for testing
            this.lengthPlusOneWasCalledForCurrentCall.add(string);
            // end : info for testing
            return super.determineLengthPlusOne(string);
        }

        // method for assertion
        public boolean isLengthPlusOneCalled(String string) {
            return lengthPlusOneWasCalledForCurrentCall.contains(string);
        }

        // method added for clean the state of current calls
        public void cleanCurrentCalls() {
            lengthPlusOneWasCalledForCurrentCall.clear();
        }

    }

    @Test   
   public void testGetLenghtPlusOne() {
    LengthWithCacheAugmentedForTest lengthWithCache = new LengthWithCacheAugmentedForTest();

      final String string = "apple";
      // here check that determineLengthPlusOne has been called once
      Assert.assertEquals(6, lengthWithCache.getLenghtPlusOne(string));
      Assert.assertTrue(lengthWithCache.isLengthPlusOneCalled(string));

      // clean call registered
      lengthWithCache.cleanCurrentCalls();

      // here check that determineLengthPlusOne has not been called
      Assert.assertEquals(6, lengthWithCache.getLenghtPlusOne(string));
      Assert.assertFalse(lengthWithCache.isLengthPlusOneCalled(string));
    }                    
}   

编辑28-07-16以显示为何需要更多代码来处理更多方案

假设,我将通过断言没有副作用来改进测试:在缓存中为密钥添加元素对于如何处理其他密钥的缓存没有影响。 此测试失败,因为它不依赖于字符串键。所以,它总是递增。

@Test
    public void verifyInvocationCountsWithDifferentElementsAdded() {
     final AtomicInteger plusOneInvkCounter = new AtomicInteger();
     LengthWithCache lengthWithCache = new LengthWithCache() {
         @Override
         protected int determineLengthPlusOne(String string) {
           plusOneInvkCounter.incrementAndGet();
          return super.determineLengthPlusOne(string);
          }
      };

     Assert.assertEquals(0, plusOneInvkCounter.get());
     lengthWithCache.getLenghtPlusOne("apple");
     Assert.assertEquals(1, plusOneInvkCounter.get());
     lengthWithCache.getLenghtPlusOne("pie");
     Assert.assertEquals(1, plusOneInvkCounter.get());
     lengthWithCache.getLenghtPlusOne("eggs");
     Assert.assertEquals(1, plusOneInvkCounter.get());        
  }

我的版本较长,因为它提供了更多功能,因此可以处理更广泛的单元测试场景。

编辑28-07-16指向整数缓存

与原始问题没有直接关系,但很少眨眼:) 您的getLenghtPlusOne(String string)应使用Integer.valueOf(int)代替new Integer(int) Integer.valueOf(int)在内部使用缓存

答案 2 :(得分:1)

感觉好像使用模拟是在过度思考它。 LengthWithCache 可以作为测试上下文中的匿名内部类重写,以获取调用计数。这不需要对正在测试的现有课程进行重组。

public class LengthWithCacheTest {
    @Test
    public void verifyLengthEval() {
        LengthWithCache lengthWithCache = new LengthWithCache();
        assertEquals(6, lengthWithCache.getLenghtPlusOne("apple"));
    }

    @Test
    public void verifyInvocationCounts() {
        final AtomicInteger plusOneInvkCounter = new AtomicInteger();
        LengthWithCache lengthWithCache = new LengthWithCache() {
            @Override
            protected int determineLengthPlusOne(String string) {
                plusOneInvkCounter.incrementAndGet();
                return super.determineLengthPlusOne(string);
            }
        };

           lengthWithCache.getLenghtPlusOne("apple");
           assertEquals(1, plusOneInvkCounter.get());

           lengthWithCache.getLenghtPlusOne("apple");
           lengthWithCache.getLenghtPlusOne("apple");
           lengthWithCache.getLenghtPlusOne("apple");
           lengthWithCache.getLenghtPlusOne("apple");
           lengthWithCache.getLenghtPlusOne("apple");
           lengthWithCache.getLenghtPlusOne("apple");

           assertEquals(1, plusOneInvkCounter.get());
    }
}
  

值得注意的是两个测试之间的分离。一个验证   如果长度eval正确,则另一个验证调用   计数。

如果需要更广泛的验证数据集,则可以将上面的测试转换为参数化测试,并提供多个数据集和期望。在下面的示例中,我添加了一个包含50个字符串(长度为1-50)的数据集,一个空字符串和一个空值。 Null失败

    @RunWith(Parameterized.class) 
    public class LengthWithCacheTest  {
        @Parameters(name="{0}")
        public static Collection<Object[]> buildTests() {
            Collection<Object[]> paramRefs = new ArrayList<Object[]>();
            paramRefs.add(new Object[]{null, 0});
            paramRefs.add(new Object[]{"", 1});
            for (int counter = 1 ; counter < 50; counter++) {
                String data = "";
                for (int index = 0 ; index < counter ; index++){
                    data += "a";
                }
                paramRefs.add(new Object[]{data, counter+1});
            }

            return paramRefs;
        }

        private String stringToTest;
        private int expectedLength;

        public LengthWithCacheTest(String string, int length) {
            this.stringToTest = string;
            this.expectedLength = length;
        }



    @Test
    public void verifyLengthEval() {
        LengthWithCache lengthWithCache = new LengthWithCache();
        assertEquals(expectedLength, lengthWithCache.getLenghtPlusOne(stringToTest));
    }

    @Test
    public void verifyInvocationCounts() {
        final AtomicInteger plusOneInvkCounter = new AtomicInteger();
        LengthWithCache lengthWithCache = new LengthWithCache() {
            @Override
            protected int determineLengthPlusOne(String string) {
                plusOneInvkCounter.incrementAndGet();
                return super.determineLengthPlusOne(string);
            }
        };

           assertEquals(0, plusOneInvkCounter.get());
           lengthWithCache.getLenghtPlusOne(stringToTest);
           assertEquals(1, plusOneInvkCounter.get());
           lengthWithCache.getLenghtPlusOne(stringToTest);
           assertEquals(1, plusOneInvkCounter.get());
           lengthWithCache.getLenghtPlusOne(stringToTest);
           assertEquals(1, plusOneInvkCounter.get());

    }
}

参数化测试是通过测试改变数据集的最佳方法之一,但它增加了测试的复杂性并且难以维护。了解但不总是正确的工具是有用的。

答案 3 :(得分:1)

由于这是一个有趣的问题,我决定编写测试。以两种不同的方式,一种是嘲弄,另一种是没有。 (就个人而言,我更喜欢没有嘲笑的版本。)在任何一种情况下,原始类都经过测试,没有任何修改:

package example;

import mockit.*;
import org.junit.*;
import static org.junit.Assert.*;

public class LengthWithCacheMockedTest {
    @Tested(availableDuringSetup = true) @Mocked LengthWithCache lengthWithCache;

    @Before
    public void recordComputedLengthPlusOneWhileFixingTheNumberOfAllowedInvocations() {
        new Expectations() {{
            lengthWithCache.determineLengthPlusOne(anyString); result = 6; times = 1;
        }};
    }

    @Test
    public void getLenghtPlusOneNotFromCacheWhenCalledTheFirstTime() {
        int length = lengthWithCache.getLenghtPlusOne("apple");

        assertEquals(6, length);
    }

    @Test
    public void getLenghtPlusOneFromCacheWhenCalledAfterFirstTime() {
        int length1 = lengthWithCache.getLenghtPlusOne("apple");
        int length2 = lengthWithCache.getLenghtPlusOne("apple");

        assertEquals(6, length1);
        assertEquals(length1, length2);
    }
}
package example;

import mockit.*;
import org.junit.*;
import static org.junit.Assert.*;

public class LengthWithCacheNotMockedTest {
    @Tested LengthWithCache lengthWithCache;

    @Test
    public void getLenghtPlusOneNotFromCacheWhenCalledTheFirstTime() {
        long t0 = System.currentTimeMillis(); // millisecond precision is enough here
        int length = lengthWithCache.getLenghtPlusOne("apple");
        long dt = System.currentTimeMillis() - t0;

        assertEquals(6, length);
        assertTrue(dt >= 100); // assume at least 100 millis to compute the expensive value
    }

    @Test
    public void getLenghtPlusOneFromCacheWhenCalledAfterFirstTime() {
        // First time: takes some time to compute.
        int length1 = lengthWithCache.getLenghtPlusOne("apple");

        // Second time: gets from cache, takes no time.
        long t0 = System.nanoTime(); // max precision here
        int length2 = lengthWithCache.getLenghtPlusOne("apple");
        long dt = System.nanoTime() - t0;

        assertEquals(6, length1);
        assertEquals(length1, length2);
        assertTrue(dt < 1000000); // 1000000 nanos = 1 millis
    }
}

只有一个细节:为了使上述测试工作,我在LengthWithCache#determineLengthPlusOne(String)方法中添加了以下行,以模拟计算需要一段时间的真实场景:

try { Thread.sleep(100); } catch (InterruptedException ignore) {}

答案 4 :(得分:0)

基于krzyk的提议,这是我完全可行的解决方案:

计算器本身:

public class LengthPlusOneCalculator {
    public int calculateLengthPlusOne(String string) {
        return string.length() + 1;
    }
}

单独的缓存机制:

public class LengthPlusOneCache {
    private LengthPlusOneCalculator lengthPlusOneCalculator;
    private java.util.Map<String, Integer> lengthPlusOneCache = new java.util.HashMap<String, Integer>();

    public LengthPlusOneCache(LengthPlusOneCalculator lengthPlusOneCalculator) {
        this.lengthPlusOneCalculator = lengthPlusOneCalculator;
    }

    public int calculateLenghtPlusOne(String string) {
        Integer cachedStringLenghtPlusOne = lengthPlusOneCache.get(string);
        if (cachedStringLenghtPlusOne != null) {
            return cachedStringLenghtPlusOne;
        }
        int stringLenghtPlusOne = lengthPlusOneCalculator.calculateLengthPlusOne(string);
        lengthPlusOneCache.put(string, new Integer(stringLenghtPlusOne));
        return stringLenghtPlusOne;
    }

}

检查LengthPlusOneCalculator

的单元测试
import static org.junit.Assert.assertEquals;
import org.junit.Test;

public class LengthPlusOneCalculatorTest {
    @Test
    public void testCalculateLengthPlusOne() {
        LengthPlusOneCalculator lengthPlusOneCalculator = new LengthPlusOneCalculator();
        assertEquals(6, lengthPlusOneCalculator.calculateLengthPlusOne("apple"));
    }

}

最后,LengthPlusOneCache的单元测试,检查调用次数:

import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.*;
import org.junit.Test;

public class LengthPlusOneCacheTest {
    @Test
    public void testNumberOfInvocations() {
        LengthPlusOneCalculator lengthPlusOneCalculatorMock = mock(LengthPlusOneCalculator.class);
        when(lengthPlusOneCalculatorMock.calculateLengthPlusOne("apple")).thenReturn(6);
        LengthPlusOneCache lengthPlusOneCache = new LengthPlusOneCache(lengthPlusOneCalculatorMock);

        verify(lengthPlusOneCalculatorMock, times(0)).calculateLengthPlusOne("apple"); // verify that not called yet
        assertEquals(6, lengthPlusOneCache.calculateLenghtPlusOne("apple"));
        verify(lengthPlusOneCalculatorMock, times(1)).calculateLengthPlusOne("apple"); // verify that already called once
        assertEquals(6, lengthPlusOneCache.calculateLenghtPlusOne("apple"));
        verify(lengthPlusOneCalculatorMock, times(1)).calculateLengthPlusOne("apple"); // verify that not called again
    }
}

我们可以安全地执行模拟机制,因为我们已经确信模拟类可以使用自己的单元测试正常工作。

通常这是内置于构建系统中的;此示例可以编译并从命令行运行,如下所示(文件junit-4.10.jarmockito-all-1.9.5.jar必须复制到工作目录):

javac -cp .;junit-4.10.jar;mockito-all-1.9.5.jar *.java
java -cp .;junit-4.10.jar org.junit.runner.JUnitCore LengthPlusOneCalculatorTest
java -cp .;junit-4.10.jar;mockito-all-1.9.5.jar org.junit.runner.JUnitCore LengthPlusOneCacheTest

但是,我仍然不满意这种方法。我的问题如下:

  1. 模拟了函数calculateLengthPlusOne。我更喜欢这样的解决方案,其中模拟或任何框架只计算调用次数,但原始代码运行。 (davidhxxx以某种方式提到,但我也没有找到一个完美的。)

  2. 代码变得有点过于复杂。这不是人们正常创造的方式。因此,如果原始代码不是我们完全控制的,那么这种方法是不够的。这可能是现实中的约束。

  3. 通常我会将函数calculateLengthPlusOne设为静态。这种方法在这种情况下不起作用。 (但也许我的Mockito知识很弱。)

  4. 如果有人可以解决这些问题,我会非常感激!