[spring-projects/spring-boot]@MockBean 与 @Repeat 结合导致“该字段不能有现有值”错误

2024-07-08 161 views
9

我需要并发运行 Spring Test。为此,我创建了一个单独的方法规则。此规则的逻辑(代码包含在重现器中)是在专用执行器中多次执行测试方法。

不幸的是,注入流程没有按预期工作并引发以下异常:

org.springframework.beans.factory.BeanCreationException: Could not inject field: <xxx>; 
    nested exception is java.lang.IllegalStateException: The field <xxx> cannot have an existing value
    at org.springframework.boot.test.mock.mockito.MockitoPostProcessor.inject(MockitoPostProcessor.java:364)

复制器和说明在这里:https://github.com/alexey-anufriev/spring-test-concurrent-execution-problem

回答

9

不幸的是,我认为您尝试采用的方法不适用于模拟 bean。该方法多次ConcurrentTestStatement调用,即尝试使用同一实例多次调用。我的理解是,该方法通常只调用一次,并且使用 JUnit 在运行测试方法之前创建的唯一实例。evaluateTestContextManager.prepareTestInstance

即使我们没有“字段不能有现有值”检查,我认为您在 Mockito 存根方面也会遇到麻烦。该when(this.someBean.getData()).thenReturn("some other data")语句可以在不同的线程中多次调用,并且每次都会尝试在同一个实例上工作。如果您有多个方法尝试设置不同的存根规则,someBean情况会变得更糟。@ConcurrentTest

我不太确定该建议你尝试什么。感觉你可能需要ApplicationContext每次调用@ConcurrentTest方法时都刷新一次。也许@sbrannen 可能有一些想法。

6

我要在这里结束这篇文章了,因为我认为我们在 Spring Boot 中无法做任何事情来提供帮助。

8

@philwebb,这种方法实际上是受到@Repeat注释的启发。但我认为@Repeat也行不通。刚刚尝试用 替换,@ConcurrentTest@Repeat出现了同样的错误。所以也许我们可以调整这个问题来修复@Repeat,从而找到一种方法让它与 一起工作@Concurrent

0

我更新了您的示例应用程序,并确认它@Repeat确实以同样的方式失败了。对我来说,这确实感觉像是一个错误。

3

@alexey-anufriev 我更希望将反引号标记从问题标题中删除,因为它在发行说明上显示效果不佳

4

对我来说,这仍然感觉像是 Spring Framework 测试框架中的一个错误,而不是 Spring Boot。javadoc 指出prepareTestInstance它“应该在测试实例化后立即调用”。使用时这并不成立,@Repeat这感觉就像是一个框架错误。

记录显示,JUnit 5 不会出现同样的问题,在 JUnit 5 中,@RepeatedTest每次调用重复测试都会创建一个新的测试类实例。

7

@sbrannen 对此有什么想法吗?

8

对我来说,这仍然感觉像是 Spring Framework 测试框架中的一个错误,而不是 Spring Boot。javadoc 指出prepareTestInstance它“应该在测试实例化后立即调用”。使用时这并不成立,@Repeat这感觉就像是一个框架错误。

Javadoc 声明@Repeat如下:

请注意,要重复执行的范围包括测试方法本身的执行以及测试装置的任何设置或拆除。

这并不表示要创建测试类的新实例。它也不表示每次重复时都会重新准备测试实例。

这与 的实施是一致的SpringJUnit4ClassRunner

然而,为了支持prepareTestInstanceSpring 的 JUnit 4 规则,SpringMethodRule确实需要RunPrepareTestInstanceCallbacks为每次重复准备测试实例,如堆栈跟踪中所见。

[2021-08-20 16:39:21.772] - 79836 SEVERE [pool-1-thread-3] --- org.springframework.test.context.TestContextManager: Caught exception while allowing TestExecutionListener [org.springframework.boot.test.mock.mockito.MockitoTestExecutionListener@2a8448fa] to prepare test instance [test.ConcurrentSampleTest@38e98121]
org.springframework.beans.factory.BeanCreationException: Could not inject field: private business_logic.SomeOtherBean test.ConcurrentSampleTest.someOtherBean; nested exception is java.lang.IllegalStateException: The field private business_logic.SomeOtherBean test.ConcurrentSampleTest.someOtherBean cannot have an existing value
    at org.springframework.boot.test.mock.mockito.MockitoPostProcessor.inject(MockitoPostProcessor.java:364)
    at org.springframework.boot.test.mock.mockito.MockitoPostProcessor.inject(MockitoPostProcessor.java:352)
    at org.springframework.boot.test.mock.mockito.MockitoTestExecutionListener.lambda$injectFields$0(MockitoTestExecutionListener.java:94)
    at org.springframework.boot.test.mock.mockito.MockitoTestExecutionListener.postProcessFields(MockitoTestExecutionListener.java:115)
    at org.springframework.boot.test.mock.mockito.MockitoTestExecutionListener.injectFields(MockitoTestExecutionListener.java:94)
    at org.springframework.boot.test.mock.mockito.MockitoTestExecutionListener.prepareTestInstance(MockitoTestExecutionListener.java:61)
    at org.springframework.test.context.TestContextManager.prepareTestInstance(TestContextManager.java:244)
    at org.springframework.test.context.junit4.statements.RunPrepareTestInstanceCallbacks.evaluate(RunPrepareTestInstanceCallbacks.java:63)
    at org.springframework.test.context.junit4.statements.SpringRepeat.evaluate(SpringRepeat.java:84)
    at org.springframework.test.context.junit4.statements.SpringFailOnTimeout.evaluate(SpringFailOnTimeout.java:87)
    at org.springframework.test.context.junit4.statements.ProfileValueChecker.evaluate(ProfileValueChecker.java:103)
    at ice.bricks.exceptions.ExceptionUtils.runSafe(ExceptionUtils.java:34)
    at test_utils.ConcurrentTestStatement.lambda$1(ConcurrentTestStatement.java:32)
    at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:515)
    at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264)
    at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)
    at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)
    at java.base/java.lang.Thread.run(Thread.java:834)
Caused by: java.lang.IllegalStateException: The field private business_logic.SomeOtherBean test.ConcurrentSampleTest.someOtherBean cannot have an existing value
    at org.springframework.util.Assert.state(Assert.java:97)
    at org.springframework.boot.test.mock.mockito.MockitoPostProcessor.inject(MockitoPostProcessor.java:358)
    ... 17 more

记录显示,JUnit 5 不会出现同样的问题,在 JUnit 5 中,@RepeatedTest每次调用重复测试都会创建一个新的测试类实例。

这是正确的,并且这指出了一个非常关键的区别。

扩展SpringJUnit4ClassRunner了 JUnit 4,BlockJUnit4ClassRunner它根本不是为测试方法的并发调用而设计的;而 JUnit Jupiter 则@RepeatedTest是为此类用例而设计的。

例如,以下基于 JUnit Jupiter 的测试类并发执行重复,并且全部通过。

为了启用并发执行,您需要设置 JVM 系统属性:-Djunit.jupiter.execution.parallel.enabled=true

@SpringBootTest(classes = { SomeBean.class, SomeOtherBean.class })
class ConcurrentSampleJupiterTests {

    @MockBean
    private SomeBean someBean;

    @MockBean
    private SomeOtherBean someOtherBean;

    @BeforeEach
    void configureMock() {
        when(this.someBean.getData()).thenReturn("some mocked data");

        System.err.printf(">> Thread: %s --> hashes for someBean/someOtherBean's:\t%d/%d%n",
                Thread.currentThread().getName(), System.identityHashCode(this.someBean),
                System.identityHashCode(this.someOtherBean));
    }

    @RepeatedTest(10)
    @Execution(ExecutionMode.CONCURRENT)
    void concurrentTest() {
        assertEquals(this.someBean.getData(), "some mocked data");
    }
}
4

谢谢,Sam。我更关心的是 的 javadocprepareTestInstance与 的行为的结合@Repeat。前者指出prepareTestInstance“应在测​​试实例化后立即调用”,而后者则导致在测试实例化后一段时间调用它。我觉得这两者是矛盾的,我认为尝试澄清 javadoc 会很好。

在 Boot 方面,Phil 和我讨论了一下这个问题,我们认为我们可以通过仅当后处理器将已初始化字段的值更改@MockBean为使用不同的模拟时失败来避免该问题。

0

不幸的是,我认为您尝试采用的方法不适用于模拟 bean。该方法多次ConcurrentTestStatement调用,即尝试使用同一实例多次调用。我的理解是,该方法通常只调用一次,并且使用 JUnit 在运行测试方法之前创建的唯一实例。evaluateTestContextManager.prepareTestInstance

没错@philwebb。“唯一实例”部分至关重要。

当您使用@RepeatJUnit 4 时,您最终会得到一个 for 循环,它会在同一个实例上调用相同的方法/字段,如果您像在中一样同时执行此操作,则ConcurrentTestStatement最终会遇到访问@MockBean字段的竞争条件。

即使我们没有“字段不能有现有值”检查,我认为您在 Mockito 存根方面也会遇到麻烦。该when(this.someBean.getData()).thenReturn("some other data")语句可以在不同的线程中多次调用,并且每次都会尝试在同一个实例上工作。如果您有多个方法尝试设置不同的存根规则,someBean情况会变得更糟。@ConcurrentTest

说实话,我不记得 Mockito 是否有任何线程本地支持共享模拟的并发使用。

我不太确定该建议你尝试什么。感觉你可能需要ApplicationContext每次调用@ConcurrentTest方法时都刷新一次。也许@sbrannen 可能有一些想法。

如果模拟设置对于所有并发使用完全相同,则使用我上面展示的 JUnit Jupiter 可能会可靠地工作。

如果每次并发测试调用都需要不同的模拟配置/期望,那么就必须查看 Mockito 是否对此类用例提供线程本地支持。

我能想到的唯一其他选择是添加对@MockBean ThreadLocal myBeanSpring Boot 需要在 中设置模拟 bean 的支持ThreadLocal。然后,并发执行的测试可以get()从 中获取当前模拟的 bean ThreadLocal

感觉好像ApplicationContext每次调用@ConcurrentTest方法时可能都需要刷新一下。

是的,这可能是另一种方法(除了我们不支持在 Spring TestContext 中加载ApplicationContext每个测试方法),或者也许模拟的 beanApplicationContext可以使用SimpleThreadScope而不是singleton

4

谢谢,Sam。我更关心的是 的 javadocprepareTestInstance与 的行为的结合@Repeat。前者指出prepareTestInstance“应在测​​试实例化后立即调用”,而后者导致它在测试实例化后一段时间被调用。

好的。我现在明白你的意思了。

如果您正在使用SpringJUnit4ClassRunnerprepareTestInstance实际上会“在测试实例化后立即调用”。

但是当您使用时SpringMethodRule,您是对的:prepareTestInstance“在测试实例化后的某个时间被调用”。

最后部分是不可避免的,因为 JUnit 4 不支持带有规则的实例级回调。

我觉得这两者是矛盾的,所以我认为尝试澄清 javadoc 会很好。

对于SpringMethodRule,这是矛盾的。我们绝对可以在 Javadoc 中添加注释来解释使用 时的不同行为SpringMethodRule

与此相关的是,我不记得为什么选择withTestInstancePreparation()在 中的重复测试调用中调用SpringMethodRule。似乎我也可以同样轻松地withTestInstancePreparation()在超时和重复之前调用。所以我不知道这是否值得重新考虑。

在 Boot 方面,Phil 和我讨论了一下这个问题,我们认为我们可以通过仅当后处理器将已初始化字段的值更改@MockBean为使用不同的模拟时失败来避免该问题。

这听起来是一个很好的增强。

8

@alexey-anufriev,作为一种解决方法......如果您可以使用SpringRunner而不是SpringMethodRule并且如果您将测试类限制为单一@Test方法,则以下方法似乎有效。

public class ConcurrentMethodRule implements MethodRule {

    @Override
    public Statement apply(Statement base, FrameworkMethod frameworkMethod, Object testInstance) {
        return new ConcurrentTestStatement(base, frameworkMethod.getMethod());
    }

}
@RunWith(SpringRunner.class)
@SpringBootTest(classes = { SomeBean.class, SomeOtherBean.class })
public class ConcurrentSampleRunnerTests {

    @Rule
    public final ConcurrentMethodRule concurrentMethodRule = new ConcurrentMethodRule();

    @Autowired
    private SomeBean someBean;

    @MockBean
    private SomeOtherBean someOtherBean;

    @ConcurrentTest
    @Test
    public void concurrentTest() {
        when(this.someOtherBean.getData()).thenReturn("some mocked data");

        System.out.printf(">> Thread: %s --> hashes for someBean/someOtherBean's:\t%d/%d%n", currentThread().getName(),
                identityHashCode(this.someBean), identityHashCode(this.someOtherBean));

        assertEquals("some mocked data", this.someBean.getData());
    }

}

也许这对你有用。

4

@sbrannen,谢谢你的建议,是的,这有一些局限性,但对我来说仍然足够好。

3

是否有计划在某个时候支持重复/并发执行?或者更容易迁移到 Junit 5?

9

我刚刚检查了修复程序,即使对于我的情况来说,它看起来也很有希望,但无论如何,我同意,JUnit 5 是最好的选择。感谢您的支持。