0

Before you say it, I know this is a piece of code that is hard to test. It is a legacy that I was going to refactor it at some point, but I just didn't want to do it right now.


I am currently in the process of migrating my test cases from PowerMockito as it turned out to be a sort of dead project.

I came upon the challenge of mimicking the behavior I have grown used to with whenNew

Use case:

ArrayList<Integer> numbers = new ArrayList();
PowerMockito.whenNew(ArrayList.class).withAnyArguments().thenReturn(numbers);

// The `doStuff` under the hood instantiates `ArrayList`
someService.doStuff();

// Finally, verify 
Assert.assertEquals(numbers.size(), 1);

In this particular case, by design, I have no access to this internal ArrayList, but I would still very much like to verify its results.

With PowerMockito, I was able to do that, but with Mockito, I am having difficulties matching the behavior. The closest one I've got was:

try(MockedConstruction<ArrayList> listConstruction = Mockito.mockConstruction(ArrayList.class, Mockito.withSettings().defaultAnswer(Answers.CALLS_REAL_METHODS))){

    someService.doStuff();

    ArrayList<Integer> numbers = listConstruction.constructed().get(0);
    Assert.assertEquals(numbers.size(), 1);
}

In this example, SomeService does construct mocked list but:

  1. It is not my instance that I created externally (maybe a good thing)
  2. The mockConstruction does not invoke the original constructor nor does it go over member initialization. This gives me an empty shell object, incapable of doing any real methods for which I get NPE.
Jovan Perovic
  • 19,846
  • 5
  • 44
  • 85
  • 2
    It was pointed out to me that test is more problematic that the implementation itself. The test here wrongly relies on deep-internal state of the service ... – Jovan Perovic Jul 12 '23 at 15:28

1 Answers1

1

The problem stated in your question is a tricky one and the solution is "hacky" as well, so just to be clear (even though it's been stated in the question as well): I do not recommend using this approach, it would be best to refactor the code and avoid mocking construction of the ArrayList. This answer is more of a "why we shouldn't go that way" showcase. That being said...

Mockito and the libraries it uses under the hood operate on the ArrayList class, which in turn refer back to the Mockito's MockedConstruction, which causes a StackOverflowError in a scenario I verified. It is a problem also described in the Mockito GitHub Issues: here and here.

To work around that we may create a method like this:

static <T> MockedConstruction<T> mockCreationOnce(Class<T> clazz) {
    // to operate on the MockedConstruction within a lambda passed to its creation
    // we have to be able to pass the object to the lambda
    // and to do that it needs to be final => AtomicReference usage
    // (it could also be a custom class with a field set at a later time;
    // the reference passed to the lambda has to be final, its contents may change)
    var constructionHolder = new AtomicReference<MockedConstruction<T>>();
    var listConstruction = Mockito.mockConstruction(
            clazz,
            context -> {
                try {
                    return Mockito.withSettings();
                } finally {
                    // we're closing the MockedConstruction after first call
                    // from within the doStuff() method
                    // to avoid the construction being mocked in Mockito internal calls
                    var construction = constructionHolder.get();
                    // we're using closeOnDemand() instead of close() to avoid exceptions here
                    // (no-op if closed already)
                    construction.closeOnDemand();
                }
            }
    );
    // here we're setting the MockedConstruction in the AtomicReference
    // it happens before the lambda call,
    // which will be invoked for the first (and last) time
    // during ArrayList constructor call in the doStuff() method
    constructionHolder.set(listConstruction);
    return listConstruction;
}

Thanks to that we can verify the tested code (for example):

void doStuff() {
    var numbers = new ArrayList<Integer>();
    numbers.add(1);
    numbers.add(2);
    numbers.add(3);
}

with such a test method:

@Test
void doStuff() {
    var listConstruction = mockCreationOnce(ArrayList.class);

    someService.doStuff();

    var mockedList = listConstruction.constructed().get(0);
    var inOrder = Mockito.inOrder(mockedList);
    inOrder.verify(mockedList).add(1);
    inOrder.verify(mockedList).add(2);
    inOrder.verify(mockedList).add(3);
    Mockito.verifyNoMoreInteractions(mockedList);
}

If we wanted to use an actual ArrayList object as a spy here, it would not be possible, because of the problem described here - that's the source of the NullPointerException. To work around that, explicit mocking can be used (which is another hack, showing that this is not the way to operate in the tests). For example for the tested code:

void doStuff() {
    var numbers = new ArrayList<Integer>();
    numbers.add(1);
    numbers.add(2);
    numbers.add(3);
    if (numbers.size() < 3) {
        throw new AssertionError("Impossible state - size: " + numbers.size());
    }
}

the test could look like this:

@Test
void doStuff() {
    var listConstruction = mockCreationOnce(ArrayList.class);
    // has to be done in a separate thread
    // because the object needs to be created first in doStuff()
    new Thread(() -> {
        while (listConstruction.constructed().isEmpty()) {
            // busy waiting for the construction to be finished before stubbing
        }
        var mock = listConstruction.constructed().get(0);
        Mockito.doAnswer(inv -> 3)
               .when(mock)
               .size();
    }).start();

    someService.doStuff();

    // no exception is thrown
}

Without the stubbing the test fails with AssertionError. An important caveat regarding the snippet above: the stubbing mechanism needs polishing because now it's subject to a race condition and "random" test failure - some locking mechanism would have to be used to work around that. Still - in my opinion it would be simplest to work towards a proper solution instead of stacking workarounds in the tests.


I recreated the code above in a GitHub repository. I verified it - the test passes.

Jonasz
  • 1,617
  • 1
  • 13
  • 19