Testing
Flutter apps using drift can always be tested with integration tests running on a real device. This guide focuses on writing unit tests for a database written in drift. Those tests can be run and debugged on your computer without additional setup, you don't need a physical device to run them.
Setup#
For this guide, we're going to test a very simple database that stores user names. The only important change from a regular drift
database is the constructor: We make the QueryExecutor argument explicit instead of having a no-args constructor that passes
a fixed executor (like a NativeDatabase) to the superclass:
import 'package:drift/drift.dart';
part 'database.g.dart';
class Users extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
}
@DriftDatabase(tables: [Users])
class MyDatabase extends _$MyDatabase {
MyDatabase(QueryExecutor e) : super(e);
@override
int get schemaVersion => 1;
/// Creates a user and returns their id
Future<int> createUser(String name) {
return into(users).insert(UsersCompanion.insert(name: name));
}
/// Changes the name of a user with the [id] to the [newName].
Future<void> updateName(int id, String newName) {
return update(users).replace(User(id: id, name: newName));
}
Stream<User> watchUserWithId(int id) {
return (select(users)..where((u) => u.id.equals(id))).watchSingle();
}
}
Installing sqlite
We can't distribute an sqlite installation as a pub package (at least not as something that works outside of a Flutter build), so you need to ensure that you have the sqlite3 shared library installed on your system.
On macOS, it's installed by default.
On Linux, you can use the libsqlite3-dev package on Ubuntu and the
sqlite3 package on Arch (other distros will have similar packages).
On Windows, you can download 'Precompiled Binaries for Windows'
and extract sqlite3.dll into a folder that's in your PATH
environment variable. Then restart your device to ensure that
all apps will run with this PATH change.
As sqlite3_flutter_libs bundles the latest sqlite3 version with your app,
using a recent sqlite3 version is recommended to avoid differences in how tests
behave from your app.
The minimum sqlite version tested with drift is 3.29.0, but many drift features
like returning or generated columns will require more recent versions.
Writing tests#
We can create an in-memory version of the database by using a
NativeDatabase.memory() instead of a FlutterQueryExecutor or other implementations. A good
place to open the database is the setUp and tearDown methods from
package:test:
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:test/test.dart';
// the file defined above, you can test any drift database of course
import 'database.dart';
void main() {
late AppDatabase database;
setUp(() {
database = AppDatabase(
DatabaseConnection(
NativeDatabase.memory(),
// Recommended for widget tests to avoid test errors.
closeStreamsSynchronously: true,
),
);
});
tearDown(() async {
await database.close();
});
}
Closing streams synchronously
By default, unsubscribing from a query stream created by drift will keep the stream open for one event
loop iteration. This is useful for e.g. Flutter apps, where rebuilds may cause a StreamBuilder
to
re-subscribe to streams frequently.
In Flutter widget tests however, it's illegal to keep these timers open after a test concludes.
To avoid issues with Drift in that setups, pass a DatabaseConnection with
closeStreamsSynchronously: true
to your database.
With that setup in place, we can finally write some tests:
test('users can be created', () async {
final id = await database.createUser('some user');
final user = await database.watchUserWithId(id).first;
expect(user.name, 'some user');
});
test('stream emits a new user when the name updates', () async {
final id = await database.createUser('first name');
final expectation = expectLater(
database.watchUserWithId(id).map((user) => user.name),
emitsInOrder(['first name', 'changed name']),
);
await database.updateName(id, 'changed name');
await expectation;
});
Testing migrations#
Drift can help you generate code for schema migrations. For more details, see this guide.
Deterministic time in tests#
In Flutter widget tests (and in plain Dart tests using fakeAsync or withClock), you may
want to mock the current date and time to make tests deterministic or to reliably replay
code depending on the current time.
In Dart, you'd typically use clock.now() instead of DateTime.now() to observe mocked clocks.
Being a C library, SQLite obviously can't know about faked clocks in Dart. This means that
CURRENT_TIMESTAMP or expressions like datetime('now') would continue to evaluate to the time
from the system running the test.
It is possible to fix this by installing a custom virtual file system
(VFS).
This intercepts all IO done by the SQLite library, and allows making it fully deterministic.
For tests, the prebuilt TestSqliteFileSystem from the sqlite3_test
package can be used to
observe clocks installed by withClock (including widget tests).
The file system should be configured in setUpAll, and can be unregistered in tearDownAll.
Each test file using this should register the VFS under a unique name, since different tests might
run in parallel in the same process.
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:file/local.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:sqlite3_test/sqlite3_test.dart';
import 'package:uuid/uuid.dart';
void main() {
late TestSqliteFileSystem vfs;
setUpAll(() {
vfs = TestSqliteFileSystem(fs: const LocalFileSystem(), name: Uuid().v4());
sqlite3.registerVirtualFileSystem(vfs);
});
tearDownAll(() => sqlite3.unregisterVirtualFileSystem(vfs));
}
You can still set up your database as described in writing tests. In tests, all time-related SQLite functions will then use mocked time values from Dart:
test('my test depending on database time', () async {
// The VFS uses package:clock to get the current time, which can be
// overridden for tests:
final moonLanding = DateTime.utc(1969, 7, 20, 20, 18, 04);
await withClock(Clock.fixed(moonLanding), () async {
// This reads the time directly, but indirect references (e.g. in triggers
// or default columns) would also use the mocked time.
final row = await database.selectExpressions([
currentDateAndTime.year,
]).getSingle();
expect(row.read(currentDateAndTime.year)!, 1969);
});
});