Mocking http.Client (updated)

In the previous post from 2022, we already saw that we can use Mockito with @GenerateNiceMocks to generate a MockClient, or use http's own MockClient and pass it to classes. But what if the code is from a package that does not accept a Client?

Investigation

In that case we can't easily pass a mock Client. Let's look at the code that will run with the test:

var response = await http.get(Uri.parse(url), headers: allHeaders);

If we dig into http's code, we see:

Future<Response> get(Uri url, {Map<String, String>? headers}) =>
    _withClient((client) => client.get(url, headers: headers));


Future<T> _withClient<T>(Future<T> Function(Client) fn) async {
  var client = Client();
  try {
    return await fn(client);
  } finally {
    client.close();
  }
}


abstract interface class Client {
  /// Creates a new platform appropriate client.
  ///
  /// Creates an `IOClient` if `dart:io` is available and a `BrowserClient` if
  /// `dart:js_interop` is available, otherwise it will throw an unsupported
  /// error.
  factory Client() => zoneClient ?? createClient();

  ...
}

@internal
Client? get zoneClient {
  final client = Zone.current[#_clientToken];
  return client == null ? null : (client as Client Function())();
}


/// Runs [body] in its own [Zone] with the [Client] returned by [clientFactory]
/// set as the default [Client].
/// [...]
/// > [!IMPORTANT]
/// > Flutter does not guarantee that callbacks are executed in a particular
/// > [Zone].
/// >
/// > Instead of using [runWithClient], Flutter developers can use a framework,
/// > such as [`package:provider`](https://pub.dev/packages/provider), to make
/// > a [Client] available throughout their applications.
/// >
/// > See the
/// > [Flutter Http Example](https://github.com/dart-lang/http/tree/master/pkgs/flutter_http_example).
R runWithClient<R>(R Function() body, Client Function() clientFactory,
        {ZoneSpecification? zoneSpecification}) =>
    runZoned(body,
        zoneValues: {#_clientToken: Zone.current.bindCallback(clientFactory)},
        zoneSpecification: zoneSpecification);
http

runWithClient

Here's how I used runWithClient to pass my MockClient:

await http.runWithClient(
  () async {
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: Provider<FirebaseAuth>(
            create: (_) => MockFirebaseAuth(),
            child: Provider<ChatService>(
              create: (_) => MockChatService(),
              child: BubbleWidget(b),
            ),
          ),
        ),
      ),
    );
  },
  () => MockClient((request) async {
    final content = File('test/airtag.html').readAsStringSync();
    return http.Response(
      content,
      200,
      headers: {'content-type': 'text/html'},
    );
  }),
);

content-type

I can see that my MockClient callback is executed but I get this exception when initializing Response:

[package_name] - Error in https://www.[site].com/[some_article].html response (Invalid argument (string): Contains invalid characters.: "<!DOCTYPE html><html lang=\"en-US\"><head><meta charSet=\"utf-8\"/>

I can see that readAsStringSync() reads in utf-8 by default, so content is fine. Should I add content-type to the headers?

I tried with headers: {'content-type': 'text/html', 'charset': 'utf-8'}, but it failed too.

Later when I googled some more, I found a post that set it up like this: headers: {'content-type': 'text/html; charset=utf-8'}. And sure enough, this worked.

Looking back, if I had carefully read the documentation, I'd have found out myself:

/// An HTTP response where the entire response body is known in advance.
class Response extends BaseResponse {
  /// The bytes comprising the body of this response.
  final Uint8List bodyBytes;

  /// The body of the response as a string.
  ///
  /// This is converted from [bodyBytes] using the `charset` parameter of the
  /// `Content-Type` header field, if available. If it's unavailable or if the
  /// encoding name is unknown:
  /// - [utf8] is used when the content-type is 'application/json' (see [RFC 3629][]).
  /// - [latin1] is used in all other cases (see [RFC 2616][])
  ///
  /// [RFC 3629]: https://www.rfc-editor.org/rfc/rfc3629.
  /// [RFC 2616]: http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html
  String get body => _encodingForHeaders(headers).decode(bodyBytes);

  /// Creates a new HTTP response with a string body.
  Response(String body, int statusCode,
http's Response.dart

Here's the examples from https://www.rfc-editor.org/rfc/rfc9110.html (redirected from RFC 2616):

For example, the following media types are equivalent in describing HTML text data encoded in the UTF-8 character encoding scheme, but the first is preferred for consistency (the "charset" parameter value is defined as being case-insensitive in [RFC2046], Section 4.1.2):

  text/html;charset=utf-8
  Text/HTML;Charset="utf-8"
  text/html; charset="utf-8"
  text/html;charset=UTF-8

Now the test works fine.

Clean up with Provider?

According to the docs for runWithClient, in Flutter, we can use a Provider instead. This makes the code much cleaner:

await tester.pumpWidget(
  MaterialApp(
    home: Scaffold(
      body: Provider<http.Client>(
        create: (_) => MockClient((request) async {
          MockClient((request) async {
            final content = File('test/airtag.html').readAsStringSync();
            return http.Response(
              content,
              200,
              headers: {'content-type': 'text/html; charset=utf-8'},
            );
          }),
        }),
        child: BubbleWidget(b),
      ),
    ),
  ),
);

Unfortunately, this did not work. I don't know the system well enough to figure out why. But basically, the Widget that was supposed to use Client and render, seemed to be immediately destroyed?... I don't even know if that's possible. I put breakpoints in that Widget's build and it was never called.

So I ended up using runWithClient. Maybe some day I'll figure out how to use Provider.