Como fazer teste de unidade de subclasses AsyncNotifier com Riverpod 2.x
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.
Conteudo
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 comoAsyncData
ouAsyncError
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á porqueAsyncData<dynamic>
não é o mesmo queAsyncData<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 umLateInitializationError
. Em vez disso, leia o provedor correspondente doProviderContainer
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
paraAsyncData
(durante a inicialização) - de
AsyncData
paraAsyncLoading
(estado de carregamento) - de
AsyncLoading
paraAsyncData
(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
eRegisterFallbackValue
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:
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! 😉