How to test forked behaviour in Ratpack using Spock and PollingConditions

Set-up

We use Ratpack as our web server “framework” of choice on the product I’m working on. It’s APIs include the ability to do some work on another thread. The Ratpack terminology for this is forking an execution.

This mechanism is useful if there’s work you want to trigger as part of processing an HTTP request, but you don’t need or want it to happen as part of the actual request processing itself. Two examples from our codebase are a) sending log messages to an external system and b) populating a cache. We want those things to happen, but we don’t want to wait until they’re done (or fail) before we send a response to the request.

The pattern for doing this looks like:

Execution.fork()
    .onError(e -> logger.error("Forked execution failed", e))
    .start(Operation.flatten(() -> cache.put(importantValue)));

I’ve used Operation.flatten() to ensure that cache.put() is only invoked on the forked execution.

Testing

We use Spock as our testing library of choice. So how do we validate in a unit test that cache.put() was actually called?

If we weren’t using a forked execution, we could just use standard interaction expectations from Spock, like this:

def "test forked method is invoked"() {
    given:
    def cache = Mock(ExternalCache)
    def serviceThatForksCachePut = new SomeJavaService(cache)
    1 * cache.put(_) >> Operation.noop()

    when:
    def result = ExecHarness.yieldSingle {
         serviceThatForksCachePut.doImportantWork()
    }

    then:
    !result.error
}

However, there no guarantee that the thread (or Ratpack execution if you’re being pedantic) will manage to complete that method call before the primary request handling thread completes and the test ends. Especially if your build agents have fewer CPUs than your development machine.

The solution

Enter PollingCondition. This API lets us include some kind of polling for some scenario/assertion in our tests. We can also use an AtomicBoolean (good concurrency practice) to store whether the mocked method has been called.

def "test forked method is invoked"() {
    given:
    def waitFor = new PollingConditions(delay: 0.5, initialDelay: 0.5, timeout: 5)
    def cacheUpdated = new AtomicBoolean(false)
    def cache = Mock(ExternalCache)
    def serviceThatForksCachePut = new SomeJavaService(cache)
    1 * cache.put(_) >> {
        cacheUpdated.set(true)
        Operation.noop()
    }

    when:
    def result = ExecHarness.yieldSingle {
         serviceThatForksCachePut.doImportantWork()
    }

    then:
    !result.error
    waitFor.eventually {
        assert cacheUpdated.get()
    }
}

Importantly, we need to assert in the eventually {} block in order that Spock knows to retry polling the condition if it fails.