22 Spring Test Common Errors

22 Spring Test Common Errors #

Hello, I’m Fu Jian.

In the previous lessons, we discussed common application errors in various commonly used Spring topics. Of course, you might not have used some of these so-called common points, such as the usage of Spring Data, which may not be necessary for some projects. In this lesson, however, let’s talk about Spring Test. I believe you cannot avoid using it unless you don’t use Spring to develop programs or you use Spring but don’t write tests. But even if you want to do the latter, your boss wouldn’t agree, right?

So, what are some common mistakes in the application of Spring Test? Here, I’ll summarize two typical ones for you. Without further ado, let’s dive into this lesson directly.

Case 1: Unable to scan resource files #

First, let’s write a HelloWorld version of a Spring Boot program for testing.

Let’s start by defining a Controller:

@RestController
public class HelloController {

    @Autowired
    HelloWorldService helloWorldService;

    @RequestMapping(path = "hi", method = RequestMethod.GET)
    public String hi() throws Exception{
        return  helloWorldService.toString() ;
    };

}

When accessing http://localhost:8080/hi, the above interface will print the autowired HelloWorldService bean. As for the definition of this bean, we will use a configuration file.

  1. Define HelloWorldService. Since the implementation of HelloWorldService is not the focus of this tutorial, we can provide a simple implementation as follows:
public class HelloWorldService {
}
  1. Define a spring.xml file. In this XML file, define the bean for HelloWorldService and place this spring.xml file in /src/main/resources:
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
    <bean id="helloWorldService" class="com.spring.puzzle.others.test.example1.HelloWorldService">
    </bean>
</beans>
  1. Define a Configuration class that imports the above XML definition. Here is an example of how to implement it:
@Configuration
@ImportResource(locations = {"spring.xml"})
public class Config {
}

After completing the above steps, we can use main() to start the program. Test the interface and everything should work as expected. Now, let’s write a test:

@SpringBootTest()
class ApplicationTests {

    @Autowired
    public HelloController helloController;

    @Test
    public void testController() throws Exception {
        String response = helloController.hi();
        Assert.notNull(response, "not null");
    }

}

When running the above test, we will encounter a failure and the error message will be as follows:

Error Screenshot

Why does the application run without any problems, but the test fails? We need to take a look at the Spring source code to find the answer.

Case Analysis #

Before understanding the root cause of this issue, let’s compare the differences in loading spring.xml between starting the program and running the test from a debugging perspective.

  1. Loading spring.xml when starting the program

First, let’s take a look at the call stack:

Call Stack Screenshot

From this, we can see that it is ultimately loaded as a ClassPathResource. The situation of this resource is as follows:

ClassPathResource Screenshot

As for the specific loading implementation, it uses ClassPathResource#getInputStream to load the spring.xml file:

ClassPathResource#getInputStream Screenshot

From the above call and code implementation, we can see that it can be successfully loaded.

  1. Loading spring.xml during testing

First, let’s take a look at the call stack:

Call Stack Screenshot

We can see that it is loaded according to ServletContextResource. The situation of this resource is as follows:

In terms of implementation, it ultimately uses MockServletContext#getResourceAsStream to load the file:

@Nullable
public InputStream getResourceAsStream(String path) {
    String resourceLocation = this.getResourceLocation(path);
    Resource resource = null;

    try {
        resource = this.resourceLoader.getResource(resourceLocation);
        return !resource.exists() ? null : resource.getInputStream();
    } catch (IOException | InvalidPathException var5) {
        if (this.logger.isWarnEnabled()) {
            this.logger.warn("Could not open InputStream for resource " + (resource != null ? resource : resourceLocation), var5);
        }

        return null;
    }
}

You can continue tracing the related code for the loading location, which is getResourceLocation():

protected String getResourceLocation(String path) {
    if (!path.startsWith("/")) {
        path = "/" + path;
    }
    // Add prefix: /src/main/resources
    String resourceLocation = this.getResourceBasePathLocation(path);
    if (this.exists(resourceLocation)) {
        return resourceLocation;
    } else {
        // {"classpath:META-INF/resources", "classpath:resources", "classpath:static", "classpath:public"};
        String[] var3 = SPRING_BOOT_RESOURCE_LOCATIONS;
        int var4 = var3.length;

        for (int var5 = 0; var5 < var4; ++var5) {
            String prefix = var3[var5];
            resourceLocation = prefix + path;
            if (this.exists(resourceLocation)) {
                return resourceLocation;
            }
        }

        return super.getResourceLocation(path);
    }
}

You will find that it attempts to load from the following locations:

classpath:META-INF/resources
classpath:resources
classpath:static
classpath:public
src/main/webapp

If you look carefully at these directories, you will also find that none of them contain spring.xml. Perhaps you think that there is a spring.xml file under the source directory src/main/resource, so it should be able to be loaded by the classpath:resources location.

But you have overlooked one thing: after the program is launched, the files in the src/main/resource directory are ultimately without any resource path. Regarding this, you can directly check the compiled directory (the local compiled directory is target\classes), as shown below:

Therefore, in the end, we cannot find spring.xml in any of the directories, and an error will occur indicating that the file cannot be loaded. The error occurs in ServletContextResource#getInputStream:

@Override
public InputStream getInputStream() throws IOException {
   InputStream is = this.servletContext.getResourceAsStream(this.path);
   if (is == null) {
      throw new FileNotFoundException("Could not open " + getDescription());
   }
   return is;
}

Issue Fix #

From the analysis of the case above, we understand the reason for the error. So how can we fix this problem? Here, we can use two methods.

  1. Place spring.xml in the loading directories

In this case, there are many loading directories, so there are also multiple ways to fix it. We can create a src/main/webapp directory and copy the spring.xml file into it. Or, we can create a resources directory under src/main/resources and put it in there.

  1. Use classpath to load @ImportResource
@Configuration
//@ImportResource(locations = {"spring.xml"})
@ImportResource(locations = {"classpath:spring.xml"})
public class Config {
}

Here, we can briefly understand the difference between different loading methods through Spring’s official documentation, reference: https://docs.spring.io/spring-framework/docs/2.5.x/reference/resources.html:

Clearly, we generally would not use the approach in this case (i.e., locations = {"spring.xml"} without any prefixes), after all, it already depends on the ApplicationContext being used. classpath is more universal, and once you have fixed it as mentioned above, you will find that the loaded resource is no longer ServletContextResource, but the same ClassPathResource as the application, so it can be loaded naturally.

So ultimately, on the surface, this problem is about a testing case, but in reality, it is an issue with the usage of @ImportResource. However, through this case, you will also understand that many methods can only work in specific situations, and you are just lucky.

Case 2: Easily-Leaded Mock #

Next, let’s take a look at another non-functional error case. Sometimes, we may find that Spring Test runs very slowly. After investigating the root cause, you will discover that it is mainly because many tests start the Spring Context. The example is as follows:

So why do some tests start the Spring Context multiple times? Before we delve into this issue, let’s simulate a scenario to reproduce this problem.

First, let’s write a few classes to be tested in the Spring Boot application:

@Service
public class ServiceOne {
}

@Service
public class ServiceTwo {
}

Then, write corresponding test classes:

@SpringBootTest()
class ServiceOneTests {

    @MockBean
    ServiceOne serviceOne;

    @Test
    public void test() {
        System.out.println(serviceOne);
    }
}

@SpringBootTest()
class ServiceTwoTests {
    @MockBean
    ServiceTwo serviceTwo;

    @Test
    public void test() {
        System.out.println(serviceTwo);
    }
}

In the above test classes, we are using @MockBean. After writing these programs, run the tests in bulk, and you will find that the Spring Context is indeed executed multiple times. So how do we understand this phenomenon? Is it an error or an expected behavior? Let’s analyze it in detail.

Case Analysis #

Under normal circumstances, when we run a test, a new Spring Context will not be created. This is because Spring Test uses a context cache to avoid duplicate creation of the Context. So, how is this cache maintained? Let’s take a look at the logic of obtaining and caching the Context through DefaultCacheAwareContextLoaderDelegate#loadContext:

public ApplicationContext loadContext(MergedContextConfiguration mergedContextConfiguration) {
    synchronized(this.contextCache) {
        ApplicationContext context = this.contextCache.get(mergedContextConfiguration);
        if (context == null) {
            try {
                context = this.loadContextInternal(mergedContextConfiguration);
                // omitted non-critical code
                this.contextCache.put(mergedContextConfiguration, context);
            } catch (Exception var6) {
            // omitted non-critical code
            }
        } else if (logger.isDebugEnabled()) {
            // omitted non-critical code
        }

        this.contextCache.logStatistics();
        return context;
    }
}

From the above code, we can see that the key of the cache is MergedContextConfiguration. So whether a new Context needs to be started for a test depends on whether the MergedContextConfiguration built based on this test class is the same. Whether they are the same depends on the implementation of hashCode():

public int hashCode() {
    int result = Arrays.hashCode(this.locations);
    result = 31 * result + Arrays.hashCode(this.classes);
    result = 31 * result + this.contextInitializerClasses.hashCode();
    result = 31 * result + Arrays.hashCode(this.activeProfiles);
    result = 31 * result + Arrays.hashCode(this.propertySourceLocations);
    result = 31 * result + Arrays.hashCode(this.propertySourceProperties);
    result = 31 * result + this.contextCustomizers.hashCode();
    result = 31 * result + (this.parent != null ? this.parent.hashCode() : 0);
    result = 31 * result + nullSafeClassName(this.contextLoader).hashCode();
    return result;
}

From the above method, you can see that if any of the elements mentioned above is different, a new Context will be created. Regarding this cache mechanism and the key’s key factors, you can refer to the Spring official documentation, which also mentions it. Here, I provide the link directly for you to read.

Click to access: https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html#testcontext-ctx-management-caching Now let’s return to this case. Why is a new Context created instead of being reused? The root cause lies in the difference in the contextCustomizers element of the two tests. If you don’t believe it, you can debug and compare them.

The MergedContextConfiguration of ServiceOneTests is shown below:

The MergedContextConfiguration of ServiceTwoTests is shown below:

Obviously, the ContextCustomizer (i.e., the key of the Context Cache) of the MergedContextConfiguration is different, so the Context is not shared. To trace back to the creation of the ContextCustomizer, let’s take a closer look.

When we run a test (testClass), we use MockitoContextCustomizerFactory#createContextCustomizer to create a ContextCustomizer. The code is as follows:

class MockitoContextCustomizerFactory implements ContextCustomizerFactory {
    MockitoContextCustomizerFactory() {
    }

    public ContextCustomizer createContextCustomizer(Class<?> testClass, List<ContextConfigurationAttributes> configAttributes) {
        DefinitionsParser parser = new DefinitionsParser();
        parser.parse(testClass);
        return new MockitoContextCustomizer(parser.getDefinitions());
    }
}

The creation process is parsed by the DefinitionsParser based on the test class (such as ServiceOneTests in this case). If the test class contains the MockBean or SpyBean annotations, the corresponding annotations will be transformed into MockDefinition and finally added to the ContextCustomizer. The parsing process is done through the DefinitionsParser#parse method:

void parse(Class<?> source) {
    this.parseElement(source);
    ReflectionUtils.doWithFields(source, this::parseElement);
}

private void parseElement(AnnotatedElement element) {
    MergedAnnotations annotations = MergedAnnotations.from(element, SearchStrategy.SUPERCLASS);

    // Processing of @MockBean
    annotations.stream(MockBean.class).map(MergedAnnotation::synthesize).forEach((annotation) -> {
        this.parseMockBeanAnnotation(annotation, element);
    });

    // Processing of @SpyBean
    annotations.stream(SpyBean.class).map(MergedAnnotation::synthesize).forEach((annotation) -> {
        this.parseSpyBeanAnnotation(annotation, element);
    });
}

private void parseMockBeanAnnotation(MockBean annotation, AnnotatedElement element) {
    Set<ResolvableType> typesToMock = this.getOrDeduceTypes(element, annotation.value());
    
    // Omitted non-critical code
    
    Iterator var4 = typesToMock.iterator();
    while(var4.hasNext()) {
        ResolvableType typeToMock = (ResolvableType)var4.next();
        MockDefinition definition = new MockDefinition(annotation.name(), typeToMock, annotation.extraInterfaces(), annotation.answer(), annotation.serializable(), annotation.reset(), QualifierDefinition.forElement(element));
        
        // Added to DefinitionsParser#definitions
        this.addDefinition(element, definition, "mock");
    }
}

To sum up, the root cause of Spring Context being re-created is the use of @MockBean, which leads to different MergedContextConfiguration being built. Since MergedContextConfiguration is used as the cache key, if the key is different, the Context cannot be reused and will be re-created. That’s why you can see multiple startup processes of the Spring Context in the case introduction, which slows down the testing speed.

Issue Fix #

Up to this point, you will find that the root cause of this slow performance is the normal phenomenon caused by using @MockBean. But if you want to speed it up, you can try to manually implement a similar function using Mockito. Of course, you can also try the following solution, which is to define all relevant @MockBean in one place. For example, for this case, the fixed solution is as follows:

public class ServiceTests {
    @MockBean
    ServiceOne serviceOne;
    @MockBean
    ServiceTwo serviceTwo;
}

@SpringBootTest()
class ServiceOneTests extends ServiceTests{
    @Test
    public void test(){
        System.out.println(serviceOne);
    }
}

@SpringBootTest()
class ServiceTwoTests  extends ServiceTests{
    @Test
    public void test(){
        System.out.println(serviceTwo);
    }
}

After rerunning the test, you will find that the Context is only created once and the speed is improved. I believe you also understand the reason why this fix works now. The cache key for each test’s corresponding Context is now the same.

Key Takeaways #

Through the two cases discussed above, I believe you have gained a better understanding of Spring Test. Let’s summarize the key points.

When using Spring Test, it is important to pay attention to the correct way of loading resource files. For example, if you are using an absolute path in the following format:

@ImportResource(locations = {"spring.xml"})

It may not always be able to load the file you want in different scenarios, so I do not recommend using absolute paths to specify resources when using @ImportResource.

Additionally, using @MockBean may cause Spring Context to be repeatedly recreated, resulting in slow tests. From the root cause perspective, this is a normal phenomenon. However, you need to be aware of this, otherwise, you may encounter various confusing situations.

If you need to speed up the tests, you can try various methods. For example, you can declare the mock beans that are dependencies in a unified place. Of course, you need to pay extra attention to whether this approach still meets your testing requirements.

Thought Question #

In Case 1, we explained why the test program could not load the spring.xml file. The root cause is that when loading the file using the following statement, they are loaded using different Resource types:

@ImportResource(locations = {"spring.xml"})

Specifically, the application loads using ClassPathResource, while the test loads using ServletContextResource. So, how did this happen?

Looking forward to your thoughts in the comments section!