diff --git a/app/build.gradle b/app/build.gradle index db1ca40..6147881 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -54,17 +54,21 @@ android { sourceSets { androidTest { java.srcDirs += "src/sharedTest/java" + assets.srcDirs += files("$projectDir/schemas".toString()) } androidTestHilt { java.srcDirs += "src/sharedTestHilt/java" + assets.srcDirs += files("$projectDir/schemas".toString()) } androidTestKoin { java.srcDirs += "src/sharedTestKoin/java" + assets.srcDirs += files("$projectDir/schemas".toString()) } test { java.srcDirs += "src/sharedTest/java" java.srcDirs += "src/robolectricTest/java" + resources.srcDirs += files("$projectDir/schemas".toString()) } testHilt { java.srcDirs += "src/sharedTestHilt/java" @@ -149,6 +153,7 @@ dependencies { testImplementation "com.google.dagger:hilt-android-testing:$hilt_version" kaptTest "com.google.dagger:hilt-compiler:$hilt_version" + androidTestImplementation "androidx.room:room-testing:$androidx_room_version" androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" androidTestImplementation "io.insert-koin:koin-test-junit4:$koin_version" androidTestImplementation "junit:junit:$testing_junit4_version" diff --git a/app/schemas/org.fnives.test.showcase.storage.LocalDatabase/2.json b/app/schemas/org.fnives.test.showcase.storage.LocalDatabase/2.json new file mode 100644 index 0000000..de49ad7 --- /dev/null +++ b/app/schemas/org.fnives.test.showcase.storage.LocalDatabase/2.json @@ -0,0 +1,34 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "3723fe73a9d3dc43de8ff3e52ec46490", + "entities": [ + { + "tableName": "FavouriteEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`content_id` TEXT NOT NULL, PRIMARY KEY(`content_id`))", + "fields": [ + { + "fieldPath": "contentId", + "columnName": "content_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "content_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3723fe73a9d3dc43de8ff3e52ec46490')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidMigrationTestRule.kt b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidMigrationTestRule.kt new file mode 100644 index 0000000..882b3eb --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidMigrationTestRule.kt @@ -0,0 +1,66 @@ +package org.fnives.test.showcase.testutils.configuration + +import android.app.Instrumentation +import androidx.room.RoomDatabase +import androidx.room.migration.AutoMigrationSpec +import androidx.room.migration.Migration +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class AndroidMigrationTestRule : SharedMigrationTestRule { + + private val migrationTestHelper: MigrationTestHelper + + constructor( + instrumentation: Instrumentation, + databaseClass: Class + ) { + migrationTestHelper = MigrationTestHelper(instrumentation, databaseClass) + } + + constructor( + instrumentation: Instrumentation, + databaseClass: Class, + specs: List + ) { + migrationTestHelper = MigrationTestHelper(instrumentation, databaseClass, specs) + } + + constructor( + instrumentation: Instrumentation, + databaseClass: Class, + specs: List, + openFactory: SupportSQLiteOpenHelper.Factory + ) { + migrationTestHelper = + MigrationTestHelper(instrumentation, databaseClass, specs, openFactory) + } + + override fun apply(base: Statement, description: Description): Statement = + migrationTestHelper.apply(base, description) + + override fun closeWhenFinished(db: RoomDatabase) = + migrationTestHelper.closeWhenFinished(db) + + override fun closeWhenFinished(db: SupportSQLiteDatabase) = + migrationTestHelper.closeWhenFinished(db) + + override fun createDatabase(name: String, version: Int): SupportSQLiteDatabase = + migrationTestHelper.createDatabase(name, version) + + override fun runMigrationsAndValidate( + name: String, + version: Int, + validateDroppedTables: Boolean, + vararg migrations: Migration + ): SupportSQLiteDatabase = + migrationTestHelper.runMigrationsAndValidate( + name, + version, + validateDroppedTables, + *migrations + ) +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidMigrationTestRuleFactory.kt b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidMigrationTestRuleFactory.kt new file mode 100644 index 0000000..b4c5395 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidMigrationTestRuleFactory.kt @@ -0,0 +1,41 @@ +package org.fnives.test.showcase.testutils.configuration + +import android.app.Instrumentation +import androidx.room.RoomDatabase +import androidx.room.migration.AutoMigrationSpec +import androidx.sqlite.db.SupportSQLiteOpenHelper + +object AndroidMigrationTestRuleFactory : SharedMigrationTestRuleFactory { + override fun createSharedMigrationTestRule( + instrumentation: Instrumentation, + databaseClass: Class + ): SharedMigrationTestRule = + AndroidMigrationTestRule( + instrumentation, + databaseClass + ) + + override fun createSharedMigrationTestRule( + instrumentation: Instrumentation, + databaseClass: Class, + specs: List + ): SharedMigrationTestRule = + AndroidMigrationTestRule( + instrumentation, + databaseClass, + specs + ) + + override fun createSharedMigrationTestRule( + instrumentation: Instrumentation, + databaseClass: Class, + specs: List, + openFactory: SupportSQLiteOpenHelper.Factory + ): SharedMigrationTestRule = + AndroidMigrationTestRule( + instrumentation, + databaseClass, + specs, + openFactory, + ) +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt index af65008..90680c0 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt @@ -12,4 +12,7 @@ object SpecificTestConfigurationsFactory : TestConfigurationsFactory { override fun createSnackbarVerification(): SnackbarVerificationTestRule = AndroidTestSnackbarVerificationTestRule + + override fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory = + AndroidMigrationTestRuleFactory } diff --git a/app/src/main/java/org/fnives/test/showcase/storage/LocalDatabase.kt b/app/src/main/java/org/fnives/test/showcase/storage/LocalDatabase.kt index 9cce6b3..1ef3145 100644 --- a/app/src/main/java/org/fnives/test/showcase/storage/LocalDatabase.kt +++ b/app/src/main/java/org/fnives/test/showcase/storage/LocalDatabase.kt @@ -7,7 +7,7 @@ import org.fnives.test.showcase.storage.favourite.FavouriteEntity @Database( entities = [FavouriteEntity::class], - version = 1, + version = 2, exportSchema = true ) abstract class LocalDatabase : RoomDatabase() { diff --git a/app/src/main/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt b/app/src/main/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt index 02606d0..3bd8504 100644 --- a/app/src/main/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt +++ b/app/src/main/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt @@ -3,11 +3,13 @@ package org.fnives.test.showcase.storage.database import android.content.Context import androidx.room.Room import org.fnives.test.showcase.storage.LocalDatabase +import org.fnives.test.showcase.storage.migation.Migration1To2 object DatabaseInitialization { fun create(context: Context): LocalDatabase = Room.databaseBuilder(context, LocalDatabase::class.java, "local_database") + .addMigrations(Migration1To2()) .allowMainThreadQueries() .build() } diff --git a/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteEntity.kt b/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteEntity.kt index 1d2a280..c02cd40 100644 --- a/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteEntity.kt +++ b/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteEntity.kt @@ -1,7 +1,11 @@ package org.fnives.test.showcase.storage.favourite +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity -data class FavouriteEntity(@PrimaryKey val contentId: String) +data class FavouriteEntity( + @ColumnInfo(name = "content_id") + @PrimaryKey val contentId: String +) diff --git a/app/src/main/java/org/fnives/test/showcase/storage/migation/Migration1To2.kt b/app/src/main/java/org/fnives/test/showcase/storage/migation/Migration1To2.kt new file mode 100644 index 0000000..64aff83 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/storage/migation/Migration1To2.kt @@ -0,0 +1,14 @@ +package org.fnives.test.showcase.storage.migation + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration1To2 : Migration(1, 2) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE FavouriteEntity RENAME TO FavouriteEntityOld") + database.execSQL("CREATE TABLE FavouriteEntity(content_id TEXT NOT NULL PRIMARY KEY)") + database.execSQL("INSERT INTO FavouriteEntity(content_id) SELECT contentId FROM FavouriteEntityOld") + database.execSQL("DROP TABLE FavouriteEntityOld") + } +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricMigrationTestHelper.java b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricMigrationTestHelper.java new file mode 100644 index 0000000..daf10f3 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricMigrationTestHelper.java @@ -0,0 +1,632 @@ +package org.fnives.test.showcase.testutils.configuration; + +import android.annotation.SuppressLint; +import android.app.Instrumentation; +import android.content.Context; +import android.database.Cursor; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.arch.core.executor.ArchTaskExecutor; +import androidx.room.AutoMigration; +import androidx.room.DatabaseConfiguration; +import androidx.room.Room; +import androidx.room.RoomDatabase; +import androidx.room.RoomOpenHelper; +import androidx.room.RoomOpenHelper.ValidationResult; +import androidx.room.migration.AutoMigrationSpec; +import androidx.room.migration.Migration; +import androidx.room.migration.bundle.DatabaseBundle; +import androidx.room.migration.bundle.DatabaseViewBundle; +import androidx.room.migration.bundle.EntityBundle; +import androidx.room.migration.bundle.FieldBundle; +import androidx.room.migration.bundle.ForeignKeyBundle; +import androidx.room.migration.bundle.FtsEntityBundle; +import androidx.room.migration.bundle.IndexBundle; +import androidx.room.migration.bundle.SchemaBundle; +import androidx.room.util.FtsTableInfo; +import androidx.room.util.TableInfo; +import androidx.room.util.ViewInfo; +import androidx.sqlite.db.SupportSQLiteDatabase; +import androidx.sqlite.db.SupportSQLiteOpenHelper; +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory; + +import org.jetbrains.annotations.NotNull; +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Copy of {@link androidx.room.testing.MigrationTestHelper MigrationTestHelper} except it uses resources folder instead of assets. + * + * This is needed since in unit tests we cannot add to the asset folder easily, without polluting the apk. + * + * reference: https://github.com/robolectric/robolectric/issues/2065 + */ +public class RobolectricMigrationTestHelper extends TestWatcher implements SharedMigrationTestRule { + private static final String TAG = "RobolectricMigrationTestHelper"; + private final String mAssetsFolder; + private final SupportSQLiteOpenHelper.Factory mOpenFactory; + private List> mManagedDatabases = new ArrayList<>(); + private List> mManagedRoomDatabases = new ArrayList<>(); + private boolean mTestStarted; + private Instrumentation mInstrumentation; + @Nullable + private List mSpecs; + @Nullable + private Class mDatabaseClass; + + + /** + * Creates a new migration helper. It uses the Instrumentation context to load the schema + * (falls back to the app resources) and the target context to create the database. + * + * @param instrumentation The instrumentation instance. + * @param databaseClass The Database class to be tested. + */ + public RobolectricMigrationTestHelper(@NonNull Instrumentation instrumentation, + @NonNull Class databaseClass) { + this(instrumentation, databaseClass, new ArrayList<>(), + new FrameworkSQLiteOpenHelperFactory()); + } + + /** + * Creates a new migration helper. It uses the Instrumentation context to load the schema + * (falls back to the app resources) and the target context to create the database. + *

+ * An instance of a class annotated with {@link androidx.room.ProvidedAutoMigrationSpec} has + * to be provided to Room using this constructor. MigrationTestHelper will map auto migration + * spec classes to their provided instances before running and validatingt the Migrations. + * + * @param instrumentation The instrumentation instance. + * @param databaseClass The Database class to be tested. + * @param specs The list of available auto migration specs that will be provided to + * Room at runtime. + */ + public RobolectricMigrationTestHelper(@NonNull Instrumentation instrumentation, + @NonNull Class databaseClass, + @NonNull List specs) { + this(instrumentation, databaseClass, specs, new FrameworkSQLiteOpenHelperFactory()); + } + + /** + * Creates a new migration helper. It uses the Instrumentation context to load the schema + * (falls back to the app resources) and the target context to create the database. + *

+ * An instance of a class annotated with {@link androidx.room.ProvidedAutoMigrationSpec} has + * to be provided to Room using this constructor. MigrationTestHelper will map auto migration + * spec classes to their provided instances before running and validatingt the Migrations. + * + * @param instrumentation The instrumentation instance. + * @param databaseClass The Database class to be tested. + * @param specs The list of available auto migration specs that will be provided to + * Room at runtime. + * @param openFactory Factory class that allows creation of {@link SupportSQLiteOpenHelper} + */ + public RobolectricMigrationTestHelper(@NonNull Instrumentation instrumentation, + @NonNull Class databaseClass, + @NonNull List specs, + @NonNull SupportSQLiteOpenHelper.Factory openFactory + ) { + String assetsFolder = databaseClass.getCanonicalName(); + mInstrumentation = instrumentation; + if (assetsFolder.endsWith("/")) { + assetsFolder = assetsFolder.substring(0, assetsFolder.length() - 1); + } + mAssetsFolder = assetsFolder; + mOpenFactory = openFactory; + mDatabaseClass = databaseClass; + mSpecs = specs; + } + + @Override + protected void starting(Description description) { + super.starting(description); + mTestStarted = true; + } + + /** + * Creates the database in the given version. + * If the database file already exists, it tries to delete it first. If delete fails, throws + * an exception. + * + * @param name The name of the database. + * @param version The version in which the database should be created. + * @return A database connection which has the schema in the requested version. + * @throws IOException If it cannot find the schema description in the assets folder. + */ + @SuppressLint("RestrictedApi") + @SuppressWarnings("SameParameterValue") + @Override + @NotNull + public SupportSQLiteDatabase createDatabase(@NotNull String name, int version) throws IOException { + File dbPath = mInstrumentation.getTargetContext().getDatabasePath(name); + if (dbPath.exists()) { + Log.d(TAG, "deleting database file " + name); + if (!dbPath.delete()) { + throw new IllegalStateException("There is a database file and I could not delete" + + " it. Make sure you don't have any open connections to that database" + + " before calling this method."); + } + } + SchemaBundle schemaBundle = loadSchema(version); + RoomDatabase.MigrationContainer container = new RoomDatabase.MigrationContainer(); + DatabaseConfiguration configuration = new DatabaseConfiguration( + mInstrumentation.getTargetContext(), + name, + mOpenFactory, + container, + null, + true, + RoomDatabase.JournalMode.TRUNCATE, + ArchTaskExecutor.getIOThreadExecutor(), + ArchTaskExecutor.getIOThreadExecutor(), + null, + true, + false, + Collections.emptySet(), + null, + null, + null, + null, + null, + null); + RoomOpenHelper roomOpenHelper = new RoomOpenHelper(configuration, + new CreatingDelegate(schemaBundle.getDatabase()), + schemaBundle.getDatabase().getIdentityHash(), + // we pass the same hash twice since an old schema does not necessarily have + // a legacy hash and we would not even persist it. + schemaBundle.getDatabase().getIdentityHash()); + return openDatabase(name, roomOpenHelper); + } + + /** + * Runs the given set of migrations on the provided database. + *

+ * It uses the same algorithm that Room uses to choose migrations so the migrations instances + * that are provided to this method must be sufficient to bring the database from current + * version to the desired version. + *

+ * After the migration, the method validates the database schema to ensure that migration + * result matches the expected schema. Handling of dropped tables depends on the + * {@code validateDroppedTables} argument. If set to true, the verification will fail if it + * finds a table that is not registered in the Database. If set to false, extra tables in the + * database will be ignored (this is the runtime library behavior). + * + * @param name The database name. You must first create this database via + * {@link #createDatabase(String, int)}. + * @param version The final version after applying the migrations. + * @param validateDroppedTables If set to true, validation will fail if the database has + * unknown + * tables. + * @param migrations The list of available migrations. + * @throws IOException If it cannot find the schema for {@code toVersion}. + * @throws IllegalStateException If the schema validation fails. + */ + @SuppressLint("RestrictedApi") + @Override + @NotNull + public SupportSQLiteDatabase runMigrationsAndValidate(@NotNull String name, + int version, + boolean validateDroppedTables, + @NotNull + Migration... migrations) throws IOException { + File dbPath = mInstrumentation.getTargetContext().getDatabasePath(name); + if (!dbPath.exists()) { + throw new IllegalStateException("Cannot find the database file for " + name + ". " + + "Before calling runMigrations, you must first create the database via " + + "createDatabase."); + } + SchemaBundle schemaBundle = loadSchema(version); + RoomDatabase.MigrationContainer container = new RoomDatabase.MigrationContainer(); + container.addMigrations(getAutoMigrations(mSpecs)); + container.addMigrations(migrations); + DatabaseConfiguration configuration = new DatabaseConfiguration( + mInstrumentation.getTargetContext(), + name, + mOpenFactory, + container, + null, + true, + RoomDatabase.JournalMode.TRUNCATE, + ArchTaskExecutor.getIOThreadExecutor(), + ArchTaskExecutor.getIOThreadExecutor(), + null, + true, + false, + Collections.emptySet(), + null, + null, + null, + null, + null, + null); + RoomOpenHelper roomOpenHelper = new RoomOpenHelper(configuration, + new MigratingDelegate(schemaBundle.getDatabase(), validateDroppedTables), + // we pass the same hash twice since an old schema does not necessarily have + // a legacy hash and we would not even persist it. + schemaBundle.getDatabase().getIdentityHash(), + schemaBundle.getDatabase().getIdentityHash()); + return openDatabase(name, roomOpenHelper); + } + + /** + * Returns a list of {@link Migration} of a database that has been generated using + * {@link AutoMigration}. + */ + @NonNull + private List getAutoMigrations(List userProvidedSpecs) { + if (mDatabaseClass == null) { + if (userProvidedSpecs.isEmpty()) { + // TODO: Detect that there are auto migrations to test when a deprecated + // constructor is used. + Log.e(TAG, "If you have any AutoMigrations in your implementation, you must use " + + "a non-deprecated MigrationTestHelper constructor to provide the " + + "Database class in order to test them. If you do not have any " + + "AutoMigrations to test, you may ignore this warning."); + return new ArrayList<>(); + } else { + throw new IllegalStateException("You must provide the database class in the " + + "MigrationTestHelper constructor in order to test auto migrations."); + } + } + + RoomDatabase db = Room.getGeneratedImplementation(mDatabaseClass, "_Impl"); + Set> requiredAutoMigrationSpecs = + db.getRequiredAutoMigrationSpecs(); + return db.getAutoMigrations( + createAutoMigrationSpecMap(requiredAutoMigrationSpecs, userProvidedSpecs) + ); + } + + /** + * Maps auto migration spec classes to their provided instance. + */ + private Map, AutoMigrationSpec> createAutoMigrationSpecMap( + Set> requiredAutoMigrationSpecs, + List userProvidedSpecs) { + Map, AutoMigrationSpec> specMap = new HashMap<>(); + if (requiredAutoMigrationSpecs.isEmpty()) { + return specMap; + } + + if (userProvidedSpecs == null) { + throw new IllegalStateException( + "You must provide all required auto migration specs in the " + + "MigrationTestHelper constructor." + ); + } + + for (Class spec : requiredAutoMigrationSpecs) { + boolean found = false; + AutoMigrationSpec match = null; + for (AutoMigrationSpec provided : userProvidedSpecs) { + if (spec.isAssignableFrom(provided.getClass())) { + found = true; + match = provided; + break; + } + } + if (!found) { + throw new IllegalArgumentException( + "A required auto migration spec (" + spec.getCanonicalName() + ") has not" + + " been provided." + ); + } + specMap.put(spec, match); + } + return specMap; + } + + + private SupportSQLiteDatabase openDatabase(String name, RoomOpenHelper roomOpenHelper) { + SupportSQLiteOpenHelper.Configuration config = + SupportSQLiteOpenHelper.Configuration + .builder(mInstrumentation.getTargetContext()) + .callback(roomOpenHelper) + .name(name) + .build(); + SupportSQLiteDatabase db = mOpenFactory.create(config).getWritableDatabase(); + mManagedDatabases.add(new WeakReference<>(db)); + return db; + } + + @Override + protected void finished(Description description) { + super.finished(description); + for (WeakReference dbRef : mManagedDatabases) { + SupportSQLiteDatabase db = dbRef.get(); + if (db != null && db.isOpen()) { + try { + db.close(); + } catch (Throwable ignored) { + } + } + } + for (WeakReference dbRef : mManagedRoomDatabases) { + final RoomDatabase roomDatabase = dbRef.get(); + if (roomDatabase != null) { + roomDatabase.close(); + } + } + } + + /** + * Registers a database connection to be automatically closed when the test finishes. + *

+ * This only works if {@code MigrationTestHelper} is registered as a Junit test rule via + * {@link org.junit.Rule Rule} annotation. + * + * @param db The database connection that should be closed after the test finishes. + */ + @Override + public void closeWhenFinished(@NotNull SupportSQLiteDatabase db) { + if (!mTestStarted) { + throw new IllegalStateException("You cannot register a database to be closed before" + + " the test starts. Maybe you forgot to annotate MigrationTestHelper as a" + + " test rule? (@Rule)"); + } + mManagedDatabases.add(new WeakReference<>(db)); + } + + /** + * Registers a database connection to be automatically closed when the test finishes. + *

+ * This only works if {@code MigrationTestHelper} is registered as a Junit test rule via + * {@link org.junit.Rule Rule} annotation. + * + * @param db The RoomDatabase instance which holds the database. + */ + @Override + public void closeWhenFinished(@NotNull RoomDatabase db) { + if (!mTestStarted) { + throw new IllegalStateException("You cannot register a database to be closed before" + + " the test starts. Maybe you forgot to annotate MigrationTestHelper as a" + + " test rule? (@Rule)"); + } + mManagedRoomDatabases.add(new WeakReference<>(db)); + } + + private SchemaBundle loadSchema(int version) throws IOException { + try { + return loadSchema(mInstrumentation.getContext(), version); + } catch (FileNotFoundException testAssetsIOExceptions) { + Log.w(TAG, "Could not find the schema file in the test assets. Checking the" + + " application assets"); + try { + return loadSchema(mInstrumentation.getTargetContext(), version); + } catch (FileNotFoundException appAssetsException) { + // throw the test assets exception instead + throw new FileNotFoundException("Cannot find the schema file in the assets folder. " + + "Make sure to include the exported json schemas in your test assert " + + "inputs. See " + + "https://developer.android.com/training/data-storage/room/" + + "migrating-db-versions#export-schema for details. Missing file: " + + testAssetsIOExceptions.getMessage()); + } + } + } + + @SuppressWarnings("ConstantConditions") + private SchemaBundle loadSchema(Context context, int version) throws IOException { + String fileName = mAssetsFolder + "/" + version + ".json"; + try { + InputStream input = getClass().getClassLoader() + .getResourceAsStream(fileName); + return SchemaBundle.deserialize(input); + } catch (NullPointerException nullPointerException) { + throw new IOException("File not found: " + fileName, nullPointerException); + } +// InputStream input = context.getAssets().open(mAssetsFolder + "/" + version + ".json"); +// return SchemaBundle.deserialize(input); + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + static TableInfo toTableInfo(EntityBundle entityBundle) { + return new TableInfo(entityBundle.getTableName(), toColumnMap(entityBundle), + toForeignKeys(entityBundle.getForeignKeys()), toIndices(entityBundle.getIndices())); + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + static FtsTableInfo toFtsTableInfo(FtsEntityBundle ftsEntityBundle) { + return new FtsTableInfo(ftsEntityBundle.getTableName(), toColumnNamesSet(ftsEntityBundle), + ftsEntityBundle.getCreateSql()); + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + static ViewInfo toViewInfo(DatabaseViewBundle viewBundle) { + return new ViewInfo(viewBundle.getViewName(), viewBundle.createView()); + } + + private static Set toIndices(List indices) { + if (indices == null) { + return Collections.emptySet(); + } + Set result = new HashSet<>(); + for (IndexBundle bundle : indices) { + result.add(new TableInfo.Index(bundle.getName(), bundle.isUnique(), + bundle.getColumnNames(), bundle.getOrders())); + } + return result; + } + + private static Set toForeignKeys( + List bundles) { + if (bundles == null) { + return Collections.emptySet(); + } + Set result = new HashSet<>(bundles.size()); + for (ForeignKeyBundle bundle : bundles) { + result.add(new TableInfo.ForeignKey(bundle.getTable(), + bundle.getOnDelete(), bundle.getOnUpdate(), + bundle.getColumns(), bundle.getReferencedColumns())); + } + return result; + } + + private static Set toColumnNamesSet(EntityBundle entity) { + Set result = new HashSet<>(); + for (FieldBundle field : entity.getFields()) { + result.add(field.getColumnName()); + } + return result; + } + + private static Map toColumnMap(EntityBundle entity) { + Map result = new HashMap<>(); + for (FieldBundle bundle : entity.getFields()) { + TableInfo.Column column = toColumn(entity, bundle); + result.put(column.name, column); + } + return result; + } + + private static TableInfo.Column toColumn(EntityBundle entity, FieldBundle field) { + return new TableInfo.Column(field.getColumnName(), field.getAffinity(), + field.isNonNull(), findPrimaryKeyPosition(entity, field), field.getDefaultValue(), + TableInfo.CREATED_FROM_ENTITY); + } + + private static int findPrimaryKeyPosition(EntityBundle entity, FieldBundle field) { + List columnNames = entity.getPrimaryKey().getColumnNames(); + int i = 0; + for (String columnName : columnNames) { + i++; + if (field.getColumnName().equalsIgnoreCase(columnName)) { + return i; + } + } + return 0; + } + + static class MigratingDelegate extends RoomOpenHelperDelegate { + private final boolean mVerifyDroppedTables; + + MigratingDelegate(DatabaseBundle databaseBundle, boolean verifyDroppedTables) { + super(databaseBundle); + mVerifyDroppedTables = verifyDroppedTables; + } + + @Override + protected void createAllTables(SupportSQLiteDatabase database) { + throw new UnsupportedOperationException("Was expecting to migrate but received create." + + "Make sure you have created the database first."); + } + + @NonNull + @Override + protected RoomOpenHelper.ValidationResult onValidateSchema( + @NonNull SupportSQLiteDatabase db) { + final Map tables = mDatabaseBundle.getEntitiesByTableName(); + for (EntityBundle entity : tables.values()) { + if (entity instanceof FtsEntityBundle) { + final FtsTableInfo expected = toFtsTableInfo((FtsEntityBundle) entity); + final FtsTableInfo found = FtsTableInfo.read(db, entity.getTableName()); + if (!expected.equals(found)) { + return new ValidationResult(false, expected.name + + "\nExpected: " + expected + "\nFound: " + found); + } + } else { + final TableInfo expected = toTableInfo(entity); + final TableInfo found = TableInfo.read(db, entity.getTableName()); + if (!expected.equals(found)) { + return new ValidationResult(false, expected.name + + "\nExpected: " + expected + " \nfound: " + found); + } + } + } + for (DatabaseViewBundle view : mDatabaseBundle.getViews()) { + final ViewInfo expected = toViewInfo(view); + final ViewInfo found = ViewInfo.read(db, view.getViewName()); + if (!expected.equals(found)) { + return new ValidationResult(false, expected + + "\nExpected: " + expected + " \nfound: " + found); + } + } + if (mVerifyDroppedTables) { + // now ensure tables that should be removed are removed. + Set expectedTables = new HashSet<>(); + for (EntityBundle entity : tables.values()) { + expectedTables.add(entity.getTableName()); + if (entity instanceof FtsEntityBundle) { + expectedTables.addAll(((FtsEntityBundle) entity).getShadowTableNames()); + } + } + Cursor cursor = db.query("SELECT name FROM sqlite_master WHERE type='table'" + + " AND name NOT IN(?, ?, ?)", + new String[]{Room.MASTER_TABLE_NAME, "android_metadata", + "sqlite_sequence"}); + //noinspection TryFinallyCanBeTryWithResources + try { + while (cursor.moveToNext()) { + final String tableName = cursor.getString(0); + if (!expectedTables.contains(tableName)) { + return new ValidationResult(false, "Unexpected table " + + tableName); + } + } + } finally { + cursor.close(); + } + } + return new ValidationResult(true, null); + } + } + + static class CreatingDelegate extends RoomOpenHelperDelegate { + + CreatingDelegate(DatabaseBundle databaseBundle) { + super(databaseBundle); + } + + @Override + protected void createAllTables(SupportSQLiteDatabase database) { + for (String query : mDatabaseBundle.buildCreateQueries()) { + database.execSQL(query); + } + } + + @NonNull + @Override + protected RoomOpenHelper.ValidationResult onValidateSchema( + @NonNull SupportSQLiteDatabase db) { + throw new UnsupportedOperationException("This open helper just creates the database but" + + " it received a migration request."); + } + } + + abstract static class RoomOpenHelperDelegate extends RoomOpenHelper.Delegate { + final DatabaseBundle mDatabaseBundle; + + RoomOpenHelperDelegate(DatabaseBundle databaseBundle) { + super(databaseBundle.getVersion()); + mDatabaseBundle = databaseBundle; + } + + @Override + protected void dropAllTables(SupportSQLiteDatabase database) { + throw new UnsupportedOperationException("cannot drop all tables in the test"); + } + + @Override + protected void onCreate(SupportSQLiteDatabase database) { + } + + @Override + protected void onOpen(SupportSQLiteDatabase database) { + } + } +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricMigrationTestHelperFactory.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricMigrationTestHelperFactory.kt new file mode 100644 index 0000000..887a7b9 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricMigrationTestHelperFactory.kt @@ -0,0 +1,41 @@ +package org.fnives.test.showcase.testutils.configuration + +import android.app.Instrumentation +import androidx.room.RoomDatabase +import androidx.room.migration.AutoMigrationSpec +import androidx.sqlite.db.SupportSQLiteOpenHelper + +object RobolectricMigrationTestHelperFactory : SharedMigrationTestRuleFactory { + override fun createSharedMigrationTestRule( + instrumentation: Instrumentation, + databaseClass: Class + ): SharedMigrationTestRule = + RobolectricMigrationTestHelper( + instrumentation, + databaseClass + ) + + override fun createSharedMigrationTestRule( + instrumentation: Instrumentation, + databaseClass: Class, + specs: List + ): SharedMigrationTestRule = + RobolectricMigrationTestHelper( + instrumentation, + databaseClass, + specs + ) + + override fun createSharedMigrationTestRule( + instrumentation: Instrumentation, + databaseClass: Class, + specs: List, + openFactory: SupportSQLiteOpenHelper.Factory + ): SharedMigrationTestRule = + RobolectricMigrationTestHelper( + instrumentation, + databaseClass, + specs, + openFactory + ) +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt index 66b0f47..5028a4b 100644 --- a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt @@ -12,4 +12,7 @@ object SpecificTestConfigurationsFactory : TestConfigurationsFactory { override fun createSnackbarVerification(): SnackbarVerificationTestRule = RobolectricSnackbarVerificationTestRule + + override fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory = + RobolectricMigrationTestHelperFactory } diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/storage/migration/MigrationToLatest.kt b/app/src/sharedTest/java/org/fnives/test/showcase/storage/migration/MigrationToLatest.kt new file mode 100644 index 0000000..75cc71b --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/storage/migration/MigrationToLatest.kt @@ -0,0 +1,92 @@ +package org.fnives.test.showcase.storage.migration + +import androidx.room.Room +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.storage.LocalDatabase +import org.fnives.test.showcase.storage.favourite.FavouriteEntity +import org.fnives.test.showcase.storage.migation.Migration1To2 +import org.fnives.test.showcase.testutils.configuration.SharedMigrationTestRule +import org.fnives.test.showcase.testutils.configuration.createSharedMigrationTestRule +import org.junit.After +import org.junit.Assert +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.stopKoin +import java.io.IOException + +/** + * reference: + * https://medium.com/androiddevelopers/testing-room-migrations-be93cdb0d975 + * https://developer.android.com/training/data-storage/room/migrating-db-versions + */ +@RunWith(AndroidJUnit4::class) +class MigrationToLatest { + + @get:Rule + val helper: SharedMigrationTestRule = createSharedMigrationTestRule( + InstrumentationRegistry.getInstrumentation(), + emptyList(), + FrameworkSQLiteOpenHelperFactory() + ) + + private fun getMigratedRoomDatabase(): LocalDatabase { + val database: LocalDatabase = Room.databaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + LocalDatabase::class.java, + TEST_DB + ) + .addMigrations(Migration1To2()) + .build() + // close the database and release any stream resources when the test finishes + helper.closeWhenFinished(database) + return database + } + + @After + fun tearDown() { + stopKoin() + } + + @Test + @Throws(IOException::class) + fun migrate1To2() { + val expectedEntities = setOf( + FavouriteEntity("123"), + FavouriteEntity("124"), + FavouriteEntity("125") + ) + val version1DB = helper.createDatabase( + name = TEST_DB, + version = 1 + ) + version1DB.run { + execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (\"123\")") + execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (124)") + execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (125)") + } + version1DB.close() + + val version2DB = helper.runMigrationsAndValidate( + name = TEST_DB, + version = 2, + validateDroppedTables = true, + Migration1To2() + ) + version2DB.close() + + val favouriteDao = getMigratedRoomDatabase().favouriteDao + + val entities = runBlocking { favouriteDao.get().first() }.toSet() + + Assert.assertEquals(expectedEntities, entities) + } + + companion object { + private const val TEST_DB = "migration-test" + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SharedMigrationTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SharedMigrationTestRule.kt new file mode 100644 index 0000000..f521053 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SharedMigrationTestRule.kt @@ -0,0 +1,60 @@ +package org.fnives.test.showcase.testutils.configuration + +import android.app.Instrumentation +import androidx.room.RoomDatabase +import androidx.room.migration.AutoMigrationSpec +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import org.junit.rules.TestRule +import java.io.IOException + +interface SharedMigrationTestRule : TestRule { + + @Throws(IOException::class) + fun createDatabase(name: String, version: Int): SupportSQLiteDatabase + + @Throws(IOException::class) + fun runMigrationsAndValidate( + name: String, + version: Int, + validateDroppedTables: Boolean, + vararg migrations: Migration + ): SupportSQLiteDatabase + + fun closeWhenFinished(db: SupportSQLiteDatabase) + fun closeWhenFinished(db: RoomDatabase) +} + +inline fun createSharedMigrationTestRule( + instrumentation: Instrumentation +): SharedMigrationTestRule = + SpecificTestConfigurationsFactory.createSharedMigrationTestRuleFactory() + .createSharedMigrationTestRule( + instrumentation, + DB::class.java + ) + +inline fun createSharedMigrationTestRule( + instrumentation: Instrumentation, + specs: List +): SharedMigrationTestRule = + SpecificTestConfigurationsFactory.createSharedMigrationTestRuleFactory() + .createSharedMigrationTestRule( + instrumentation, + DB::class.java, + specs + ) + +inline fun createSharedMigrationTestRule( + instrumentation: Instrumentation, + specs: List, + openFactory: SupportSQLiteOpenHelper.Factory +): SharedMigrationTestRule = + SpecificTestConfigurationsFactory.createSharedMigrationTestRuleFactory() + .createSharedMigrationTestRule( + instrumentation, + DB::class.java, + specs, + openFactory + ) diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SharedMigrationTestRuleFactory.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SharedMigrationTestRuleFactory.kt new file mode 100644 index 0000000..c99713e --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SharedMigrationTestRuleFactory.kt @@ -0,0 +1,27 @@ +package org.fnives.test.showcase.testutils.configuration + +import android.app.Instrumentation +import androidx.room.RoomDatabase +import androidx.room.migration.AutoMigrationSpec +import androidx.sqlite.db.SupportSQLiteOpenHelper + +interface SharedMigrationTestRuleFactory { + + fun createSharedMigrationTestRule( + instrumentation: Instrumentation, + databaseClass: Class, + ): SharedMigrationTestRule + + fun createSharedMigrationTestRule( + instrumentation: Instrumentation, + databaseClass: Class, + specs: List + ): SharedMigrationTestRule + + fun createSharedMigrationTestRule( + instrumentation: Instrumentation, + databaseClass: Class, + specs: List, + openFactory: SupportSQLiteOpenHelper.Factory + ): SharedMigrationTestRule +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/TestConfigurationsFactory.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/TestConfigurationsFactory.kt index af3c0d5..ed406a1 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/TestConfigurationsFactory.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/TestConfigurationsFactory.kt @@ -15,4 +15,6 @@ interface TestConfigurationsFactory { fun createLoginRobotConfiguration(): LoginRobotConfiguration fun createSnackbarVerification(): SnackbarVerificationTestRule + + fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory }