Fala, pessoal! Tudo certo? Todos nós já nos deparamos com cenários de teste em que o objeto sob teste (SUT) depende de outro objeto, cujo retorno é feito a partir de um callback. Como poderíamos estressar nosso SUT? Nesse artigo, vamos explorar como testar esses cenários!

Contexto

Imagine que nosso sistema possui uma funcionalidade de transferência entre contas. Geralmente, o processo de transferência envolve algumas etapas, como verificar o saldo, validar a conta de destino e receber o identificador de sucesso da transferência.

Em termos de código teríamos

abstract class BankRepository {
	void transfer(
		required Function(String transactionId) onSuccess,
		required Function(String error) onError,
		required String fromAccount,
		required String toAccount,
		required double amount,
	);
}
import 'package:flutter_bloc/flutter_bloc.dart';
 
abstract class BankState {}
 
class InitialState extends BankState {}
 
class TransferInProgress extends BankState {}
 
class TransferSuccess extends BankState {
  final String transactionId;
  TransferSuccess(this.transactionId);
}
 
class TransferError extends BankState {
  final String error;
  TransferError(this.error);
}
 
class BankCubit extends Cubit<BankState> {
  final BankService _bankService;
 
  BankCubit(this._bankService) : super(InitialState());
 
  void transfer(String fromAccount, String toAccount, double amount) {
    emit(TransferInProgress());
    _bankService.transferMoney(
      fromAccount: fromAccount,
      toAccount: toAccount,
      amount: amount,
      onSuccess: (transactionId) {
        emit(TransferSuccess(transactionId));
      },
      onError: (error) {
        emit(TransferError(error));
      },
    );
  }
}
 

Vamos dar uma olhada como poderíamos garantir a emissão tanto no estado de sucesso quanto no de erro?

Garantindo o estado de sucesso

import 'package:bloc_test/bloc_test.dart';
import 'package:test/test.dart';
import 'package:mocktail/mocktail.dart';
 
class MockBankService extends Mock implements BankService {}
 
void main() {
  group('BankCubit', () {
    late BankCubit bankCubit;
    late MockBankService mockBankService;
 
    setUp(() {
      mockBankService = MockBankService();
      bankCubit = BankCubit(mockBankService);
    });
 
    blocTest<BankCubit, BankState>(
      'emits [TransferInProgress, TransferSuccess] when transfer is successful',
      build: () {
        when(() => mockBankService.transferMoney(
          fromAccount: any(named: 'fromAccount'),
          toAccount: any(named: 'toAccount'),
          amount: any(named: 'amount'),
          onSuccess: any(named: 'onSuccess'),
          onError: any(named: 'onError'),
        )).thenAnswer((invocation) {
          final onSuccess = invocation.namedArguments[#onSuccess] as Function(String);
          onSuccess("TX123456");
        });
 
        return bankCubit;
      },
      act: (cubit) => cubit.transfer("123", "456", 100.0),
      expect: () => [
        TransferInProgress(),
        TransferSuccess("TX123456"),
      ],
    );
  });
}
 

Garantindo o estado de erro

blocTest<BankCubit, BankState>(
  'emits [TransferInProgress, TransferError] when transfer fails',
  build: () {
    when(() => mockBankService.transferMoney(
      fromAccount: any(named: 'fromAccount'),
      toAccount: any(named: 'toAccount'),
      amount: any(named: 'amount'),
      onSuccess: any(named: 'onSuccess'),
      onError: any(named: 'onError'),
    )).thenAnswer((invocation) {
      final onError = invocation.namedArguments[#onError] as Function(String);
      onError("Insufficient funds");
    });
    return bankCubit;
  },
  act: (cubit) => cubit.transfer("123", "456", 2000.0),
  expect: () => [
    TransferInProgress(),
    TransferError("Insufficient funds"),
  ],
);

Explicação

Tanto no cenário de erro quanto no de sucesso, capturamos o Callback correspondente usando o invocation.

O invocation atua como um ‘gravador’, armazenando todas as interações com o método mockado, juntamento com os parâmetros utilizados.

Como acessar os argumentos nomeados?

Para acessarmos os argumentos nomeados por meio do invocation utilizaremos a propriedade namedArguments. Dessa forma conseguiremos ter acesso ao callback que foi passado para o método e chamá-lo manualmente, permitindo que exercitemos os cenários de teste. Para isso:

final onSuccess = invocation.namedArguments[const Symbol('onSuccess')];

ou

final onSuccess = invocation.namedArguments[#onSuccess];

Conclusão

Em alguns casos, podemos nos deparar com callbacks em nossos SUT, e, à primeira vista, eles podem parecer desfiadores. Mas, como vimos, o uso do mocktail facilita nossa vida nesses casos.

Ao capturarmos os argumentos passados para o callback através do invocation, conseguimos simular os valores necessários dado o contexto de nosso cenário. Em nosso exemplo, isso permitiu garantir a emissão correta dos estados, de acordo com o retorno de nosso serviço.