Skip to content

Testing Guide

This guide covers testing strategies, tools, and procedures for Gazer Mobile Stream Studio.

Testing Overview

The project uses a multi-layered testing approach:

  • Unit Tests: Test individual components in isolation
  • Integration Tests: Test component interactions
  • Instrumentation Tests: Test UI and device interactions
  • Manual Testing: Real-world scenario validation

Test Structure

app/src/
├── test/                           # Unit tests (JVM)
│   ├── java/com/androidrtmp/
│   │   ├── managers/
│   │   │   ├── UsbCaptureManagerTest.kt
│   │   │   └── StreamingManagerTest.kt
│   │   ├── utils/
│   │   └── TestUtils.kt
│   └── resources/                  # Test resources
└── androidTest/                    # Instrumentation tests (Android)
    ├── java/com/androidrtmp/
    │   ├── ui/
    │   │   ├── MainActivityTest.kt
    │   │   └── CameraOverlayTest.kt
    │   └── integration/
    │       ├── VideoCapturePipelineTest.kt
    │       └── StreamingIntegrationTest.kt
    └── assets/                     # Test assets

Unit Testing

Framework Setup

We use JUnit 5 with MockK for mocking:

// In app/build.gradle.kts
dependencies {
    testImplementation("junit:junit:4.13.2")
    testImplementation("io.mockk:mockk:1.13.8")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
    testImplementation("androidx.arch.core:core-testing:2.2.0")
}

Example Unit Tests

UsbCaptureManagerTest.kt

@ExtendWith(MockKExtension::class)
class UsbCaptureManagerTest {
    @MockK
    private lateinit var context: Context

    @MockK
    private lateinit var usbManager: UsbManager

    @MockK
    private lateinit var usbDevice: UsbDevice

    private lateinit var captureManager: UsbCaptureManager

    @BeforeEach
    fun setup() {
        MockKAnnotations.init(this)
        captureManager = UsbCaptureManager(context, usbManager)
    }

    @Test
    fun `connectToDevice returns success when device is compatible`() = runTest {
        // Given
        every { usbDevice.vendorId } returns 0x1234
        every { usbDevice.productId } returns 0x5678
        every { usbManager.hasPermission(usbDevice) } returns true
        every { usbManager.openDevice(usbDevice) } returns mockk()

        // When
        val result = captureManager.connectToDevice(usbDevice)

        // Then
        assertTrue(result.isSuccess)
        verify { usbManager.openDevice(usbDevice) }
    }

    @Test
    fun `connectToDevice returns failure when device is incompatible`() = runTest {
        // Given
        every { usbDevice.vendorId } returns 0x9999
        every { usbDevice.productId } returns 0x9999

        // When
        val result = captureManager.connectToDevice(usbDevice)

        // Then
        assertTrue(result.isFailure)
        verify(exactly = 0) { usbManager.openDevice(any()) }
    }

    @Test
    fun `connectionState emits Connected when device connects successfully`() = runTest {
        // Given
        val observer = mockk<Observer<ConnectionState>>(relaxed = true)
        captureManager.connectionState.observeForever(observer)

        every { usbDevice.vendorId } returns 0x1234
        every { usbManager.hasPermission(usbDevice) } returns true
        every { usbManager.openDevice(usbDevice) } returns mockk()

        // When
        captureManager.connectToDevice(usbDevice)

        // Then
        verify { observer.onChanged(ConnectionState.Connected(usbDevice)) }
    }
}

StreamCompositorTest.kt

class StreamCompositorTest {
    private lateinit var compositor: StreamCompositor

    @BeforeEach
    fun setup() {
        compositor = StreamCompositor()
    }

    @Test
    fun `composition combines video and overlay correctly`() = runTest {
        // Given
        val videoFrame = VideoFrame(
            data = ByteArray(1280 * 720 * 3),
            format = VideoFormat.YUV420,
            width = 1280,
            height = 720,
            timestamp = System.currentTimeMillis()
        )

        val overlayBitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888)

        val videoFlow = flowOf(videoFrame)
        val overlayFlow = flowOf(overlayBitmap)

        // When
        compositor.setMainVideoSource(videoFlow)
        compositor.setOverlaySource(overlayFlow)
        compositor.startComposition()

        // Then
        val compositeFrames = compositor.compositeFrames.take(1).toList()
        assertEquals(1, compositeFrames.size)
        assertEquals(1280, compositeFrames[0].width)
        assertEquals(720, compositeFrames[0].height)
    }
}

Running Unit Tests

# Run all unit tests
./gradlew test

# Run tests for specific variant
./gradlew testDebugUnitTest

# Run specific test class
./gradlew test --tests="com.androidrtmp.UsbCaptureManagerTest"

# Run with coverage
./gradlew testDebugUnitTestCoverage

Instrumentation Testing

Framework Setup

// In app/build.gradle.kts
dependencies {
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
    androidTestImplementation("androidx.test:runner:1.5.2")
    androidTestImplementation("androidx.test:rules:1.5.0")
    androidTestImplementation("io.mockk:mockk-android:1.13.8")
}

UI Testing with Espresso

MainActivityTest.kt

@RunWith(AndroidJUnit4::class)
class MainActivityTest {
    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    @Test
    fun streamingButtonTogglesCorrectly() {
        // Given - app is launched
        onView(withId(R.id.btn_start_streaming))
            .check(matches(isDisplayed()))
            .check(matches(withText("Start Streaming")))

        // When - user taps start streaming
        onView(withId(R.id.btn_start_streaming))
            .perform(click())

        // Then - button changes to stop streaming
        onView(withId(R.id.btn_start_streaming))
            .check(matches(withText("Stop Streaming")))
    }

    @Test
    fun bitrateSliderUpdatesValue() {
        // When - user moves bitrate slider
        onView(withId(R.id.slider_bitrate))
            .perform(setProgress(5000))

        // Then - bitrate value is updated
        onView(withId(R.id.tv_bitrate_value))
            .check(matches(withText("5000 kbps")))
    }

    @Test
    fun cameraOverlayToggleWorks() {
        // When - user toggles camera overlay
        onView(withId(R.id.switch_camera_overlay))
            .perform(click())

        // Then - overlay becomes visible
        onView(withId(R.id.camera_overlay))
            .check(matches(isDisplayed()))
    }
}

CameraOverlayTest.kt

@RunWith(AndroidJUnit4::class)
class CameraOverlayTest {
    private lateinit var cameraOverlay: CameraOverlayView
    private lateinit var scenario: ActivityScenario<TestActivity>

    @Before
    fun setup() {
        scenario = ActivityScenario.launch(TestActivity::class.java)
        scenario.onActivity { activity ->
            cameraOverlay = activity.findViewById(R.id.camera_overlay)
        }
    }

    @Test
    fun overlayCanBeDragged() {
        // Given - overlay is visible and draggable
        onView(withId(R.id.camera_overlay))
            .check(matches(isDisplayed()))

        // When - user drags overlay
        onView(withId(R.id.camera_overlay))
            .perform(
                actionWithAssertions(
                    GeneralSwipeAction(
                        Swipe.SLOW,
                        GeneralLocation.CENTER,
                        GeneralLocation.TOP_RIGHT,
                        Press.FINGER
                    )
                )
            )

        // Then - overlay position is updated
        // Verify through custom ViewMatcher or position checking
    }

    @Test
    fun overlayRespectsBounds() {
        // Test that overlay stays within parent bounds when dragged
        onView(withId(R.id.camera_overlay))
            .perform(
                actionWithAssertions(
                    GeneralSwipeAction(
                        Swipe.FAST,
                        GeneralLocation.CENTER,
                        CoordinatesProvider { view ->
                            val parent = view.parent as View
                            floatArrayOf(
                                parent.width + 100f,  // Beyond right edge
                                parent.height + 100f  // Beyond bottom edge
                            )
                        },
                        Press.FINGER
                    )
                )
            )

        // Verify overlay is constrained within bounds
    }
}

Integration Testing

VideoCapturePipelineTest.kt

@RunWith(AndroidJUnit4::class)
class VideoCapturePipelineTest {
    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    @Test
    fun videoPipelineProcessesFrames() = runTest {
        // This test requires actual USB device connected
        // Skip if no test hardware available
        assumeTrue("USB capture device required", hasUsbCaptureDevice())

        activityRule.scenario.onActivity { activity ->
            val captureManager = activity.usbCaptureManager
            val renderer = activity.videoRenderer

            // Connect to USB device
            val devices = captureManager.getCompatibleDevices()
            assumeTrue("No compatible USB devices", devices.isNotEmpty())

            // Start video capture and verify frames are processed
            runBlocking {
                val result = captureManager.connectToDevice(devices.first())
                assertTrue("Failed to connect to device", result.isSuccess)

                // Wait for frames to start flowing
                delay(2000)

                // Verify renderer is active
                assertTrue("Renderer should be active", renderer.isRendering.value)
            }
        }
    }

    private fun hasUsbCaptureDevice(): Boolean {
        val usbManager = InstrumentationRegistry
            .getInstrumentation()
            .targetContext
            .getSystemService(Context.USB_SERVICE) as UsbManager

        return usbManager.deviceList.values.any { device ->
            // Check for known capture card vendor IDs
            device.vendorId in listOf(0x0fd9, 0x1b80, 0x2040)
        }
    }
}

Running Instrumentation Tests

# Run all instrumentation tests
./gradlew connectedAndroidTest

# Run specific test class
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.androidrtmp.ui.MainActivityTest

# Run with coverage
./gradlew createDebugCoverageReport

Manual Testing

Hardware Testing Scenarios

USB Capture Card Testing

  1. Device Connection
  2. [ ] Connect various USB capture cards
  3. [ ] Verify automatic device detection
  4. [ ] Test permission request flow
  5. [ ] Check device compatibility filtering

  6. Video Capture

  7. [ ] Test different input resolutions
  8. [ ] Verify various video formats (YUV, JPEG, etc.)
  9. [ ] Test frame rate consistency
  10. [ ] Check for dropped frames

  11. Hot-plug Testing

  12. [ ] Disconnect device during capture
  13. [ ] Reconnect device and verify recovery
  14. [ ] Test multiple connect/disconnect cycles

Streaming Testing

  1. RTMP Connection
  2. [ ] Test connection to Twitch
  3. [ ] Test connection to YouTube Live
  4. [ ] Test custom RTMP endpoints
  5. [ ] Verify RTMPS support

  6. Quality Settings

  7. [ ] Test various bitrate settings
  8. [ ] Verify resolution scaling
  9. [ ] Test frame rate configurations
  10. [ ] Check encoding quality

  11. Network Conditions

  12. [ ] Test on Wi-Fi connections
  13. [ ] Test on mobile data
  14. [ ] Test with unstable connections
  15. [ ] Verify reconnection logic

Camera Overlay Testing

  1. Basic Functionality
  2. [ ] Toggle overlay on/off
  3. [ ] Test camera permission flow
  4. [ ] Verify overlay positioning
  5. [ ] Check overlay resizing

  6. Interaction Testing

  7. [ ] Drag overlay to different positions
  8. [ ] Resize using pinch gestures
  9. [ ] Test position presets
  10. [ ] Verify overlay settings dialog

Device Testing Matrix

Phone Testing (Portrait/Landscape)

  • Samsung Galaxy S20+ (Android 11)
  • Google Pixel 6 (Android 12)
  • OnePlus 9 (Android 11)
  • Xiaomi Mi 11 (Android 11)

Tablet Testing (Portrait/Landscape)

  • Samsung Galaxy Tab S7 (Android 11)
  • iPad (via Android emulation - limited)
  • Generic 10" Android tablet (Android 10)

Test Checklists

Pre-release Testing Checklist

  • [ ] All unit tests pass
  • [ ] All instrumentation tests pass
  • [ ] Manual testing completed on 3+ devices
  • [ ] USB capture tested with 2+ capture cards
  • [ ] Streaming tested on 2+ platforms
  • [ ] Performance testing completed
  • [ ] Memory leak testing completed
  • [ ] Battery usage profiled

Regression Testing Checklist

  • [ ] Core functionality unchanged
  • [ ] No new crashes introduced
  • [ ] Performance not degraded
  • [ ] Memory usage stable
  • [ ] Battery usage not increased

Performance Testing

Memory Testing

@Test
fun memoryUsageStaysWithinLimits() {
    val runtime = Runtime.getRuntime()
    val initialMemory = runtime.totalMemory() - runtime.freeMemory()

    // Simulate intensive video processing
    repeat(100) {
        // Process video frames
        processVideoFrame(createTestFrame())
    }

    System.gc() // Force garbage collection
    Thread.sleep(1000) // Wait for GC

    val finalMemory = runtime.totalMemory() - runtime.freeMemory()
    val memoryIncrease = finalMemory - initialMemory

    // Memory increase should be less than 50MB
    assertTrue("Memory leak detected: ${memoryIncrease}bytes", 
               memoryIncrease < 50 * 1024 * 1024)
}

Frame Rate Testing

@Test
fun frameRateConsistency() = runTest {
    val frameTimestamps = mutableListOf<Long>()

    videoCapture.videoFrames
        .take(100)
        .collect { frame ->
            frameTimestamps.add(frame.timestamp)
        }

    val intervals = frameTimestamps
        .zipWithNext { a, b -> b - a }
        .map { it.toDouble() }

    val averageInterval = intervals.average()
    val expectedInterval = 1000.0 / 30.0 // 30 FPS

    // Frame rate should be within 10% of target
    val tolerance = expectedInterval * 0.1
    assertTrue("Inconsistent frame rate", 
               abs(averageInterval - expectedInterval) < tolerance)
}

Test Data and Mocking

Mock Video Frames

object TestUtils {
    fun createTestVideoFrame(
        width: Int = 1280,
        height: Int = 720,
        format: VideoFormat = VideoFormat.YUV420
    ): VideoFrame {
        val dataSize = when (format) {
            VideoFormat.YUV420 -> width * height * 3 / 2
            VideoFormat.NV21 -> width * height * 3 / 2
            VideoFormat.JPEG -> width * height / 4 // Approximate
            VideoFormat.MJPEG -> width * height / 4
        }

        return VideoFrame(
            data = ByteArray(dataSize) { (it % 256).toByte() },
            format = format,
            width = width,
            height = height,
            timestamp = System.currentTimeMillis()
        )
    }

    fun createTestBitmap(
        width: Int = 320,
        height: Int = 240
    ): Bitmap {
        val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(bitmap)
        canvas.drawColor(Color.BLUE)
        return bitmap
    }
}

Mock Streaming Server

class MockRtmpServer {
    private var isRunning = false
    private val connections = mutableListOf<MockConnection>()

    fun start(port: Int = 1935) {
        isRunning = true
        // Start mock RTMP server for testing
    }

    fun stop() {
        isRunning = false
        connections.clear()
    }

    fun getConnectionCount(): Int = connections.size

    fun getReceivedFrameCount(): Int = connections.sumOf { it.frameCount }
}

Continuous Integration Testing

The GitHub Actions workflow runs automated tests:

# .github/workflows/build-android.yml (test section)
- name: Run unit tests
  run: ./gradlew test

- name: Run instrumentation tests
  if: matrix.api-level >= 24
  run: ./gradlew connectedAndroidTest

- name: Upload test results
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: test-results
    path: |
      app/build/reports/tests/
      app/build/test-results/

Test Coverage

Generate Coverage Reports

# Generate coverage for unit tests
./gradlew testDebugUnitTestCoverage

# Generate coverage for instrumentation tests  
./gradlew createDebugCoverageReport

# Combined coverage report
./gradlew jacocoTestReport

Coverage Targets

  • Overall Coverage: > 80%
  • Core Components: > 90%
  • UI Components: > 70%
  • Utility Classes: > 95%

This comprehensive testing approach ensures the Android RTMP USB Capture app is robust, reliable, and ready for production use across different devices and scenarios.