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¶
- Device Connection
- [ ] Connect various USB capture cards
- [ ] Verify automatic device detection
- [ ] Test permission request flow
-
[ ] Check device compatibility filtering
-
Video Capture
- [ ] Test different input resolutions
- [ ] Verify various video formats (YUV, JPEG, etc.)
- [ ] Test frame rate consistency
-
[ ] Check for dropped frames
-
Hot-plug Testing
- [ ] Disconnect device during capture
- [ ] Reconnect device and verify recovery
- [ ] Test multiple connect/disconnect cycles
Streaming Testing¶
- RTMP Connection
- [ ] Test connection to Twitch
- [ ] Test connection to YouTube Live
- [ ] Test custom RTMP endpoints
-
[ ] Verify RTMPS support
-
Quality Settings
- [ ] Test various bitrate settings
- [ ] Verify resolution scaling
- [ ] Test frame rate configurations
-
[ ] Check encoding quality
-
Network Conditions
- [ ] Test on Wi-Fi connections
- [ ] Test on mobile data
- [ ] Test with unstable connections
- [ ] Verify reconnection logic
Camera Overlay Testing¶
- Basic Functionality
- [ ] Toggle overlay on/off
- [ ] Test camera permission flow
- [ ] Verify overlay positioning
-
[ ] Check overlay resizing
-
Interaction Testing
- [ ] Drag overlay to different positions
- [ ] Resize using pinch gestures
- [ ] Test position presets
- [ ] 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.