Repository: customerio/customerio-android
Pull request #550: chore: add tests for modal in-app view controller
Author: login: mrehan27
Created: 2025-05-16T09:30:36Z | Merged: 2025-05-19T09:00:53Z

part of: [MBL-1128](https://linear.app/customerio/issue/MBL-1128/utilize-in-app-view-updates-to-increase-test-coverage)

### Changes

- Added unit tests for `ModalInAppMessageViewController`
- Added tests for the default implementation of `InAppHostViewDelegate`
- Renamed `isMessageDisplayed` to `shouldDispatchDisplayEvent` in `ModalInAppMessageViewController` for improved clarity


Diff:
diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/ModalInAppMessageViewController.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/ModalInAppMessageViewController.kt
index c009a3433..862cbfbd7 100644
--- a/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/ModalInAppMessageViewController.kt
+++ b/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/ModalInAppMessageViewController.kt
@@ -1,5 +1,6 @@
 package io.customer.messaginginapp.ui.controller
 
+import androidx.annotation.VisibleForTesting
 import io.customer.messaginginapp.state.InAppMessagingAction
 import io.customer.messaginginapp.ui.bridge.InAppHostViewDelegate
 import io.customer.messaginginapp.ui.bridge.InAppPlatformDelegate
@@ -13,7 +14,8 @@ internal class ModalInAppMessageViewController(
     viewDelegate = viewDelegate,
     platformDelegate = platformDelegate
 ) {
-    private var isMessageDisplayed: Boolean = true
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    var shouldDispatchDisplayEvent: Boolean = true
 
     init {
         attachEngineWebView()
@@ -29,9 +31,9 @@ internal class ModalInAppMessageViewController(
 
     override fun routeLoaded(route: String) {
         super.routeLoaded(route)
-        if (!isMessageDisplayed) return
+        if (!shouldDispatchDisplayEvent) return
 
-        isMessageDisplayed = false
+        shouldDispatchDisplayEvent = false
         engineWebViewDelegate?.setAlpha(1.0F)
         currentMessage?.let { message ->
             inAppMessagingManager.dispatch(InAppMessagingAction.DisplayMessage(message))
diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/bridge/InAppHostViewDelegateTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/bridge/InAppHostViewDelegateTest.kt
new file mode 100644
index 000000000..bbff2d28f
--- /dev/null
+++ b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/bridge/InAppHostViewDelegateTest.kt
@@ -0,0 +1,108 @@
+package io.customer.messaginginapp.ui.bridge
+
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.core.view.isVisible
+import io.customer.commontest.config.TestConfig
+import io.customer.commontest.config.testConfigurationDefault
+import io.customer.messaginginapp.gist.presentation.engine.EngineWebView
+import io.customer.messaginginapp.state.InAppMessagingManager
+import io.customer.messaginginapp.testutils.core.JUnitTest
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import org.amshove.kluent.shouldBeInstanceOf
+import org.junit.jupiter.api.Test
+
+class InAppHostViewDelegateTest : JUnitTest() {
+    private lateinit var hostViewMock: ViewGroup
+    private lateinit var delegate: InAppHostViewDelegate
+
+    override fun setup(testConfig: TestConfig) {
+        super.setup(
+            testConfigurationDefault {
+                diGraph {
+                    sdk {
+                        overrideDependency(mockk<InAppMessagingManager>(relaxed = true))
+                    }
+                }
+            }
+        )
+
+        hostViewMock = mockk(relaxed = true)
+        delegate = InAppHostViewDelegateImpl(hostViewMock)
+    }
+
+    @Test
+    fun addChildView_givenEngineWebView_expectViewAddedToParent() {
+        every { hostViewMock.addView(any()) } just Runs
+        val engineWebView = mockk<EngineWebView>()
+        val engineWebViewDelegate = mockk<EngineWebViewDelegate>().apply {
+            every { getView() } returns engineWebView
+        }
+
+        delegate.addView(engineWebViewDelegate)
+
+        verify { hostViewMock.addView(engineWebView) }
+    }
+
+    @Test
+    fun removeChildView_givenEngineWebView_expectViewRemovedFromParent() {
+        every { hostViewMock.removeView(any()) } just Runs
+        val engineWebView = mockk<EngineWebView>()
+        val engineWebViewDelegate = mockk<EngineWebViewDelegate>().apply {
+            every { getView() } returns engineWebView
+        }
+
+        delegate.removeView(engineWebViewDelegate)
+
+        verify { hostViewMock.removeView(engineWebView) }
+    }
+
+    @Test
+    fun createEngineWebViewInstance_givenContext_expectNewInstanceCreated() {
+        val hostView = spyk(FrameLayout(contextMock)).apply {
+            every { context } returns contextMock
+        }
+        val delegate = InAppHostViewDelegateImpl(hostView)
+
+        val result = delegate.createEngineWebViewInstance()
+
+        result.shouldBeInstanceOf<EngineWebView>()
+    }
+
+    @Test
+    fun postOnUIThread_givenRunnable_expectRunnablePostedToView() {
+        val runnable = mockk<() -> Unit>(relaxed = true)
+        every { hostViewMock.post(any()) } answers {
+            firstArg<Runnable>().run()
+            true
+        }
+
+        delegate.post(runnable)
+
+        verify { runnable.invoke() }
+    }
+
+    @Test
+    fun setVisibility_givenVisibleTrue_expectViewVisible() {
+        every { hostViewMock.isVisible = true } just Runs
+
+        delegate.isVisible = true
+
+        verify { hostViewMock.visibility = View.VISIBLE }
+    }
+
+    @Test
+    fun setVisibility_givenVisibleFalse_expectViewGone() {
+        every { hostViewMock.isVisible = false } just Runs
+
+        delegate.isVisible = false
+
+        verify { hostViewMock.visibility = View.GONE }
+    }
+}
diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/ModalMessageViewControllerTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/ModalMessageViewControllerTest.kt
new file mode 100644
index 000000000..e16e8ce5d
--- /dev/null
+++ b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/ModalMessageViewControllerTest.kt
@@ -0,0 +1,142 @@
+package io.customer.messaginginapp.ui.controller
+
+import io.customer.commontest.config.TestConfig
+import io.customer.commontest.config.testConfigurationDefault
+import io.customer.commontest.extensions.assertCalledOnce
+import io.customer.commontest.extensions.assertNoInteractions
+import io.customer.commontest.extensions.random
+import io.customer.messaginginapp.state.InAppMessagingAction
+import io.customer.messaginginapp.state.InAppMessagingManager
+import io.customer.messaginginapp.testutils.core.JUnitTest
+import io.customer.messaginginapp.testutils.extension.createInAppMessage
+import io.customer.messaginginapp.ui.bridge.EngineWebViewDelegate
+import io.customer.messaginginapp.ui.bridge.InAppHostViewDelegate
+import io.customer.messaginginapp.ui.bridge.InAppPlatformDelegate
+import io.customer.messaginginapp.ui.bridge.ModalInAppMessageViewCallback
+import io.mockk.clearMocks
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verifyOrder
+import org.amshove.kluent.shouldBeEqualTo
+import org.amshove.kluent.shouldBeFalse
+import org.amshove.kluent.shouldBeNull
+import org.amshove.kluent.shouldNotBeNull
+import org.junit.jupiter.api.Test
+
+class ModalMessageViewControllerTest : JUnitTest() {
+    private val engineWebViewDelegate = mockk<EngineWebViewDelegate>(relaxed = true)
+    private val inAppMessagingManager: InAppMessagingManager = mockk(relaxed = true)
+    private val viewDelegate: InAppHostViewDelegate = mockk(relaxed = true)
+
+    override fun setup(testConfig: TestConfig) {
+        super.setup(
+            testConfigurationDefault {
+                diGraph {
+                    sdk {
+                        overrideDependency(inAppMessagingManager)
+                    }
+                }
+            }
+        )
+
+        every { viewDelegate.createEngineWebViewInstance() } returns engineWebViewDelegate
+    }
+
+    private fun createViewController(): ModalInAppMessageViewController {
+        val platformDelegate: InAppPlatformDelegate = mockk(relaxed = true)
+        val instance = ModalInAppMessageViewController(
+            platformDelegate = platformDelegate,
+            viewDelegate = viewDelegate
+        )
+        return spyk(instance)
+    }
+
+    @Test
+    fun init_givenControllerInitialized_expectEngineAttached() {
+        val controller = createViewController()
+
+        controller.engineWebViewDelegate.shouldNotBeNull()
+    }
+
+    @Test
+    fun engineCleanup_givenEngineDetached_expectCallbackCleared() {
+        val controller = createViewController()
+        controller.viewCallback = mockk<ModalInAppMessageViewCallback>(relaxed = true)
+
+        controller.detachEngineWebView()
+
+        controller.viewCallback.shouldBeNull()
+    }
+
+    @Test
+    fun engineCleanup_givenAlreadyDetached_expectNoOp() {
+        val controller = createViewController()
+        controller.detachEngineWebView()
+        controller.viewCallback = mockk<ModalInAppMessageViewCallback>(relaxed = true)
+
+        controller.detachEngineWebView()
+
+        controller.viewCallback.shouldNotBeNull()
+    }
+
+    @Test
+    fun routeLoaded_givenDisplayEventPending_expectDispatchTriggered() {
+        val controller = createViewController()
+        val givenMessage = createInAppMessage()
+        val givenRoute = String.random
+        controller.currentMessage = givenMessage
+
+        controller.routeLoaded(givenRoute)
+
+        controller.shouldDispatchDisplayEvent.shouldBeFalse()
+        controller.currentRoute shouldBeEqualTo givenRoute
+        verifyOrder {
+            engineWebViewDelegate.setAlpha(1.0F)
+            inAppMessagingManager.dispatch(
+                InAppMessagingAction.DisplayMessage(givenMessage)
+            )
+        }
+    }
+
+    @Test
+    fun routeLoaded_givenDisplayEventAlreadyDispatched_expectNoDispatch() {
+        val controller = createViewController()
+        val givenMessage = createInAppMessage()
+        val givenRoute = String.random
+        controller.currentMessage = givenMessage
+        controller.shouldDispatchDisplayEvent = false
+        clearMocks(engineWebViewDelegate)
+
+        controller.routeLoaded(givenRoute)
+
+        controller.shouldDispatchDisplayEvent.shouldBeFalse()
+        controller.currentRoute shouldBeEqualTo givenRoute
+        assertNoInteractions(engineWebViewDelegate, inAppMessagingManager)
+    }
+
+    @Test
+    fun bootstrapped_givenValidMessageId_expectNoOp() {
+        val controller = createViewController()
+        val givenMessage = createInAppMessage()
+        controller.currentMessage = givenMessage
+
+        controller.bootstrapped()
+
+        controller.engineWebViewDelegate.shouldNotBeNull()
+        controller.currentMessage.shouldNotBeNull()
+    }
+
+    @Test
+    fun bootstrapped_givenEmptyMessageId_expectEngineCleanupTriggered() {
+        val controller = createViewController()
+        val givenMessage = createInAppMessage(messageId = "")
+        controller.currentMessage = givenMessage
+
+        controller.bootstrapped()
+
+        assertCalledOnce { controller.detachEngineWebView() }
+        controller.engineWebViewDelegate.shouldBeNull()
+        controller.currentMessage.shouldBeNull()
+    }
+}


Reviews:
user: login: Shahroz16
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T08:06:35Z
user: login: mrehan27
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T08:49:11Z
user: login: mahmoud-elmorabea
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T09:00:15Z
user: login: mrehan27
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T09:12:23Z

Review comments:
diff_hunk: @@ -13,7 +14,8 @@ internal class ModalInAppMessageViewController(
     viewDelegate = viewDelegate,
     platformDelegate = platformDelegate
 ) {
-    private var isMessageDisplayed: Boolean = true
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    internal var shouldDispatchDisplayEvent: Boolean = true
path: messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/ModalInAppMessageViewController.kt
user: login: Shahroz16
body: the class is `internal`, making the properties internal, if its a style thing thats alright but its not needed. 
created_at: 2025-05-19T08:00:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,7 +14,8 @@ internal class ModalInAppMessageViewController(
     viewDelegate = viewDelegate,
     platformDelegate = platformDelegate
 ) {
-    private var isMessageDisplayed: Boolean = true
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    internal var shouldDispatchDisplayEvent: Boolean = true
path: messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/ModalInAppMessageViewController.kt
user: login: mrehan27
body: Yup, not needed in already internal class, removed it.
created_at: 2025-05-19T08:49:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,7 +14,8 @@ internal class ModalInAppMessageViewController(
     viewDelegate = viewDelegate,
     platformDelegate = platformDelegate
 ) {
-    private var isMessageDisplayed: Boolean = true
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    var shouldDispatchDisplayEvent: Boolean = true
path: messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/ModalInAppMessageViewController.kt
user: login: mahmoud-elmorabea
body: I suggest we don't expose internal state like that for tests, tests should depend on behavior.

For setup, let's just invoke the behavior that makes this true or false, then run our test code.
For verification, let's verify behavior changes instead of verifying the boolean itself 
created_at: 2025-05-19T08:59:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,7 +14,8 @@ internal class ModalInAppMessageViewController(
     viewDelegate = viewDelegate,
     platformDelegate = platformDelegate
 ) {
-    private var isMessageDisplayed: Boolean = true
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    var shouldDispatchDisplayEvent: Boolean = true
path: messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/ModalInAppMessageViewController.kt
user: login: mrehan27
body: We are already doing that actually, updating this in next PR back to private.
created_at: 2025-05-19T09:12:23Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-16T09:30:53Z
body: <!-- sample app builds --> 
# Sample app builds 📱

Below you will find the list of the latest versions of the sample apps. It's recommended to always download the latest builds of the sample apps to accurately test the pull request. 

---
author_association: NONE
user: login: codecov[bot]
created_at: 2025-05-16T09:33:53Z
body: ## [Codecov](https://app.codecov.io/gh/customerio/customerio-android/pull/550?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Please [upload](https://docs.codecov.com/docs/codecov-uploader) report for BASE (`feature/inline-in-app-messages@5c41ea8`). [Learn more](https://docs.codecov.io/docs/error-reference?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio#section-missing-base-commit) about missing BASE report.

<details><summary>Additional details and impacted files</summary>


```diff
@@                        Coverage Diff                        @@
##             feature/inline-in-app-messages     #550   +/-   ##
=================================================================
  Coverage                                  ?   54.54%           
  Complexity                                ?      432           
=================================================================
  Files                                     ?      117           
  Lines                                     ?     3465           
  Branches                                  ?      444           
=================================================================
  Hits                                      ?     1890           
  Misses                                    ?     1441           
  Partials                                  ?      134           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/customerio/customerio-android/pull/550?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-16T09:36:23Z
body: ## 📏 SDK Binary Size Comparison Report

No changes detected in SDK binary size ✅

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-16T09:36:26Z
body: * java_layout: `mbl-1128-modal-controller-test (1747387903)`
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-16T09:36:44Z
body: Build available to test
Version: `mbl-1128-modal-controller-test-SNAPSHOT`
Repository: `https://s01.oss.sonatype.org/content/repositories/snapshots/`
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-16T09:38:52Z
body: * kotlin_compose: `mbl-1128-modal-controller-test (1747387906)`
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-19T08:53:58Z
body: * java_layout: `mbl-1128-modal-controller-test (1747644548)`
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-19T08:57:39Z
body: * kotlin_compose: `mbl-1128-modal-controller-test (1747644565)`
author_association: NONE