Skip to content

Testing migrations

Important Note

If you are using the make-migrations command, tests are already generated for you.

While migrations can be written manually without additional help from drift, dedicated tools testing your migrations help to ensure that they are correct and aren't loosing any data.

Drift's migration tooling consists of the following steps:

  1. After each change to your schema, use a tool to export the current schema into a separate file.
  2. Use a drift tool to generate test code able to verify that your migrations are bringing the database into the expected schema.
  3. Use generated code to make writing schema migrations easier.

This page describes steps 2 and 3. It assumes that you're already following step 1 by exporting your schema when it changes. Also consider the general page on writing unit tests with drift. In particular, you may have to manually install sqlite3 on your system as sqlite3_flutter_libs does not apply to unit tests.

Writing tests

After you've exported the database schemas into a folder, you can generate old versions of your database class based on those schema files. For verifications, drift will generate a much smaller database implementation that can only be used to test migrations.

You can put this test code wherever you want, but it makes sense to put it in a subfolder of test/. If we wanted to write them to test/generated_migrations/, we could use

$ dart run drift_dev schema generate drift_schemas/ test/generated_migrations/

After that setup, it's finally time to write some tests! For instance, a test could look like this:

import 'package:test/test.dart';
import 'package:drift_dev/api/migrations.dart';

// The generated directory from before.
import 'generated_migrations/schema.dart';


void main() {
  
late SchemaVerifier verifier;

  
setUpAll(() {
    
// GeneratedHelper() was generated by drift, the verifier is an api
    
// provided by drift_dev.
    verifier 
= SchemaVerifier(GeneratedHelper());
  
})
;

  
test('upgrade from v1 to v2', () async {
    
// Use startAt(1) to obtain a database connection with all tables
    
// from the v1 schema.
    
final connection = await verifier.startAt(1);
    
final db = MyDatabase(connection);

    
// Use this to run a migration to v2 and then validate that the
    
// database has the expected schema.
    
await verifier.migrateAndValidate(db, 2);
  
})
;
}

In general, a test looks like this:

  1. Use verifier.startAt() to obtain a connection to a database with an initial schema. This database contains all your tables, indices and triggers from that version, created by using Migrator.createAll.
  2. Create your application database with that connection. For this, create a constructor in your database class that accepts a QueryExecutor and forwards it to the super constructor in GeneratedDatabase. Then, you can pass the result of calling newConnection() to that constructor to create a test instance of your database.
  3. Call verifier.migrateAndValidate(db, version). This will initiate a migration towards the target version (here, 2). Unlike the database created by startAt, this uses the migration logic you wrote for your database.

migrateAndValidate will extract all CREATE statement from the sqlite_schema table and semantically compare them. If it sees anything unexpected, it will throw a SchemaMismatch exception to fail your test.

Writing testable migrations

To test migrations towards an old schema version (e.g. from v1 to v2 if your current version is v3), your onUpgrade handler must be capable of upgrading to a version older than the current schemaVersion. For this, check the to parameter of the onUpgrade callback to run a different migration if necessary. Or, use step-by-step migrations which do this automatically.

Verifying data integrity

In addition to the changes made in your table structure, its useful to ensure that data that was present before a migration is still there after it ran. You can use schemaAt to obtain a raw Database from the sqlite3 package in addition to a connection. This can be used to insert data before a migration. After the migration ran, you can then check that the data is still there.

Note that you can't use the regular database class from you app for this, since its data classes always expect the latest schema. However, you can instruct drift to generate older snapshots of your data classes and companions for this purpose. To enable this feature, pass the --data-classes and --companions command-line arguments to the drift_dev schema generate command:

$ dart run drift_dev schema generate --data-classes --companions drift_schemas/ test/generated_migrations/

Then, you can import the generated classes with an alias:

import 'generated_migrations/schema_v1.dart' as v1;
import 'generated_migrations/schema_v2.dart' as v2;

This can then be used to manually create and verify data at a specific version:

void main() {
  
// ...
  
test('upgrade from v1 to v2', () async {
    
final schema = await verifier.schemaAt(1);

    
// Add some data to the table being migrated
    
final oldDb = v1.DatabaseAtV1(schema.newConnection());
    
await oldDb.into(oldDb.todos).insert(v1.TodosCompanion.insert(
          title
: 'my first todo entry',
          content
: 'should still be there after the migration',
        
))
;
    
await oldDb.close();

    
// Run the migration and verify that it adds the name column.
    
final db = MyDatabase(schema.newConnection());
    
await verifier.migrateAndValidate(db, 2);
    
await db.close();

    
// Make sure the entry is still here
    
final migratedDb = v2.DatabaseAtV2(schema.newConnection());
    
final entry = await migratedDb.select(migratedDb.todos).getSingle();
    
expect(entry.id, 1);
    
expect(entry.dueDate, isNull); // default from the migration
    
await migratedDb.close();
  
})
;
}

Verifying a database schema at runtime

Instead (or in addition to) writing tests to ensure your migrations work as they should, you can use a new API from drift_dev 1.5.0 to verify the current schema without any additional setup.

import 'package:drift/drift.dart';

// import the migrations tooling
import 'package:drift_dev/api/migrations.dart';

const kDebugMode = true;

abstract class _$MyDatabase extends GeneratedDatabase {
  
_$MyDatabase(super.executor);
}


class MyDatabase extends _$MyDatabase {
  
MyDatabase(super.executor);

  
@override
  
Iterable<TableInfo<Tabledynamic>> get allTables =>
      
throw UnimplementedError();

  
@override
  
int get schemaVersion => throw UnimplementedError();

  
@override
  
MigrationStrategy get migration => MigrationStrategy(
        onCreate
: (m) async {/* ... */},
        onUpgrade
: (m, from, to) async {/* your existing migration logic */},
        beforeOpen
: (details) async {
          
// your existing beforeOpen callback, enable foreign keys, etc.

          
if (kDebugMode) {
            
// This check pulls in a fair amount of code that's not needed
            
// anywhere else, so we recommend only doing it in debug builds.
            
await validateDatabaseSchema();
          
}
        
}
,
      
)
;
}

When you use validateDatabaseSchema, drift will transparently:

  • collect information about your database by reading from sqlite3_schema.
  • create a fresh in-memory instance of your database and create a reference schema with Migrator.createAll().
  • compare the two. Ideally, your actual schema at runtime should be identical to the fresh one even though it grew through different versions of your app.

When a mismatch is found, an exception with a message explaining exactly where another value was expected will be thrown. This allows you to find issues with your schema migrations quickly.