Search code examples
mockitopowermockspring-testpowermockito

Mockito (How to correctly mock nested objects)


I have this next class:

@Service
public class BusinessService {
    @Autowired
    private RedisService redisService;
    
    private void count() {
        String redisKey = "MyKey";
        AtomicInteger counter = null;
        if (!redisService.isExist(redisKey))
            counter = new AtomicInteger(0);
        else
            counter = redisService.get(redisKey, AtomicInteger.class);

        try {
            counter.incrementAndGet();
            redisService.set(redisKey, counter, false);
            logger.info(String.format("Counter incremented by one. Current counter = %s", counter.get()));
        } catch (JsonProcessingException e) {
            logger.severe(String.format("Failed to increment counter."));
        }
    }


    // Remaining code
}

and this this my RedisService.java class

@Service
public class RedisService {
    private Logger logger = LoggerFactory.getLogger(RedisService.class);

    @Autowired
    private RedisConfig redisConfig;

    @PostConstruct
    public void postConstruct() {
        try {
            String redisURL = redisConfig.getUrl();
            logger.info("Connecting to Redis at " + redisURL);
            syncCommands = RedisClient.create(redisURL).connect().sync();
        } catch (Exception e) {
            logger.error("Exception connecting to Redis: " + e.getMessage(), e);
        }
    }

    
    public boolean isExist(String redisKey) {
        return syncCommands.exists(new String[] { redisKey }) == 1 ? true : false;
    }

    public <T extends Serializable> void set(String key, T object, boolean convertObjectToJson) throws JsonProcessingException {
        if (convertObjectToJson)
            syncCommands.set(key, writeValueAsString(object));
        else
            syncCommands.set(key, String.valueOf(object));
    }
    // Remaining code
}

and this is my test class

@Mock
private RedisService redisService;

@Spy
@InjectMocks
BusinessService businessService = new BusinessService();

@Before
public void setup() {
    MockitoAnnotations.initMocks(this);
}

@Test
public void myTest() throws Exception {
    for (int i = 0; i < 50; i++)
        Whitebox.invokeMethod(businessService, "count");
    // Remaining code
}

my problem is the counter always equals to one in logs when running tests

Counter incremented by one. Current counter = 1(printed 50 times)

and it should print:

Counter incremented by one. Current counter = 1

Counter incremented by one. Current counter = 2

...

...

Counter incremented by one. Current counter = 50

this means the Redis mock always passed as a new instance to BusinessService in each method call inside each loop, so how I can force this behavior to become only one instance used always for Redis inside the test method ??

Note: Above code is just a sample to explain my problem, but it's not a complete code.


Solution

  • Your conclusion that a new RedisService is somehow created in each iteration is wrong.

    The problem is that it is a mock object for which you haven’t set any behaviours, so it responds with default values for each method call (null for objects, false for bools, 0 for ints etc).

    You need to use Mockito.when to set behaviour on your mocks.

    There is some additional complexity caused by the fact that:

    • you run the loop multiple times, and behaviour of the mocks differ between first and subsequent iterations
    • you create cached object in method under test. I used doAnswer to capture it.
    • You need to use doAnswer().when() instead of when().thenAnswer as set method returns void
    • and finally, atomicInt variable is modified from within the lambda. I made it a field of the class.
    • As the atomicInt is modified each time, I again used thenAnswer instead of thenReturn for get method.
    class BusinessServiceTest {
        @Mock
        private RedisService redisService;
        
        @InjectMocks
        BusinessService businessService = new BusinessService();
    
        AtomicInteger atomicInt = null;
    
        @BeforeEach
        public void setup() {
            MockitoAnnotations.initMocks(this);
        }
    
        @Test
        public void myTest() throws Exception {
            // given
            Mockito.when(redisService.isExist("MyKey"))
                    .thenReturn(false)
                    .thenReturn(true);
    
            Mockito.doAnswer((Answer<Void>) invocation -> {
                atomicInt = invocation.getArgument(1);
                return null;
            }).when(redisService).set(eq("MyKey"), any(AtomicInteger.class), eq(false));
    
            Mockito.when(redisService.get("MyKey", AtomicInteger.class))
                   .thenAnswer(invocation -> atomicInt);
    
            // when
            for (int i = 0; i < 50; i++) {
                Whitebox.invokeMethod(businessService, "count");
            }
            // Remaining code
        }
    }
    

    Having said that, I still find your code questionable.

    • You store AtomicInteger in Redis cache (by serializing it to String). This class is designed to be used by multiple threads in a process, and the threads using it the same counter need to share the same instance. By serializing it and deserializing on get, you are getting multiple instances of the (conceptually) same counter, which, to my eyes, looks like a bug.
    • smaller issue: You shouldn't normally test private methods
    • 2 small ones: there is no need to instantiate the field annotated with @InjectMocks. You don't need @Spy as well.