Skip to content

Loading classpath resources to String with a custom JUnit extension

Published on
  • Spring Boot
  • Junit

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!

java
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:

java
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:

java
@Value("classpath:payload.json");
Resource payload;

This also works on the JUnit test methods:

java
@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:

java
@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:

java
@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:

java
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:

java
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!

java
@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:

java
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:

java
@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.

Let's stay in touch and follow me on Twitter: @maciejwalkowiak

Subscribe to RSS feed