Search code examples
javacachingjunitmockitocaffeine

Unit tests on Caffeine LoadingCache implementation pass individually but one fails when run together


The unit tests (JUnit, Mockito) that I've written for my Caffeine CacheLoader implementation all succeed when I run them individually, but one of them fails when I run them all together. I believe that I am following best practices in using @Before for all of my test object setups.

When run with the others, the test testGet_WhenCalledASecondAndThirdTimeBeyondCacheDuration_LoadingMethodCalledASecondTime fails every time with the following error:

org.mockito.exceptions.verification.TooLittleActualInvocations: 
testDataSource.getObjectWithKey(
    "mountain-bikes"
);
Wanted 2 times:
-> at ErrorHandlingLoadingCacheFactoryTest.testGet_WhenCalledASecondAndThirdTimeBeyondCacheDuration_LoadingMethodCalledASecondTime(ErrorHandlingLoadingCacheFactoryTest.java:67)
But was 1 time:
-> at ErrorHandlingCacheLoader.load(ErrorHandlingCacheLoader.java:41)

Something would appear to be carrying over between the tests, but given what I am doing in my @Before method I'm not sure how that could be. I've tried calling the following in an @After method:

  • invalidateAll()
  • cleanUp()
  • Mockito.reset(testDataSource)

I have also tried to manually pass a singleThreadExecutor to the cache builder, and waiting for it to finish whatever it is doing in @After in case that has anything to do with it.

My Caffeine CacheLoader implementation just overrides the reload method to return the currently cached value if the attempt to refresh it fails (throws an exception). Other than that, its pretty vanilla.

@Component
public class ErrorHandlingLoadingCacheFactory {

    private final Ticker ticker;

    @Autowired
    public ErrorHandlingLoadingCacheFactory(Ticker ticker) {
        this.ticker = ticker;
    }

    public <T> LoadingCache<String, T> buildCache(String cacheName,
                                                        long duration,
                                                        TimeUnit timeUnit,
                                                        Function<String, T> valueResolver) {
        return Caffeine.newBuilder()
                .refreshAfterWrite(duration, timeUnit)
                .ticker(ticker)
                .build(new ErrorHandlingCacheLoader<>(cacheName, valueResolver));
    }
}
/**
 *  a LoadingCache that retains stale cache values if
 *  an attempt to retrieve a fresh value for a given key fails.
 *
 * @param <K> the cache key type
 * @param <V> the cache value type
 */
class ErrorHandlingCacheLoader<K, V> implements CacheLoader<K, V> {
    private final static Logger logger = LoggerFactory.getLogger(ErrorHandlingCacheLoader.class);

    private final String cacheName;
    private final Function<K, V> valueResolver;

    /**
     * Create a cache.
     *
     * @param cacheName the cache name
     * @param valueResolver the method used to get a value for a key
     */
    public ErrorHandlingCacheLoader(String cacheName, Function<K, V> valueResolver) {
        this.cacheName = cacheName;
        this.valueResolver = valueResolver;
    }

    /**
     * Load the initial cache value for a given key.
     * @param key the cache key
     * @return the initial value to cache
     */
    @Override
    public V load(@NonNull K key) {
        return valueResolver.apply(key);
    }

    /**
     * Attempt to reload a value for a given key.
     * @param key the cache key
     * @param oldValue the currently cached value for the given key
     * @return
     */
    @Override
    public V reload(@NonNull K key, V oldValue) {
        V value = oldValue;
        try {
            value = valueResolver.apply(key);
        } catch (RuntimeException e) {
            logger.warn("Failed to retrieve value for key '{}' in cache '{}'. Returning currently cached value '{}'.", key, cacheName, oldValue);
        }
        return value;
    }
}
public class ErrorHandlingLoadingCacheFactoryTest {

    private ErrorHandlingLoadingCacheFactory errorHandlingLoadingCacheFactory;

    private FakeTicker fakeTicker;
    private TestDataSource testDataSource;

    private LoadingCache<String, TestObject> loadingCache;

    @Before
    public void setUp() {
        fakeTicker = new FakeTicker();
        testDataSource = mock(TestDataSource.class);
        errorHandlingLoadingCacheFactory = new ErrorHandlingLoadingCacheFactory(fakeTicker::read);
        loadingCache = errorHandlingLoadingCacheFactory.buildCache("testCache", 1, TimeUnit.HOURS, testDataSource::getObjectWithKey);
    }

    @After
    public void tearDown() {
        validateMockitoUsage();
    }

    @Test
    public void testGet_WhenCalledTwiceWithinCachePeriod_LoadingMethodCalledOnce() {
        // Arrange
        TestObject testObject = new TestObject("Mountain Bikes");
        when(testDataSource.getObjectWithKey("mountain-bikes")).thenReturn(testObject);

        // Act
        TestObject result1 = loadingCache.get("mountain-bikes");
        TestObject result2 = loadingCache.get("mountain-bikes");

        // Assert
        verify(testDataSource, times(1)).getObjectWithKey("mountain-bikes");
        assertThat(result1).isEqualTo(testObject);
        assertThat(result2).isEqualTo(testObject);
    }

    @Test
    public void testGet_WhenCalledASecondAndThirdTimeBeyondCacheDuration_LoadingMethodCalledASecondTime() {
        // Arrange
        TestObject testObject1 = new TestObject("Mountain Bikes 1");
        TestObject testObject2 = new TestObject("Mountain Bikes 2");
        when(testDataSource.getObjectWithKey("mountain-bikes")).thenReturn(testObject1, testObject2);

        // Act
        TestObject result1 = loadingCache.get("mountain-bikes");
        fakeTicker.advance(2, TimeUnit.HOURS);
        TestObject result2 = loadingCache.get("mountain-bikes");
        TestObject result3 = loadingCache.get("mountain-bikes");

        // Assert
        verify(testDataSource, times(2)).getObjectWithKey("mountain-bikes");
        assertThat(result1).isEqualTo(testObject1);
        assertThat(result2).isEqualTo(testObject1);
        assertThat(result3).isEqualTo(testObject2);
    }

    @Test(expected = RuntimeException.class)
    public void testGet_WhenFirstLoadCallThrowsRuntimeException_ThrowsRuntimeException() {
        // Arrange
        when(testDataSource.getObjectWithKey("mountain-bikes")).thenThrow(new RuntimeException());

        // Act
        loadingCache.get("mountain-bikes");
    }

    @Test
    public void testGet_WhenFirstLoadCallSuccessfulButSecondThrowsRuntimeException_ReturnsCachedValueFromFirstCall() {
        // Arrange
        TestObject testObject1 = new TestObject("Mountain Bikes 1");
        when(testDataSource.getObjectWithKey("mountain-bikes")).thenReturn(testObject1).thenThrow(new RuntimeException());

        // Act
        TestObject result1 = loadingCache.get("mountain-bikes");
        fakeTicker.advance(2, TimeUnit.HOURS);
        TestObject result2 = loadingCache.get("mountain-bikes");

        // Assert
        verify(testDataSource, times(2)).getObjectWithKey("mountain-bikes");
        assertThat(result1).isEqualTo(testObject1);
        assertThat(result2).isEqualTo(testObject1);
    }

    @Test
    public void testGet_WhenFirstLoadCallSuccessfulButSecondThrowsRuntimeException_SubsequentGetsReturnCachedValueFromFirstCall() {
        // Arrange
        TestObject testObject1 = new TestObject("Mountain Bikes 1");
        when(testDataSource.getObjectWithKey("mountain-bikes")).thenReturn(testObject1).thenThrow(new RuntimeException());

        // Act
        TestObject result1 = loadingCache.get("mountain-bikes");
        fakeTicker.advance(2, TimeUnit.HOURS);
        TestObject result2 = loadingCache.get("mountain-bikes");
        TestObject result3 = loadingCache.get("mountain-bikes");

        // Assert
        verify(testDataSource, times(2)).getObjectWithKey("mountain-bikes");
        assertThat(result1).isEqualTo(testObject1);
        assertThat(result2).isEqualTo(testObject1);
        assertThat(result3).isEqualTo(testObject1);
    }

    @Test(expected = NullPointerException.class)
    public void testGet_WhenKeyIsNull_ThrowsNullPointerException() {
        // Arrange
        String key = null;

        // Act
        loadingCache.get(key);
    }

    @Test
    public void testGet_WhenFirstLoadCallReturnsNull_DoesNotCacheResult() {
        // Arrange
        TestObject testObject1 = new TestObject("Mountain Bikes 1");
        when(testDataSource.getObjectWithKey("mountain-bikes")).thenReturn(null).thenReturn(testObject1);

        // Act
        TestObject result1 = loadingCache.get("mountain-bikes");
        TestObject result2 = loadingCache.get("mountain-bikes");

        // Assert
        verify(testDataSource, times(2)).getObjectWithKey("mountain-bikes");
        assertThat(result1).isEqualTo(null);
        assertThat(result2).isEqualTo(testObject1);
    }

    @Data
    class TestObject {
        private String id;
        public TestObject(String id) {
            this.id = id;
        }
    }

    interface TestDataSource {
        TestObject getObjectWithKey(String key);
    }
}

Solution

  • Ben Manes suggested in his comment that I use Runnable::run as the LoadingCache's executor when running unit tests, which did the trick!

    I implemented a second protected buildCache method on my factory that additionally takes an Executor parameter, which my test class uses to pass Runnable::run.

    The updated ErrorHandlingLoadingCacheFactory:

    public class ErrorHandlingLoadingCacheFactory {
    
        private final Ticker ticker;
    
        @Autowired
        public ErrorHandlingLoadingCacheFactory(Ticker ticker) {
            this.ticker = ticker;
        }
    
        /**
         * Create an in-memory LoadingCache
         *
         * @param cacheName the name of the cache
         * @param duration how long to keep values in the cache before attempting to refresh them
         * @param timeUnit the unit of time of the given duration
         * @param valueResolver the method to call to get a value to load into the cache for a given key
         * @param <T> the type of object to store into the cache
         * @return the newly created cache
         */
        public <T> LoadingCache<String, T> buildCache(String cacheName,
                                                            long duration,
                                                            TimeUnit timeUnit,
                                                            Function<String, T> valueResolver) {
            return buildCache(cacheName, duration, timeUnit, valueResolver, ForkJoinPool.commonPool());
        }
    
        /**
         * Create an in-memory LoadingCache
         *
         * @param cacheName the name of the cache
         * @param duration how long to keep values in the cache before attempting to refresh them
         * @param timeUnit the unit of time of the given duration
         * @param valueResolver the method to call to get a value to load into the cache for a given key
         * @param executor the executor for the cache to use
         * @param <T> the type of object to store into the cache
         * @return the newly created cache
         */
        protected <T> LoadingCache<String, T> buildCache(String cacheName,
                                                         long duration,
                                                         TimeUnit timeUnit,
                                                         Function<String, T> valueResolver,
                                                         Executor executor) {
            return Caffeine.newBuilder()
                    .refreshAfterWrite(duration, timeUnit)
                    .ticker(ticker)
                    .executor(executor)
                    .build(new ErrorHandlingCacheLoader<>(cacheName, valueResolver));
        }
    }
    

    the updated setUp() method in ErrorHandlingLoadingCacheFactoryTest:

    ...
    @Before
        public void setUp() {
            fakeTicker = new FakeTicker();
            testDataSource = mock(TestDataSource.class);
            errorHandlingLoadingCacheFactory = new ErrorHandlingLoadingCacheFactory(fakeTicker::read);
            loadingCache = errorHandlingLoadingCacheFactory.buildCache("testCache", 1, TimeUnit.HOURS, testDataSource::getObjectWithKey, Runnable::run);
        }
    ...
    

    There must have been a race between my tests that my single-threaded executor didn't catch, probably because I didn't properly wait for it to terminate in my @After method. Ben suggested that if I used awaitTermination on the single-threaded executor, that might also have worked.