supabase_test
Test helpers for apps and packages built on the Supabase Dart and Flutter
clients. Your tests run against a real SupabaseClient whose HTTP layer is
stubbed per endpoint and whose realtime socket is served in memory, so no
Supabase stack, network access or hand-rolled fakes are needed. The test suites of the Supabase client packages themselves
run on the same primitives.
Getting started
Add the package as a dev dependency:
dev_dependencies:
supabase_test: ^0.1.0
Create a client with testSupabaseClient, hand it a MockSupabaseHttpClient,
and stub the endpoints the code under test talks to:
import 'package:supabase_test/supabase_test.dart';
import 'package:test/test.dart';
void main() {
test('loads the open todos', () async {
final httpClient = MockSupabaseHttpClient()
..stubTable('todos', rows: [
{'id': 1, 'task': 'Ship it', 'status': false},
]);
final supabase = testSupabaseClient(httpClient: httpClient);
addTearDown(supabase.dispose);
final todos = await supabase.from('todos').select();
expect(todos, hasLength(1));
});
}
The typed table API works the same way, since it runs on the same requests:
supabase.table(Todos.table).select().where(Todos.id.eq(1)).single() is
answered by the same stubTable, and typed streams by the same realtime
transport.
testSupabaseClient is a regular SupabaseClient wired for tests: it
configures the in-memory storage the pkce flow requires, turns off the token
auto refresh so no timer outlives the test, and defaults the API key to an
unsigned test JWT. Dispose it when the test ends, for example with
addTearDown(supabase.dispose).
Stubbing endpoints
MockSupabaseHttpClient answers requests from stubs registered per endpoint:
final httpClient = MockSupabaseHttpClient()
// Database reads and writes: /rest/v1/<table>
..stubTable('todos', rows: [
{'id': 1, 'task': 'Ship it', 'status': false},
])
// Postgres functions called through rpc: /rest/v1/rpc/<function>
..stubRpc('add_them', body: 3)
// Edge functions: /functions/v1/<function>
..stubEdgeFunction('hello', body: {'message': 'hi'})
// The token endpoint, so signInWithPassword and friends succeed
..stubSignIn();
Storage has shorthands for the calls apps make most, which know the paths and the response shapes of the storage API:
httpClient
..stubStorageUpload('avatars', 'me.png')
..stubStorageDownload('avatars', 'me.png', bytes: pngBytes)
..stubStorageSignedUrl('avatars', 'me.png')
..stubStorageList('avatars', objects: [storageObjectJson('me.png')])
..stubStorageRemove('avatars', paths: ['me.png']);
Anything else is stubbed through the general stub, which matches on method
and URL path. A path matches a request whose path is the same or ends in it,
so stubs written for /rest/v1/todos keep working for a project served under
a path prefix:
httpClient.stub(
{'name': 'avatars', 'id': 'avatars', 'public': true},
method: 'GET',
path: '/storage/v1/bucket/avatars',
);
Three rules cover most test setups:
-
The latest matching stub wins. Register broad defaults in
setUpand override them inside a single test. -
timeslimits how often a stub answers. Stub a sequence by registering the later responses first, or model state that changes between calls:httpClient.stubTable('todos', rows: []); httpClient.stubTable('todos', rows: [newTodo], times: 1); // First select returns [newTodo], every one after that returns []. -
querynarrows a stub to matching query parameters. The stub answers only requests whose query carries every entry, so differently filtered reads of one table can receive different rows:httpClient.stubTable('todos', query: {'id': 'eq.1'}, rows: [first]); httpClient.stubTable('todos', query: {'id': 'eq.2'}, rows: [second]); -
schemanarrows a table or function stub to one schema. A request made throughschema('archive')carries the schema in a header, so a stub for thepublictable and one for thearchivetable of the same name can answer differently. A stub without a schema answers every schema. -
An unmatched request throws. The
StateErrornames the request and the registered stubs, so a typo in a path surfaces as a failing test with the mismatch spelled out instead of a silent wrong answer.
Failures are stubbed with statusCode and the error shape of the service:
httpClient.stubTable(
'todos',
rows: {'message': 'permission denied', 'code': '42501'},
statusCode: 403,
);
// supabase.from('todos').select() now throws a PostgrestApiException.
Single rows and counts
stubTable and stubRpc shape their rows the way PostgREST would for the
query that arrives. A query ending in single() receives the only row of a
one-row list, and the PGRST116 error when the list holds any other number
of rows, so the same stub serves both a list query and a single-row query:
httpClient.stubTable('todos', rows: [
{'id': 1, 'task': 'Ship it'},
]);
final todos = await supabase.from('todos').select();
final todo = await supabase.from('todos').select().eq('id', 1).single();
A query asking for a count receives the number of stubbed rows, or the
count you pass when the stub represents one page of a larger table:
httpClient.stubTable('todos', rows: [{'id': 1}], count: 42);
final page = await supabase
.from('todos')
.select()
.limit(1)
.count(CountOption.exact);
// page.data has one row, page.count is 42.
Responses that depend on the request
When a fixed body is not enough, stubHandler builds the response from the
request it receives, with the body already read. jsonResponse wraps a JSON
body with the right content type:
httpClient.stubHandler(
(request) {
final params = request.jsonBody as Map<String, dynamic>;
return jsonResponse(params['a'] + params['b']);
},
path: '/rest/v1/rpc/add_them',
);
httpClient.stubHandler(
(request) => request.url.queryParameters['city'] == null
? jsonResponse({'message': 'city is required'}, statusCode: 400)
: jsonResponse({'weather': 'sunny'}),
path: '/functions/v1/weather',
);
The handler may be asynchronous and may return any http.Response, so a
plain text or binary edge function response is a Response.bytes away. A
Uint8List passed as the body of stub or stubEdgeFunction is sent as
bytes with the content type application/octet-stream.
Failures, stalls and status sequences
Not every response is a body under a status. stubError fails the request
the way a client fails when the network is gone, stubStall never answers
it, and stubStatuses walks it through a sequence of statuses, one per
request, repeating the last one once they run out:
httpClient
// The first two requests throw, the ones after them are answered.
..stubError(ClientException('Offline'), times: 2)
// Never answered, so only a timeout or an abort ends the request.
..stubStall(path: '/functions/v1/slow')
// 503, 503, then 200 for this request and every one after it.
..stubStatuses([503, 503, 200], body: [], path: '/rest/v1/todos');
stubText answers with a body that is not JSON, the HTML error page of a
gateway for example, and carries the reason phrase the client falls back to:
httpClient.stubText(
'<html><body>502 Bad Gateway</body></html>',
statusCode: 502,
reasonPhrase: 'Bad Gateway',
);
A client shared between tests is wiped with reset, which forgets the
registered stubs and the recorded requests.
Asserting on requests
The client records every request it answered in requests, with the body
already read, and requestsTo narrows them down by path and method:
await supabase.from('todos').select().eq('status', true);
await supabase.from('todos').insert({'task': 'Write tests'});
final select = httpClient.requestsTo('/rest/v1/todos', method: 'GET').single;
expect(select.queryParameters['status'], 'eq.true');
final insert = httpClient.requestsTo('/rest/v1/todos', method: 'POST').single;
expect(insert.jsonBody, {'task': 'Write tests'});
Headers are looked up case-insensitively, as on the request itself. For what
does not survive the wire format, request holds the BaseRequest the
client received, so a multipart upload can be asserted on through its
files:
final upload = httpClient.requests.single.request as MultipartRequest;
expect(upload.files.single.contentType.mimeType, 'image/png');
Testing auth
To run a test as a signed-in user, signInTestUser puts the client into a
signed-in state without any network traffic:
final session = await signInTestUser(
supabase.auth,
userId: 'user-1',
email: 'someone@example.com',
);
// currentUser and currentSession are set, and every request now carries
// the session token:
await supabase.from('todos').select();
expect(
httpClient.requests.last.headers['Authorization'],
'Bearer ${session.accessToken}',
);
To test a sign-in flow itself, stub the token endpoint instead and call the real API:
httpClient.stubSignIn(user: testUserJson(id: 'user-1'));
await supabase.auth.signInWithPassword(
email: 'someone@example.com',
password: 'password',
);
stubSignUp, stubSignOut and stubUser cover the sign-up, logout and user
endpoints the same way, so signUp, signOut, getUser and updateUser
run against stubs too.
For code that inspects tokens, unsignedTestJwt and signedTestJwt craft
JWTs carrying exactly the claims you pass, with no auto-injected iat and no
claim overrides, and decodeTestJwtClaims reads them back for assertions.
The fixtures testUserJson and testSessionResponseJson produce the JSON
shapes the auth server would return.
Testing realtime
Realtime runs over a WebSocket rather than HTTP, so it is answered by a
MockRealtimeTransport instead: a realtime server in memory that joins
channels, acknowledges pushes and heartbeats, and lets the test push server
events:
final realtime = MockRealtimeTransport();
final supabase = testSupabaseClient(httpClient: httpClient, realtime: realtime);
final channel = supabase.channel('todos');
final inserts = channel.onPostgresChanges(
event: PostgresChangeEvent.insert,
schema: 'public',
table: 'todos',
);
channel.subscribe();
await channel.onStatusChange.firstWhere(
(change) => change.status == RealtimeSubscribeStatus.subscribed,
);
realtime.emitPostgresChange(
table: 'todos',
event: PostgresChangeEvent.insert,
newRecord: {'id': 1, 'task': 'Ship it'},
);
final payload = await inserts.first;
expect(payload.newRecord['task'], 'Ship it');
emitPostgresChange reaches every joined channel bound to the table and
event, which includes the channel a stream() opens, so a stream test
stubs the initial rows with stubTable and then emits the changes it wants
to see applied. Filters are not evaluated; emit only the changes the code
under test should see. emitBroadcast delivers a broadcast message to a
channel, emit sends any RealtimeMessage, and closeConnection drops the
connection so reconnect handling can be exercised. Everything the client sent
is recorded in sent, and joinedTopics lists what it subscribed to.
Flutter apps
Widget tests initialize supabase_flutter through initializeTestSupabase
from package:supabase_flutter/testing.dart, which applies the same test
defaults as testSupabaseClient and takes the same mock HTTP client:
import 'package:supabase_flutter/testing.dart';
final supabase = await initializeTestSupabase(httpClient: httpClient);
addTearDown(supabase.dispose);
Testing against a real stack
Mocks are for fast unit tests. For integration coverage, run your tests
against a local Supabase stack started with the
Supabase CLI and point
your client at the URL and keys supabase start prints.
Migrating from mock_supabase_http_client
The community package mock_supabase_http_client kept an in-memory database
and interpreted every filter, order and limit of a query. supabase_test
takes the other approach and answers each endpoint with what you stub, which
keeps a test independent of the query it runs and keeps the mock free of
subtle differences to PostgREST. The pieces map as follows:
- Rows that used to be inserted through the client are passed to
stubTableasrows. The filters of a query are not applied, so stub the rows the query is expected to return. registerRpcFunctionandregisterEdgeFunctionbecomestubRpcandstubEdgeFunctionfor fixed responses, orstubHandlerwhen the response depends on the parameters.postgrestExceptionTriggerbecomes a stub with an errorstatusCode, or astubHandlerthat chooses the status per request.resetkeeps its name.
A note on scope
Every helper is annotated with @visibleForTesting, so the analyzer warns if
one ends up in production code.
Libraries
- internal
- Fixtures and mock clients the test suites of the Supabase client packages themselves run on.
- supabase_test
- Test helpers for apps and packages built on the Supabase clients.