Como fazer teste de unidade de subclasses AsyncNotifier com Riverpod 2.x

Tempo de leitura: 7 minutes

Escrever aplicativos Flutter ficou muito mais fácil com o lançamento do Riverpod 2.0.

A nova sintaxe @riverpod nos permite usar build_runner para gerar todos os provedores dinamicamente.

E a nova classe AsyncNotifier facilita a execução da inicialização assíncrona com uma API mais ergonômica, em comparação com o bom e velho StateNotifier.

 

Mas quando se trata de escrever testes, as coisas podem ficar complicadas e pode ser um desafio fazê-los funcionar.

E se atualizarmos nosso código substituindo StateNotifier por AsyncNotifier, descobriremos que testes antigos baseados em StateNotifier não funcionarão mais.

Portanto, neste artigo, aprenderemos como escrever testes unitários para subclasses AsyncNotifier.

Aqui está o que iremos cobrir:

  • como trabalhar com ProviderContainer e substituir provedores dentro de nossos testes
  • como configurar um listener de provedor usando um ProviderSubscription
  • como verificar se o ouvinte é chamado usando o pacote mocktail

Ao longo do caminho, descobriremos algumas dicas e destacaremos as vantagens de testar com listeners vs streams.

Ao final deste artigo, você terá um melhor entendimento e um modelo claro para escrever testes unitários com Riverpod. 💪

A documentação oficial do Riverpod já inclui uma página útil sobre testes, mas não mostra como escrever testes assíncronos para classes com dependências. Este artigo preencherá as lacunas.

 

Exemplo: uma subclasse AsyncNotifier

Como exemplo, vamos considerar a seguinte subclasse AsyncNotifier:

// 1. importe isto
import 'package:riverpod_annotation/riverpod_annotation.dart';

// 2. declara um arquivo part
part 'auth_controller.g.dart';

// 3. anotar
@riverpod
// 4. estender assim
class AuthController extends _$AuthController {
  // 5. substitua o método [build] para retornar um [FutureOr]
  @override
  FutureOr<void> build() {
    // 6. retornar um valor (ou não fazer nada se o tipo de retorno for nulo)
  }

  Future<void> signInAnonymously() async {
    // 7. leia o repositório usando ref
    final authRepository = ref.read(authRepositoryProvider);
    // 8. definir o estado de carregamento
    state = const AsyncLoading();
    // 9. faça login e atualize o estado (dados ou erro)
    state = await AsyncValue.guard(authRepository.signInAnonymously);
  }
}

O objetivo desta aula é:

  • faça login anonimamente usando authRepository como uma dependência
  • notificar qualquer widget de ouvinte quando o estado mudar (dados, carregamento ou erro)

Já abordei esta classe de exemplo no artigo anterior sobre Notifier e AsyncNotifier, portanto não vou repetir todos os detalhes aqui.

Em vez disso, vamos direto aos testes. 👇

 

Visão geral dos testes de unidade

Mais uma vez, aqui está a classe que queremos testar:

@riverpod
class AuthController extends _$AuthController {
  @override
  FutureOr<void> build() {
    // o estado será [AsyncData] quando este método retornar
  }

  Future<void> signInAnonymously() async {
    final authRepository = ref.read(authRepositoryProvider);
    state = const AsyncLoading();
    // o estado será [AsyncData] em caso de sucesso ou [AsyncError] em caso de erro
    state = await AsyncValue.guard(authRepository.signInAnonymously);
  }
}

Para testar completamente esta classe, precisamos verificar se o estado foi inicializado corretamente quando criamos o AuthController.

E quando chamamos o método signInAnonymously, devemos verificar se:

  • o estado está definido como AsyncLoading
  • authRepository.signInAnonymously() é chamado
  • quano a chamada AsyncValue.guard retorna, o estado é definido como AsyncData ou AsyncError

Para cobrir todos esses cenários, escreveremos três testes separados:

  • Estado inicial
  • sucesso de login
  • falha de login

Então vamos começar. 👇

 

Escrevendo o primeiro teste (inicialização)

Aqui está o código completo para o primeiro teste, que é baseado no guia de teste nos documentos oficiais do Riverpod:

import 'package:mocktail/mocktail.dart';

// crie uma simulação para a classe que precisamos testar
class MockAuthRepository extends Mock implements FakeAuthRepository {}

// uma classe Listener genérica, usada para acompanhar quando um provedor notifica seus ouvintes
class Listener<T> extends Mock {
  void call(T? previous, T next);
}

void main() {
  // um método auxiliar para criar um ProviderContainer que substitui o authRepositoryProvider
  ProviderContainer makeProviderContainer(MockAuthRepository authRepository) {
    final container = ProviderContainer(
      overrides: [
        authRepositoryProvider.overrideWithValue(authRepository),
      ],
    );
    addTearDown(container.dispose);
    return container;
  }

  test('initial state is AsyncData', () {
    final authRepository = MockAuthRepository();
    // crie o ProviderContainer com o repositório de autenticação simulado
    final container = makeProviderContainer(authRepository);
    // crie um listener
    final listener = Listener<AsyncValue<void>>();
    // ouça o provedor e ligue para [listener] sempre que seu valor mudar
    container.listen(
      authControllerProvider,
      listener,
      fireImmediately: true,
    );
    // verifica
    verify(
      // o método build retorna um valor imediatamente, então esperamos AsyncData
      () => listener(null, const AsyncData<void>(null)),
    );
    // verifique se o listener não é mais chamado
    verifyNoMoreInteractions(listener);
    // verifique se [signInAnonymously] não foi chamado durante a inicialização
    verifyNever(authRepository.signInAnonymously);
  });
});

A parte mais importante do teste é a chamada para container.listen().

final listener = Listener<AsyncValue<void>>();
// ouça o provedor e ligue para [listener] sempre que seu valor mudar
container.listen(
  authControllerProvider,
  listener,
  fireImmediately: true,
);

Chamar esse método no authControllerProvider faz com que o AuthController subjacente seja inicializado, pois todos os provedores são carregados lentamente.

E como o listener em si é um objeto simulado, podemos usá-lo para verificar se ele é chamado:

verify(
  // o método build retorna um valor imediatamente, então esperamos AsyncData
  () => listener(null, const AsyncData<void>(null)),
);

Nesse caso, esperamos que o valor anterior seja null e o próximo valor seja AsyncData<void>(null).

Sempre use anotações de tipo ao trabalhar com genéricos em testes. Se você não fizer isso e simplesmente usar AsyncData(null), o teste falhará porque AsyncData<dynamic> não é o mesmo que AsyncData<void>.

Nosso primeiro teste foi concluído e podemos reutilizar a maior parte do código enquanto nos concentramos nos testes restantes.

 

Escrevendo o segundo teste (sucesso)

Aqui está o código completo para o segundo teste:

test('sucesso de login', () async {
  final authRepository = MockAuthRepository();
  // método stub para retornar sucesso
  when(authRepository.signInAnonymously).thenAnswer((_) => Future.value());
  // crie o ProviderContainer com o repositório de autenticação simulado
  final container = makeProviderContainer(authRepository);
  // crie um listener
  final listener = Listener<AsyncValue<void>>();
  // listen para o provider e call [listener] sempre que seu valor muda
  container.listen(
    authControllerProvider,
    listener,
    fireImmediately: true,
  );
  // armazene isso em uma variável, pois precisaremos dela várias vezes
  const data = AsyncData<void>(null);
  // verifique o valor inicial do método de construção
  verify(() => listener(null, data));
  // obtenha o controlador via container.read
  final controller = container.read(authControllerProvider.notifier);
  // run
  await controller.signInAnonymously();
  // verify
  verifyInOrder([
    // transição dos dados para o estado de carregamento
    () => listener(data, AsyncLoading<void>()),
    // transição do estado de carregamento para dados
    () => listener(AsyncLoading<void>(), data),
  ]);
  verifyNoMoreInteractions(listener);
  verify(authRepository.signInAnonymously).called(1);
});

O código de configuração é bastante semelhante ao teste anterior.

O que é mais interessante é este código:

// obtenha o controlador via container.read
final controller = container.read(authControllerProvider.notifier);
// run
await controller.signInAnonymously();
// verify
verifyInOrder([
  // transição dos dados para o estado de carregamento
  () => listener(data, AsyncLoading<void>()),
  // transição do estado de carregamento para dados
  () => listener(AsyncLoading<void>(), data),
]);
verifyNoMoreInteractions(listener);
verify(authRepository.signInAnonymously).called(1);

Nesse caso, obtemos nosso controlador lendo-o do contêiner.

Se você estiver testando uma classe que usa ref internamente, não poderá instanciá-la diretamente, pois isso levará a um LateInitializationError. Em vez disso, leia o provedor correspondente do ProviderContainer para garantir que o estado do provedor seja inicializado corretamente.

Então, podemos usá-lo para chamar controller.signInAnonymously().

E depois disso, podemos verificar se o estado está definido duas vezes com o método verifyInOrder.

E finalmente, podemos verificar se o método authRepository.signInAnonymously foi chamado.

 

O teste falha… e agora?

Mas se executarmos o teste acima, obteremos um erro assustador:

Matching call #0 not found. All calls: [VERIFIED]
Listener<AsyncValue<void>>.call(null, AsyncData<void>(value: null)),
Listener<AsyncValue<void>>.call(AsyncData<void>(value: null), AsyncLoading<void>(value: null)),
Listener<AsyncValue<void>>.call(AsyncLoading<void>(value: null), AsyncData<void>(value: null))

À primeira vista parece que tudo está em ordem já que o listener é chamado para cada transição de estado:

  • de null para AsyncData (durante a inicialização)
  • de AsyncData para AsyncLoading (estado de carregamento)
  • de AsyncLoading para AsyncData (estado final após a conclusão do login)

Mas, olhando mais de perto, há uma diferença sutil entre o valor real e o valor esperado:

  • AsyncLoading<void>(valor: null) != AsyncLoading<void>()

Isso acontece porque mesmo que definamos o estado como AsyncLoading() dentro de nosso controlador, AsyncNotifier injetará os dados anteriores (null neste caso). E isso faz com que o teste falhe. 😭

Depois de puxar meu cabelo um pouco, descobri uma solução. 👇

 

Solução: use um matcher

Resumindo: para fazer o teste funcionar, podemos substituir este código:

// verify
verifyInOrder([
  // transição dos dados para o estado de carregamento
  () => listener(data, AsyncLoading<void>()),
  // transição do estado de carregamento para dados
  () => listener(AsyncLoading<void>(), data),
]);

Com isso:

verifyInOrder([
  //define o estado de carregamento
  // * usa um matcher desde AsyncLoading != AsyncLoading com dados
  () => listener(data, any(that: isA<AsyncLoading>())),
  // dados quando concluído
  () => listener(any(that: isA<AsyncLoading>()), data),
]);

Isso resultará em mais uma mensagem de erro:

Bad state: A test tried to use `any` or `captureAny` on a parameter of type `AsyncValue<void>`, but
registerFallbackValue was not previously called to register a fallback value for `AsyncValue<void>`.

Podemos resolver isso adicionando um método setUpAll ao nosso arquivo de teste:

setUpAll(() {
    registerFallbackValue(const AsyncLoading<void>());
  });

E com isso implementado, o teste agora foi bem-sucedido. ✅

Você pode aprender mais sobre any e RegisterFallbackValue na documentação do mocktail.

 

Escrevendo o terceiro teste (falha)

O último teste que precisamos escrever é bastante semelhante ao anterior:

test('sign-in failure', () async {
  // setup
  final authRepository = MockAuthRepository();
  final exception = Exception('Connection failed');
  when(authRepository.signInAnonymously).thenThrow(exception);
  final container = makeProviderContainer(authRepository);
  final listener = Listener<AsyncValue<void>>();
  container.listen(
    authControllerProvider,
    listener,
    fireImmediately: true,
  );
  const data = AsyncData<void>(null);
  // verifica o valor inicial do método build
  verify(() => listener(null, data));
  // run
  final controller = container.read(authControllerProvider.notifier);
  await controller.signInAnonymously();
  // verify
  verifyInOrder([
     // define o estado de carregamento
     // * usa um matcher desde AsyncLoading != AsyncLoading com dados
    () => listener(data, any(that: isA<AsyncLoading>())),
    // erro ao concluir
    () => listener(
        any(that: isA<AsyncLoading>()), any(that: isA<AsyncError>())),
  ]);
  verifyNoMoreInteractions(listener);
  verify(authRepository.signInAnonymously).called(1);
});

As únicas diferenças são que:

  • nosso repositório de autenticação simulado para lançar uma exceção
  • verificamos se o estado final é qualquer (any: isA<AsyncError>())

E com isso concluímos todos os testes da classe AuthController.

Para referência, aqui está o código-fonte completo:

AuthController tests

 

Teste com Streams versus Teste com Listeners

Testar com streams é notoriamente complicado no Dart.
Em comparação, testar com xlisteners tem uma grande vantagem.
E é isso que executamos primeiro e verificamos depois:

// rode primeiro
final controller = container.read(authControllerProvider.notifier);
await controller.signInAnonymously();
// verificar apos
verifyInOrder([
  // define o estado de carregamento
  // * usa um matcher desde AsyncLoading != AsyncLoading com dados  
  () => listener(data, any(that: isA<AsyncLoading>())),
  // erro ao concluir
  () => listener(
      any(that: isA<AsyncLoading>()), any(that: isA<AsyncError>())),
]);

Compare isso com o código que escreveríamos ao trabalhar com streams:

// um controlador baseado em [StateNotifier]
final controller = container.read(authControllerProvider.notifier);
// espere mais tarde
expectLater(
  controller.stream,
  emitsInOrder(const [
    AsyncLoading<void>(),
    AsyncData<void>(null),
  ]),
);
// então rodamos
await controller.signInAnonymously();

Neste caso, estamos testando um StateNotifier que nos fornece um Stream que podemos usar para verificar as mudanças de estado.

Isso é contra-intuitivo porque temos que usar expectLater e escrever as expectativas antes de chamar o método em teste (controller.signInAnonymously).

Em vez disso, a maneira recomendada de escrever testes para nossas classes notificadoras é usar listeners:

final listener = Listener();
container.listen(
  authControllerProvider,
  listener,
  fireImmediately: true,
);

Dessa forma, podemos primeiro executar o código em teste e depois verificar se o listener foi chamado. 👌

 

Conclusão: escrever testes é difícil

Como vimos, escrever testes unitários pode ser difícil e há algumas dicas que precisamos ter em mente.

Para evitar perder tempo precioso com testes reprovados:

  • sempre use anotações de tipo (em seu código e testes)
  • use matchers como qualquer outro quando não for possível criar valores esperados que sejam 100% iguais aos valores reais

Ao escrever este artigo, quis preparar o caminho para que você não tenha que lutar com os mesmos problemas.

E embora o código que escrevemos acima possa parecer estranho à primeira vista, você pode usá-lo como modelo ao testar suas próprias classes Notifier com Riverpod.

Portanto, não desista de escrever testes! 😉