Home > Mobile >  How to add custom beans globally to spring boot test context
How to add custom beans globally to spring boot test context

Time:02-19

I implemented firebase messaging into my spring boot application. For that I created a bean like described here for easy access to a FirebaseMessaging instance. That bean then is injected into a service. Said service is then injected into my controller were the service methods that utilize the FirebaseMessaging bean are called.

Now my problem is that the testsuite for that project (using JUnit5 & TestContainers) fails as the following stacktrace is thrown for each method:

MyControllerTest > testFunction() FAILED
    java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:132
        Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException at ConstructorResolver.java:800
            Caused by: org.springframework.beans.factory.BeanCreationException at ConstructorResolver.java:658
        Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException at ConstructorResolver.java:800
                Caused by: org.springframework.beans.BeanInstantiationException at SimpleInstantiationStrategy.java:185
            Caused by: org.springframework.beans.factory.BeanCreationException at ConstructorResolver.java:658
                Caused by: org.springframework.beans.BeanInstantiationException at SimpleInstantiationStrategy.java:185
                    Caused by: java.lang.IllegalStateException at Preconditions.java:513
                    Caused by: java.lang.IllegalStateException at Preconditions.java:513

Root Cause:

Caused by: java.lang.IllegalStateException: FirebaseApp name my-app already exists!
    at com.google.common.base.Preconditions.checkState(Preconditions.java:513)
    at com.google.firebase.FirebaseApp.initializeApp(FirebaseApp.java:222)
    at com.google.firebase.FirebaseApp.initializeApp(FirebaseApp.java:215)
    at my.example.app.firebase.FirebaseMessagingBean.firebaseMessaging(FirebaseMessagingBean.kt:25)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:78)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.base/java.lang.reflect.Method.invoke(Method.java:567)
    at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:154)
    ... 125 more

Note: The errors only occour when running the testsuite as a whole. If I run a test class on it's own no error appears.

On method to solve that problem is to add a @MockBean to each class so the test context is loading the bean. So with that I could go on and simply add that to each test class, but I would prefer a method that injects a FirebaseMessaging @MockBean into each test class automatically the testsuite don't fails each time a dev forgets to add the @MockBean to his test class.

What is a proper way to achieve that?

(This code is in Kotlin, but I'ld also happily accept java code for the solution.)

Code:

@Component
class FirebaseMessagingBean {
    @Bean
    fun firebaseMessaging(): FirebaseMessaging {
        val privateKeyFileURL = ClassLoader.getSystemResource("firebase-cloud-messaging.json")
            ?: throw IOException("Couldn't read firebase credentials")
        val privateKeyStream = File(privateKeyFileURL.file).inputStream()
        val googleCredentials = GoogleCredentials.fromStream(privateKeyStream)
        val firebaseOptions = FirebaseOptions.builder().setCredentials(googleCredentials)
            .build()
        val app = FirebaseApp.initializeApp(firebaseOptions, "my-app")
        return FirebaseMessaging.getInstance(app)
    }
}
@Service
class FirebaseNotificationService(
    val firebaseMessaging: FirebaseMessaging
) {
    fun sendNotification(msg: String) { /* ... */ }
}
@RestController
@RequestMapping("/api/route")
class MyController(
    val firebaseNotificationService: FirebaseNotificationService,
) {
    @PostMapping()
    fun postRoute(): ResponseEntity<Any> {
        firebaseNotificationService.sendNotification("msg")
        // ...
    }
}

Test class example

@AutoConfigureMockMvc
@SpringBootTest
class DeviceControllerTest {
    // injections
    
    @Test
    fun testFunction() { /* ... */ }
}

CodePudding user response:

I fixed the problem by only initiliazing the FirebaseApp instance when there wasn't one instantiated before.

The updated bean:

class FirebaseMessagingBean {
    @Bean
    fun firebaseMessaging(): FirebaseMessaging {
        val privateKeyFileURL = ClassLoader.getSystemResource("firebase-cloud-messaging.json")
            ?: throw IOException("Couldn't read firebase credentials")
        val privateKeyStream = File(privateKeyFileURL.file).inputStream()
        val googleCredentials = GoogleCredentials.fromStream(privateKeyStream)
        val firebaseOptions = FirebaseOptions
            .builder()
            .setCredentials(googleCredentials)
            .build()
        val app = try {
            FirebaseApp.getInstance()
        } catch (iae: IllegalStateException) {
            FirebaseApp.initializeApp(firebaseOptions)
        }
        return FirebaseMessaging.getInstance(app)
    }
}

CodePudding user response:

Your problem originates from the fact, that Firebase uses a FirebaseApp object as static singleton factory, which disallows to be called more than once for the same app. When running a single test this is not a problem, because the call is only issued once. When running multiple test suites however, the wrapping Bean is created anew for every ApplicationContext but the FirebaseApp object is kept for the runtime of the JVM and NOT re-created. Because initializeApp is invoked multiple times for the same app on the same FirebaseApp object, the error shown above is yielded.

One way to solve this, is by catching the exception thrown by the factory, as shown in your answer. However, you could also take advantage of Kotlin features to solve this issue, without creating a costly Exception for every test.

private val firebaseMessaging: FirebaseMessaging by lazy {
    val privateKeyFileURL = ClassLoader.getSystemResource("firebase-cloud-messaging.json")
        ?: throw IOException("Couldn't read firebase credentials")
    val privateKeyStream = File(privateKeyFileURL.file).inputStream()
    val googleCredentials = GoogleCredentials.fromStream(privateKeyStream)
    val firebaseOptions = FirebaseOptions.builder().setCredentials(googleCredentials)
        .build()
    val app = FirebaseApp.initializeApp(firebaseOptions, "my-app")

    FirebaseMessaging.getInstance(app)
}

@Component
class FirebaseMessagingBean {

    @Bean
    fun firebaseMessaging(): FirebaseMessaging = firebaseMessaging
}

By utilizing the lazy delegate, the initialization is called once only, and only when first required.

  • Related