lejard-h / chopper

Chopper is an http client generator using source_gen and inspired from Retrofit.
https://hadrien-lejard.gitbook.io/chopper
Other
713 stars 123 forks source link

Document Mocking of ChopperClient and Mock Mixins #540

Closed ratzrattillo closed 10 months ago

ratzrattillo commented 10 months ago

Is your feature request related to a problem? Please describe. I do not know how to properly do unit testing with Chopper My Problem is described detailed in https://github.com/lejard-h/chopper/discussions/539

Describe the solution you'd like I would like to have extended documentation on how to properly Mock Chopper Clients and do Unit tests with Chopper.

Describe alternatives you've considered None

Additional context https://github.com/lejard-h/chopper/discussions/539

Guldem commented 10 months ago

When looking at #539 I don't fully understand what you're trying to achieve. If you want to test the responses returned by the ChopperService? Do you want to make sure the json is correctly converted?

If I look at the example code I have the feeling that you are mostly testing logic in the test itself. Also it looks like you're testing things that are covered by tests in the Chopper and Freezed packages itself.

If you want a ChopperService to return a specific model for testing that could easily be accomplished with mockito/mocktail. For example something like this with mocktail:

@ChopperApi()
abstract class MyService extends ChopperService {
  @Get(path: 'capabilities')
  Future<Response<List<DeviceCapabilities>>> getDeviceCapabilities();
}

class MockMyService extends Mock implements MyService{}

void main() {
  final mockService = MockMyService();
  test('mock call', () async {
    when(()=>mockService.getDeviceCapabilities()).thenAnswer((_) async => [DeviceCapabilities(...));

    // Do other stuff needed

    final result = await mockService.getDeviceCapabilities();
    expect(result, expected);
    verify(()=>mockService.getDeviceCapabilities()).called(1);
  });
}

I also would like to point out that the JsonConverter does not convert json into an custom object like DeviceCapabilities. See: https://hadrien-lejard.gitbook.io/chopper/faq#write-a-custom-jsonconverter

ratzrattillo commented 10 months ago

Thank you @Guldem , your answer is bringing me closer to understand what I actually want.

I want to test, that the chopper client properly converts the received json response to a custom object. During testing, the responses should not come from the network, but from a file. Another usecase I have is: Replace the underlying httpClient of the ChopperClient with a mocked version, so I can develop my application even if the real backend services are not running.

To fulfill my usecase, would the right method be to move the mocked responses directly into the implementation of class MockMyService like e.g.:?

class MockMyService extends Mock implements MyService{
  // when(()=>this.getDeviceCapabilities()).thenAnswer((_) async => [DeviceCapabilities(...));
}

Could this implementation then still be overwritten in tests using the when().thenAnswer() methods?

Thanks for your hint with the converter. It showed me that my assumption was wrong, that the JsonConverter can already convert Json to custom objects. I want to use the converter from https://hadrien-lejard.gitbook.io/chopper/faq#write-a-custom-jsonconverter in my project now. It seems like tests already exist: https://github.com/lejard-h/chopper/blob/master/chopper/test/converter_test.dart

Your example code showed me, that it is easy to change the response of the mockService using the when().thenAnswer() methods. However it seems like the [DeviceCapabilities(...)] should somehow be wrapped in a chopper.Response() and i do not know how to do that. Do you have a hint for me here?

ratzrattillo commented 10 months ago

I created a custom class mockHttpClient Builder, which contains all the responses I would expect from the service. This class gives me back an HTTPClient that returns the expected responses, or an error response:

import 'dart:io';

import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';

class MockHttpClientBuilder {
  MockClient generateClient() {
    return MockClient(
      (request) async {
        try {
          final response = await _getResponseByPath(request.url.path);
          return http.Response(response, 200);
        } on PathNotFoundException catch (e) {
          return http.Response(e.message, 404);
        } catch (e) {
          return http.Response('Server Error', 500);
        }
      },
    );
  }

  MockClient generateErrorClient(String message, int errorcode) {
    return MockClient(
      (request) async {
        return http.Response(message, errorcode);
      },
    );
  }

  Future<String> _getResponseByPath(String path) async {
    switch (path) {
      case '/capabilities':
        return await _readResponseFromAsset(
            'assets/network/device_capabilities.json');
      case '/settings':
        return await _readResponseFromAsset(
            'assets/network/device_settings.json');
      case '/select_device':
        return await _readResponseFromAsset(
            'assets/network/select_device.json');
      case '/create_rx_fg' || '/create_tx_fg':
        return await _readResponseFromAsset('assets/network/create_fg.json');
      default:
        throw PathNotFoundException(path, OSError());
    }
  }

  Future<String> _readResponseFromAsset(String assetPath) async =>
      rootBundle.loadString(assetPath);
}

The Converter to transform Json to custom objects i took mostly from https://hadrien-lejard.gitbook.io/chopper/faq#decoding-json-using-isolates

// From: https://hadrien-lejard.gitbook.io/chopper/faq#decoding-json-using-isolates
import 'dart:async' show FutureOr;
import 'dart:convert' show jsonDecode;

import 'package:chopper/chopper.dart' as chopper;
import 'package:my_flutter_app/network/json_decode_service.dart';
import 'package:squadron/squadron.dart';

import 'package:json_annotation/json_annotation.dart';

part 'json_serializable_converter_squadron.g.dart';

void initSquadron(String id) {
  Squadron.setId(id);
  Squadron.setLogger(ConsoleSquadronLogger());
  Squadron.logLevel = SquadronLogLevel.warning; //all
  Squadron.debugMode = false; //true
}

@JsonSerializable()
class ResourceError {
  final String type;
  final String message;

  ResourceError(this.type, this.message);

  static const fromJsonFactory = _$ResourceErrorFromJson;

  Map<String, dynamic> toJson() => _$ResourceErrorToJson(this);
}

typedef JsonFactory<T> = T Function(Map<String, dynamic> json);

/// This JsonConverter works with or without a WorkerPool
class JsonSerializableWorkerPoolConverter extends chopper.JsonConverter {
  const JsonSerializableWorkerPoolConverter(this.factories, [this.workerPool]);

  final Map<Type, JsonFactory> factories;
  final JsonDecodeServiceWorkerPool? workerPool;

  T? _decodeMap<T>(Map<String, dynamic> values) {
    /// Get jsonFactory using Type parameters
    /// if not found or invalid, throw error or return null
    final jsonFactory = factories[T];
    if (jsonFactory == null || jsonFactory is! JsonFactory<T>) {
      /// throw serializer not found error;
      return null;
    }

    return jsonFactory(values);
  }

  List<T> _decodeList<T>(Iterable values) =>
      values.where((v) => v != null).map<T>((v) => _decode<T>(v)).toList();

  dynamic _decode<T>(entity) {
    if (entity is Iterable) return _decodeList<T>(entity as List);

    if (entity is Map) return _decodeMap<T>(entity as Map<String, dynamic>);

    return entity;
  }

  @override
  FutureOr<chopper.Response<ResultType>> convertResponse<ResultType, Item>(
    chopper.Response response,
  ) async {
    // use [JsonConverter] to decode json
    final jsonRes = await super.convertResponse(response);

    return jsonRes.copyWith<ResultType>(body: _decode<Item>(jsonRes.body));
  }

  @override
  FutureOr<chopper.Response> convertError<ResultType, Item>(
      chopper.Response response) async {
    // use [JsonConverter] to decode json
    final jsonRes = await super.convertError(response);

    return jsonRes.copyWith<ResourceError>(
      body: ResourceError.fromJsonFactory(jsonRes.body),
    );
  }

  @override
  FutureOr<dynamic> tryDecodeJson(String data) async {
    try {
      // if there is a worker pool use it, otherwise run in the main thread
      return workerPool != null
          ? await workerPool!.jsonDecode(data)
          : jsonDecode(data);
    } catch (error) {
      print(error);

      chopper.chopperLogger.warning(error);

      return data;
    }
  }
}

Now i can run my tests with the mocked http client:

import 'dart:convert';
import 'package:my_flutter_app/network/mock_http_client_builder.dart';
import 'package:squadron/squadron.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_flutter_app/network/futuresdr_service.dart';
import 'package:my_flutter_app/network/responses/barrel.dart';
import 'package:my_flutter_app/network/json_decode_service.dart';
import 'package:my_flutter_app/network/json_serializable_converter_squadron.dart';

const String apiUrl = 'http://localhost:4000';

Future<void> main() async {
  TestWidgetsFlutterBinding.ensureInitialized();

  initSquadron('decode_json_worker_pool');

  // Create Worker Pool
  final jsonDecodeServiceWorkerPool = JsonDecodeServiceWorkerPool(
    concurrencySettings: ConcurrencySettings.oneCpuThread,
  );

  final converter = JsonSerializableWorkerPoolConverter(
    {
      DeviceCapabilities: DeviceCapabilities.fromJsonFactory,
      ChannelCapabilities: ChannelCapabilities.fromJsonFactory,
      DeviceSettings: DeviceSettings.fromJsonFactory,
      ChannelSettings: ChannelSettings.fromJsonFactory,
    },
    // make sure to provide the WorkerPool to the JsonConverter
    jsonDecodeServiceWorkerPool,
  );

  setUp(() async {
    await jsonDecodeServiceWorkerPool.start();
  });

  tearDown(() async {
    jsonDecodeServiceWorkerPool.stop();
  });

  group('getDeviceCapabilities', () {
    test(
        'returns an List<DeviceCapabilities> if the http call completes successfully',
        () async {
      final mockHttpClient = MockHttpClientBuilder().generateClient();
      final mockedFutureSDRService =
          FutureSDRService.create(mockHttpClient, converter);

      final response = await rootBundle
          .loadString('assets/network/device_capabilities.json', cache: false);

      final responseJson = json.decode(response);

      final parsed = List<DeviceCapabilities>.empty(growable: true);
      for (var entry in responseJson) {
        parsed.add(DeviceCapabilities.fromJson(entry));
      }

      final result = await mockedFutureSDRService.getDeviceCapabilities();

      expect(result.body, parsed);
      //verify(() => mockedFutureSDRService.getDeviceCapabilities()).called(1);
      //expect(result.body, isA<List<DeviceCapabilities>>());
    });

    test('throws an exception if the http call completes with an error',
        () async {
      final mockHttpClient =
          MockHttpClientBuilder().generateErrorClient('Server Error', 500);
      final mockedFutureSDRService =
          FutureSDRService.create(mockHttpClient, converter);

      final result = await mockedFutureSDRService.getDeviceCapabilities();

      expect(result.statusCode, 500);
    });
  });
}

I like my solution so far, because i can exchange the httpClient easily. The only thing i would like to improve is to have the setup and shutdown of the converter directly integrated in the ChopperService, but i didnt find a proper way of doingthat so far.

Thanks for your help so far and I wish you a happy new year!

techouse commented 10 months ago

@ratzrattillo I'm not sure why you need to test decoding your models from JSON with a mocked Chopper client. What's the purpose of doing that? The Chopper functionality has already been tested by us package maintainers. You should simply feed that raw JSON directly to your model's fromJson method if you use json_serializable for example.

ratzrattillo commented 10 months ago

@techouse Yes, actually you are right. I do not seem to need that. The information regarding the custom converter was nonetheless definitely helpful to me. Thank you for your help. I will close this issue as it arose from a lack of understanding on my side.