External Interface Layer
The final piece of the Framework is the most flexible one, since it work as a wrapper for any external dependency code from libraries and modules. If coded properly, they will protect you from dependencies migrations and version upgrades.
As usual, let's study the example first:
class TestInterface extends ExternalInterface<TestRequest, TestResponse> {
TestInterface(GatewayProvider provider)
: super([() => provider.getGateway(context)]);
@override
void handleRequest() {
// For normal Gateways
on<FutureTestRequest>(
(request, send) async {
await Future.delayed(Duration(milliseconds: 100));
send(Right(TestResponse('success')));
},
);
// For WatcherGateways
on<StreamTestRequest>(
(request, send) async {
final stream = Stream.periodic(
Duration(milliseconds: 100),
(count) => count,
);
final subscription = stream.listen(
(count) => send(Right(TestResponse(count.toString()))),
);
await Future.delayed(Duration(milliseconds: 500));
subscription.cancel();
},
);
}
}
First let's understand the constructor. It requires a list of Gateway references, which are normally retrieved from providers. During tests, you can add the object reference direcly.
When the External Interface gets created by its Provider, this connection will attach the object to the mechanism that the Gateway uses to send Requests.
The handleRequest method will have one or multiple calls of the on method, each one associated to a Request Type. These types must extend from the Response type specified on the generics class declaration.
Each of the on calls will send back an Either instance, where the Right value is a SuccessResponse, and the Left is a FailureResponse.
External Interfaces are meant to listen to groups of Requests that use the same dependency. Clean Framework has default implementations of external interfaces for Firebase, GraphQL and REST services, ready to be used in any application, you just need to create the providers using them.
Testing and Coding the External Interface
For the final changes on our Add Machine app, we will move the code for the static number in the Gateway to the External Interface. There are no further chages on the current tests, but as an exercise you can add an integration test that confirms the last scenario by adding a way to navigate to the feature, pop out, then open it again to confirm the number is preserved.
This is the remaining code:
lib/features/add_machine/external_interface/add_machine_external_interface.dart
class AddMachineExternalInterface
extends ExternalInterface<Request, AddMachineGetTotalResponse> {
int _savedNumber;
AddMachineExternalInterface({
required List<GatewayConnection> gatewayConnections,
int number = 0,
}) : _savedNumber = number,
super(gatewayConnections);
@override
void handleRequest() {
on<AddMachineGetTotalRequest>((request, send) {
send(AddMachineGetTotalResponse(_savedNumber));
});
on<AddMachineSetTotalRequest>((request, send) {
_savedNumber = request.number;
send(AddMachineGetTotalResponse(_savedNumber));
});
}
@override
FailureResponse onError(Object error) {
// left empty, enhance as an exercise later
return UnknownFailureResponse();
}
}
See how now we handle two types of request, one to just get the saved total, and the other to modify the total before sending the current value. This requires the creation of another Gateway and request, as follows:
lib/features/add_machine/external_interface/add_machine_set_total_gateway.dart
class AddMachineSetTotalGateway extends Gateway<
AddMachineSetTotalOutput,
AddMachineSetTotalRequest,
AddMachineGetTotalResponse,
AddMachineGetTotalInput> {
AddMachineSetTotalGateway({
ProvidersContext? context,
UseCaseProvider? provider,
UseCase? useCase,
}) : super(context: context, provider: provider, useCase: useCase);
@override
buildRequest(AddMachineSetTotalOutput output) {
return AddMachineSetTotalRequest(output.number);
}
@override
FailureInput onFailure(covariant FailureResponse failureResponse) {
throw UnimplementedError();
}
@override
onSuccess(covariant AddMachineGetTotalResponse response) {
return AddMachineGetTotalInput(response.number);
}
}
class AddMachineSetTotalRequest extends Request {
final int number;
AddMachineSetTotalRequest(this.number);
}
And here are the changes for the rest of components:
lib/features/add_machine/domain/add_machine_use_case.dart
class AddMachineUseCase extends UseCase<AddMachineEntity> {
AddMachineUseCase()
: super(entity: AddMachineEntity(0), outputFilters: {
AddMachineUIOutput: (AddMachineEntity e) =>
AddMachineUIOutput(total: e.total),
}) {
onCreate();
}
void onAddNumber(int number) async {
await request(AddMachineSetTotalOutput(number + entity.total),
onSuccess: (AddMachineGetTotalInput input) {
return AddMachineEntity(input.number);
}, onFailure: (_) {
return entity;
});
}
void onCreate() async {
await request(AddMachineGetTotalOutput(),
onSuccess: (AddMachineGetTotalInput input) {
return AddMachineEntity(input.number);
},
onFailure: (_) => entity);
}
}
lib/features/add_machine/presentation/add_machine_presenter.dart
class AddMachinePresenter extends Presenter<AddMachineViewModel,
AddMachineUIOutput, AddMachineUseCase> {
AddMachinePresenter({
required UseCaseProvider provider,
required PresenterBuilder<AddMachineViewModel> builder,
}) : super(provider: provider, builder: builder);
@override
AddMachineViewModel createViewModel(useCase, output) => AddMachineViewModel(
total: output.total.toString(),
onAddNumber: (number) => _onAddNumber(useCase, number));
void _onAddNumber(AddMachineUseCase useCase, String number) {
useCase.onAddNumber(int.parse(number));
}
@override
void onLayoutReady(context, AddMachineUseCase useCase) => useCase.onCreate();
}
The main change is that now the Use Case uses a specific method to handle the request to change the saved number, instead of using an input filter.
And finally, some minor corrections to all the tests, just to enable all the providers:
final context = ProvidersContext();
late UseCaseProvider addMachineUseCaseProvider;
late GatewayProvider getTotalGatewayProvider;
late GatewayProvider setTotalGatewayProvider;
late ExternalInterfaceProvider externalInterfaceProvider;
void main() {
final sumTotalWidget = find.byKey(Key('SumTotalWidget'));
void setup() {
addMachineUseCaseProvider = UseCaseProvider((_) => AddMachineUseCase());
getTotalGatewayProvider = GatewayProvider<AddMachineGetTotalGateway>((_) =>
AddMachineGetTotalGateway(
context: context, provider: addMachineUseCaseProvider));
setTotalGatewayProvider = GatewayProvider<AddMachineSetTotalGateway>((_) =>
AddMachineSetTotalGateway(
context: context, provider: addMachineUseCaseProvider));
externalInterfaceProvider = ExternalInterfaceProvider((_) =>
AddMachineExternalInterface(
gatewayConnections: <GatewayConnection<Gateway>>[
() => getTotalGatewayProvider.getGateway(context),
() => setTotalGatewayProvider.getGateway(context),
]));
getTotalGatewayProvider.getGateway(context);
setTotalGatewayProvider.getGateway(context);
externalInterfaceProvider.getExternalInterface(context);
}
/// Given I have navigated to the Add Machine feature
/// Then I will see the Add Machine screen
/// And the total shown will be 0.
uiTest(
'AddMachineUI unit test - Scenario 1',
context: context,
builder: () => AddMachineUI(provider: addMachineUseCaseProvider),
setup: setup,
verify: (tester) async {
expect(find.text('Add Machine'), findsOneWidget);
expect(sumTotalWidget, findsOneWidget);
expect(find.descendant(of: sumTotalWidget, matching: find.text('0')),
findsOneWidget);
},
);
/// Given I opened the Add Machine feature
/// When I write a number on the number field
/// And I press the "Add" button
/// Then the total shown will be the entered number.
uiTest(
'AddMachineUI unit test - Scenario 2',
context: context,
builder: () => AddMachineUI(provider: addMachineUseCaseProvider),
setup: setup,
verify: (tester) async {
final numberField = find.byKey(Key('NumberField'));
expect(numberField, findsOneWidget);
await tester.enterText(numberField, '15');
final addButton = find.byKey(Key('AddButton'));
expect(addButton, findsOneWidget);
await tester.tap(addButton);
await tester.pumpAndSettle();
await tester.pumpAndSettle();
expect(sumTotalWidget, findsOneWidget);
expect(find.descendant(of: sumTotalWidget, matching: find.text('15')),
findsOneWidget);
},
);
/// Given I have entered a number on the Add Machine feature
/// When I write another number and press "Add"
/// Then the total shown will be the sum of both numbers.
uiTest(
'AddMachineUI unit test - Scenario 3',
context: context,
builder: () => AddMachineUI(provider: addMachineUseCaseProvider),
setup: setup,
verify: (tester) async {
final numberField = find.byKey(Key('NumberField'));
expect(numberField, findsOneWidget);
await tester.enterText(numberField, '15');
final addButton = find.byKey(Key('AddButton'));
expect(addButton, findsOneWidget);
await tester.tap(addButton);
await tester.pumpAndSettle();
expect(sumTotalWidget, findsOneWidget);
expect(find.descendant(of: sumTotalWidget, matching: find.text('15')),
findsOneWidget);
await tester.enterText(numberField, '7');
await tester.tap(addButton);
await tester.pumpAndSettle();
expect(find.descendant(of: sumTotalWidget, matching: find.text('22')),
findsOneWidget);
},
);
/// Given I have added one or more numbers on the Add Machine feature
/// When I navigate away and open the feature again
/// Then the total shown is the previous total that was shown.
uiTest(
'AddMachineUI unit test - Scenario 4',
context: context,
builder: () => AddMachineUI(provider: addMachineUseCaseProvider),
setup: () {
setup();
final gateway = setTotalGatewayProvider.getGateway(context);
// We add a pre-existent request, so by the time the UI is build,
// the use case already has this value
gateway.transport(AddMachineSetTotalRequest(740));
},
verify: (tester) async {
expect(find.descendant(of: sumTotalWidget, matching: find.text('740')),
findsOneWidget);
},
);
}