Merge pull request #38 from fknives/issue#3-room-migration-test

Issue#3 room migration test
This commit is contained in:
Gergely Hegedis 2022-01-23 12:56:42 +02:00 committed by GitHub
commit b01ddc2fbe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1072 additions and 2 deletions

View file

@ -17,6 +17,12 @@ android {
versionName "1.0" versionName "1.0"
buildConfigField "String", "BASE_URL", '"https://606844a10add49001733fe6b.mockapi.io/"' buildConfigField "String", "BASE_URL", '"https://606844a10add49001733fe6b.mockapi.io/"'
kapt {
arguments {
arg("room.schemaLocation", "$projectDir/schemas")
}
}
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
@ -48,17 +54,21 @@ android {
sourceSets { sourceSets {
androidTest { androidTest {
java.srcDirs += "src/sharedTest/java" java.srcDirs += "src/sharedTest/java"
assets.srcDirs += files("$projectDir/schemas".toString())
} }
androidTestHilt { androidTestHilt {
java.srcDirs += "src/sharedTestHilt/java" java.srcDirs += "src/sharedTestHilt/java"
assets.srcDirs += files("$projectDir/schemas".toString())
} }
androidTestKoin { androidTestKoin {
java.srcDirs += "src/sharedTestKoin/java" java.srcDirs += "src/sharedTestKoin/java"
assets.srcDirs += files("$projectDir/schemas".toString())
} }
test { test {
java.srcDirs += "src/sharedTest/java" java.srcDirs += "src/sharedTest/java"
java.srcDirs += "src/robolectricTest/java" java.srcDirs += "src/robolectricTest/java"
resources.srcDirs += files("$projectDir/schemas".toString())
} }
testHilt { testHilt {
java.srcDirs += "src/sharedTestHilt/java" java.srcDirs += "src/sharedTestHilt/java"
@ -144,6 +154,7 @@ dependencies {
testImplementation "com.google.dagger:hilt-android-testing:$hilt_version" testImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
kaptTest "com.google.dagger:hilt-compiler:$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 "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
androidTestImplementation "io.insert-koin:koin-test-junit4:$koin_version" androidTestImplementation "io.insert-koin:koin-test-junit4:$koin_version"
androidTestImplementation "junit:junit:$testing_junit4_version" androidTestImplementation "junit:junit:$testing_junit4_version"

View file

@ -0,0 +1,34 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "36d840e89667f36e0c265593da36fe23",
"entities": [
{
"tableName": "FavouriteEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contentId` TEXT NOT NULL, PRIMARY KEY(`contentId`))",
"fields": [
{
"fieldPath": "contentId",
"columnName": "contentId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"contentId"
],
"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, '36d840e89667f36e0c265593da36fe23')"
]
}
}

View file

@ -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')"
]
}
}

View file

@ -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<out RoomDatabase>
) {
migrationTestHelper = MigrationTestHelper(instrumentation, databaseClass)
}
constructor(
instrumentation: Instrumentation,
databaseClass: Class<out RoomDatabase>,
specs: List<AutoMigrationSpec>
) {
migrationTestHelper = MigrationTestHelper(instrumentation, databaseClass, specs)
}
constructor(
instrumentation: Instrumentation,
databaseClass: Class<out RoomDatabase>,
specs: List<AutoMigrationSpec>,
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
)
}

View file

@ -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<out RoomDatabase>
): SharedMigrationTestRule =
AndroidMigrationTestRule(
instrumentation,
databaseClass
)
override fun createSharedMigrationTestRule(
instrumentation: Instrumentation,
databaseClass: Class<out RoomDatabase>,
specs: List<AutoMigrationSpec>
): SharedMigrationTestRule =
AndroidMigrationTestRule(
instrumentation,
databaseClass,
specs
)
override fun createSharedMigrationTestRule(
instrumentation: Instrumentation,
databaseClass: Class<out RoomDatabase>,
specs: List<AutoMigrationSpec>,
openFactory: SupportSQLiteOpenHelper.Factory
): SharedMigrationTestRule =
AndroidMigrationTestRule(
instrumentation,
databaseClass,
specs,
openFactory,
)
}

View file

@ -12,4 +12,7 @@ object SpecificTestConfigurationsFactory : TestConfigurationsFactory {
override fun createSnackbarVerification(): SnackbarVerificationTestRule = override fun createSnackbarVerification(): SnackbarVerificationTestRule =
AndroidTestSnackbarVerificationTestRule AndroidTestSnackbarVerificationTestRule
override fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory =
AndroidMigrationTestRuleFactory
} }

View file

@ -5,7 +5,11 @@ import androidx.room.RoomDatabase
import org.fnives.test.showcase.storage.favourite.FavouriteDao import org.fnives.test.showcase.storage.favourite.FavouriteDao
import org.fnives.test.showcase.storage.favourite.FavouriteEntity import org.fnives.test.showcase.storage.favourite.FavouriteEntity
@Database(entities = [FavouriteEntity::class], version = 1, exportSchema = false) @Database(
entities = [FavouriteEntity::class],
version = 2,
exportSchema = true
)
abstract class LocalDatabase : RoomDatabase() { abstract class LocalDatabase : RoomDatabase() {
abstract val favouriteDao: FavouriteDao abstract val favouriteDao: FavouriteDao

View file

@ -3,11 +3,13 @@ package org.fnives.test.showcase.storage.database
import android.content.Context import android.content.Context
import androidx.room.Room import androidx.room.Room
import org.fnives.test.showcase.storage.LocalDatabase import org.fnives.test.showcase.storage.LocalDatabase
import org.fnives.test.showcase.storage.migation.Migration1To2
object DatabaseInitialization { object DatabaseInitialization {
fun create(context: Context): LocalDatabase = fun create(context: Context): LocalDatabase =
Room.databaseBuilder(context, LocalDatabase::class.java, "local_database") Room.databaseBuilder(context, LocalDatabase::class.java, "local_database")
.addMigrations(Migration1To2())
.allowMainThreadQueries() .allowMainThreadQueries()
.build() .build()
} }

View file

@ -1,7 +1,11 @@
package org.fnives.test.showcase.storage.favourite package org.fnives.test.showcase.storage.favourite
import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@Entity @Entity
data class FavouriteEntity(@PrimaryKey val contentId: String) data class FavouriteEntity(
@ColumnInfo(name = "content_id")
@PrimaryKey val contentId: String
)

View file

@ -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")
}
}

View file

@ -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<WeakReference<SupportSQLiteDatabase>> mManagedDatabases = new ArrayList<>();
private List<WeakReference<RoomDatabase>> mManagedRoomDatabases = new ArrayList<>();
private boolean mTestStarted;
private Instrumentation mInstrumentation;
@Nullable
private List<AutoMigrationSpec> mSpecs;
@Nullable
private Class<? extends RoomDatabase> 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<? extends RoomDatabase> 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.
* <p>
* 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<? extends RoomDatabase> databaseClass,
@NonNull List<AutoMigrationSpec> 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.
* <p>
* 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<? extends RoomDatabase> databaseClass,
@NonNull List<AutoMigrationSpec> 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.<Integer>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.
* <p>
* 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.
* <p>
* 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.<Integer>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<Migration> getAutoMigrations(List<AutoMigrationSpec> 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<Class<? extends AutoMigrationSpec>> requiredAutoMigrationSpecs =
db.getRequiredAutoMigrationSpecs();
return db.getAutoMigrations(
createAutoMigrationSpecMap(requiredAutoMigrationSpecs, userProvidedSpecs)
);
}
/**
* Maps auto migration spec classes to their provided instance.
*/
private Map<Class<? extends AutoMigrationSpec>, AutoMigrationSpec> createAutoMigrationSpecMap(
Set<Class<? extends AutoMigrationSpec>> requiredAutoMigrationSpecs,
List<AutoMigrationSpec> userProvidedSpecs) {
Map<Class<? extends AutoMigrationSpec>, 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<? extends AutoMigrationSpec> 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<SupportSQLiteDatabase> dbRef : mManagedDatabases) {
SupportSQLiteDatabase db = dbRef.get();
if (db != null && db.isOpen()) {
try {
db.close();
} catch (Throwable ignored) {
}
}
}
for (WeakReference<RoomDatabase> dbRef : mManagedRoomDatabases) {
final RoomDatabase roomDatabase = dbRef.get();
if (roomDatabase != null) {
roomDatabase.close();
}
}
}
/**
* Registers a database connection to be automatically closed when the test finishes.
* <p>
* 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.
* <p>
* 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<TableInfo.Index> toIndices(List<IndexBundle> indices) {
if (indices == null) {
return Collections.emptySet();
}
Set<TableInfo.Index> 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<TableInfo.ForeignKey> toForeignKeys(
List<ForeignKeyBundle> bundles) {
if (bundles == null) {
return Collections.emptySet();
}
Set<TableInfo.ForeignKey> 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<String> toColumnNamesSet(EntityBundle entity) {
Set<String> result = new HashSet<>();
for (FieldBundle field : entity.getFields()) {
result.add(field.getColumnName());
}
return result;
}
private static Map<String, TableInfo.Column> toColumnMap(EntityBundle entity) {
Map<String, TableInfo.Column> 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<String> 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<String, EntityBundle> 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<String> 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) {
}
}
}

View file

@ -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<out RoomDatabase>
): SharedMigrationTestRule =
RobolectricMigrationTestHelper(
instrumentation,
databaseClass
)
override fun createSharedMigrationTestRule(
instrumentation: Instrumentation,
databaseClass: Class<out RoomDatabase>,
specs: List<AutoMigrationSpec>
): SharedMigrationTestRule =
RobolectricMigrationTestHelper(
instrumentation,
databaseClass,
specs
)
override fun createSharedMigrationTestRule(
instrumentation: Instrumentation,
databaseClass: Class<out RoomDatabase>,
specs: List<AutoMigrationSpec>,
openFactory: SupportSQLiteOpenHelper.Factory
): SharedMigrationTestRule =
RobolectricMigrationTestHelper(
instrumentation,
databaseClass,
specs,
openFactory
)
}

View file

@ -12,4 +12,7 @@ object SpecificTestConfigurationsFactory : TestConfigurationsFactory {
override fun createSnackbarVerification(): SnackbarVerificationTestRule = override fun createSnackbarVerification(): SnackbarVerificationTestRule =
RobolectricSnackbarVerificationTestRule RobolectricSnackbarVerificationTestRule
override fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory =
RobolectricMigrationTestHelperFactory
} }

View file

@ -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<LocalDatabase>(
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"
}
}

View file

@ -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 <reified DB : RoomDatabase> createSharedMigrationTestRule(
instrumentation: Instrumentation
): SharedMigrationTestRule =
SpecificTestConfigurationsFactory.createSharedMigrationTestRuleFactory()
.createSharedMigrationTestRule(
instrumentation,
DB::class.java
)
inline fun <reified DB : RoomDatabase> createSharedMigrationTestRule(
instrumentation: Instrumentation,
specs: List<AutoMigrationSpec>
): SharedMigrationTestRule =
SpecificTestConfigurationsFactory.createSharedMigrationTestRuleFactory()
.createSharedMigrationTestRule(
instrumentation,
DB::class.java,
specs
)
inline fun <reified DB : RoomDatabase> createSharedMigrationTestRule(
instrumentation: Instrumentation,
specs: List<AutoMigrationSpec>,
openFactory: SupportSQLiteOpenHelper.Factory
): SharedMigrationTestRule =
SpecificTestConfigurationsFactory.createSharedMigrationTestRuleFactory()
.createSharedMigrationTestRule(
instrumentation,
DB::class.java,
specs,
openFactory
)

View file

@ -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<out RoomDatabase>,
): SharedMigrationTestRule
fun createSharedMigrationTestRule(
instrumentation: Instrumentation,
databaseClass: Class<out RoomDatabase>,
specs: List<AutoMigrationSpec>
): SharedMigrationTestRule
fun createSharedMigrationTestRule(
instrumentation: Instrumentation,
databaseClass: Class<out RoomDatabase>,
specs: List<AutoMigrationSpec>,
openFactory: SupportSQLiteOpenHelper.Factory
): SharedMigrationTestRule
}

View file

@ -15,4 +15,6 @@ interface TestConfigurationsFactory {
fun createLoginRobotConfiguration(): LoginRobotConfiguration fun createLoginRobotConfiguration(): LoginRobotConfiguration
fun createSnackbarVerification(): SnackbarVerificationTestRule fun createSnackbarVerification(): SnackbarVerificationTestRule
fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory
} }