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>
There are at least two ways to get this working:
Use @SpringBootTest
without classes
argument.
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
.