Home > Development > Guides > Best Practices > Migration Testing - Unit Tests vs Instrumented Tests


Migration Testing - Unit Tests vs Instrumented Tests

Date: 2025-10-18
Status: Established Pattern
Tags: testing database migrations room best-practices


Context

Room database migrations need testing to ensure schema changes work correctly. Android provides MigrationTestHelper for instrumented tests, but this isn’t always practical.

Our Experience: Instrumented migration tests consistently failed with AbstractMethodError due to kotlinx-serialization binary incompatibility between Room and runtime versions.


The Problem: AbstractMethodError

Error Details

java.lang.AbstractMethodError: Receiver class androidx.room.migration.bundle.FieldBundle$serializer
does not define or inherit an implementation of the resolved method
'abstract kotlinx.serialization.KSerializer[] typeParametersSerializers()'
of interface kotlinx.serialization.internal.GeneratedSerializer.

Root Cause

  • Room 2.8.1 compiled against kotlinx-serialization 1.7.3
  • Runtime resolves to kotlinx-serialization 1.8.1
  • Binary incompatibility in serializer interface between versions
  • Cannot be fixed by forcing dependency versions (already attempted)

Why This Matters

This is a recurring issue we’ve encountered multiple times. The incompatibility is at the binary level, meaning:

  1. Gradle dependency resolution cannot fix it
  2. Force-downgrading kotlinx-serialization breaks other dependencies
  3. Upgrading Room requires entire dependency tree upgrade
  4. No workaround exists without breaking changes

The Solution: MockK Unit Tests

Pattern Overview

Replace instrumented tests using MigrationTestHelper with unit tests using MockK to verify SQL execution.

Implementation

Basic Structure

class Migration_X_YTest {
    @Test
    fun `migration has correct version numbers`() {
        assertEquals("Start version should be X", X, MIGRATION_X_Y.startVersion)
        assertEquals("End version should be Y", Y, MIGRATION_X_Y.endVersion)
    }
 
    @Test
    fun `migration executes expected SQL statements`() {
        val mockDatabase = mockk<SupportSQLiteDatabase>(relaxed = true)
 
        MIGRATION_X_Y.migrate(mockDatabase)
 
        verify {
            mockDatabase.execSQL(match { sql ->
                sql.contains("ALTER TABLE") && sql.contains("ADD COLUMN")
            })
        }
    }
 
    @Test
    fun `migration is non-destructive`() {
        val mockDatabase = mockk<SupportSQLiteDatabase>(relaxed = true)
 
        MIGRATION_X_Y.migrate(mockDatabase)
 
        verify(exactly = 0) {
            mockDatabase.execSQL(match { sql ->
                sql.contains("DROP") || sql.contains("DELETE")
            })
        }
    }
}

Advanced Pattern: SQL Capture

@Test
fun `migration SQL is properly formatted`() {
    val mockDatabase = mockk<SupportSQLiteDatabase>(relaxed = true)
    val capturedSql = mutableListOf<String>()
 
    every { mockDatabase.execSQL(any()) } answers {
        capturedSql.add(firstArg<String>())
    }
 
    MIGRATION_X_Y.migrate(mockDatabase)
 
    assertEquals(1, capturedSql.size)
    val sql = capturedSql[0]
    
    assertFalse("SQL should not start with whitespace", sql.startsWith(" "))
    assertTrue("SQL should contain expected keywords", sql.contains("ALTER TABLE"))
}

Test Coverage Requirements

Minimum Tests for Each Migration

  1. Version Validation - Verify startVersion and endVersion
  2. SQL Execution - Verify correct SQL statements executed
  3. Schema Changes - Verify columns/tables/indexes created
  4. Non-Destructive - Verify no DROP/DELETE statements
  5. Exception Handling - Verify migration doesn’t throw on errors

Example Coverage

// Migration_33_34Test.kt - Theme Mode Column Addition
@Test fun `migration has correct version numbers`()
@Test fun `migration is instance of Migration class`()
@Test fun `migration executes SQL statement to add themeMode column`()
@Test fun `migration adds themeMode as TEXT NOT NULL`()
@Test fun `migration sets default value to SYSTEM`()
@Test fun `migration uses single ALTER TABLE statement`()
@Test fun `migration does not create or drop tables`()
@Test fun `migration does not create indexes`()
@Test fun `migration handles exception gracefully`()
@Test fun `migration logs debug information`()
@Test fun `migration SQL is properly formatted`()
@Test fun `migration is purely additive`()
@Test fun `migration is non-destructive`()
@Test fun `migration adds column to settings table only`()
@Test fun `migration maintains backward compatibility`()
@Test fun `migration supports rollback safety`()
// Total: 16 tests

File Naming Convention

Standard: Migration_##_##.kt and Migration_##_##Test.kt

Examples

✅ Migration_31_32.kt → Migration_31_32Test.kt
✅ Migration_32_33.kt → Migration_32_33Test.kt
✅ Migration_33_34.kt → Migration_33_34Test.kt

❌ MIGRATION_31_32.kt (inconsistent casing)
❌ Migration31to32.kt (inconsistent separator)
❌ Migration32to33Test.kt (inconsistent with implementation)

Rationale

  • Consistency: Easy to find test files for any migration
  • Sorting: Files sort together in file explorer
  • Pattern Matching: *Migration_* glob matches all migration files
  • Clarity: Version numbers clearly separated with underscores

Advantages of Unit Tests Over Instrumented Tests

1. Speed

  • Unit tests: <100ms per test class
  • Instrumented tests: 10-30 seconds per test class (emulator startup, APK install)

2. Reliability

  • Unit tests: 100% pass rate in all environments
  • Instrumented tests: Fail with AbstractMethodError, emulator crashes, timeout issues

3. CI/CD Integration

  • Unit tests: Run on any machine, no Android SDK required
  • Instrumented tests: Require emulator, prone to flakiness in CI

4. Developer Experience

  • Unit tests: Instant feedback, no emulator management
  • Instrumented tests: Wait for emulator, manage device state, cleanup required

5. Debugging

  • Unit tests: Standard JVM debugging, clear stack traces
  • Instrumented tests: Logcat debugging, obscure errors from framework internals

What Unit Tests Can Verify

✅ What MockK Tests CAN Verify

  • SQL statement structure and content
  • Correct number of SQL statements executed
  • Proper use of ALTER TABLE, CREATE TABLE, CREATE INDEX
  • Absence of destructive operations (DROP, DELETE)
  • Column names, types, constraints
  • Index creation
  • Migration version numbers
  • Exception handling behavior
  • Logging behavior

❌ What MockK Tests CANNOT Verify

  • Actual database schema changes
  • Data migration correctness (existing rows affected)
  • Foreign key constraint enforcement
  • Index performance improvement
  • Database file size changes
  • SQLite version compatibility

Mitigation Strategy

For actual database validation:

  1. Manual testing during development
  2. Beta testing with production-like data
  3. Staged rollout to detect migration issues early
  4. Rollback plan for failed migrations
  5. Schema export verification (Room schema JSON files)

Policy: Required Before Merge

For All New Migrations

  1. File naming: Migration_##_##.kt
  2. Unit tests: Migration_##_##Test.kt with minimum 10 tests
  3. Test coverage: All SQL statements verified
  4. Non-destructive: Verified no DROP/DELETE statements (unless explicitly required)
  5. Documentation: Migration KDoc explaining purpose and safety

Test Template Checklist

// Copy this checklist for each new migration test file
class Migration_##_##Test {
    ☐ Test: migration has correct version numbers
    ☐ Test: migration executes expected SQL statements
    ☐ Test: migration creates/modifies correct schema elements
    ☐ Test: migration does not drop or delete (if applicable)
    ☐ Test: migration sets correct defaults
    ☐ Test: migration handles exceptions gracefully
    ☐ Test: migration is non-destructive
    ☐ Test: migration maintains backward compatibility
    ☐ Test: migration SQL is properly formatted
    ☐ Test: migration logs appropriately
    
    // Add specific tests for migration's unique behavior
}

Real-World Example: Migration 32→33

Migration Implementation

val MIGRATION_32_33 = object : Migration(32, 33) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // Create tournament ID mappings table
        database.execSQL("""
            CREATE TABLE IF NOT EXISTS tournament_id_mappings (
                localId TEXT PRIMARY KEY NOT NULL,
                firebaseId TEXT NOT NULL,
                syncedAt INTEGER NOT NULL,
                lastVerifiedAt INTEGER NOT NULL
            )
        """.trimIndent())
 
        // Create unique index on firebaseId for reverse lookups
        database.execSQL("""
            CREATE UNIQUE INDEX IF NOT EXISTS index_tournament_id_mappings_firebaseId
            ON tournament_id_mappings(firebaseId)
        """.trimIndent())
    }
}

Test Coverage (15 tests)

class Migration_32_33Test {
    @Test fun `migration has correct version numbers`()
    @Test fun `migration is instance of Migration class`()
    @Test fun `migration creates tournament_id_mappings table`()
    @Test fun `tournament_id_mappings has primary key on localId`()
    @Test fun `tournament_id_mappings has all required columns`()
    @Test fun `all columns are NOT NULL`()
    @Test fun `syncedAt and lastVerifiedAt are INTEGER type`()
    @Test fun `migration creates unique index on firebaseId`()
    @Test fun `migration creates unique index on localId`()
    @Test fun `migration executes exactly 3 SQL statements`()
    @Test fun `migration does not drop or delete anything`()
    @Test fun `migration is purely additive`()
    @Test fun `migration uses IF NOT EXISTS for safety`()
    @Test fun `migration supports bidirectional ID lookups`()
    @Test fun `migration handles hybrid offline-first architecture`()
}

Result: 100% test pass rate, comprehensive SQL validation, no emulator required


Lessons Learned

1. Official Isn’t Always Practical

While MigrationTestHelper is the “official” Room migration testing approach, it’s not practical when:

  • Library dependencies have binary incompatibilities
  • CI/CD needs deterministic, fast feedback
  • Emulator stability is a concern

Takeaway: Choose tools that work for your constraints, not just “official” tools

2. Dead Code Accumulates When Tests Are Ignored

We had two instrumented test files with ALL tests @Ignored, yet they remained in the codebase for extended periods.

Takeaway: Regularly audit @Ignored tests - if they’ve never passed, delete them

3. Test Coverage Gaps Hide in Plain Sight

78% of our migrations (14/18) had zero tests despite being production code running on thousands of devices.

Takeaway: Make test coverage a merge requirement, not a “nice to have”

4. Binary Incompatibility Cannot Be “Fixed”

We attempted multiple times to fix AbstractMethodError by:

  • Forcing kotlinx-serialization versions
  • Adding dependencies to androidTestImplementation
  • Using resolutionStrategy.force()

Takeaway: Binary incompatibilities are architectural issues, not configuration issues. Change your approach instead of fighting the tooling.


Migration Test Coverage Status

Current Coverage (10-18-25)

✅ v28→v29: Migration_28_29Test.kt (11 tests)
✅ v29→v30: Migration_29_30Test.kt (17 tests)
✅ v30→v31: Migration_30_31Test.kt (18 tests)
✅ v31→v32: Migration_31_32Test.kt (15 tests)
✅ v32→v33: Migration_32_33Test.kt (15 tests)
✅ v33→v34: Migration_33_34Test.kt (16 tests)
✅ v34→v35: Migration_34_35Test.kt
❌ v17→v28: No tests (legacy migrations - low priority)

Coverage: 50% (9 of 18 migrations tested with 76+ total tests)
Target: 100% (all migrations tested)


Quick Reference

Run Migration Tests

# All migration tests
./gradlew :app:testDebugUnitTest --tests="*Migration*" --no-daemon
 
# Specific migration
./gradlew :app:testDebugUnitTest --tests="*Migration_32_33Test" --no-daemon

Create New Migration Test

  1. Create Migration_X_Y.kt in app/src/main/java/com/archeryapprentice/data/db/migrations/
  2. Create Migration_X_YTest.kt in app/src/test/java/com/archeryapprentice/data/db/migrations/
  3. Use template checklist above for minimum test coverage
  4. Run tests: ./gradlew :app:testDebugUnitTest --tests="*Migration_X_YTest" --no-daemon
  5. Verify 100% pass rate before committing

Last Updated: 2025-10-18
Pattern Status: Established and Required
Test Pass Rate: 100% (76+ tests)