springspring-bootspockspring-retry

Spock test fails, RetrySynchronizationManager.getContext() is null


I wrote a Spock test to exercise a service using Spring-retry, the test context doesn't get setup correctly somehow.

The specific error is (line breaks inserted by @kriegaex):

[ERROR]
  IntegrationTestSpec.test success the first time:25 » 
  NullPointer Cannot invoke "org.springframework.retry.RetryContext.getRetryCount()"
  because the return value of
  "org.springframework.retry.support.RetrySynchronizationManager.getContext()"
  is null

There is a previous question asking about the same error but there is no code reproducing the problem provided so it didn't get very far. This question has full code included below, it's also at https://github.com/nathan-hughes/spring-retry-examples.

The failing test:

package ndhtest

import spock.lang.Specification
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.beans.factory.annotation.Autowired
import org.spockframework.spring.SpringBean
import spock.lang.Subject

@SpringBootTest(classes = [MyRetryableService, RandomNumberService])
class IntegrationTestSpec extends Specification {

    @SpringBean
    RandomNumberService randomNumberService = Stub(RandomNumberService)

    @Subject
    @Autowired
    MyRetryableService myRetryableService

    def "test success the first time"() {
        given:
        randomNumberService.randomNumber() >> 1

        when:
        int result = myRetryableService.doStuff('hello')

        then:
        result == 1
    }
}

The code to test:

package ndhtest;

import lombok.extern.slf4j.Slf4j;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.retry.annotation.EnableRetry;

@SpringBootApplication
@EnableRetry
@Slf4j
@RequiredArgsConstructor
public class SpringBootConsoleApplication implements CommandLineRunner {

    private final MyRetryableService myRetryableService;

    public static void main(String ... args) throws Exception {
        log.info("starting application");
        SpringApplication.run( SpringBootConsoleApplication.class);
        log.info("finishing application");
    }

    @Override
    public void run(String ... args) {
        log.info("executing command line runner");
        try {
            int i = myRetryableService.doStuff("hello");
            log.info("myRetryableService returned {}", i);
        } catch (Exception e) {
            log.error("caught exception", e);
        }
    }
}

The service that does the retrying, it has logging that gets the retry count, this works when running the code outside of the Spock test:

package ndhtest;

import java.util.Random;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.retry.annotation.Retryable;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.support.RetrySynchronizationManager;
import org.springframework.stereotype.Service;

@Service
@Slf4j
@RequiredArgsConstructor
public class MyRetryableService {

    private final RandomNumberService randomNumberService;

    @Retryable(value = {RetryableException.class}, maxAttempts = 2, backoff = @Backoff(delay=100))
    public int doStuff(String s) {

        log.info("doing stuff with {}, try = {}", s, RetrySynchronizationManager.getContext().getRetryCount() + 1);
        int i = randomNumberService.randomNumber();

        // simulate having something bad happen that is recoverable
        if (i % 2 == 0) {
            String issue = "oops";
            log.warn("condition = {}", issue);
                throw new RetryableException(issue);
            }

        // simulate having something bad happen that is not recoverable
        if (i % 5 == 0) {
            String issue = "ohnoes";
            log.warn("condition = {}", issue);
            throw new IllegalArgumentException(issue);
        }

        return i;
    }

    @Recover
    public int recover(RetryableException e, String s) {

        log.info("in recover for RetryableException, s is {}", s);
        return -1;
    }

    @Recover
    public int recover(RuntimeException e, String s) {

        log.info("in recover for RuntimeException, s is {}", s);
        throw e;
    }
}

Supporting cast:

package ndhtest;

import java.util.Random; 
import org.springframework.stereotype.Service;

@Service
public class RandomNumberService {

    private Random random = new Random();

    public int randomNumber() {
        return random.nextInt();
    }
}
package ndhtest;

public class RetryableException extends RuntimeException {

    public RetryableException(String message, Throwable throwable) {
        super(message, throwable);
    }

    public RetryableException(String message) {
        this(message, null);
    }
}

the pom file:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.1.6</version>
    <relativePath/>
</parent>
<groupId>ndhtest</groupId>
<artifactId>spring-retry-example</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
    <java.version>17</java.version>
    <groovy.version>4.0.5</groovy.version>
    <springboot.version>3.1.6</springboot.version>
    <lombok.version>1.18.30</lombok.version>
    <spock.version>2.4-M1-groovy-4.0</spock.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-aspects</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.retry</groupId>
        <artifactId>spring-retry</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.spockframework</groupId>
        <artifactId>spock-core</artifactId>
        <version>${spock.version}</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.spockframework</groupId>
        <artifactId>spock-spring</artifactId>
        <version>${spock.version}</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.apache.groovy</groupId>
        <artifactId>groovy</artifactId>
        <version>${groovy.version}</version>
    </dependency>
</dependencies>
<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <version>${springboot.version}</version>
            <configuration>
                <mainClass>ndhtest.SpringBootConsoleApplication</mainClass>
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.codehaus.gmavenplus</groupId>
            <artifactId>gmavenplus-plugin</artifactId>
            <version>2.0.0</version>
            <executions>
                <execution>
                    <goals>
                        <goal>compile</goal>
                        <goal>compileTests</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
        <plugin>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.0.0-M7</version>
            <configuration>
                <useModulePath>false</useModulePath> <!-- https://issues.apache.org/jira/browse/SUREFIRE-1809 -->
                <useFile>false</useFile>
                <includes>
                    <include>**/*Test</include>
                    <include>**/*Spec</include>
                </includes>
                    <statelessTestsetReporter implementation="org.apache.maven.plugin.surefire.extensions.junit5.JUnit5Xml30StatelessReporter">
                    <disable>false</disable>
                    <version>3.0</version>
                    <usePhrasedFileName>false</usePhrasedFileName>
                    <usePhrasedTestSuiteClassName>true</usePhrasedTestSuiteClassName>
                    <usePhrasedTestCaseClassName>true</usePhrasedTestCaseClassName>
                    <usePhrasedTestCaseMethodName>true</usePhrasedTestCaseMethodName>
                </statelessTestsetReporter>
            </configuration>
        </plugin>
    </plugins>
</build>

Solution

  • There are at least two ways to get this working:

    1. Use @SpringBootTest without classes argument.

    2. Add RetrySynchronizationManager.register(Mock(RetryContext)) at the beginning of your test, as suggested here in the other question you also linked to. Then, you can continue to use classes in @SpringBootTest.