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 setUp and override them inside a single test.

  • times limits 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 [].
    
  • query narrows 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]);
    
  • schema narrows a table or function stub to one schema. A request made through schema('archive') carries the schema in a header, so a stub for the public table and one for the archive table of the same name can answer differently. A stub without a schema answers every schema.

  • An unmatched request throws. The StateError names 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 stubTable as rows. The filters of a query are not applied, so stub the rows the query is expected to return.
  • registerRpcFunction and registerEdgeFunction become stubRpc and stubEdgeFunction for fixed responses, or stubHandler when the response depends on the parameters.
  • postgrestExceptionTrigger becomes a stub with an error statusCode, or a stubHandler that chooses the status per request.
  • reset keeps 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.