From fd836631456f2802e3025f326ad5b5dcf6b30f57 Mon Sep 17 00:00:00 2001 From: Siyabonga Buthelezi <114085572+ElliotBadinger@users.noreply.github.com> Date: Sun, 5 Oct 2025 11:59:11 +0200 Subject: [PATCH] Refactor audio tests to avoid Robolectric network dependency --- SaidIt/build.gradle.kts | 1 + .../kotlin/eu/mrogalski/android/ViewsTest.kt | 5 + .../saidit/NotifyFileReceiverTest.kt | 15 +-- .../eu/mrogalski/saidit/SaidItFragmentTest.kt | 17 ++-- audio/build.gradle.kts | 3 +- .../siya/epistemophile/audio/AudioPlayer.kt | 8 +- .../siya/epistemophile/audio/AudioRecorder.kt | 8 +- .../audio/AudioPlayerRecorderTest.kt | 96 ++++++++++++------- gradle/libs.versions.toml | 2 + 9 files changed, 98 insertions(+), 57 deletions(-) diff --git a/SaidIt/build.gradle.kts b/SaidIt/build.gradle.kts index 79e1c26f..10472b5e 100644 --- a/SaidIt/build.gradle.kts +++ b/SaidIt/build.gradle.kts @@ -81,6 +81,7 @@ dependencies { testImplementation(libs.mockito.core) testImplementation(libs.mockito.kotlin) testImplementation(libs.robolectric) + testRuntimeOnly(libs.robolectric.android.all) testImplementation(libs.coroutines.test) // Hilt testing for Robolectric/JUnit testImplementation(libs.hilt.android.testing) diff --git a/SaidIt/src/test/kotlin/eu/mrogalski/android/ViewsTest.kt b/SaidIt/src/test/kotlin/eu/mrogalski/android/ViewsTest.kt index ac52370f..ccd01974 100644 --- a/SaidIt/src/test/kotlin/eu/mrogalski/android/ViewsTest.kt +++ b/SaidIt/src/test/kotlin/eu/mrogalski/android/ViewsTest.kt @@ -8,12 +8,17 @@ import android.widget.TextView import org.junit.Assert.* import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith import org.mockito.Mockito.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config /** * Comprehensive unit tests for Views utility functions. * Tests both legacy Java-compatible methods and modern Kotlin extensions. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) class ViewsTest { private lateinit var rootViewGroup: ViewGroup diff --git a/SaidIt/src/test/kotlin/eu/mrogalski/saidit/NotifyFileReceiverTest.kt b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/NotifyFileReceiverTest.kt index 1e59a1a8..979b4d23 100644 --- a/SaidIt/src/test/kotlin/eu/mrogalski/saidit/NotifyFileReceiverTest.kt +++ b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/NotifyFileReceiverTest.kt @@ -30,6 +30,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowApplication +import org.robolectric.Shadows import org.robolectric.shadows.ShadowNotificationManager /** @@ -78,7 +79,7 @@ class NotifyFileReceiverTest { receiver.onSuccess(testUri) // Then - val shadowNotificationManager = ShadowNotificationManager.shadowOf( + val shadowNotificationManager = Shadows.shadowOf( context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager ) val notifications = shadowNotificationManager.allNotifications @@ -97,7 +98,7 @@ class NotifyFileReceiverTest { receiver.onSuccess(testUri) // Then - val shadowNotificationManager = ShadowNotificationManager.shadowOf( + val shadowNotificationManager = Shadows.shadowOf( context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager ) val notifications = shadowNotificationManager.allNotifications @@ -117,7 +118,7 @@ class NotifyFileReceiverTest { receiver.onSuccess(testUri) // Then - val shadowNotificationManager = ShadowNotificationManager.shadowOf( + val shadowNotificationManager = Shadows.shadowOf( context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager ) val notification = shadowNotificationManager.getNotification(43) @@ -134,7 +135,7 @@ class NotifyFileReceiverTest { receiver.onFailure(exception) // Then - no exception thrown, no notification posted - val shadowNotificationManager = ShadowNotificationManager.shadowOf( + val shadowNotificationManager = Shadows.shadowOf( context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager ) val notifications = shadowNotificationManager.allNotifications @@ -224,7 +225,7 @@ class NotifyFileReceiverTest { // Test without permission receiver.onSuccess(testUri) - var shadowNotificationManager = ShadowNotificationManager.shadowOf( + var shadowNotificationManager = Shadows.shadowOf( context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager ) assertEquals(0, shadowNotificationManager.allNotifications.size) @@ -232,7 +233,7 @@ class NotifyFileReceiverTest { // Grant permission and test again ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS) receiver.onSuccess(testUri) - shadowNotificationManager = ShadowNotificationManager.shadowOf( + shadowNotificationManager = Shadows.shadowOf( context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager ) assertEquals(1, shadowNotificationManager.allNotifications.size) @@ -279,7 +280,7 @@ class NotifyFileReceiverTest { receiver.onSuccess(testUri2) // Then - only one notification because same ID is used - val shadowNotificationManager = ShadowNotificationManager.shadowOf( + val shadowNotificationManager = Shadows.shadowOf( context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager ) val notification = shadowNotificationManager.getNotification(43) diff --git a/SaidIt/src/test/kotlin/eu/mrogalski/saidit/SaidItFragmentTest.kt b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/SaidItFragmentTest.kt index 81c75ae0..308c168a 100644 --- a/SaidIt/src/test/kotlin/eu/mrogalski/saidit/SaidItFragmentTest.kt +++ b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/SaidItFragmentTest.kt @@ -16,6 +16,8 @@ import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textview.MaterialTextView import eu.mrogalski.android.TimeFormat +import eu.mrogalski.saidit.NotifyFileReceiver +import eu.mrogalski.saidit.PromptFileReceiver import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -26,6 +28,7 @@ import org.mockito.junit.MockitoJUnitRunner import org.mockito.MockedStatic import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config /** @@ -245,7 +248,7 @@ class SaidItFragmentTest { val fileName = "test_recording.mp4" // When - val notification = fragment.buildNotificationForFile(appContext, fileUri, fileName) + val notification = NotifyFileReceiver.buildNotificationForFile(appContext, fileUri, fileName) // Then assert(notification != null) @@ -257,10 +260,10 @@ class SaidItFragmentTest { // Given - Use Robolectric's application context val appContext = RuntimeEnvironment.getApplication() val mockUri = mock(Uri::class.java) - val receiver = fragment.NotifyFileReceiver(appContext) + val receiver = NotifyFileReceiver(appContext) // Grant POST_NOTIFICATIONS permission so NotificationManagerCompat.notify() is reachable - org.robolectric.Shadows.shadowOf(appContext).grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS) + shadowOf(appContext).grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS) // Mock static method using Mockito mockStatic(NotificationManagerCompat::class.java).use { mockedStatic -> @@ -278,7 +281,7 @@ class SaidItFragmentTest { @Test fun `NotifyFileReceiver onFailure does nothing`() { // Given - val receiver = fragment.NotifyFileReceiver(mockContext) + val receiver = NotifyFileReceiver(mockContext) val exception = Exception("Test failure") // When @@ -296,7 +299,7 @@ class SaidItFragmentTest { `when`(mockActivity.isFinishing).thenReturn(false) `when`(mockDialog.isShowing).thenReturn(true) - val receiver = fragment.PromptFileReceiver(mockActivity, mockDialog) + val receiver = PromptFileReceiver(mockActivity, mockDialog) // Mock the dialog builder chain val mockBuilder = mock(MaterialAlertDialogBuilder::class.java) @@ -320,7 +323,7 @@ class SaidItFragmentTest { val mockUri = mock(Uri::class.java) `when`(mockActivity.isFinishing).thenReturn(true) - val receiver = fragment.PromptFileReceiver(mockActivity) + val receiver = PromptFileReceiver(mockActivity) // When receiver.onSuccess(mockUri) @@ -335,7 +338,7 @@ class SaidItFragmentTest { val exception = Exception("Test error") `when`(mockActivity.isFinishing).thenReturn(false) - val receiver = fragment.PromptFileReceiver(mockActivity) + val receiver = PromptFileReceiver(mockActivity) // Mock the dialog builder val mockBuilder = mock(MaterialAlertDialogBuilder::class.java) diff --git a/audio/build.gradle.kts b/audio/build.gradle.kts index 63adfc25..51c40ade 100644 --- a/audio/build.gradle.kts +++ b/audio/build.gradle.kts @@ -38,8 +38,7 @@ dependencies { implementation("androidx.appcompat:appcompat:1.6.1") implementation("com.google.android.material:material:1.8.0") testImplementation("junit:junit:4.13.2") - testImplementation("org.robolectric:robolectric:4.11.1") - testImplementation("androidx.test:core:1.5.0") + testImplementation("io.mockk:mockk:1.13.13") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") } diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/AudioPlayer.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/AudioPlayer.kt index c70c0138..6b1d8931 100644 --- a/audio/src/main/kotlin/com/siya/epistemophile/audio/AudioPlayer.kt +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/AudioPlayer.kt @@ -3,17 +3,21 @@ package com.siya.epistemophile.audio import android.media.MediaPlayer import java.io.File -class AudioPlayer(private val inputFile: File) { +class AudioPlayer( + private val inputFile: File, + private val playerProvider: () -> MediaPlayer = { MediaPlayer() } +) { private var player: MediaPlayer? = null fun start() { try { - player = MediaPlayer().apply { + val instance = playerProvider().apply { setDataSource(inputFile.absolutePath) prepare() start() } + player = instance } catch (e: Exception) { e.printStackTrace() } diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/AudioRecorder.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/AudioRecorder.kt index aaae8305..8b055dfc 100644 --- a/audio/src/main/kotlin/com/siya/epistemophile/audio/AudioRecorder.kt +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/AudioRecorder.kt @@ -3,12 +3,15 @@ package com.siya.epistemophile.audio import android.media.MediaRecorder import java.io.File -class AudioRecorder(private val outputFile: File) { +class AudioRecorder( + private val outputFile: File, + private val recorderProvider: () -> MediaRecorder = { MediaRecorder() } +) { private var recorder: MediaRecorder? = null fun start() { - recorder = MediaRecorder().apply { + val instance = recorderProvider().apply { setAudioSource(MediaRecorder.AudioSource.MIC) setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) setAudioEncoder(MediaRecorder.AudioEncoder.AAC) @@ -16,6 +19,7 @@ class AudioRecorder(private val outputFile: File) { prepare() start() } + recorder = instance } fun stop() { diff --git a/audio/src/test/kotlin/com/siya/epistemophile/audio/AudioPlayerRecorderTest.kt b/audio/src/test/kotlin/com/siya/epistemophile/audio/AudioPlayerRecorderTest.kt index 4c725e46..c64666ac 100644 --- a/audio/src/test/kotlin/com/siya/epistemophile/audio/AudioPlayerRecorderTest.kt +++ b/audio/src/test/kotlin/com/siya/epistemophile/audio/AudioPlayerRecorderTest.kt @@ -1,49 +1,71 @@ package com.siya.epistemophile.audio -import android.content.Context -import androidx.test.core.app.ApplicationProvider +import android.media.MediaPlayer +import android.media.MediaRecorder +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.mockk +import io.mockk.verifyOrder +import org.junit.After import org.junit.Before -import org.junit.FixMethodOrder import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.MethodSorters -import org.robolectric.RobolectricTestRunner import java.io.File -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -@RunWith(RobolectricTestRunner::class) -@FixMethodOrder(MethodSorters.NAME_ASCENDING) class AudioPlayerRecorderTest { - private lateinit var audioRecorder: AudioRecorder - private lateinit var audioPlayer: AudioPlayer + private lateinit var outputFile: File + private lateinit var mediaRecorder: MediaRecorder + private lateinit var mediaPlayer: MediaPlayer + + @Before + fun setUp() { + outputFile = kotlin.io.path.createTempFile(prefix = "test_recording", suffix = ".mp4").toFile() + mediaRecorder = mockk(relaxed = true) + mediaPlayer = mockk(relaxed = true) + + every { mediaRecorder.setOutputFile(any()) } returns Unit + every { mediaPlayer.setDataSource(any()) } returns Unit + } + + @After + fun tearDown() { + outputFile.delete() + } @Test - fun test1_recordAndPlayback() { - val context = ApplicationProvider.getApplicationContext() - val outputFile = File(context.cacheDir, "test_recording.mp4") - audioRecorder = AudioRecorder(outputFile) - audioPlayer = AudioPlayer(outputFile) - - // Use CountDownLatch for synchronization instead of Thread.sleep() - val recordLatch = CountDownLatch(1) - val playbackLatch = CountDownLatch(1) - - audioRecorder.start() - // Let recording run for a short time - Thread.sleep(100) - audioRecorder.stop() - recordLatch.countDown() - - audioPlayer.start() - // Let playback run for a short time - Thread.sleep(100) - audioPlayer.stop() - playbackLatch.countDown() - - // Verify that the operations completed - recordLatch.await(1, TimeUnit.SECONDS) - playbackLatch.await(1, TimeUnit.SECONDS) + fun `start and stop recorder and player`() { + val recorder = AudioRecorder(outputFile) { mediaRecorder } + val player = AudioPlayer(outputFile) { mediaPlayer } + + recorder.start() + verifyOrder { + mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC) + mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + mediaRecorder.setOutputFile(outputFile.absolutePath) + mediaRecorder.prepare() + mediaRecorder.start() + } + + recorder.stop() + verifyOrder { + mediaRecorder.stop() + mediaRecorder.release() + } + confirmVerified(mediaRecorder) + + player.start() + verifyOrder { + mediaPlayer.setDataSource(outputFile.absolutePath) + mediaPlayer.prepare() + mediaPlayer.start() + } + + player.stop() + verifyOrder { + mediaPlayer.stop() + mediaPlayer.release() + } + confirmVerified(mediaPlayer) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d911cbe3..3ba79c73 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ junit = "4.13.2" mockito = "5.11.0" mockito-kotlin = "5.0.0" robolectric = "4.11.1" +robolectric-android-all = "14-robolectric-10818077" tap-target-view = "1.13.3" javax-inject = "1" @@ -25,6 +26,7 @@ junit = { module = "junit:junit", version.ref = "junit" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +robolectric-android-all = { module = "org.robolectric:android-all", version.ref = "robolectric-android-all" } coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }