-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Description
What is your question?
Due to the API of the Share plugin being only static methods, it's proving to be difficult to write testable code around these interactions. A standard practice we have in our project (and most other enterprise solutions I've worked on) is to wrap dependencies to decouple them from other layers of our code. In this case, I'd like to wrap sharing functionality in a ShareClient class so that if I needed to swap out concrete sharing implementations later (e.g. using something other than the share_plus package) then I only need to refactor the implementation of this client.
Here's a sample implementation:
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart';
/// {@template share_client_exception}
/// Exception thrown if a [ShareClient] operation fails.
/// {@endtemplate}
class ShareClientException implements Exception {
/// {@macro share_client_exception}
const ShareClientException(this.error);
/// Error thrown during the share operation.
final Object error;
}
/// {@template share_client}
/// A client for sharing files using the platform's native share functionality.
/// {@endtemplate}
class ShareClient {
/// {@macro share_client}
const ShareClient({
AssetBundle? assetBundle,
}) : _assetBundle = assetBundle;
final AssetBundle? _assetBundle;
/// Initiates a file sharing action for the resource at [url] using the
/// platform's native sharing capabilities.
///
/// [mimeType] should be specified to allow the [Share] plugin to infer the
/// correct file extension.
///
/// Throws [ShareClientException] if an error occurs while attempting to
/// share a file.
Future<void> shareFileFromUrl(
String url, {
String? mimeType,
}) async {
try {
final uri = Uri.parse(url);
final assetBundle = _assetBundle ?? NetworkAssetBundle(uri);
final bytes = (await assetBundle.load(url)).buffer.asUint8List();
await Share.shareXFiles([
XFile.fromData(
bytes,
mimeType: mimeType,
)
]);
} catch (error, stackTrace) {
Error.throwWithStackTrace(
ShareClientException('Unable to share file from $url'),
stackTrace,
);
}
}
/// A wrapper for [shareFileFromUrl] specific to image file types.
Future<void> shareImageFromUrl(String url) async {
await shareFileFromUrl(url, mimeType: 'image/png');
}
}This is onerous to test because the plugin cannot be easily mocked since it is using a static method. So then we have to go a layer down, which is to mock out the platform interactions at the MethodChannel level:
// ignore_for_file: prefer_const_constructors
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:share_client/share_client.dart';
import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart';
class _MockAssetBundle extends Mock implements AssetBundle {}
const String kTemporaryPath = 'temporaryPath';
class FakePathProviderPlatform extends Fake
with MockPlatformInterfaceMixin
implements PathProviderPlatform {
@override
Future<String?> getTemporaryPath() async {
return kTemporaryPath;
}
}
void main() {
group('ShareClient', () {
const shareChannel = MethodChannelShare.channel;
final log = <MethodCall>[];
late ShareClient shareClient;
late AssetBundle assetBundle;
const url = 'https://www.test.com/test.jpg';
setUp(() {
TestWidgetsFlutterBinding.ensureInitialized();
PathProviderPlatform.instance = FakePathProviderPlatform();
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger
.setMockMethodCallHandler(
shareChannel,
(call) async => log.add(call),
);
assetBundle = _MockAssetBundle();
when(() => assetBundle.load(any()))
.thenAnswer((_) => Future.value(ByteData(8)));
shareClient = ShareClient(
assetBundle: assetBundle,
);
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger
.setMockMethodCallHandler(shareChannel, null);
log.clear();
});
test('can be instantiated', () {
expect(shareClient, isNotNull);
});
group('shareFileFromUrl', () {
test('completes', () {
expect(shareClient.shareFileFromUrl(url), completes);
});
test('returns normally', () {
expect(() => shareClient.shareFileFromUrl(url), returnsNormally);
});
});
});
}Now because this plugin interacts with the filesystem implicitly, we have to mock out the PathProvider platform interactions as their plugin suggests. However, when running the completes test case, you can see the following failure:
error: PathNotFoundException: Cannot open file, path = 'temporaryPath/f56611ce-327c-49d1-a584-09643a91d7fc.octet-stream' (OS Error: No such file or directory, errno = 2)
stackTrace: #0 _checkForErrorResponse (dart:io/common.dart:42:9)
#1 _File.open. (dart:io/file_impl.dart:364:7)
#2 StackZoneSpecification._registerUnaryCallback.. (package:stack_trace/src/stack_zone_specification.dart:124:36)
#3 StackZoneSpecification._run (package:stack_trace/src/stack_zone_specification.dart:204:15)
#4 StackZoneSpecification._registerUnaryCallback. (package:stack_trace/src/stack_zone_specification.dart:124:24)
#5 _rootRunUnary (dart:async/zone.dart:1406:47)
#6 _CustomZone.runUnary (dart:async/zone.dart:1307:19)
#7 StackZoneSpecification._registerUnaryCallback. (package:stack_trace/src/stack_zone_specification.dart:124:15)
At this point, I have to throw my hands up because I don't see a way to mock out the file system interactions beyond what we have access to since the Share plugin is creating File objects and attempting to write to them. Even if I could, I think this test is already reaching too far into the implementation of Share.shareXFiles in order to test the code surrounding it to be a reasonably maintainable test, when in normal use cases we would just mock out the behavior of shareXFiles, if it were a non-static method on an object that we could inject a mock in place of through constructor injection.
Am I missing something from a testability aspect here? If not, has there been consideration on how to structure the interface of this plugin to allow for greater testability? I'm curious how others in the community deal with this.
Checklist before submitting a question
- I Google'd a solution and I couldn't find it
- I searched on StackOverflow for a solution and I couldn't find it
- I read the README.md file of the plugin
- I am using the latest version of the plugin
- All dependencies are up to date with
flutter pub upgrade - I did a
flutter clean - I tried running the example project