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.