XD-3381: Provide test support for modules

This commit is contained in:
Eric Bottard
2015-08-12 14:18:28 +02:00
committed by David Turanski
parent 6d01a2ef79
commit e2497efa39
9 changed files with 647 additions and 6 deletions

24
pom.xml
View File

@@ -9,7 +9,7 @@
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-build</artifactId>
<version>1.1.0.BUILD-SNAPSHOT</version>
<relativePath />
<relativePath/>
</parent>
<scm>
<url>https://github.com/spring-cloud/spring-cloud-stream</url>
@@ -20,6 +20,7 @@
<properties>
<java.version>1.7</java.version>
<spring-boot.version>1.3.0.BUILD-SNAPSHOT</spring-boot.version>
<spring-framework.version>4.2.1.BUILD-SNAPSHOT</spring-framework.version>
<spring-integration.version>4.2.0.BUILD-SNAPSHOT</spring-integration.version>
<spring-cloud-lattice.version>1.1.0.BUILD-SNAPSHOT</spring-cloud-lattice.version>
</properties>
@@ -30,6 +31,7 @@
<module>spring-cloud-stream-module-launcher</module>
<module>spring-cloud-stream-samples</module>
<module>docs</module>
<module>spring-cloud-stream-test-support</module>
</modules>
<dependencyManagement>
<dependencies>
@@ -67,6 +69,11 @@
<artifactId>spring-cloud-stream-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-binder-spi</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-binder-local</artifactId>
@@ -82,11 +89,6 @@
<artifactId>spring-cloud-stream-binder-rabbit</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-binder-spi</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-binder-test</artifactId>
@@ -97,6 +99,16 @@
<artifactId>spring-cloud-stream-module-launcher</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-test-support</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-messaging</artifactId>
<version>${spring-framework.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.integration</groupId>
<artifactId>spring-integration-core</artifactId>

View File

@@ -0,0 +1,41 @@
<?xml version="1.0"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-parent</artifactId>
<version>1.0.0.BUILD-SNAPSHOT</version>
</parent>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-test-support</artifactId>
<name>Spring Cloud Stream Test Support</name>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-binder-spi</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -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<Message<?>> forChannel(MessageChannel channel);
}

View File

@@ -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<ul>
* <li>does nothing about binding consumers, leaving the channel as-is, so that a test author can interact with it directly,</li>
* <li>registers a queue channel on the producer side, so that it is easy to assert what is received.</li>
* </ul>
*
* @author Eric Bottard
* @see MessageQueueMatcher
*/
public class TestSupportBinder implements Binder<MessageChannel> {
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<MessageChannel, BlockingQueue<Message<?>>> results = new HashMap<>();
private BlockingQueue register(MessageChannel channel) {
LinkedBlockingDeque<Message<?>> 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<Message<?>> forChannel(MessageChannel channel) {
BlockingQueue<Message<?>> queue = results.get(channel);
Assert.notNull(queue, "Channel [" + channel + "] was not bound by " + TestSupportBinder.class);
return queue;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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}.
*
* <p>Expected usage is of the form (with appropriate static imports):
* <pre>
* 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));
* }
*
* }</pre>
* </p>
*
* @author Eric Bottard
*/
public class MessageQueueMatcher<T> extends BaseMatcher<BlockingQueue<Message<?>>> {
private final Matcher<T> delegate;
private final long timeout;
private Extractor<Message<?>, T> extractor;
private Map<BlockingQueue<Message<?>>, T> actuallyReceived = new HashMap<>();
private final TimeUnit unit;
public MessageQueueMatcher(Matcher<T> delegate, long timeout, TimeUnit unit, Extractor<Message<?>, T> extractor) {
this.delegate = delegate;
this.timeout = timeout;
this.unit = unit;
this.extractor = extractor;
}
@Override
public boolean matches(Object item) {
@SuppressWarnings("unchecked")
BlockingQueue<Message<?>> queue = (BlockingQueue<Message<?>>) 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<Message<?>> queue = (BlockingQueue<Message<?>>) 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<T> 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 <P> MessageQueueMatcher<P> receivesMessageThat(Matcher<Message<P>> messageMatcher) {
return new MessageQueueMatcher(messageMatcher, -1, null, new Extractor<Message<P>, Message<P>>("a message that ") {
@Override
public Message<P> apply(Message<P> m) {
return m;
}
});
}
@SuppressWarnings("unchecked")
public static <P> MessageQueueMatcher<P> receivesPayloadThat(Matcher<P> payloadMatcher) {
return new MessageQueueMatcher(payloadMatcher, -1, null, new Extractor<Message<P>, P>("a message whose payload ") {
@Override
public P apply(Message<P> m) {
return m.getPayload();
}
});
}
/**
* A transformation to be applied to a received message before asserting, <i>e.g.</i> to only inspect the payload.
*/
public static abstract class Extractor<R, T> implements Function<R, T>, SelfDescribing {
private final String behaviorDescription;
protected Extractor(String behaviorDescription) {
this.behaviorDescription = behaviorDescription;
}
@Override
public void describeTo(Description description) {
description.appendText(behaviorDescription);
}
}
}

View File

@@ -0,0 +1,2 @@
org.springframework.boot.autoconfigure.EnableAutoConfiguration:\
org.springframework.cloud.stream.test.binder.TestSupportBinderAutoConfiguration

View File

@@ -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<String> message = new GenericMessage<>("hello");
processor.input().send(message);
Message<String> received = (Message<String>) 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";
}
}
}

View File

@@ -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<Message<?>> 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<Message<?>, String> headerExtractor = new MessageQueueMatcher.Extractor<Message<?>, 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 + ">"));
}
}