英文:
Doing `whenNew` with Mockito 3.5+ (and without PowerMock)
问题
在你说之前,我知道这是一段难以测试的代码。这是一个我打算在某个时候重构的遗留问题,但我现在不想做。
我目前正在将我的测试用例从 PowerMockito 迁移,因为它事实证明是一个类似于已经停滞的项目。
我遇到了模拟我已经习惯使用 whenNew
行为的挑战。
用例:
ArrayList<Integer> numbers = new ArrayList();
PowerMockito.whenNew(ArrayList.class).withAnyArguments().thenReturn(numbers);
// 在底层,`doStuff` 实例化了 `ArrayList`
someService.doStuff();
// 最后,验证
Assert.assertEquals(numbers.size(), 1);
在这种特定情况下,按设计,我无法访问这个内部的 ArrayList
,但我仍然非常想验证它的结果。
使用 PowerMockito,我能做到,但是使用 Mockito,我在匹配行为方面遇到了困难。我得到的最接近的是:
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);
}
在这个例子中,SomeService
确实构造了模拟列表,但:
- 这不是我在外部创建的实例(也许这是一件好事)
mockConstruction
不会调用原始构造函数,也不会执行成员初始化。这给了我一个空壳对象,无法执行任何真实方法,因此我得到了 NPE。
英文:
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:
- It is not my instance that I created externally (maybe a good thing)
- 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.
答案1
得分: 1
以下是您要翻译的内容:
问题在您的问题中表述得有些棘手,解决方案也有些“巧妙”,因此,为了明确(尽管在问题中也已经提到了):我不建议使用这种方法,最好重构代码,避免模拟构建ArrayList
。这个答案更多地是一个“为什么我们不应该走这条路”的展示。话虽如此...
Mockito及其底层使用的库在ArrayList
类上操作,而ArrayList
类又引用Mockito的MockedConstruction
,这在我验证过的情况下会导致StackOverflowError
问题。这也是Mockito GitHub问题中描述的问题:这里和这里。
为了解决这个问题,我们可以创建一个像这样的方法:
static <T> MockedConstruction<T> mockCreationOnce(Class<T> clazz) {
// 为了在传递给其创建的lambda中操作MockedConstruction
// 我们必须能够将对象传递给lambda
// 为了做到这一点,它需要是final => 使用AtomicReference
// (它也可以是一个稍后设置字段的自定义类;
// 传递给lambda的引用必须是final,其内容可能会更改)
var constructionHolder = new AtomicReference<MockedConstruction<T>>();
var listConstruction = Mockito.mockConstruction(
clazz,
context -> {
try {
return Mockito.withSettings();
} finally {
// 我们在doStuff()方法中的第一次调用后关闭MockedConstruction
// 以避免在Mockito内部调用中模拟构造
var construction = constructionHolder.get();
// 我们使用closeOnDemand()而不是close()来避免在此处抛出异常
// (如果已经关闭,则不执行任何操作)
construction.closeOnDemand();
}
}
);
// 在这里,我们将MockedConstruction设置在AtomicReference中
// 这发生在lambda调用之前,
// 在doStuff()方法中ArrayList构造函数的调用期间,将调用一次(也是最后一次)
constructionHolder.set(listConstruction);
return listConstruction;
}
有了这个方法,我们可以验证被测试的代码(例如):
void doStuff() {
var numbers = new ArrayList<Integer>();
numbers.add(1);
numbers.add(2);
numbers.add(3);
}
使用这样的测试方法:
@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);
}
如果我们想在这里使用实际的ArrayList
对象作为间谍,是不可能的,因为描述在这里的问题就是导致NullPointerException
的原因。为了解决这个问题,可以使用显式模拟(这是另一种hack,表明这不是在测试中操作的正确方式)。例如,对于被测试的代码:
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());
}
}
测试可以像这样:
@Test
void doStuff() {
var listConstruction = mockCreationOnce(ArrayList.class);
// 必须在单独的线程中执行
// 因为需要在doStuff()中首先创建对象
new Thread(() -> {
while (listConstruction.constructed().isEmpty()) {
// 在存根之前等待构造完成
}
var mock = listConstruction.constructed().get(0);
Mockito.doAnswer(inv -> 3)
.when(mock)
.size();
}).start();
someService.doStuff();
// 不会抛出异常
}
关于上面片段的一个重要注意事项:存根机制需要进一步完善,因为现在它受到竞态条件和“随机”测试失败的影响 - 必须使用某种锁定机制来解决这个问题。尽管如此,我认为最简单的方法是朝着一个适当的解决方案努力工作,而不是在测试中堆叠解决办法。
我在GitHub仓库中重新创建了上面的代码。我已验证它 - 测试通过。
英文:
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.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论