8

My question is really a follow up question to

RabbitMQ Integration Test and Threading

There it states to wrap "your listeners" and pass in a CountDownLatch and eventually all the threads will merge. This answer works if we were manually creating and injecting the message listener but for @RabbitListener annotations... i'm not sure how to pass in a CountDownLatch. The framework is auto magically creating the message listener behind the scenes.

Are there any other approaches?

Community
  • 1
  • 1
Selwyn
  • 3,118
  • 1
  • 26
  • 33

2 Answers2

5

With the help of @Gary Russell I was able to get an answer and used the following solution.

Conclusion: I must admit i'm indifferent about this solution (feels like a hack) but this is the only thing I could get to work and once you get over the initial one time setup and actually understand the 'work flow' it is not so painful. Basically comes down to defining ( 2 ) @Beans and adding them to your Integration Test config.

Example solution posted below with explanations. Please feel free to suggest improvements to this solution.

1. Define a ProxyListenerBPP that during spring initialization will listen for a specified clazz (i.e our test class that contains @RabbitListener) and inject our custom CountDownLatchListenerInterceptor advice defined in the next step.

import org.aopalliance.aop.Advice;
import org.springframework.aop.framework.ProxyFactoryBean;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.BeanFactoryAware;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.core.Ordered;
import org.springframework.core.PriorityOrdered;

/**
 * Implements BeanPostProcessor bean... during spring initialization we will
 * listen for a specified clazz 
 * (i.e our @RabbitListener annotated class) and 
 * inject our custom CountDownLatchListenerInterceptor advice
 * @author sjacobs
 *
 */
public class ProxyListenerBPP implements BeanPostProcessor, BeanFactoryAware, Ordered, PriorityOrdered{

    private BeanFactory beanFactory;
    private Class<?> clazz;
    public static final String ADVICE_BEAN_NAME = "wasCalled";

    public ProxyListenerBPP(Class<?> clazz) {
        this.clazz = clazz;
    }


    @Override
    public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
        this.beanFactory = beanFactory;
    }

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        return bean;
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {

        if (clazz.isAssignableFrom(bean.getClass())) {
            ProxyFactoryBean pfb = new ProxyFactoryBean();
            pfb.setProxyTargetClass(true); // CGLIB, false for JDK proxy (interface needed)
            pfb.setTarget(bean);
            pfb.addAdvice(this.beanFactory.getBean(ADVICE_BEAN_NAME, Advice.class));
            return pfb.getObject();
        }
        else {
            return bean;
        }
    }

    @Override
    public int getOrder() {
        return Ordered.LOWEST_PRECEDENCE - 1000; // Just before @RabbitListener post processor
    }

2. Create the MethodInterceptor advice impl that will hold the reference to the CountDownLatch. The CountDownLatch needs to be referenced in both in the Integration test thread and inside the async worker thread in the @RabbitListener. So we can later release back to the Integration Test thread as soon as the @RabbitListener async thread has completed execution. No need for polling.

import java.util.concurrent.CountDownLatch;

import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;

/**
 * AOP MethodInterceptor that maps a <b>Single</b> CountDownLatch to one method and invokes 
 * CountDownLatch.countDown() after the method has completed execution. The motivation behind this 
 * is for integration testing purposes of Spring RabbitMq Async Worker threads to be able to merge
 * the Integration Test thread after an Async 'worker' thread completed its task. 
 * @author sjacobs
 *
 */
public class CountDownLatchListenerInterceptor implements MethodInterceptor {

    private CountDownLatch  countDownLatch =  new CountDownLatch(1);

    private final String methodNameToInvokeCDL ;

    public CountDownLatchListenerInterceptor(String methodName) {
        this.methodNameToInvokeCDL = methodName;
    }

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        String methodName = invocation.getMethod().getName();

        if (this.methodNameToInvokeCDL.equals(methodName) ) {

            //invoke async work 
            Object result = invocation.proceed();

            //returns us back to the 'awaiting' thread inside the integration test
            this.countDownLatch.countDown();

            //"reset" CountDownLatch for next @Test (if testing for more async worker)
            this.countDownLatch = new CountDownLatch(1);

            return result;
        } else
            return invocation.proceed();
    }


    public CountDownLatch getCountDownLatch() {
        return countDownLatch;
    }
}

3. Next add to your Integration Test Config the following @Bean(s)

public class SomeClassThatHasRabbitListenerAnnotationsITConfig extends BaseIntegrationTestConfig {

    // pass into the constructor the test Clazz that contains the @RabbitListener annotation into the constructor
    @Bean
    public static ProxyListenerBPP listenerProxier() { // note static
        return new ProxyListenerBPP(SomeClassThatHasRabbitListenerAnnotations.class);
    }

     // pass the method name that will be invoked by the async thread in SomeClassThatHasRabbitListenerAnnotations.Class
    // I.E the method name annotated with @RabbitListener or @RabbitHandler
    // in our example 'listen' is the method name inside SomeClassThatHasRabbitListenerAnnotations.Class
    @Bean(name=ProxyListenerBPP.ADVICE_BEAN_NAME)
    public static Advice wasCalled() {
        String methodName = "listen";  
        return new CountDownLatchListenerInterceptor( methodName );
    }

    // this is the @RabbitListener bean we are testing
    @Bean
    public SomeClassThatHasRabbitListenerAnnotations rabbitListener() {
         return new SomeClassThatHasRabbitListenerAnnotations();
    }

}

4. Finally, in the integration @Test call... after sending a message via rabbitTemplate to trigger the async thread... now call the CountDownLatch#await(...) method obtained from the interceptor and make sure to pass in a TimeUnit args so it can timeout in case of long running process or something goes wrong. Once the async the Integration Test thread is notified (awakened) and now we can finally begin to actually test/validate/verify the results of the async work.

@ContextConfiguration(classes={ SomeClassThatHasRabbitListenerAnnotationsITConfig.class } )
public class SomeClassThatHasRabbitListenerAnnotationsIT extends BaseIntegrationTest{

    @Inject 
    private CountDownLatchListenerInterceptor interceptor;

    @Inject
    private RabbitTemplate rabbitTemplate;

    @Test
    public void shouldReturnBackAfterAsyncThreadIsFinished() throws Exception {

     MyObject payload = new MyObject();
     rabbitTemplate.convertAndSend("some.defined.work.queue", payload);
        CountDownLatch cdl = interceptor.getCountDownLatch();      

        // wait for async thread to finish
        cdl.await(10, TimeUnit.SECONDS);    // IMPORTANT: set timeout args. 

        //Begin the actual testing of the results of the async work
        // check the database? 
        // download a msg from another queue? 
        // verify email was sent...
        // etc... 
}
slavoo
  • 5,798
  • 64
  • 37
  • 39
Selwyn
  • 3,118
  • 1
  • 26
  • 33
  • Feel free to open a ['new feature' JIRA Issue](https://jira.spring.io/browse/AMQP) so we can put adding some hooks for test support on the roadmap. – Gary Russell Dec 29 '15 at 15:25
1

It's a bit more tricky with @RabbitListener but the simplest way is to advise the listener.

With the custom listener container factory just have your test case add the advice to the factory.

The advice would be a MethodInterceptor; the invocation will have 2 arguments; the channel and the (unconverted) Message. The advice has to be injected before the container(s) are created.

Alternatively, get a reference to the container using the registry and add the advice later (but you'll have to call initialize() to force the new advice to be applied).

An alternative would be a simple BeanPostProcessor to proxy your listener class before it is injected into the container. That way, you will see the method argumen(s) after any conversion; you will also be able to verify any result returned by the listener (for request/reply scenarios).

If you are not familiar with these techniques, I can try to find some time to spin up a quick example for you.

EDIT

I issued a pull request to add an example to EnableRabbitIntegrationTests. This adds a listener bean with 2 annotated listener methods, a BeanPostProcessor that proxies the listener bean before it is injected into a listener container. An Advice is added to the proxy which counts latches down when the expected messages are received.

Gary Russell
  • 166,535
  • 14
  • 146
  • 179
  • thanks for the quick response. An example would be greatly appreciated – Selwyn Dec 24 '15 at 16:25
  • [Here you go](https://github.com/garyrussell/spring-amqp/commit/ced13241e7c3e32c140752ec601c33774b8b77c0). – Gary Russell Dec 24 '15 at 17:07
  • thanks for the answer. Me personally i'm not a big fan introducing AOP, reflection api, etc... into business logic especially tests and integration tests. I would like the decipherability of the tests to be as intuitive as possible (KISS principle). Would it be possible to create an enhancement a new annotation 'EnableRabbitCountDownLatch' that takes for arguments int countDown and creates a countDownLatch bean that can later be injected into our tests? I guess the annotation could be placed in a config or maybe as a part of 'EnableRabbit' i'm not sure exactly the best place. – Selwyn Dec 25 '15 at 16:15
  • so the countDown() would trigger after the method annotated with RabbitListener finishes execution. Or is this request too specific of a use case? I really like new 'RabbitListener' abstraction that makes creation of messageListeners simple but looks like it comes with a price during integration tests – Selwyn Dec 25 '15 at 16:17
  • `>introducing AOP, reflection api, etc... into business logic`. It's not touching your business logic, it's an extremely lightweight shim between the listener container and your listener. The first test counts down before the call; the second one after but you can do whatever you want. We could consider adding test support to the framework e.g. `@RabbitIntegrationTest` but most likely we would implement it using this technique; we wouldn't want to pollute the mainline code with testing artifacts. Of course a complete integration test would validate whatever is done downstream of your listener. – Gary Russell Dec 25 '15 at 16:52
  • `>assertTrue(this.interceptor.oneWayLatch.await(10, TimeUnit.SECONDS));` calling that inside the integration test is introducing logic that has nothing to do with the business test case. I have no problems with spring INTERNALLY using aop, reflection, etc... and I have tried to implement this example and im really struggling with it. I'm getting strange behavior. My tests pass if I run them normally but fail if I try to step through them using the the debugger... Moreover, the scalability is limited because you have to implement 1 countDownLatch per 'RabbitListener' method call. – Selwyn Dec 26 '15 at 16:00
  • and it becomes difficult to test other features. For instance, if I want to test multiple calls the to 1 rabbit listener in the same test, or if want to test a custom 'validator', or expected exception handling via a RetryHandler. Merging the threads manually via countDownLatch complicates these. I'm really kind of lost right now as to how to proceed further, I want the async threads in production for the rabbitListener container via annotation but for the Integration Test I would really like to 'some how' just execute all the logic sequentially without having to deal with concurrency issues – Selwyn Dec 26 '15 at 16:00
  • I provided a very simple advice, you can make the advice as complex as you want - inject new latches from each test etc; I am not sure what your concern is with `calling that inside the integration test is introducing logic that has nothing to do with the business taste case.` - this is always an issue when testing async applications - somehow wait for some external event to complete; You should cover the functionality of your listener with unit tests. As I said, for a complete e2e integration test, you really need to trigger something to verify from whatever's downstream of your listener. – Gary Russell Dec 26 '15 at 16:07
  • fair enough but I guess I just wanted to be 'abstracted' from the internals of spring framework/rabbit... felt like I was fighting the framework with this issue. Regardless, I got this to work eventually thank you for the examples. – Selwyn Dec 27 '15 at 14:56
  • As I'm new to this, is there a simple JUnit/Test project with convertAndSend and on the other end the "real" @RabbitListener and wrapping as discussed without touching the real production code. Something I can git clone and run and try. – powder366 Mar 14 '17 at 09:30
  • You should ask new questions rather then adding a new question in a comment. See [the reference documentation about testing support](http://docs.spring.io/spring-amqp//reference/html/_reference.html#testing). If you have a specific question after reading that; as it as a new question tagged with [spring-amqp]. – Gary Russell Mar 14 '17 at 13:01