supabase / supabase-flutter

Flutter integration for Supabase. This package makes it simple for developers to build secure and scalable products.
https://supabase.com/
MIT License
707 stars 166 forks source link

I can't mock a SupabaseClient instance #714

Open fueripe-desu opened 10 months ago

fueripe-desu commented 10 months ago

Describe the bug I'm trying to mock SupabaseClient using the mockito package and it keeps giving me this timeout exception: TimeoutException after 0:00:30.000000: Test timed out after 30 seconds. See https://pub.dev/packages/test#timeouts dart:isolate _RawReceivePort._handleMessage

To Reproduce In order to reproduce this exception, you can use the following test:

@GenerateNiceMocks([
  MockSpec<SupabaseClient>(),
  MockSpec<SupabaseQueryBuilder>(),
  MockSpec<PostgrestFilterBuilder<List<Map<String, dynamic>>>>(),
  MockSpec<PostgrestResponse<List<Map<String, dynamic>>>>()
])
import 'sync_remote_datasource_test.mocks.dart';

class FakeDatabaseClient extends DatabaseClientInterface {
  FakeDatabaseClient({
    required this.supabaseClient,
  });
  final MockSupabaseClient supabaseClient;

  @override
  // TODO: implement local
  Isar get local => throw UnimplementedError();

  @override
  MockSupabaseClient get remote => supabaseClient;
}

void main() {
  late SyncRemoteDataSourceImpl syncRemoteDataSourceImpl;
  late FakeDatabaseClient mockDatabaseClient;

  setUp(() {
    mockDatabaseClient = FakeDatabaseClient(
      supabaseClient: MockSupabaseClient(),
    );
    syncRemoteDataSourceImpl =
        SyncRemoteDataSourceImpl(client: mockDatabaseClient);
  });

  test('Should return a list of languages from the remote database', () async {
    // arrange
    final fixtureMap = fixture('language_list_fixture.json');

    final mockQueryBuilder = MockSupabaseQueryBuilder();
    final parsedList = json.decode(fixtureMap) as List<dynamic>;

    final expectedResult = parsedList
        .map((dynamic item) =>
            Map<String, dynamic>.from(item as Map<String, dynamic>))
        .toList();

    final mockPostgrestFilterBuilder = MockPostgrestFilterBuilder();
    final mockPostgrestResponse = MockPostgrestResponse();

    // Mock the behavior of `from` method
    when(mockDatabaseClient.remote.from(any)).thenAnswer(
      (_) => mockQueryBuilder,
    );

    // Mock the behavior of `select` method to return
    // the PostgrestFilterBuilder instance
    when(mockQueryBuilder.select<List<Map<String, dynamic>>>(any))
        .thenAnswer((_) => mockPostgrestFilterBuilder);

    // Return your expected result when using the mocked
    // PostgrestFilterBuilder instance
    when(mockPostgrestFilterBuilder.execute())
        .thenAnswer((_) async => mockPostgrestResponse);

    // Mock the behavior of `data` getter on PostgrestResponse to return
    // your expected result
    when(mockPostgrestResponse.data).thenReturn(expectedResult);

    // act
    final result = await syncRemoteDataSourceImpl.fetchLanguages();

    // assert
    expect(result, expectedResult);
  });
}

The definition of the DatabaseClientInterface is the following:

abstract class DatabaseClientInterface {
  SupabaseClient get remote;
  Isar get local;
}

The fixture() function just reads a string synchronously from the fixtures directory:

String fixture(String name) => File('test/fixtures/$name').readAsStringSync();

The structure of the JSON file I'm trying to read (just for more context) is the following:

[
    {
        "uuid": "2094025c-85a2-43d9-b51d-decad64fd3b5",
        "created_at": "2023-11-10 19:03:42.601115+00",
        "user_id": "a37ab29c-7bed-4acc-ad9d-9ae7177d4da4",
        "value": "English"
    },
    {
        "uuid": "ea04054d-6757-4f74-9e72-19f1fb8b3c3a",
        "created_at": "2023-08-10 19:03:42.601115+00",
        "user_id": "a37ab29c-7bed-4acc-ad9d-9ae7177d4da4",
        "value": "Portuguese"
    },
    {
        "uuid": "004e0027-4bbe-4d28-97e4-6dc9acd9fd96",
        "created_at": "2023-04-10 19:03:42.601115+00",
        "user_id": "a37ab29c-7bed-4acc-ad9d-9ae7177d4da4",
        "value": "Japanese"
    }
]

And here is the SyncRemoteDatasourceImpl definition:

abstract class SyncRemoteDataSource {
  Future<bool> getSyncState();
  Future<List<Map<String, dynamic>>> fetchLanguages();
}

class SyncRemoteDataSourceImpl implements SyncRemoteDataSource {
  SyncRemoteDataSourceImpl({required this.client});
  final DatabaseClientInterface client;

  @override
  Future<List<Map<String, dynamic>>> fetchLanguages() async {
    final languages = await client.remote.from('languages').select('*');
    return languages as List<Map<String, dynamic>>;
  }

  @override
  Future<bool> getSyncState() {
    // TODO: implement getSyncState
    throw UnimplementedError();
  }
}

Expected behavior The expected behavior would be for the test to pass without throwing a timeout exception.

Version: On Linux/macOS

altime_client|functions_client"
└── supabase_flutter 1.10.25
    ├── supabase 1.11.11
    │   ├── functions_client 1.3.2
    │   ├── gotrue 1.12.6
    │   ├── postgrest 1.5.2
    │   ├── realtime_client 1.4.0
    │   ├── storage_client 1.5.4

Additional context Since I'm very new to Supabase and everything, I don't really know if this is a bug or if it is my logic that is flawed, or if mocking the SupabaseClient is possible in the first place, but I have tried doing this in a lot of different ways and all of them just kept throwing me this timeout exception. Something I might want to mention is that, before doing this way, I started searching for more information about mocking the SupabaseClient and I came across this old Github issue from the supabase-dart package, where the user was trying to achieve something very similar to what I want, and an example of a mock client was proposed in the issue, I used it and I had to adapt it and remove some errors, but then it kept throwing me this timeout exception.

Here is the link of the issue I mentioned: https://github.com/supabase/supabase-dart/issues/12

Vinzent03 commented 10 months ago

Your code example is quite large so I don't know where exactly the issue happens. Additionally, I'm not really familiar with your mock package. I guess there is some cleanup needed for the isolate we use internally for json decoding.

fueripe-desu commented 10 months ago

Your code example is quite large so I don't know where exactly the issue happens. Additionally, I'm not really familiar with your mock package. I guess there is some cleanup needed for the isolate we use internally for json decoding.

Yes, I shall admit that my code is a lot verbose, and this version I posted here had a little bug that I've later found out, because in the decorator used to generate mocks, this one:

@GenerateNiceMocks([
  MockSpec<SupabaseClient>(),
  MockSpec<SupabaseQueryBuilder>(),
  MockSpec<PostgrestFilterBuilder<List<Map<String, dynamic>>>>(),
  MockSpec<PostgrestResponse<List<Map<String, dynamic>>>>()
])

PostgrestFilterBuilder() and PostgrestResponse() were marked with the generic type List<Map<String, dynamic>>> but Supabase returns List<dynamic> instead, so I fixed it, but the bug still happens in the test.

Regarding the mockito package, it just implements a class getting rid of its required parameters and passing mock parameters to it, then you can simply mock the behavior of the class for example:

final mockQueryBuilder = MockSupabaseQueryBuilder();
final mockPostgrestFilterBuilder = MockPostgrestFilterBuilder();

when(mockQueryBuilder.select<List<Map<String, dynamic>>>(any))
        .thenAnswer((_) => mockPostgrestFilterBuilder);

In this line, both these mock classes were created by mockito, so they don't really need parameters you can just instantiate them, then this when().thenAnswer() method is just saying that when this method is called in the production code while running the test it will just return the value of thenAnswer() just to mock the behavior because it's not the real class.

My guess of what could be the actual problem could be that mockito doesn't really work that well with isolates or the isolate used for JSON decoding in the actual Supabase source code has some kind of problem.

The workaround I used for solving this problem was creating a FakeSupabaseClient class that copies the syntax of Supabase but it works totally offline without mocking the HTTP client.

Vinzent03 commented 9 months ago

Thanks for the explanation and simplification. I guess the constructor of SupabaseClient is still called, which creates the isolate. Do you have any way to call .dispose() on that object?

fueripe-desu commented 9 months ago

I tried calling the .dispose() method like this to see if something would change:

test('Should return a list of languages from the remote database', () async {
    // arrange
    final fixtureMap = fixture('language_list_fixture.json');

    final mockQueryBuilder = MockSupabaseQueryBuilder();
    final parsedList = json.decode(fixtureMap) as List<dynamic>;

    final expectedResult = parsedList
        .map((dynamic item) =>
            Map<String, dynamic>.from(item as Map<String, dynamic>))
        .toList();

    final mockPostgrestFilterBuilder = MockPostgrestFilterBuilder();
    final mockPostgrestResponse = MockPostgrestResponse();

    // Mock the behavior of `from` method
    when(mockDatabaseClient.remote.from(any)).thenAnswer(
      (_) => mockQueryBuilder,
    );

    // Mock the behavior of `select` method to return
    // the PostgrestFilterBuilder instance
    when(mockQueryBuilder.select<List<dynamic>>(any, any))
        .thenAnswer((_) => mockPostgrestFilterBuilder);

    // Return your expected result when using the mocked
    // PostgrestFilterBuilder instance
    when(mockPostgrestFilterBuilder.execute())
        .thenAnswer((_) async => mockPostgrestResponse);

    // Mock the behavior of `data` getter on PostgrestResponse to return
    // your expected result
    when(mockPostgrestResponse.data).thenReturn(expectedResult);

    // act
    final result = await syncRemoteDataSourceImpl.fetchLanguages();

    // assert
    expect(result, expectedResult);

    await mockDatabaseClient.supabaseClient.dispose();
  });

but mockito overrides the .dispose() method, so it doesn't really have any effect, and I can't access the isolate because it is a private property of SupabaseClient.

dshukertjr commented 2 weeks ago

@fueripe-desu We just published a package to mock Supabase client. It works a bit different from how mockito works, but would love to hear what you think. https://pub.dev/packages/mock_supabase_http_client