diff --git a/pom.xml b/pom.xml index 8f7b03a78..5fd5c3d19 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ org.springframework.cloud spring-cloud-build 1.1.0.BUILD-SNAPSHOT - + https://github.com/spring-cloud/spring-cloud-stream @@ -20,6 +20,7 @@ 1.7 1.3.0.BUILD-SNAPSHOT + 4.2.1.BUILD-SNAPSHOT 4.2.0.BUILD-SNAPSHOT 1.1.0.BUILD-SNAPSHOT @@ -30,6 +31,7 @@ spring-cloud-stream-module-launcher spring-cloud-stream-samples docs + spring-cloud-stream-test-support @@ -67,6 +69,11 @@ spring-cloud-stream-common ${project.version} + + org.springframework.cloud + spring-cloud-stream-binder-spi + ${project.version} + org.springframework.cloud spring-cloud-stream-binder-local @@ -82,11 +89,6 @@ spring-cloud-stream-binder-rabbit ${project.version} - - org.springframework.cloud - spring-cloud-stream-binder-spi - ${project.version} - org.springframework.cloud spring-cloud-stream-binder-test @@ -97,6 +99,16 @@ spring-cloud-stream-module-launcher ${project.version} + + org.springframework.cloud + spring-cloud-stream-test-support + ${project.version} + + + org.springframework + spring-messaging + ${spring-framework.version} + org.springframework.integration spring-integration-core diff --git a/spring-cloud-stream-test-support/pom.xml b/spring-cloud-stream-test-support/pom.xml new file mode 100644 index 000000000..680fce58b --- /dev/null +++ b/spring-cloud-stream-test-support/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + org.springframework.cloud + spring-cloud-stream-parent + 1.0.0.BUILD-SNAPSHOT + + org.springframework.cloud + spring-cloud-stream-test-support + Spring Cloud Stream Test Support + + UTF-8 + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter-test + + + org.springframework.cloud + spring-cloud-stream-binder-spi + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.cloud + spring-cloud-stream + test + + + diff --git a/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/MessageCollector.java b/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/MessageCollector.java new file mode 100644 index 000000000..611c5fc6f --- /dev/null +++ b/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/MessageCollector.java @@ -0,0 +1,35 @@ +/* + * Copyright 2015 the original author or authors. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.stream.test.binder; + +import java.util.concurrent.BlockingQueue; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; + +/** + * Maintains a map between (output) channels and messages received (in FIFO order). To be injected in tests that + * can then run assertions on the enqueued messages. + * + * @author Eric Bottard + */ +public interface MessageCollector { + + /** + * Obtain a queue that will receive messages sent to the given channel. + */ + public BlockingQueue> forChannel(MessageChannel channel); +} diff --git a/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestSupportBinder.java b/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestSupportBinder.java new file mode 100644 index 000000000..5bc267e7e --- /dev/null +++ b/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestSupportBinder.java @@ -0,0 +1,153 @@ +/* + * Copyright 2015 the original author or authors. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.stream.test.binder; + +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingDeque; + +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.cloud.stream.test.matcher.MessageQueueMatcher; +import org.springframework.integration.channel.QueueChannel; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.util.Assert; + +/** + * A minimal binder that
    + *
  • does nothing about binding consumers, leaving the channel as-is, so that a test author can interact with it directly,
  • + *
  • registers a queue channel on the producer side, so that it is easy to assert what is received.
  • + *
+ * + * @author Eric Bottard + * @see MessageQueueMatcher + */ +public class TestSupportBinder implements Binder { + + private final MessageCollectorImpl messageCollector = new MessageCollectorImpl(); + + + @Override + public void bindConsumer(String name, MessageChannel inboundBindTarget, Properties properties) { + } + + @Override + public void bindPubSubConsumer(String name, MessageChannel inboundBindTarget, Properties properties) { + + } + + /** + * Registers a single subscriber to the channel, that enqueues messages for later retrieval and assertion in tests. + */ + @Override + @SuppressWarnings("unchecked") + public void bindProducer(String name, MessageChannel outboundBindTarget, Properties properties) { + final BlockingQueue queue = messageCollector.register(outboundBindTarget); + ((SubscribableChannel)outboundBindTarget).subscribe(new MessageHandler() { + @Override + public void handleMessage(Message message) throws MessagingException { + queue.add(message); + } + }); + + } + + @Override + public void unbindProducer(String name, MessageChannel channel) { + messageCollector.unregister(channel); + } + + @Override + public void bindPubSubProducer(String name, MessageChannel outboundBindTarget, Properties properties) { + + } + + @Override + public void unbindConsumers(String name) { + + } + + @Override + public void unbindProducers(String name) { + + } + + @Override + public void unbindConsumer(String name, MessageChannel inboundBindTarget) { + + } + + @Override + public void bindRequestor(String name, MessageChannel requests, MessageChannel replies, Properties properties) { + + } + + @Override + public void bindReplier(String name, MessageChannel requests, MessageChannel replies, Properties properties) { + + } + + @Override + public MessageChannel bindDynamicProducer(String name, Properties properties) { + return null; + } + + @Override + public MessageChannel bindDynamicPubSubProducer(String name, Properties properties) { + return null; + } + + @Override + public boolean isCapable(Capability capability) { + return false; + } + + public MessageCollector messageCollector() { + return messageCollector; + } + + /** + * Maintains mappings between channels and queues. + * + * @author Eric Bottard + */ + private static class MessageCollectorImpl implements MessageCollector{ + + private Map>> results = new HashMap<>(); + + private BlockingQueue register(MessageChannel channel) { + LinkedBlockingDeque> result = new LinkedBlockingDeque<>(); + Assert.isTrue(!results.containsKey(channel), "Channel [" + channel + "] was already bound"); + results.put(channel, result); + return result; + } + + private void unregister(MessageChannel channel) { + Assert.notNull(results.remove(channel), "Trying to unregister a mapping for an unknown channel [" + channel + "]"); + } + + public BlockingQueue> forChannel(MessageChannel channel) { + BlockingQueue> queue = results.get(channel); + Assert.notNull(queue, "Channel [" + channel + "] was not bound by " + TestSupportBinder.class); + return queue; + } + } +} diff --git a/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestSupportBinderAutoConfiguration.java b/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestSupportBinderAutoConfiguration.java new file mode 100644 index 000000000..80f029636 --- /dev/null +++ b/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestSupportBinderAutoConfiguration.java @@ -0,0 +1,46 @@ +/* + * Copyright 2015 the original author or authors. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.stream.test.binder; + +import org.springframework.boot.autoconfigure.AutoConfigureOrder; +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +/** + * Installs the {@link TestSupportBinder} and exposes {@link TestSupportBinder.MessageCollectorImpl} to be injected in tests. + * + * Note that this auto-configuration has higher priority than regular binders, so adding + * this on the classpath in test scope is sufficient to have support kick in. + * + * @author Eric Bottard + */ +@Configuration +@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE) +public class TestSupportBinderAutoConfiguration { + + @Bean + public MessageCollector messageCollector() { + return testSupportBinder().messageCollector(); + } + + @Bean + public TestSupportBinder testSupportBinder() { + return new TestSupportBinder(); + } + +} diff --git a/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/matcher/MessageQueueMatcher.java b/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/matcher/MessageQueueMatcher.java new file mode 100644 index 000000000..cc84758da --- /dev/null +++ b/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/matcher/MessageQueueMatcher.java @@ -0,0 +1,160 @@ +/* + * Copyright 2015 the original author or authors. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.stream.test.matcher; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; + +import org.hamcrest.BaseMatcher; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.SelfDescribing; + +import org.springframework.cloud.stream.test.binder.TestSupportBinder; +import org.springframework.integration.util.Function; +import org.springframework.messaging.Message; + +/** + * A Hamcrest Matcher meant to be used in conjunction with {@link TestSupportBinder}. + * + *

Expected usage is of the form (with appropriate static imports): + *

+ * public class TransformProcessorApplicationTests {
+ *
+ *    {@literal @}Autowired
+ *    {@literal @}ModuleChannels(TransformProcessor.class)
+ *    private Processor processor;
+ *
+ *    {@literal @}Autowired
+ *    private MessageCollectorImpl messageCollector;
+ *
+ *
+ *    {@literal @}Test
+ *    public void testUsingExpression() {
+ *        processor.input().send(new GenericMessage{@literal <}Object>("hello"));
+ *        assertThat(messageCollector.forChannel(processor.output()), receivesPayloadThat(is("hellofoo")).within(10));
+ *    }
+ *
+ * }
+ *

+ * + * @author Eric Bottard + */ +public class MessageQueueMatcher extends BaseMatcher>> { + + private final Matcher delegate; + + private final long timeout; + + private Extractor, T> extractor; + + private Map>, T> actuallyReceived = new HashMap<>(); + + private final TimeUnit unit; + + public MessageQueueMatcher(Matcher delegate, long timeout, TimeUnit unit, Extractor, T> extractor) { + this.delegate = delegate; + this.timeout = timeout; + this.unit = unit; + this.extractor = extractor; + } + + + @Override + public boolean matches(Object item) { + @SuppressWarnings("unchecked") + BlockingQueue> queue = (BlockingQueue>) item; + Message received = null; + try { + if (timeout > 0) { + received = queue.poll(timeout, unit); + } else if (timeout == 0) { + received = queue.poll(); + } else { + received = queue.take(); + } + } + catch (InterruptedException e) { + return false; + } + T unwrapped = extractor.apply(received); + actuallyReceived.put(queue, unwrapped); + return delegate.matches(unwrapped); + } + + @Override + public void describeMismatch(Object item, Description description) { + @SuppressWarnings("unchecked") + BlockingQueue> queue = (BlockingQueue>) item; + T value = actuallyReceived.get(queue); + if (value != null) { + description.appendText("received: ").appendValue(value); + } else { + description.appendText("timed out after " + timeout + " " + unit.name().toLowerCase()); + } + } + + public MessageQueueMatcher within(long timeout, TimeUnit unit) { + return new MessageQueueMatcher<>(this.delegate, timeout, unit, this.extractor); + } + + @Override + public void describeTo(Description description) { + description.appendText("Channel to receive ").appendDescriptionOf(extractor).appendDescriptionOf(delegate); + } + + @SuppressWarnings("unchecked") + public static

MessageQueueMatcher

receivesMessageThat(Matcher> messageMatcher) { + return new MessageQueueMatcher(messageMatcher, -1, null, new Extractor, Message

>("a message that ") { + @Override + public Message

apply(Message

m) { + return m; + } + }); + } + + @SuppressWarnings("unchecked") + public static

MessageQueueMatcher

receivesPayloadThat(Matcher

payloadMatcher) { + return new MessageQueueMatcher(payloadMatcher, -1, null, new Extractor, P>("a message whose payload ") { + @Override + public P apply(Message

m) { + return m.getPayload(); + } + }); + } + + /** + * A transformation to be applied to a received message before asserting, e.g. to only inspect the payload. + */ + public static abstract class Extractor implements Function, SelfDescribing { + + private final String behaviorDescription; + + protected Extractor(String behaviorDescription) { + this.behaviorDescription = behaviorDescription; + } + + @Override + public void describeTo(Description description) { + description.appendText(behaviorDescription); + } + } + + + +} diff --git a/spring-cloud-stream-test-support/src/main/resources/META-INF/spring.factories b/spring-cloud-stream-test-support/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..baa0d75ca --- /dev/null +++ b/spring-cloud-stream-test-support/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration:\ +org.springframework.cloud.stream.test.binder.TestSupportBinderAutoConfiguration diff --git a/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/ExampleTest.java b/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/ExampleTest.java new file mode 100644 index 000000000..af2ad41be --- /dev/null +++ b/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/ExampleTest.java @@ -0,0 +1,79 @@ +/* + * Copyright 2015 the original author or authors. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.stream.test; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.IntegrationTest; +import org.springframework.boot.test.SpringApplicationConfiguration; +import org.springframework.cloud.stream.annotation.EnableModule; +import org.springframework.cloud.stream.annotation.ModuleChannels; +import org.springframework.cloud.stream.annotation.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.cloud.stream.test.binder.TestSupportBinder; +import org.springframework.integration.annotation.Transformer; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +/** + * Integration test that validates that {@link org.springframework.cloud.stream.test.binder.TestSupportBinder} applies + * correctly. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringApplicationConfiguration(classes = ExampleTest.MyProcessor.class) +@IntegrationTest({"server.port=-1"}) +@DirtiesContext +public class ExampleTest { + + @Autowired + @ModuleChannels(MyProcessor.class) + private Processor processor; + + @Autowired + private MessageCollector messageCollector; + + @Test + @SuppressWarnings("unchecked") + public void testWiring() { + Message message = new GenericMessage<>("hello"); + processor.input().send(message); + Message received = (Message) messageCollector.forChannel(processor.output()).poll(); + assertThat(received.getPayload(), equalTo("hello world")); + } + + + @SpringBootApplication + @EnableModule(Processor.class) + public static class MyProcessor { + + @Autowired + private Processor channels; + + @Transformer(inputChannel = Processor.INPUT, outputChannel = Processor.OUTPUT) + public String transform(String in) { + return in + " world"; + } + } + +} diff --git a/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/matcher/MessageQueueMatcherTest.java b/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/matcher/MessageQueueMatcherTest.java new file mode 100644 index 000000000..7d056e5a3 --- /dev/null +++ b/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/matcher/MessageQueueMatcherTest.java @@ -0,0 +1,113 @@ +/* + * Copyright 2015 the original author or authors. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.stream.test.matcher; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; + +import java.util.Collections; +import java.util.concurrent.BlockingDeque; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.TimeUnit; + +import org.hamcrest.StringDescription; +import org.junit.Test; + +import org.springframework.messaging.Message; +import org.springframework.messaging.support.GenericMessage; + +/** + * Tests for MessageQueueMatcher. + * + * @author Eric Bottard + */ +public class MessageQueueMatcherTest { + + private final BlockingDeque> queue = new LinkedBlockingDeque<>(); + + private final StringDescription description = new StringDescription(); + + @Test + public void testTimeout() { + Message msg = new GenericMessage<>("hello"); + MessageQueueMatcher matcher = MessageQueueMatcher.receivesMessageThat(is(msg)).within(2, TimeUnit.MILLISECONDS); + + boolean result = matcher.matches(queue); + assertThat(result, is(false)); + matcher.describeMismatch(queue, description); + assertThat(description.toString(), is("timed out after 2 milliseconds")); + } + + @Test + public void testMatch() { + Message msg = new GenericMessage<>("hello"); + MessageQueueMatcher matcher = MessageQueueMatcher.receivesMessageThat(is(msg)); + + queue.offer(msg); + + boolean result = matcher.matches(queue); + assertThat(result, is(true)); + } + + @Test + public void testMisMatch() { + Message msg = new GenericMessage<>("hello"); + Message other = new GenericMessage<>("world"); + + MessageQueueMatcher matcher = MessageQueueMatcher.receivesMessageThat(is(msg)); + + queue.offer(other); + + boolean result = matcher.matches(queue); + assertThat(result, is(false)); + matcher.describeMismatch(queue, description); + assertThat(description.toString(), is("received: <" + other + ">")); + } + + @Test + public void testExtractor() { + Message msg = new GenericMessage<>("hello", Collections.singletonMap("foo", (Object) "bar")); + + MessageQueueMatcher.Extractor, String> headerExtractor = new MessageQueueMatcher.Extractor, String>("whose 'foo' header") { + @Override + public String apply(Message message) { + return message.getHeaders().get("foo", String.class); + } + }; + + MessageQueueMatcher matcher = new MessageQueueMatcher<>(is("bar"), -1, null, headerExtractor); + queue.offer(msg); + boolean result = matcher.matches(queue); + assertThat(result, is(true)); + + matcher = new MessageQueueMatcher<>(is("wizz"), -1, null, headerExtractor); + queue.offer(msg); + result = matcher.matches(queue); + assertThat(result, is(false)); + matcher.describeMismatch(queue, description); + assertThat(description.toString(), is("received: \"bar\"")); + } + + @Test + public void testDescription() { + Message msg = new GenericMessage<>("hello"); + + MessageQueueMatcher matcher = MessageQueueMatcher.receivesMessageThat(is(msg)); + + description.appendDescriptionOf(matcher); + assertThat(description.toString(), is("Channel to receive a message that is <" + msg + ">")); + } +}