測試支援

為非同步應用編寫整合測試必然比測試簡單應用更復雜。當引入諸如 @RabbitListener 註解之類的抽象時,複雜性會進一步增加。問題在於如何驗證在傳送訊息後,監聽器按預期接收到了訊息。

框架本身有許多單元測試和整合測試。有些使用模擬物件,另一些則使用真實的 RabbitMQ broker 進行整合測試。您可以參考這些測試來獲取一些測試場景的想法。

Spring AMQP 1.6 版本引入了 spring-rabbit-test jar 包,它為測試其中一些更復雜的場景提供了支援。預計這個專案會隨著時間推移而擴充套件,但我們需要社群反饋來提出有助於測試所需的功能建議。請使用 JIRA 或 GitHub Issues 提供此類反饋。

@SpringRabbitTest

使用此註解將基礎設施 Bean 新增到 Spring 測試 ApplicationContext 中。例如,在使用 `@SpringBootTest` 時則不需要這樣做,因為 Spring Boot 的自動配置會新增這些 Bean。

註冊的 Bean 包括:

  • CachingConnectionFactory (`autoConnectionFactory`)。如果存在 `@RabbitEnabled`,則使用其連線工廠。

  • RabbitTemplate (`autoRabbitTemplate`)

  • RabbitAdmin (`autoRabbitAdmin`)

  • RabbitListenerContainerFactory (`autoContainerFactory`)

此外,還會新增與 `@EnableRabbit` 相關聯的 Bean(用於支援 `@RabbitListener`)。

JUnit 5 示例
@SpringJUnitConfig
@SpringRabbitTest
public class MyRabbitTests {

	@Autowired
	private RabbitTemplate template;

	@Autowired
	private RabbitAdmin admin;

	@Autowired
	private RabbitListenerEndpointRegistry registry;

	@Test
	void test() {
        ...
	}

	@Configuration
	public static class Config {

        ...

	}

}

使用 JUnit 4 時,將 `@SpringJUnitConfig` 替換為 `@RunWith(SpringRunnner.class)`。

Mockito Answer 實現

目前提供了兩種 Answer 實現來協助測試。

第一種是 LatchCountDownAndCallRealMethodAnswer,它提供了 Answer 的實現,返回 `null` 並遞減一個計數閂 (latch)。以下示例展示瞭如何使用 LatchCountDownAndCallRealMethodAnswer

LatchCountDownAndCallRealMethodAnswer answer = this.harness.getLatchAnswerFor("myListener", 2);
doAnswer(answer)
    .when(listener).foo(anyString(), anyString());

...

assertThat(answer.await(10)).isTrue();

第二種是 LambdaAnswer,它提供了一種機制,可以選擇性地呼叫真實方法,並有機會根據 `InvocationOnMock` 和結果(如果有)返回自定義結果。

考慮以下 POJO

public class Thing {

    public String thing(String thing) {
        return thing.toUpperCase();
    }

}

以下類測試 Thing POJO

Thing thing = spy(new Thing());

doAnswer(new LambdaAnswer<String>(true, (i, r) -> r + r))
    .when(thing).thing(anyString());
assertEquals("THINGTHING", thing.thing("thing"));

doAnswer(new LambdaAnswer<String>(true, (i, r) -> r + i.getArguments()[0]))
    .when(thing).thing(anyString());
assertEquals("THINGthing", thing.thing("thing"));

doAnswer(new LambdaAnswer<String>(false, (i, r) ->
    "" + i.getArguments()[0] + i.getArguments()[0])).when(thing).thing(anyString());
assertEquals("thingthing", thing.thing("thing"));

從版本 2.2.3 開始,Answer 實現會捕獲被測試方法丟擲的任何異常。使用 answer.getExceptions() 獲取它們的引用。

@RabbitListenerTestRabbitListenerTestHarness 結合使用時,使用 harness.getLambdaAnswerFor("listenerId", true, …​) 來獲取為監聽器正確構建的 Answer。

@RabbitListenerTestRabbitListenerTestHarness

使用 @RabbitListenerTest 註解您的某個 @Configuration 類,會使框架用一個名為 RabbitListenerTestHarness 的子類替換標準的 RabbitListenerAnnotationBeanPostProcessor(它也透過 @EnableRabbit 啟用了 @RabbitListener 檢測)。

RabbitListenerTestHarness 以兩種方式增強了監聽器。首先,它將監聽器包裝在一個 Mockito Spy 中,從而實現正常的 Mockito 存根和驗證操作。它還可以向監聽器新增一個 Advice,從而能夠訪問引數、結果以及丟擲的任何異常。您可以使用 @RabbitListenerTest 的屬性來控制啟用哪一種(或兩種)。後者提供用於訪問關於呼叫過程的低階資料。它還支援阻塞測試執行緒,直到呼叫非同步監聽器為止。

final @RabbitListener 方法不能被 Spy 或 Advice。此外,只有帶有 id 屬性的監聽器才能被 Spy 或 Advice。

考慮一些示例。

以下示例使用了 spy

@Configuration
@RabbitListenerTest
public class Config {

    @Bean
    public Listener listener() {
        return new Listener();
    }

    ...

}

public class Listener {

    @RabbitListener(id="foo", queues="#{queue1.name}")
    public String foo(String foo) {
        return foo.toUpperCase();
    }

    @RabbitListener(id="bar", queues="#{queue2.name}")
    public void foo(@Payload String foo, @Header("amqp_receivedRoutingKey") String rk) {
        ...
    }

}

public class MyTests {

    @Autowired
    private RabbitListenerTestHarness harness; (1)

    @Test
    public void testTwoWay() throws Exception {
        assertEquals("FOO", this.rabbitTemplate.convertSendAndReceive(this.queue1.getName(), "foo"));

        Listener listener = this.harness.getSpy("foo"); (2)
        assertNotNull(listener);
        verify(listener).foo("foo");
    }

    @Test
    public void testOneWay() throws Exception {
        Listener listener = this.harness.getSpy("bar");
        assertNotNull(listener);

        LatchCountDownAndCallRealMethodAnswer answer = this.harness.getLatchAnswerFor("bar", 2); (3)
        doAnswer(answer).when(listener).foo(anyString(), anyString()); (4)

        this.rabbitTemplate.convertAndSend(this.queue2.getName(), "bar");
        this.rabbitTemplate.convertAndSend(this.queue2.getName(), "baz");

        assertTrue(answer.await(10));
        verify(listener).foo("bar", this.queue2.getName());
        verify(listener).foo("baz", this.queue2.getName());
    }

}
1 將 harness 注入到測試用例中,以便我們可以訪問 spy。
2 獲取 spy 的引用,以便我們可以驗證它是否按預期被呼叫。由於這是一個傳送和接收操作,無需掛起測試執行緒,因為它已經在 RabbitTemplate 中等待回覆時被掛起。
3 在這種情況下,我們只使用傳送操作,因此需要一個計數閂來等待容器執行緒上對監聽器的非同步呼叫。我們使用 Answer<?> 實現之一來輔助。重要提示:由於監聽器的 spy 方式,務必使用 harness.getLatchAnswerFor() 來獲取為 spy 正確配置的 Answer。
4 配置 spy 以呼叫 Answer

以下示例使用了捕獲 advice

@Configuration
@ComponentScan
@RabbitListenerTest(spy = false, capture = true)
public class Config {

}

@Service
public class Listener {

    private boolean failed;

    @RabbitListener(id="foo", queues="#{queue1.name}")
    public String foo(String foo) {
        return foo.toUpperCase();
    }

    @RabbitListener(id="bar", queues="#{queue2.name}")
    public void foo(@Payload String foo, @Header("amqp_receivedRoutingKey") String rk) {
        if (!failed && foo.equals("ex")) {
            failed = true;
            throw new RuntimeException(foo);
        }
        failed = false;
    }

}

public class MyTests {

    @Autowired
    private RabbitListenerTestHarness harness; (1)

    @Test
    public void testTwoWay() throws Exception {
        assertEquals("FOO", this.rabbitTemplate.convertSendAndReceive(this.queue1.getName(), "foo"));

        InvocationData invocationData =
            this.harness.getNextInvocationDataFor("foo", 0, TimeUnit.SECONDS); (2)
        assertThat(invocationData.getArguments()[0], equalTo("foo"));     (3)
        assertThat((String) invocationData.getResult(), equalTo("FOO"));
    }

    @Test
    public void testOneWay() throws Exception {
        this.rabbitTemplate.convertAndSend(this.queue2.getName(), "bar");
        this.rabbitTemplate.convertAndSend(this.queue2.getName(), "baz");
        this.rabbitTemplate.convertAndSend(this.queue2.getName(), "ex");

        InvocationData invocationData =
            this.harness.getNextInvocationDataFor("bar", 10, TimeUnit.SECONDS); (4)
        Object[] args = invocationData.getArguments();
        assertThat((String) args[0], equalTo("bar"));
        assertThat((String) args[1], equalTo(queue2.getName()));

        invocationData = this.harness.getNextInvocationDataFor("bar", 10, TimeUnit.SECONDS);
        args = invocationData.getArguments();
        assertThat((String) args[0], equalTo("baz"));

        invocationData = this.harness.getNextInvocationDataFor("bar", 10, TimeUnit.SECONDS);
        args = invocationData.getArguments();
        assertThat((String) args[0], equalTo("ex"));
        assertEquals("ex", invocationData.getThrowable().getMessage()); (5)
    }

}
1 將 harness 注入到測試用例中,以便我們可以訪問 spy。
2 使用 harness.getNextInvocationDataFor() 獲取呼叫資料 - 在本例中,由於是請求/回覆場景,無需等待任何時間,因為測試執行緒已在 RabbitTemplate 中等待結果時被掛起。
3 然後我們可以驗證引數和結果是否符合預期。
4 這次我們需要等待一段時間來獲取資料,因為這是容器執行緒上的非同步操作,並且我們需要掛起測試執行緒。
5 當監聽器丟擲異常時,該異常可在呼叫資料的 throwable 屬性中獲取。
與 harness 一起使用自定義的 Answer<?> 時,為了正常執行,這些 Answer 應該繼承 ForwardsInvocation,並從 harness (getDelegate("myListener")) 獲取實際的監聽器(而不是 spy),然後呼叫 super.answer(invocation)。請參閱提供的 Mockito Answer<?> 實現 原始碼以獲取示例。

使用 TestRabbitTemplate

提供了 TestRabbitTemplate 來執行一些基本的整合測試,而無需 broker。當您將其作為 @Bean 新增到測試用例中時,它會發現上下文中所有的監聽器容器,無論是宣告為 @Bean<bean/>,還是使用 @RabbitListener 註解。它目前只支援按佇列名稱路由。模板從容器中提取訊息監聽器,並在測試執行緒上直接呼叫它。對於返回回覆的監聽器,支援請求-回覆訊息傳遞 (sendAndReceive 方法)。

以下測試用例使用了該模板

@RunWith(SpringRunner.class)
public class TestRabbitTemplateTests {

    @Autowired
    private TestRabbitTemplate template;

    @Autowired
    private Config config;

    @Test
    public void testSimpleSends() {
        this.template.convertAndSend("foo", "hello1");
        assertThat(this.config.fooIn, equalTo("foo:hello1"));
        this.template.convertAndSend("bar", "hello2");
        assertThat(this.config.barIn, equalTo("bar:hello2"));
        assertThat(this.config.smlc1In, equalTo("smlc1:"));
        this.template.convertAndSend("foo", "hello3");
        assertThat(this.config.fooIn, equalTo("foo:hello1"));
        this.template.convertAndSend("bar", "hello4");
        assertThat(this.config.barIn, equalTo("bar:hello2"));
        assertThat(this.config.smlc1In, equalTo("smlc1:hello3hello4"));

        this.template.setBroadcast(true);
        this.template.convertAndSend("foo", "hello5");
        assertThat(this.config.fooIn, equalTo("foo:hello1foo:hello5"));
        this.template.convertAndSend("bar", "hello6");
        assertThat(this.config.barIn, equalTo("bar:hello2bar:hello6"));
        assertThat(this.config.smlc1In, equalTo("smlc1:hello3hello4hello5hello6"));
    }

    @Test
    public void testSendAndReceive() {
        assertThat(this.template.convertSendAndReceive("baz", "hello"), equalTo("baz:hello"));
    }
    @Configuration
    @EnableRabbit
    public static class Config {

        public String fooIn = "";

        public String barIn = "";

        public String smlc1In = "smlc1:";

        @Bean
        public TestRabbitTemplate template() throws IOException {
            return new TestRabbitTemplate(connectionFactory());
        }

        @Bean
        public ConnectionFactory connectionFactory() throws IOException {
            ConnectionFactory factory = mock(ConnectionFactory.class);
            Connection connection = mock(Connection.class);
            Channel channel = mock(Channel.class);
            willReturn(connection).given(factory).createConnection();
            willReturn(channel).given(connection).createChannel(anyBoolean());
            given(channel.isOpen()).willReturn(true);
            return factory;
        }

        @Bean
        public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory() throws IOException {
            SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
            factory.setConnectionFactory(connectionFactory());
            return factory;
        }

        @RabbitListener(queues = "foo")
        public void foo(String in) {
            this.fooIn += "foo:" + in;
        }

        @RabbitListener(queues = "bar")
        public void bar(String in) {
            this.barIn += "bar:" + in;
        }

        @RabbitListener(queues = "baz")
        public String baz(String in) {
            return "baz:" + in;
        }

        @Bean
        public SimpleMessageListenerContainer smlc1() throws IOException {
            SimpleMessageListenerContainer container = new SimpleMessageListenerContainer(connectionFactory());
            container.setQueueNames("foo", "bar");
            container.setMessageListener(new MessageListenerAdapter(new Object() {

                public void handleMessage(String in) {
                    smlc1In += in;
                }

            }));
            return container;
        }

    }

}

JUnit 4 @Rules

Spring AMQP 1.7 及更高版本提供了一個額外的 jar 包,名為 spring-rabbit-junit。該 jar 包含一些實用工具 @Rule 例項,用於執行 JUnit 4 測試。有關 JUnit 5 測試,請參閱 JUnit 5 Conditions

使用 BrokerRunning

BrokerRunning 提供了一種機制,當 broker 未執行時(預設在 localhost 上),可以讓測試成功透過。

它還提供了實用方法來初始化和清空佇列,以及刪除佇列和交換機。

以下示例展示了它的用法

@ClassRule
public static BrokerRunning brokerRunning = BrokerRunning.isRunningWithEmptyQueues("foo", "bar");

@AfterClass
public static void tearDown() {
    brokerRunning.removeTestQueues("some.other.queue.too") // removes foo, bar as well
}

有幾個 isRunning…​ 靜態方法,例如 isBrokerAndManagementRunning(),它驗證 broker 是否啟用了管理外掛。

配置 Rule

有時您希望在沒有 broker 時測試失敗,例如在夜間 CI 構建中。要在執行時停用該 rule,請將名為 RABBITMQ_SERVER_REQUIRED 的環境變數設定為 true

您可以使用 setter 方法或環境變數覆蓋 broker 屬性,例如 hostname

以下示例展示瞭如何使用 setter 方法覆蓋屬性

@ClassRule
public static BrokerRunning brokerRunning = BrokerRunning.isRunningWithEmptyQueues("foo", "bar");

static {
    brokerRunning.setHostName("10.0.0.1")
}

@AfterClass
public static void tearDown() {
    brokerRunning.removeTestQueues("some.other.queue.too") // removes foo, bar as well
}

您還可以透過設定以下環境變數來覆蓋屬性

public static final String BROKER_ADMIN_URI = "RABBITMQ_TEST_ADMIN_URI";
public static final String BROKER_HOSTNAME = "RABBITMQ_TEST_HOSTNAME";
public static final String BROKER_PORT = "RABBITMQ_TEST_PORT";
public static final String BROKER_USER = "RABBITMQ_TEST_USER";
public static final String BROKER_PW = "RABBITMQ_TEST_PASSWORD";
public static final String BROKER_ADMIN_USER = "RABBITMQ_TEST_ADMIN_USER";
public static final String BROKER_ADMIN_PW = "RABBITMQ_TEST_ADMIN_PASSWORD";

這些環境變數會覆蓋預設設定(amqp 的 localhost:5672 和管理 REST API 的 localhost:15672/api/)。

更改主機名會影響 amqpmanagement REST API 連線(除非明確設定了 admin uri)。

BrokerRunning 還提供了一個名為 setEnvironmentVariableOverridesstatic 方法,允許您傳入一個包含這些變數的 map。它們會覆蓋系統環境變數。如果您希望在多個測試套件中使用不同的測試配置,這可能會很有用。重要提示:在呼叫任何建立 rule 例項的 isRunning() 靜態方法之前,必須呼叫此方法。變數值將應用於此呼叫之後建立的所有例項。呼叫 clearEnvironmentVariableOverrides() 可以將 rule 重置為使用預設設定(包括任何實際的環境變數)。

在您的測試用例中,建立連線工廠時可以使用 brokerRunninggetConnectionFactory() 返回該 rule 的 RabbitMQ ConnectionFactory。以下示例展示瞭如何操作

@Bean
public CachingConnectionFactory rabbitConnectionFactory() {
    return new CachingConnectionFactory(brokerRunning.getConnectionFactory());
}

使用 LongRunningIntegrationTest

LongRunningIntegrationTest 是一個用於停用長時間執行測試的 rule。您可能希望在開發系統上使用它,但確保在例如夜間 CI 構建時停用此 rule。

以下示例展示了它的用法

@Rule
public LongRunningIntegrationTest longTests = new LongRunningIntegrationTest();

要在執行時停用該 rule,請將名為 RUN_LONG_INTEGRATION_TESTS 的環境變數設定為 true

JUnit 5 Conditions

2.0.2 版本引入了對 JUnit 5 的支援。

使用 @RabbitAvailable 註解

這個類級別註解類似於 JUnit 4 @Rules 中討論的 BrokerRunning @Rule。它由 RabbitAvailableCondition 處理。

該註解有三個屬性

  • queues:一個佇列陣列,在每個測試之前宣告(並清空),並在所有測試完成後刪除。

  • management:如果您的測試還需要在 broker 上安裝管理外掛,則將其設定為 true

  • purgeAfterEach:(自 2.2 版本起)當為 true(預設值)時,queues 會在測試之間被清空。

它用於檢查 broker 是否可用,如果不可用則跳過測試。如 配置 Rule 中所述,如果名為 RABBITMQ_SERVER_REQUIRED 的環境變數為 true,則在沒有 broker 時會立即導致測試失敗。您可以使用 配置 Rule 中討論的環境變數來配置該 condition。

此外,RabbitAvailableCondition 支援對引數化測試建構函式和方法進行引數解析。支援兩種引數型別

  • BrokerRunningSupport:例項(在 2.2 版本之前,這是一個 JUnit 4 的 BrokerRunning 例項)

  • ConnectionFactoryBrokerRunningSupport 例項的 RabbitMQ 連線工廠

以下示例展示了兩者

@RabbitAvailable(queues = "rabbitAvailableTests.queue")
public class RabbitAvailableCTORInjectionTests {

    private final ConnectionFactory connectionFactory;

    public RabbitAvailableCTORInjectionTests(BrokerRunningSupport brokerRunning) {
        this.connectionFactory = brokerRunning.getConnectionFactory();
    }

    @Test
    public void test(ConnectionFactory cf) throws Exception {
        assertSame(cf, this.connectionFactory);
        Connection conn = this.connectionFactory.newConnection();
        Channel channel = conn.createChannel();
        DeclareOk declareOk = channel.queueDeclarePassive("rabbitAvailableTests.queue");
        assertEquals(0, declareOk.getConsumerCount());
        channel.close();
        conn.close();
    }

}

前面的測試位於框架本身中,用於驗證引數注入以及 condition 正確建立了佇列。

一個實際的使用者測試可能如下所示

@RabbitAvailable(queues = "rabbitAvailableTests.queue")
public class RabbitAvailableCTORInjectionTests {

    private final CachingConnectionFactory connectionFactory;

    public RabbitAvailableCTORInjectionTests(BrokerRunningSupport brokerRunning) {
        this.connectionFactory =
            new CachingConnectionFactory(brokerRunning.getConnectionFactory());
    }

    @Test
    public void test() throws Exception {
        RabbitTemplate template = new RabbitTemplate(this.connectionFactory);
        ...
    }
}

當您在測試類中使用 Spring 註解應用上下文時,可以透過名為 RabbitAvailableCondition.getBrokerRunning() 的靜態方法獲取 condition 的連線工廠的引用。

從 2.2 版本開始,getBrokerRunning() 返回一個 BrokerRunningSupport 物件;之前,返回的是 JUnit 4 的 BrokerRunnning 例項。新類與 BrokerRunning 具有相同的 API。

以下測試來自框架本身,並展示了用法

@RabbitAvailable(queues = {
        RabbitTemplateMPPIntegrationTests.QUEUE,
        RabbitTemplateMPPIntegrationTests.REPLIES })
@SpringJUnitConfig
@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD)
public class RabbitTemplateMPPIntegrationTests {

    public static final String QUEUE = "mpp.tests";

    public static final String REPLIES = "mpp.tests.replies";

    @Autowired
    private RabbitTemplate template;

    @Autowired
    private Config config;

    @Test
    public void test() {

        ...

    }

    @Configuration
    @EnableRabbit
    public static class Config {

        @Bean
        public CachingConnectionFactory cf() {
            return new CachingConnectionFactory(RabbitAvailableCondition
                    .getBrokerRunning()
                    .getConnectionFactory());
        }

        @Bean
        public RabbitTemplate template() {

            ...

        }

        @Bean
        public SimpleRabbitListenerContainerFactory
                            rabbitListenerContainerFactory() {

            ...

        }

        @RabbitListener(queues = QUEUE)
        public byte[] foo(byte[] in) {
            return in;
        }

    }

}

使用 @LongRunning 註解

類似於 LongRunningIntegrationTest JUnit 4 @Rule,此註解會導致測試被跳過,除非某個環境變數(或系統屬性)設定為 true。以下示例展示瞭如何使用它

@RabbitAvailable(queues = SimpleMessageListenerContainerLongTests.QUEUE)
@LongRunning
public class SimpleMessageListenerContainerLongTests {

    public static final String QUEUE = "SimpleMessageListenerContainerLongTests.queue";

...

}

預設情況下,變數名為 RUN_LONG_INTEGRATION_TESTS,但您可以在註解的 value 屬性中指定變數名。