Quite frequently when writing integration test I have to use a String usually containing JSON representing either an HTTP request, or a payload of a message for example, RabbitMQ.
Till Java 13 (or in practice till Java 17), multiline strings in Java were pain to maintain, because Java did not really support multiline strings!
String payload= "{\n"
+" \"resource\": \"/{proxy+}\",\n"
+" \"path\": \"/path/to/resource\",\n"
+" \"httpMethod\": \"POST\",\n"
+" \"isBase64Encoded\": false,\n"
+" \"queryStringParameters\": {\n"
+" \"foo\": \"bar\"\n"
+" }\n"
+" }\n";
Things got better with Java 13 and text blocks:
String json = """
{
"resource": "/{proxy+}",
"path": "/path/to/resource",
"httpMethod": "POST",
"isBase64Encoded": false,
"queryStringParameters": {
"foo": "bar"
}
}
""";
So now, if the payload is small, and does make the test unreadable, I tend just to use it directly in the Java code, but when the payload is larger or I am on Java 11/8, I prefer to put these JSON structures in src/test/resources
and just load them in test methods.
Spring comes with a resource abstraction which lets us load resources from classpath, file system, URLs and much more in a clean declarative way:
@Value("classpath:payload.json");
Resource payload;
This also works on the JUnit test methods:
@Test
void someTest(@Value("classpath:payload.json") Resource payload) {
// ...
}
The problem is though - I need a String
, not a Resource
, and the Resource
does not expose a method that returns the content as a String
. So I always end up either looking at my old code or Googling for how to convert an InputStream
to String
end end up with a method like:
@SpringBootTest
class AppTests {
@Test
void someTest(@Value("classpath:payload.json") Resource payload) {
String content = asString(payload);
}
static String asString(Resource resource) {
try (InputStream is = resource.getInputStream()) {
return StreamUtils.copyToString(is, UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
Not too bad but I would like putting an annotation to be enough. What I need is:
@SpringBootTest
class AppTests {
@Test
void contextLoads(@StringResource("classpath:payload.json") String payload) {
// ...
}
}
Spring based @StringResource
@StringResource
of course does not exist, so lets create it and lets make a JUnit 5 extension - specifically a parameter resolver - that will load this classpath resource to String
.
The annotation code is fairly straightforward:
import org.junit.jupiter.api.extension.ExtendWith;
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface StringResource {
String value();
}
Now the parameter resolver. It is much simpler than I initially thought, because we can access application context created with @SpringBootTest
using SpringExtension.getApplicationContext(..)
method:
public class StringResourceParameterResolver implements ParameterResolver {
@Override
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
throws ParameterResolutionException {
return parameterContext.isAnnotated(StringResource.class) && parameterContext.getParameter().getType()
.equals(String.class);
}
@Override
public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
throws ParameterResolutionException {
ApplicationContext applicationContext = SpringExtension.getApplicationContext(extensionContext);
Resource resource = applicationContext.getResource(
parameterContext.findAnnotation(StringResource.class).map(StringResource::value).orElseThrow());
return asString(resource);
}
private static String asString(Resource resource) {
try (InputStream is = resource.getInputStream()) {
return StreamUtils.copyToString(is, UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
To use an extension, we need to enable it. There are several ways how extensions can be enabled, with putting @ExtendWith(StringResourceParameterResolver.class)
as the most straightforward one. But, we can follow the same approach that is used in JUnit 5 own @ParameterizedTest
annotation. We can put @ExtendWith
on the StringResource
annotation itself!
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ExtendWith(StringResourceParameterResolver.class)
public @interface StringResource {
String value();
}
No-Spring @StringResource
alternative
The above approach works great in Spring integration tests, but of course won't work for regular unit tests or in general non-spring tests.
Spring independent version of StringResourceParameterResolver
looks like this:
public class StringResourceParameterResolver implements ParameterResolver {
@Override public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
throws ParameterResolutionException {
return parameterContext.isAnnotated(StringResource.class) && parameterContext.getParameter().getType()
.equals(String.class);
}
@Override public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
throws ParameterResolutionException {
String location = parameterContext.findAnnotation(StringResource.class).map(StringResource::value).orElseThrow();
try (InputStream is = getClass().getClassLoader().getResourceAsStream(location)) {
return new String(is.readAllBytes(), UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
But you need to keep in mind that since it only resolves files from classpath, it does not support any prefixes so you use it as following:
@Test
void someTest(@StringResource("foo.txt") String foo) throws IOException {
// ..
}
Conclusion
This is just an example of an elegant solution to an annoying problem and I hope it perhaps may inspire you to create custom JUnit extensions that helps you get rid of boilerplate in your project.