Testing sqflite code with sqflite_common_ffi
The native sqflite plugin cannot run in dart test or flutter test.
package:sqflite_common_ffi provides databaseFactoryFfi, a real SQLite
implementation (via package:sqlite3) that runs on the developer machine and
on Linux/macOS/Windows CI runners, so database code is tested against SQLite
itself rather than mocks.
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:test/test.dart';
void main() {
sqfliteFfiInit();
test('insert and query', () async {
var db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
await db.execute('CREATE TABLE Test (id INTEGER PRIMARY KEY, value TEXT)');
await db.insert('Test', {'value': 'a'});
expect(await db.query('Test'), [
{'id': 1, 'value': 'a'},
]);
await db.close();
});
}
Guidelines
Setup
- Add
sqflite_common_ffitodev_dependencies(todependenciesif the app itself uses it on desktop).sqlite3 >= 3bundles SQLite through build hooks: run tests withdart test/flutter test, nothing to install. - Import
package:sqflite_common_ffi/sqflite_ffi.dartin tests. It exports the wholesqflite_commonAPI (Database,OpenDatabaseOptions,inMemoryDatabasePath,DatabaseException, globaldatabaseFactory,openDatabase...). In Flutter tests it replacespackage:sqflite/sqflite.dart(importing both triggers theunnecessary_importlint); both expose the same globaldatabaseFactory. - Call
sqfliteFfiInit()once, at the top ofmain()or insetUpAll. Required on Windows, harmless elsewhere. - Mark Dart VM test files with
@TestOn('vm')(beforelibrary;) when the package also runs tests on the web:databaseFactoryFfithrowsUnsupportedErrorin a browser.
Choosing the factory
- Code that receives a
DatabaseFactory(recommended design): passdatabaseFactoryFfiand openinMemoryDatabasePath. - Code that calls the global
openDatabase()/deleteDatabase()(typical Flutter code): setdatabaseFactory = databaseFactoryFfi;insetUpAll(or at the top ofmain). Do it once per test process; setting it again only prints a warning. testWidgets(Flutter widget tests): usedatabaseFactoryFfiNoIsolate. The isolate-based factory can hang under the fake async zone ofWidgetTester; the no-isolate factory runs SQLite synchronously in the test isolate.- Plain
test()inside a Flutter package works with either factory;databaseFactoryFfiis closest to production.
Isolation between tests
- Prefer
inMemoryDatabasePath: eachopenDatabaseafter aclose()gives an empty database and nothing is left on disk. Alwaysclose()in the test (or intearDown) so the next test starts fresh. - For file-based tests (migrations,
onUpgrade, reopen scenarios) use a unique relative name per test: it resolves under.dart_tool/sqflite_common_ffi/databases, is created on open and is ignored by git in most setups. Callfactory.deleteDatabase(path)insetUpso a previous run cannot leak state. - Do not share one
Databaseacross tests through a global; open it insetUpand close it intearDown.
Assertions
- Errors are
DatabaseException: assert withisNoSuchTableError('Test'),isUniqueConstraintError('Test.value'),isSyntaxError(),isDatabaseClosedError()orgetResultCode(), never on message text. expect(await db.query(...), [...])works: rows areList<Map<String, Object?>>and deep-equal to literal maps.await db.getVersion()returns theuser_versionand is a quick check thatonCreate/onUpgraderan.
CI
- Linux, macOS and Windows runners all work with
sqlite3 >= 3without extra packages. Only the legacysqlite3v2 setup neededsudo apt-get install libsqlite3-devon Ubuntu. - Keep test paths relative or under a temp directory; avoid absolute developer-machine paths.
Examples
Testing a class that takes a DatabaseFactory
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:test/test.dart';
class NoteStore {
NoteStore(this.factory);
final DatabaseFactory factory;
late Database db;
Future<void> open(String path) async {
db = await factory.openDatabase(
path,
options: OpenDatabaseOptions(
version: 1,
onCreate: (db, _) => db.execute(
'CREATE TABLE Note (id INTEGER PRIMARY KEY, text TEXT NOT NULL)',
),
),
);
}
Future<int> add(String text) => db.insert('Note', {'text': text});
Future<List<String>> texts() async =>
(await db.query('Note', orderBy: 'id')).map((r) => r['text'] as String).toList();
}
void main() {
sqfliteFfiInit();
late NoteStore store;
setUp(() async {
store = NoteStore(databaseFactoryFfi);
await store.open(inMemoryDatabasePath);
});
tearDown(() => store.db.close());
test('add', () async {
await store.add('hello');
expect(await store.texts(), ['hello']);
});
test('not null constraint', () async {
try {
await store.db.insert('Note', {'text': null});
fail('should throw');
} on DatabaseException catch (e) {
expect(e.isNotNullConstraintError('Note.text'), isTrue);
}
});
}
Flutter test for code using the global openDatabase
import 'package:flutter_test/flutter_test.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
void main() {
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
test('openDatabase works in flutter test', () async {
var db = await openDatabase(
inMemoryDatabasePath,
version: 1,
onCreate: (db, version) async {
await db.execute('CREATE TABLE Test (id INTEGER PRIMARY KEY, value TEXT)');
},
);
await db.insert('Test', {'value': 'my_value'});
expect(await db.query('Test'), [
{'id': 1, 'value': 'my_value'},
]);
await db.close();
});
}
Widget test: no isolate
import 'package:flutter_test/flutter_test.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
void main() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfiNoIsolate;
testWidgets('database in a widget test', (tester) async {
var db = await openDatabase(inMemoryDatabasePath);
await db.execute('CREATE TABLE Test (id INTEGER PRIMARY KEY)');
expect(await db.query('Test'), isEmpty);
await db.close();
});
}
Migration test with a file that is reopened
@TestOn('vm')
library;
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:test/test.dart';
void main() {
sqfliteFfiInit();
final factory = databaseFactoryFfi;
const path = 'migration_test.db'; // under .dart_tool/sqflite_common_ffi/databases
setUp(() => factory.deleteDatabase(path));
test('upgrade from 1 to 2 adds a column', () async {
var db = await factory.openDatabase(
path,
options: OpenDatabaseOptions(
version: 1,
onCreate: (db, _) => db.execute('CREATE TABLE Test (id INTEGER PRIMARY KEY)'),
),
);
await db.close();
db = await factory.openDatabase(
path,
options: OpenDatabaseOptions(
version: 2,
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
await db.execute('ALTER TABLE Test ADD COLUMN name TEXT');
}
},
),
);
expect(await db.getVersion(), 2);
await db.insert('Test', {'name': 'ok'});
await db.close();
});
}
Expecting a missing table error
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:test/test.dart';
void main() {
sqfliteFfiInit();
test('no such table', () async {
var db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
try {
await db.query('Missing');
fail('should throw');
} on DatabaseException catch (e) {
expect(e.isNoSuchTableError('Missing'), isTrue);
} finally {
await db.close();
}
});
}
Common mistakes
- Forgetting
sqfliteFfiInit(): tests pass on macOS/Linux and fail on Windows. - Using
databaseFactoryFfiinsidetestWidgets: usedatabaseFactoryFfiNoIsolate. - Calling the global
openDatabase()without settingdatabaseFactoryfirst:StateError: databaseFactory not initialized. - Not closing databases between tests, so
singleInstancereturns the previous test's database (same path) with stale tables. - Reusing a file path across tests without
deleteDatabaseinsetUp. - Running the test file in a browser configuration: add
@TestOn('vm'). - Matching on exception messages instead of
DatabaseExceptionhelpers.