Testes no Flutter: O que é? Quando usar? Como começar a aprender?

Testes no Flutter: O que é? Quando usar? Como começar a aprender?
Ricarth Lima
Ricarth Lima

Compartilhe

Chegou a hora de você tornar suas aplicações em Flutter mais seguras e livres de bugs cobrindo-as com testes, certo? Então você veio para o lugar certo!

Talvez você esteja começando a aprender Dart e Flutter agora, mas já ouviu falar que "testar código" é uma excelente prática e já quer enraizá-la nos seus conhecimentos.

Ou, quem sabe, você até já tem bastante conhecimento em Flutter, mas nunca usou testes, e notou que as empresas no mercado muitas vezes solicitam que a pessoa desenvolvedora tenha essa experiência!

Ou, até, você já possua um projeto sólido na sua empresa, mas que por vezes precisa ser refatorado, e a cada refatoração você percebe que seria ótimo ter uma forma de automatizar os testes para garantir que tudo ainda esteja funcionando corretamente.

Para todos esses casos, neste artigo você aprenderá:

  • O que são, quais os diferentes tipos e quando usar testes;
  • O que são e como usar testes de unidade, de widget e de integração no Flutter;
  • Configuração do ambiente e seus primeiros testes;
  • Como usar com maestria o expect e o matcher;
  • Como simular dependências externas com mocks e stubs;
  • Conhecendo o TDD e como ele se aplica ao Flutter;
  • Como testes se conectam com Integração Contínua no Flutter.

E, eu te garanto, esse é só o começo! Nesse artigo vamos mergulhar bem fundo no universo de testes, com o foco no Flutter.

Além de entender o que, e como funciona, vamos analisar quando usar, entender as vantagens e conhecer a diferença entre os tipos de testes.

Segure-se e vamos lá! 🚀

Quando usar testes?

Imagine que você está desenvolvendo uma aplicação que gerencia as notas de pessoas estudantes e considere que você acabou de escrever o seguinte trecho de código:

List<Student> lista_estudantes = await getListStudentsFromServer();
int lista_notas = await getListScoreFromServer(examDate: DateTime.parse("2024-03-01"));

for (int i = 0; i < lista_notas.lenght(); i++){
    lista_estudante[i].score = lista_notas[i];
}

O que temos aqui?

  1. Bom, na primeira linha recebemos do servidor uma lista de estudantes, e sabemos que há um tipo Student na nossa aplicação;
  2. Na segunda linha, pedimos ao servidor as notas de um exame que ocorreu em um dia específico;
  3. Por fim, no laço de repetição for alimentamos as notas dos estudantes com a informação que veio no servidor.

Desconsiderando melhorias de lógica que poderiam acontecer no back-end servidor, e lidando apenas com o que temos em mãos (e, acredite em mim, isso é mais comum do que você pensa), algo pode dar errado nesse trecho de código? E quando eu digo "dar errado", quero dizer coisas catastróficas mesmo, como:

  • A aplicação “crashar” (se fechar) ou travar;
  • A aplicação mostrar um erro para pessoa usuária final;
  • A aplicação ficar presa em um estado e não gerar nenhuma conclusão.

Sim! Muito sim! Muita coisa pode dar errado nesse pequeno trecho de código!

Só falando de conectividade, o que acontece se a aplicação não conseguir se conectar ao servidor? O que acontece se a gente solicitar ao servidor uma data da prova que não existe? O que acontece se o tamanho da lista de estudantes e da lista de notas forem diferentes?

Podemos notar que algumas dessas coisas estão sob o nosso controle, certo? Poderíamos melhorar nossa lógica, utilizar de tratamento de erros, ou mesmo solicitar melhorias para a equipe de back-end.

Mas e as coisas que não estão sob o nosso controle, ao menos não em tempo hábil?

Agora imagina que esse trecho de código que fizemos é apenas uma parte de uma funcionalidade que está interligada com toda uma cadeia de código que depende (e é dependência) do trabalho de várias outras pessoas no nosso time?

Como poderíamos garantir para o nosso time que uma adição ou alteração de código como essa não causará um efeito cascata e danificará várias partes da aplicação que nem imaginamos?

É exatamente aí que os testes podem nos ajudar!

Banner da Escola de Mobile: Matricula-se na escola de Mobile. Junte-se a uma comunidade de mais de 500 mil estudantes. Na Alura você tem acesso a todos os cursos em uma única assinatura; tem novos lançamentos a cada semana; desafios práticos. Clique e saiba mais!

Para quê servem os testes?

No mundo ideal, os testes são utilizados ao longo de todo ciclo de vida do desenvolvimento de software!

Desde as fases iniciais de concepção, até a manutenção contínua do produto que já está na mão das pessoas usuárias!

Há filosofias que dizem até que devemos escrever testes primeiro, só aí depois escrever código, já pensou nisso? Falaremos mais sobre essas ideias mais para frente.

Imagina que você escreveu muito código ontem: foram várias classes, funções, telas e as mais diferentes funcionalidades.

Quando o dia acabou, você rodou o código uma última vez e tudo funcionou!

Ótimo! Você foi dormir, e no dia seguinte, a primeira coisa que você alterou nesse código "desfaz" algo que você tinha construído ontem.

Porém, você nem percebe pois é algo muito sutil, e você repete o ciclo de escrever muito código também.

Só que no final do dia, quando você vai dar aquela "última rodada no código" você percebe que o trampo que você fez ontem não funciona mais, e que vai ter que gastar todo o dia de amanhã com retrabalho.

Chato, né? Isso tudo poderia ter sido evitado se, ao longo do dia, a cada funcionalidade nova fosse você testasse o código para saber se ele se comporta ainda como o esperado.

Mas vai além!

Testes são fundamentais durante a integração de diferentes componentes ou módulos de software, muitas vezes feitos por pessoas diferentes, de equipes diferentes, em momentos diferentes, garantindo que todo esse esforço funcione em conjunto de maneira harmoniosa e livre de bugs.

Espero ter te convencido que testes são muito importantes em todas as etapas de desenvolvimento para assegurar a qualidade do software, detectar problemas precocemente e fornecer confiança na estabilidade e desempenho do sistema.

Mas você, a essa altura, deve estar se perguntando: "o que raios é, de fato, um teste???"

O que são testes?

Lembra do nosso código lá em cima? Aquele que adicionava a nota do estudante no seu objeto?

Imagina que legal se tivesse como pedir para o computador para, sempre que algum trecho de código mudasse significativamente, verificar se algumas coisas continuam funcionando? Coisas como:

  • O servidor respondeu a primeira requisição em tempo hábil;
  • O servidor respondeu a segunda requisição em tempo hábil;
  • A lista de estudantes tem o mesmo tamanho da lista de notas.

Nossa! Seria incrível! Dessa forma, a gente poderia focar em implementar novas funcionalidades ou mesmo refatorar algumas que já existem e, se o computador dissesse que está tudo OK com essas verificações, poderíamos seguir com a paz no coração que não quebramos nada que já estava feito.

Pasmem. Isso é testes! Nem doeu, né?

Sendo um pouco mais técnico, "testes", no contexto de desenvolvimento de software, são processos sistemáticos de verificação e validação realizados para garantir a qualidade, funcionalidade e confiabilidade de um sistema de software.

Eles envolvem a execução controlada de partes específicas do código para detectar erros, defeitos ou comportamentos inesperados.

Os testes podem abranger diferentes aspectos do software, como sua lógica interna, interface do usuário e integração com outros sistemas.

Existem vários tipos de testes, e a disponibilidade deles pode variar de ferramenta para ferramenta, mas alguns tipos comuns são:

  1. Testes Unitários: Testes realizados em unidades individuais de código, como funções ou métodos, para garantir que cada unidade funcione corretamente isoladamente.

Por exemplo, em um sistema de login, um teste unitário pode verificar se a função de validação de senha retorna verdadeiro para senhas corretas e falso para senhas incorretas.

  1. Testes de Integração: Testes realizados para verificar se as unidades de código se integram corretamente e funcionam juntas conforme o esperado.

Por exemplo, em um sistema de comércio eletrônico, um teste de integração pode verificar se o processo de adicionar um item ao carrinho de compras atualiza corretamente o estoque e o total do pedido.

  1. Testes Funcionais: Testes que verificam se o software atende aos requisitos funcionais especificados, por exemplo, em um sistema de reserva de passagens aéreas, um teste funcional pode verificar se é possível reservar um voo com sucesso, selecionando os destinos desejados e inserindo informações de pagamento válidas.

  2. Testes de Aceitação do Usuário (UAT): Testes realizados pelos usuários(as) finais ou por representantes do(a) cliente para validar se o software atende aos requisitos e expectativas da pessoa usuária.

Um exemplo seria um sistema de gerenciamento de projetos, os(as) usuários(as) finais podem realizar testes para verificar se todas as funcionalidades necessárias estão presentes e se a interface do(a) usuário(a) é intuitiva.

  1. Testes de Desempenho: Testes realizados para avaliar o desempenho e a escalabilidade do software em diferentes condições de carga e uso.

Um exemplo clássico seria em um aplicativo de redes sociais, testes de desempenho podem ser realizados para verificar como o sistema se comporta quando milhares de usuários estão acessando simultaneamente.

  1. Testes de Segurança: Testes realizados para identificar vulnerabilidades de segurança no software e garantir que ele seja resistente a ataques, como por exemplo, em um sistema bancário online, testes de segurança podem ser realizados para verificar se há vulnerabilidades como injeção de SQL ou cross-site scripting (XSS).

Ao identificar problemas precocemente e assegurar que o software atenda aos requisitos e expectativas do(a) usuário(a), os testes desempenham um papel fundamental na melhoria da qualidade do produto final e na redução de custos associados a defeitos encontrados após o lançamento.

Três categorias de testes no Flutter

Agora que pegamos bem o conceito de testes, vamos aplicar tudo isso para nossa realidade com Flutter.

Quanto mais funcionalidade nossa aplicação tem, cada vez mais difícil fica de testarmos tudo na mão, né?

É serviço pra cá, é tela pra lá, widget que não acaba mais.

Os testes automatizados do Flutter nos ajudam a garantir que nossos aplicativos funcionem corretamente antes, durante e depois da publicação, além de aumentar nossa velocidade em notar e corrigir os temidos bugs.

No Flutter, os testes automatizados se encaixam em três principais categorias:

  • O teste de unidade, que em inglês você verá muito como "unit test". Esse teste é o mais simples e direto, pois testa uma única função, método ou classe;
  • O teste de widget, que em inglês você verá como "widget test", e que é conhecido em outras tecnologias que possuem estruturas de interface de usuário como "teste de componente".

Já esse teste é um tanto mais robusto pois testa um widget inteiro, e como sabemos, tudo que é visual no Flutter, é um widget, até mesmo telas completas.

  • O teste de integração, o famoso do inglês "integration test". Esse é o mais robusto dos testes pois testa o aplicativo todo ou, pelo menos, grande parte dele.

Em geral, um aplicativo bem testado possui realmente muitos testes de unidade, vários testes de widget e alguns testes de integração, o suficiente para cobrir pelo menos os casos de uso mais importantes.

Caso você nunca tenha ouvido falar de "casos de uso", eles são uma técnica utilizada na engenharia de requisitos de software, que consistem em descrever interações entre atores externos e o sistema em desenvolvimento.

Os casos de uso representam cenários específicos de uso do sistema, detalhando as ações que uma pessoa usuária ou sistema externo realiza e as respostas do sistema que está sendo desenvolvido.

Esta perspectiva da quantidade de cada um dos testes se baseia no fato de que várias características mudam drasticamente de um tipo de teste para outro, características essas que variam entre "tempo para projetar o teste" até "velocidade de execução do teste".

A própria documentação do Flutter criou uma tabela fazendo esse comparativo:

"Tradeoff"Teste de UnidadeTeste de WidgetTeste de Integração
ConfiabilidadeBaixaMédiaAlta
Custo de ManutençãoBaixoMédioAlto
DependênciasPoucasAlgumasMuitas
Velocidade de execuçãoRápidoRápidoLento

Vamos entender essa tabela? Dá para tirar muita coisa interessante dela!

Primeiro, o que é "tradeoff"?

Essa é mais uma daquelas palavras metidas que vem do inglês que poderia ser até traduzida (nesse caso para "troca"), mas fica faltando algum detalhe no significado.

Na prática um "tradeoff" é a escolha entre duas alternativas onde ganhar algo implica perder outra coisa.

É uma troca comum na vida cotidiana, como decidir entre estudar para um exame e sair com amigos, onde optar por uma atividade significa sacrificar a outra.

Por exemplo, escolher estudar pode melhorar o desempenho no exame, mas significa perder tempo social.

Em essência, “tradeoffs” exigem avaliação cuidadosa das opções disponíveis e reconhecimento de que escolher uma implica abrir mão da outra.

A mesma coisa é com nossos testes de unidade, de widget e de integração. Não podemos fazer infinitos testes de todos os tipos, então precisamos escolher bem quais deles vamos usar.

Confiabilidade

Olhando para Confiabilidade, o que essa linha quer dizer? Basicamente o quão "passar" nesse teste quer dizer que problemas serão evitados na nossa aplicação.

Veja, um teste de unidade testa apenas uma função, um método ou uma classe.

Nossa aplicação pode ter centenas dessas. Se tudo estiver certo com ela, ótimo, mas isso não garante muita confiabilidade em geral.

Já um widget pode se usar de várias funções classe, além de ter ligação direta com a forma que as pessoas usuárias vão interagir com nossa aplicação.

Quando um widget passa em um teste que fizemos, esse já é um indício melhor que, no geral, as coisas vão funcionar direitinho.

Por fim, um teste de integração testará como vários widgets, serviços, funções, classes, banco de dados se comportará.

Ele leva muita coisa em consideração, portanto se o teste que fizemos nesse nível passou, esse é um excelente indício de que a aplicação está funcionando do jeito que esperávamos.

Custo de manutenção

"Mas então, se eles são mais confiáveis, por quê a gente não escreve só testes de integração direto?”

Calma! Lembra que em um "tradeoff" a gente sempre perde alguma coisa?

Essa linha "Custo de Manutenção" é bem sutil, mas extremamente importante dado que nosso tempo é limitado.

Ela basicamente quer dizer "quanto custa escrever e manter esse teste?".

Um teste de unidade, muitas vezes, pode ser escrito em menos de um minuto, ou seja, são poucas linhas de código e que são muito diretas no que fazem, por isso tanto escrever quanto corrigir esse teste tem esforço praticamente zero.

Já um teste de widget deve levar muito mais coisas em consideração, desde comportamentos lógicos que podem variar, até mesmo tamanho de telas e responsividade.

Por isso, tanto escrever quanto manter um teste de widget é um tanto mais custoso em tempo e esforço que um teste de unidade.

E tudo que foi dito para o teste de widget, se aplica ao teste de integração. Imagina um teste que testa toda a aplicação ou grande parte dela?

Quantos widgets, serviços externos, tratamento de erros precisam fazer parte desse teste.

Escrever e mantê-lo exigirá muito mais tempo, esforço e conhecimento por parte da equipe de desenvolvimento.

Dependências

Você deve se lembrar que quando falamos de boas práticas, quase sempre dizemos que é interessante "modularizarmos" nosso código, isso é, dividi-lo sempre que possível em pequenas partes irredutíveis.

E o que ganhamos com isso? Os trechos de código, como funções, métodos, classes, widgets e telas, diminuem a interdependência entre si, tornando-os mais claros, simples e fácil de dar manutenção.

De certa forma esse conceito também se aplica a testes até certo ponto. Por exemplo, quando estamos escrevendo um teste de unidade imagina-se que a quantidade de dependência de outros trechos de código e até mesmo de outros testes será baixa ou nula, afinal apenas um pequeno aspecto do código está sendo testado.

Já quando criamos um teste de widget é inevitável imaginar que haverão mais dependências, como classes do tipo models, funções auxiliares e por aí vai.

Não é uma questão de evitarmos esse tipo de teste, mas de sabermos que eles serão mais complexos por conter mais dependências.

E o mesmo se aplica aos teste de integração, só que em uma escala ainda maior!

Velocidade de execução

Se você nunca trabalhou com testes, talvez não tenha parado para pensar que o computador precisa de fato rodar o trecho de código que será testado, e todas as suas dependências! Isso certamente leva algum tempo, e exatamente sobre isso que essa linha fala.

Se tratando dos testes de unidade e de widget, se bem escritos, eles podem ser executados em poucos segundos, às vezes em frações de segundos.

O que é importante pois, como vimos, eles serão muitos.

Já os testes de integração, em geral, são verdadeiramente lentos, pois testam diversos aspectos da aplicação.

Como configurar o ambiente de testes de unidade

Configurar sua aplicação para começar a receber testes é, na verdade, bem simples. Inicialmente vamos usar dois pacotes, o flutter_test e o próprio tests.

O pacote test é a forma padrão de escrever e rodar testes em Dart, é um pacote que está disponível no pub.dev e que é desenvolvido, publicado e mantido pela equipe de desenvolvimento do Flutter.

Esse pacote cria toda o ambiente que permite testar códigos em Dart e Flutter e, portanto, a leitura da sua documentação é extremamente recomendada.

Já o pacote flutter_test é uma biblioteca construída com base no pacote test e que adiciona funcionalidades com utilitários e métodos específicos para testar widgets e interações dentro do Flutter.

Porém, há um detalhe muito importante nisso! Você, em geral, não precisa instalar a dependência flutter_test pois ela já vem instalada nativamente na sua aplicação quando você cria um projeto Flutter!

Daí, é claro, basta que você instale o pacote test e sua aplicação estará pronta para receber seus primeiros testes.

Para isso, rode no terminal o seguinte comando:

flutter pub add dev:test

Para verificar se tudo funcionou corretamente, você deve abrir seu arquivo pubspect.yaml, e nele, você irá encontrar uma seção de "dependências de desenvolvimento" como essa:

...
dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
  test: ^1.24.9
...

Com o test e flutter_test disponíveis já conseguimos fazer vários dos testes de unidade e de widgets, porém, quando chegar o momento de simular dependências ou escrever testes de integração, você deverá instalar outros pacotes.

A tabela a seguir condensa os pacotes recomendados pelo Flutter para cada das situações citadas:

Tipo do TestePacotes recomendadosLinha de comando
Unidadetestflutter pub add dev:test
Widgetsflutter_testNão é necessário
Dependências Simuladasmockito e build_runnerflutter pub add dev:mockito dev:build_runner
Integraçãointegration_testflutter pub add 'dev:flutter_test:{"sdk":"flutter"}' 'dev:integration_test:{"sdk":"flutter"}'

Fundamentos de testes de unidade no Flutter

Ótimo! Agora que sabemos diferenciar bem os três principais tipos de teste que usaremos no Flutter, e que já sabemos configurar o ambiente para escrever e rodar esses testes, que tal começarmos pelo mais básico deles? O teste de unidade!

Não se engane, apesar de ser mais simples e direto, uma boa cobertura de testes de unidade podem te fazer ganhar muito tempo e poupar muito esforço no longo prazo!

No Flutter, testes de unidade testam apenas uma única função, método ou classe. Seu objetivo é verificar a exatidão de uma unidade lógica sob as mais diversas variedades de condições.

Criando uma classe para testar

Antes de mais nada precisamos criar dois arquivos: o arquivo que será testado e o arquivo com os testes.

O arquivo que será testado sempre estará dentro da pasta (ou de alguma subpasta da pasta) lib, já o arquivo com os testes sempre estará dentro da pasta (ou de alguma subpasta da pasta) test.

Caso seu projeto não possua uma pasta test você deve criá-la na raiz de seu projeto.

flutter_testes_estudo/
    lib/
        contador.dart
    test/
        contador_test.dart

Como vimos, precisamos criar também um código que será testado. Para isso, vamos criar uma classe que apenas incrementa e decrementa um valor:

class Contador {
    int valor = 0;

    void incrementar(){
        valor = valor + 1;
    }

    void decrementar(){
        valor = valor - 1;
    }
}

Lembra que eu falei que existem filosofias de desenvolvimento que criam os testes antes mesmo de criar o código?

Não chegamos nelas ainda, portanto nesse caso, estamos seguindo um caminho natural para quem está iniciando o estudo de testes: criando o código que será testado antes de criar o teste.

Escrevendo nosso primeiro teste

Agora é a hora de criar o arquivo de testes. Note que, por convenção, ele terá o mesmo subdiretório e o mesmo nome do arquivo que será testado, exceto pelo sufixo _test antes do .dart.

Então já que temos um contador.dart na raiz da lib, vamos criar um contador_test.dart na raiz da pasta test.

Vamos começar testando as três coisas mais básicas:

  1. O atributo 'valor' deve começar em 0;
  2. O método 'incrementar' deve aumentar o valor de 'valor' em 1;
  3. O método 'decrementar' deve diminuir o valor de 'valor' em 1.

No nosso arquivo contador_test.dart comece importando tanto nossa classe Contador, como o próprio pacote test:

import "package:flutter_testes_estudo/contador.dart";
import 'package:test/test.dart';

Depois, adicione uma função main() que será executada quando o arquivo de teste for rodado.

void main(){}

Dentro da função main podemos usar nossa primeira sintaxe de teste: a group. O que um "grupo" faz é agrupar vários testes para uma só execução. Além de organização de código, essa categorização também torna seu teste mais legível e permite rodar cada grupo de uma vez.

Quando criamos um grupo, é importante que os testes dentro dele façam sentido entre si.

Para nosso caso podemos criar um grupo chamado "Testar inicialização, incremento e decremento".

void main(){
    group("Testar inicialização, incremento e decremento:", (){
    });
}

Note no código que group é uma função que espera, pelo menos, dois argumentos: o primeiro é o título do grupo escrito em uma String simples.

O segundo é um callback, uma função, que dentro dela estarão nossos testes.

Agora com o grupo criado, basta usarmos a função test para fazer um teste, começando pela nossa primeira proposição, que era: "O atributo 'valor' deve começar em 0".

void main(){
    group("Testar inicialização, incremento e decremento:", (){

    test("O atributo 'valor' deve começar em 0", (){
        Contador contador = Contador();
    });

    });
}

Interessante! Podemos notar que a sintaxe para estruturar um teste é parecida, sendo o primeiro argumento título do teste e o segundo argumento uma função onde podemos executar o teste dentro.

Mas tudo bem, queremos testar se o contador está começando ou não como zero, certo?

No nosso código até inicializamos o contador, mas falta alguma coisa que faça de fato esse "teste"!

Uma forma de dizer ao Flutter que nós esperamos que nessa situação o resultado seja zero!

É exatamente para isso que utilizaremos a função expect, para dizer ao Flutter que, ao fim, esperamos que alguma variável ou atributo tenha um valor que definiremos.

...
test("O atributo 'valor' deve começar em 0", () {
  Contador contador = Contador();
  expect(contador.valor, 0);
});
...

A função expect recebe, pelo menos, dois argumentos. O primeiro é onde passamos um valor que será alvo da nossa comparação, que no nosso caso é o atributo valor dentro de contador.

O segundo é um matcher que fará de fato nossa comparação.

No código acima, estamos comparando se o valor em contador.valor é igual a 0, se for, o teste passará, se não, o teste falhará!

Note que, diferente de um return ou um throw em uma função comum, um expect não encerra a execução da função test.

Isso quer dizer que dentro de um mesmo teste você pode fazer várias verificações usando except, e todas elas deverão passar para que aquele teste seja considerado bem sucedido.

Rodando nosso primeiro teste

Agora basta rodar nosso teste! Para isso você pode fazer de diversas formas, a mais direta delas é indo no terminal da aplicação e escrevendo:

flutter test test/contador_test.dart

Você deverá ver como resultado uma mensagem que diz "All tests passed", ou seja, "Todos os testes passaram" no seu terminal.

É possível também rodar com a ajuda da depuração da IDE que você está usando. Para isso, basta "rodar como depuração" da forma que você já faz normalmente com código Dart:

  • Com o arquivo aberto:
    • No VSCode vá em "Run > Start Debugging" (ou simplesmente pressione F5);
    • No IntelliJ vá em "Run > Run 'tests in contador_test.dart'".

Que tal testar um caso de falha? Mude propositalmente o valor inicial de valor para qualquer coisa que não seja 0 e rode o teste!

Recomendo rodar diretamente pelo terminal, ou sem o modo "debugg" para que você possa ver como um teste se comporta em falha sem a intervenção da IDE.

Princípio da independência entre os testes de unidade

Considere o seguinte código como uma continuação da cobertura de testes que estávamos criando:

import "package:flutter_testes_estudo/contador.dart";
import 'package:test/test.dart';

void main() {
  group("Testar inicialização, incremento e decremento:", () {
    test("O atributo 'valor' deve começar em 0.", () {
      Contador contador = Contador();
      expect(contador.valor, 0);
    });

    test("O método 'incrementar' deve aumentar o valor de 'valor' em 1.", () {
      Contador contador = Contador();
      contador.incrementar();
      expect(contador.valor, 1);
    });

    test("O método 'decrementar' deve diminuir o valor de 'valor' em 1.", () {
      Contador contador = Contador();
      contador.decrementar();
      expect(contador.valor, -1);
    });
  });
}

Você deve ter notado uma coisa bem interessante: para cada teste, inicializamos o Contador novamente. "Tá certo isso?".

Eu entendo que é quase instintivo a vontade de criarmos apenas uma instância de Contador em escopo fora de todos os test, e usá-la em cada um dos testes.

Para falar a verdade, isso até funcionaria em código, mas quebraria com nosso princípio da independência dos testes.

Quando estamos lidando com testes, em especial os de unidade, desejamos construí-los da forma mais independente possível.

É como se criássemos um pequeno universo ali dentro de test, apenas para testar um conceito, e destruímos ele logo na sequência.

Esse princípio previne que um resultado de um teste (seja ele esperado ou não), influencie no teste seguinte, o que poderia causar ambiguidades ou até mesmo perda da confiabilidade, afinal não temos mais um cenário isolado.

E é exatamente por isso que para cada teste, criamos uma instância nova de Contador.

Explorando expect e matcher

Debulhando o expect

Não é exagero dizer que a função expect é uma das mais importantes quando estamos fazendo um teste de unidade, portanto, conhecer todas as suas potencialidades é essencial para escrevermos bons testes!

void expect(
    dynamic actual,
    dynamic matcher, {
    String? reason,
    Object? skip,
    @Deprecated('Will be removed in 0.13.0.') bool verbose = false,
    @Deprecated('Will be removed in 0.13.0.') ErrorFormatter? formatter,
})

Segundo a definição da documentação, a função expect afirma que actual corresponde a matcher.

Que tal analisarmos os parâmetros dessa função um a um?

  • dynamic actual: Trata-se do valor que será testado, note que o tipo esperado é dynamic, ou seja, pode assumir qualquer tipo em tempo de execução.
  • dynamic matcher: Já o matcher é o comparador. Se estivermos comparando apenas dois inteiros, como nos nossos exemplos anteriores, eles se comportará como um equals(), ou seja, "é igual a". Por exemplo: expect(contador.valor, 0); pode ser lido como "espera-se que o valor em contador seja igual a zero". Veremos mais para frente que esse comparador não precisa ser necessariamente equals.
  • String? reason: Explicita a razão dessa comparação. Em geral esse valor não é atribuído pois o matcher já gera essa razão automaticamente.
  • Object skip: Define se esse except será ou não pulado (ignorado). Aqui você pode passar tanto um booleano (como uma condição), quanto uma String. Se for recebido um true ou uma String o except será ignorado. Esse atributo é especialmente útil quando temos mais de uma except por test.

Considerando outros tipos de matcher

Agora considere a seguinte situação: você criou o seguinte método na nossa classe contador e agora ele precisa ser testado:

List<int> multiplicarValor(List<int> listaParaMultiplicar) {
    for (int numero in listaParaMultiplicar) {
      numero = numero * valor;
    }
    return listaParaMultiplicar;
}

Com esse novo método muitas coisas precisam ser testadas, certo?

  • Se o retorno do método não é nulo;
  • Se o retorno do método não é vazio;
  • Se valor é positivo e o elemento de listaParaMultiplicar é positivo, o valor transformado deve ser positivo;
  • Entre muitos outros.

Como faríamos alguns desses testes?

test("O método 'atribuir valores' deve retornar uma lista não nula.", () {
  Contador contador = Contador();
  contador.valor = 2;
  List<int> resultado = contador.multiplicarValor([-3, -2, -1, 0, 1, 2, 3]);
  expect(resultado, !null); // ERRO AQUI!
});

Não podemos simplesmente tentar comparar se "resultado é igual a não-nulo". Isso está dando errado pois estamos usando o matcher errado.

Lembre-se que, se não definirmos algum matcher, o padrão usado é o equals, mas a documentação de matcher nos provê uma série de outros "comparadores" úteis para situações específicas, entre eles o isNotNull.

test("O método 'multiplicarValor' deve retornar uma lista não nula.", () {
      Contador contador = Contador();
      contador.valor = 2;
      List<int> resultado = contador.multiplicarValor([-3, -2, -1, 0, 1, 2, 3]);
      expect(resultado, isNotNull);
});

Agora sim! Usamos o comparador certo, verificamos se resultado é nulo ou não, e nosso teste roda corretamente.

A lista de "comparadores" é extensa, por isso separei aqui alguns dos que eu considero essenciais que você conheça!

Para conhecer todos os outros, recomendo a leitura da documentação.

MatcherPropósito
equalsO mais básico dos comparadores, compara se um objeto é igual ao outro, não necessariamente apenas número, mas qualquer objeto. Funciona de forma análoga ao ==.
isNegative, isNonNegative, isPositive, isNonPositive, isZeroMuito útil para comparações numéricas, esses matcher fazem verificações do número comparado com o número 0.
lessThan, lessThanOrEqualTo, greatThan, greatThanOrEqualToOutros comparadores extremamente úteis, esses fazem verificações análogas aos operadores <, <=, >, >=.
isTrueSimilar a fezer um equals(true).
isFalseDiferente de fazer um equals(false), o comparador isFalse combinará com qualquer objeto que não seja um bool de valor true.
isEmptyVerifica se uma coleção está vazia. Esse comparador só é aplicável a objetos que possuam o método isEmpty.
isNotEmptyVerifica se uma coleção não está vazia. Esse comparador só é aplicável a objetos que possuam o método isNotEmpty.
isNaNVerifica se o objeto testado é um "Not a Number", ou seja, um elemento que não pode ser convertido para um número.
isList, isMapVerifica se o objeto testado é uma das coleções mais usadas no Flutter, List ou Map respectivamente.
isNullVerifica se o objeto testado é nulo.
isNotNullVerifica se objeto testado é diferente de nulo.
contains(Object? expected)Verifica se a coleção testada contém o objeto expected esperado.
returnsNormallyVerifica se a função comparada é encerrada sem levantar nenhuma exceção.
isA()Verifica se o objeto comparado é uma um instância de.

Esperando por exceções

Um matcher especial que precisa também ser citado é o throwsA, que verifica se o método testado levanta uma exceção específica.

Considere que agora nosso método multiplicaValor possui a seguinte estrutura:

List<int> multiplicarValor(List<int> listaParaMultiplicar) {
    if (listaParaMultiplicar.isEmpty) throw ListaVaziaException();

    for (int numero in listaParaMultiplicar) {
      numero = numero * valor;
    }
    return listaParaMultiplicar;
}

E, é claro, para que esse código funcione precisamos ter implementado ListaVaziaException:

class ListaVaziaException implements Exception {
  @override
  String toString() {
    return "ListaVaziaException\\nA lista não pode ser vazia";
  }
}

Seria interessante testar se, de fato, a exceção é lançada quando passamos uma lista vazia para esse método.

Para isso podemos escrever no nosso arquivo de testes:

test(
        "O método 'multiplicarValor' deve lançar uma exceção se receber uma lista vazia.",
        () {
    Contador contador = Contador();
    expect(() => contador.multiplicarValor([]),
        throwsA(isA<ListaVaziaException>()));
});

Devemos nos atentar para alguns detalhes nessa implementação.

O primeiro deles é que não chamamos diretamente contador.multiplicaValor([]) no actual do except, e sim () => contador.multiplicaValor([]).

Isso é importante pois não nos interessa testar o valor de retorno do método (como era na primeira situação), e sim a própria execução do método (como no segundo caso).

O segundo detalhe é justamente o uso do throwsA() que é um matcher que espera outro matcher como argumento.

Nesse caso podemos usar o isA<T>() que aprendemos para fazer a comparação de tipo e assim podemos verificar de multiplicaValor de fato lança uma exceção do tipo ListaVaziaException quando recebe uma lista vazia.

Como simular dependências externas com mocks e stubs

Quando usar dublês

Lembra que conversamos sobre a independência dos testes. Seja entre eles, seja de fatores externos?

Segundo a própria documentação do Flutter, "os testes de unidade geralmente não leem ou gravam no disco, renderizam na tela ou recebem ações do usuário de fora do processo que executa o teste".

Afinal, além dos problemas já citados, quando utilizamos dependências externas, alguns outros inconvenientes podem acontecer:

  • Chamar serviços ou bancos de dados ativos retarda a execução do teste;
  • Um teste aprovado pode começar a falhar se um serviço web ou banco de dados retornar resultados inesperados. Isso é conhecido como "teste instável";
  • É difícil testar todos os cenários possíveis de sucesso e falha usando um serviço web ou banco de dados ativo;
  • Podemos acabar sobrecarregando um servidor na nuvem fazendo testes.

O problema é: às vezes, nossos testes de fato dependem de classes que buscam dados de serviços web, ou banco de dados ativos, ou outras dependências externas para fazerem sentido. O que fazer nesses casos?

Imagine, por exemplo, que agora queremos salvar o valor de Contador em um banco de dados local, e para isso, criamos uma classe que gerencia esse banco de dados usando sqflite:

class ContadorDatabase {
  Future<void> abrirBanco() async {
    //... Abre o banco
  }

  Future<void> fecharBanco() async {
      //... Fecha o banco
    }

  Future<void> salvar(int valor) async {
      //... Salva `valor` no banco
      throw UnimplementedError();
  }

  Future<int> ler() async {
      //... Recupera `valor` no banco
      throw UnimplementedError();
  }

  Future<int> remover() async {
      //... Remove valor do banco
      throw UnimplementedError();
  }
}

Como o foco deste artigo não é o uso do sqflite, não destrincharemos como essa classe ContadorDatabase funciona.

As únicas coisas que precisamos saber é que ela existe, que ela irá ser usada por Contador e, por consequência, se tornará uma dependência externa de teste.

Existem diversas operações que poderíamos fazer agora que temos a potencialidade da persistência de dados mas, para facilitar, vamos usar apenas duas, ler do banco e salvar no banco:

class Contador {
    ...

    lerDoBanco() async {
        final ContadorDatabase db = ContadorDatabase();
        int dbValor = await db.ler();
        valor = dbValor;
    }

    salvarNoBanco() async {
        final ContadorDatabase db = ContadorDatabase();     
        db.salvar(valor);
    }
}

Agora só falta testar essas duas funções! Mas como faríamos isso?

teste("Salvar dados no banco", (){
    Contador contador = Contador();
    contador.valor = 26;
    contador.salvarNoBanco(); //Dependência externa
    ...
});

Percebe que seguindo por esse caminho chamaríamos o banco de verdade todas as vezes? Podendo lidar com todos os problemas que já citamos? E agora?

Tipos de dublês

O que podemos fazer nessas situações é simular essas dependências externas, e essas simulações são feitas com o que chamamos “dublês”.

Em testes de Flutter, os dublês são usados para simular comportamentos de partes do sistema que o teste está interagindo.

Existem diferentes tipos de dublês que podem ser usados:

  1. Dummy (Fictício): Um dublê dummy é uma implementação simples de uma classe ou interface que não faz nada além de satisfazer os requisitos de tipo. É útil quando você precisa passar um objeto como argumento para um método, mas não está realmente interessado em seu comportamento durante o teste.
  2. Fake (Falso): Um dublê fake é uma implementação simplificada de uma classe que simula o comportamento de uma implementação real, mas de uma maneira mais leve. Por exemplo, em vez de usar um banco de dados real, você pode usar um banco de dados em memória para testes.
  3. Stub (Espantalho): Um stub é um tipo de dublê que fornece respostas pré-programadas para chamadas de método durante o teste. Ele é configurado com dados específicos para retornar quando chamado.
  4. Mock (Simulado): Um mock é um tipo de dublê que registra as interações com ele, permitindo que você verifique mais tarde se o comportamento esperado ocorreu.

Você pode definir expectativas sobre como os métodos serão chamados e, em seguida, verificar se essas expectativas foram cumpridas.

Cada tipo de dublê tem seu próprio uso e propósito, e a escolha do tipo certo depende do que você está testando e do nível de controle e verificação que você deseja em seus testes.

Neste artigo veremos os mocks e como podem trabalhar juntos para nos livrar das dependências externas nos nossos testes.

Instalando o Mockito

Como vimos anteriormente, o pacote recomendado pela documentação do Flutter para execução dos mocks é o mockito, e para que ele funcione precisamos também adicionar a dependência build_runner que será responsável por autogerar alguns códigos.

Para adicionar as dependências, execute no terminal:

flutter pub add dev:mockito dev:build_runner

Criando mocks

Para preparamos o mockito para simular nossa classe ContadorDatabase precisaremos fazer algumas configurações na própria classe.

Começando com a importação do pacote:

import 'package:mockito/annotations.dart';

Depois, precisamos adicionar uma anotação para informar ao mockito qual classe ele deverá gerar um mock.

Para isso, basta adicionar GenerateMocks([classes_aqui]) no nosso arquivo de testes que usará o mock.

...
import 'package:flutter_testes_estudo/data/contador_database.dart';
import 'package:mockito/annotations.dart';

@GenerateMocks([ContadorDatabase])
void main() {
    group("Testar inicialização, incremento e decremento:", () {
...

Por último, precisamos solicitar que o build_runner gere o código automaticamente com a ajuda do mockito.

Para isso, devemos salvar nosso arquivo contador_database.dart e rodar o seguinte comando no terminal:

dart run build_runner build

Você notará que um arquivo contador_test.mocks.dart será gerado ao lado do nosso contador_test.dart.

Se você abri-lo, notará que ele possui uma classe chamada MockContadorDatabase, e será essa classe que usaremos nos nossos testes para substituir a dependência externa ContadorDatabase.

🚨 Atenção! Não é recomendado que se edite manualmente arquivos autogerados no Flutter.

Se você quiser fazer alterações nos seus mocks o ideal é configurar as classes e o mockito novamente, só então rodar o build_runner mais uma vez.

Aplicando os mocks

Só tem um problema!

Se você notar no arquivo Contador, as linhas de lerDoBanco e salvarDoBanco seguem usando ContadorDatabase, e não nosso MockContadorDatabase.

Como resolver isso?

Temos um dilema:

  1. Se, por um lado, quando estivermos usando nossa aplicação de verdade, não queremos usar o mock e sim a classe real que se comunica com o banco de dados;
  2. Por outro, quando estivermos testando, não queremos usar o banco de dados real, e sim o mock.

Como fazer essa distinção?

Note que MockContadorDatabase no arquivo autogerado “implementa” um ContadorDatabase.

Ou seja, na prática, nossos métodos lerDoBanco e salvarDoBanco poderiam usar tanto o mock quanto a classe real.

Podemos refatorar esses métodos para receber por parâmetro um ContadorDatabase, que será um mock apenas nos testes.

Esta prática é comum durante refatoração de código para cobri-los com testes.

lerDoBanco(ContadorDatabase db) async {
  int dbValor = await db.ler();
  valor = dbValor;
}

salvarNoBanco(ContadorDatabase db) async {
  db.salvar(valor);
}

Em ambos os métodos, ao invés de instanciar um novo ContadorDatabase, passamos a pedi-lo por parâmetro.

Utilizando Stubs

Agora só falta testar! E é nesse momento que usaremos o conceito de stubs para gerar dados controlados no ambiente de testes.

Para fazer isso usaremos a função when que configurará nossas funções do mock para resultados controlados.

group("Métodos de persistência de dados:", () {
  test("Ler dados no banco.", () async {
    MockContadorDatabase mockContadorDatabase = MockContadorDatabase();

    when(mockContadorDatabase.ler()).thenAnswer((_) async => 25);

    Contador contador = Contador();
    expect(contador.valor, 0, reason: "Contador deve iniciar em zero.");

    await contador.lerDoBanco(mockContadorDatabase);

    expect(
      contador.valor,
      25,
      reason: "Contador deve ter atualizado valor com dado do banco.",
    );
  });
});
  1. A primeira coisa que fazemos é instanciar um MockContadorDatabase que será usado no teste;
  2. Logo na sequência criamos nosso stub que diz:
    1. when(mockContadorDatabase.ler()) , ou seja, quando o método ler() de mockContadorDatabase for chamado faça algo;
    2. Esse “algo” é definido .thenAnswer((_) async => 25) que retorna 25.

Note que o que fazemos nessas linhas é basicamente preparar nosso mock com um stub que diz que quando o método .ler() for chamado, ao invés dele ir no banco buscar a resposta real, ele vai apenas retornar o valor 25.

Para nosso teste é como se ele tivesse de fato ido no banco e achado o valor 25 lá.

De resto é o que conhecemos:

  1. Inicializamos o contador com Contador contador = Contador();;
  2. Fazemos uma verificação inicial para confirmar que valor iniciou-se com 0;
  3. Rodamos await contador.lerDoBanco(mockContadorDatabase); passando o mock ao invés de um banco real;
    1. Note que essa função é assíncrona, então precisamos esperar que ela termine. Por isso esse é o primeiro test com o modificador async que usamos.
  4. Verificamos se o valor mudou.

Incrível! Sem programar uma linha de código de sqflite ou qualquer outro banco, pudemos escrever um tester para garantir que nossos métodos que interagem com um (futuro) banco se comportem como esperado!

Testando widgets

Porém, não só de testes de unidade vive nosso Flutter, certo?

Parte essencial do fluxo das pessoas por nossa aplicação acontecerá na interface gráfica e, no Flutter, essa é construída com base em widgets!

Então, é importantíssimo testarmos também essa parte da aplicação.

Considere que, para dar vida ao nosso contador, criamos a seguinte interface no arquivo lib/screens/contador_screen.dart:

class ContadorScreen extends StatefulWidget {
  const ContadorScreen({super.key});

  @override
  State<ContadorScreen> createState() => _ContadorScreenState();
}

class _ContadorScreenState extends State<ContadorScreen> {
  Contador contador = Contador();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          const Text(
            "O mais SIMPLES dos\ncontadores",
            textAlign: TextAlign.center,
            style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
          ),
          const SizedBox(height: 32),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            crossAxisAlignment: CrossAxisAlignment.center,
            children: [
              ElevatedButton(
                onPressed: () {
                  setState(() {
                    contador.decrementar();
                  });
                },
                child: const Text("-"),
              ),
              Text(
                "${contador.valor}",
                style: const TextStyle(fontSize: 24),
              ),
              ElevatedButton(
                onPressed: () {
                  setState(() {
                    contador.incrementar();
                  });
                },
                child: const Text("+"),
              ),
            ],
          ),
        ],
      ),
    );
  }
}
A animação mostra um contador digital simples, num aparelho de celular, que aumenta progressivamente os números. O fundo é branco e os números, que estão no centro da imagem, são pretos e mudam rapidamente de 0 até 9 em um loop contínuo. O design é minimalista, com uma fonte digital típica de relógios digitais ou contadores eletrônicos.

Ótimo! Mas agora vamos pensar: quais características são essenciais nessa tela? Independentemente se a aplicação mudar de aparência ou funcionalidades, queremos que essas características se mantenham? Eu consigo pensar em algumas:

  • O texto de título da aplicação deve ser mostrado na tela;
  • O texto contendo o valor de contador deve ser mostrado na tela;
  • Os botões de decrementar e adicionar devem ser mostrados na tela;
  • Ao clicar no botão de decrementar o texto de contador deve diminuir em um;
  • Ao clicar no botão de adicionar o texto de contador deve aumentar em um.

Claro, não vamos implementar isso tudo agora, mas com algumas bases já conseguimos entender a lógica por trás dos testes de widgets!

Teste de widgets com finder

Como estaremos testando outro arquivo, é justo criarmos também um novo arquivo de testes seguindo a mesma organização em test/screens/contador_screen_test.dart.

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter_testes_estudo/screens/contador_screen.dart';

void main() {
  testWidgets("O título da aplicação é mostrado na tela.", (widgetTester) async {
    await widgetTester.pumpWidget(
      const MaterialApp(
        home: ContadorScreen(),
      ),
    );

    final Finder titleFinder = find.text("O mais SIMPLES dos\ncontadores");

    expect(titleFinder, findsOneWidget);
  });
}

O que está acontecendo aqui?

  • Primeiro notamos que usamos testWidgets e não mais test como era com os testes de unidade, mas a estrutura se mantém a mesma, exceto pelo fato que o callback possui o parâmetro widgetTester para usarmos;
  • O método pumpWidget constrói nosso widget no ambiente de testes. Note que, para criar nossa tela, precisei cercá-la como um MaterialApp assim como faríamos no nosso aplicativo, pois o widgets possuem dependências entre si e não funcionariam sem elas. Note, porém, que essa é uma dependência interna, diferente de um banco de dados ou uma API;
  • Uma vez que nosso widget foi construído no ambiente de testes, podemos analisar vários aspectos dele;
  • Um Finder busca se há algo específico sendo mostrado, que no caso do find.text é um texto;
  • Já o matcher findsOneWidget verifica se o Finder que criamos achou pelo menos um widget.

Perfeito! Com esse conhecimento já podemos testar diversas características de diversos widgets, e todo segredo está no Finder, é com ele que podemos fazer procuras mais específicas.

Para se aprofundar nos diversos tipos de finders recomendo a leitura da documentação.

Por outro lado, notamos também que foi usando um novo matcher que ainda não tínhamos falado anteriormente: o findsOneWidget.

Esse, assim como outros, são matchers providos pelo pacote flutter_test especificamente para o teste de widgets e você pode conhecer os demais na documentação.

Simulando ações da pessoa usuária

Mas aí deve surgir uma dúvida: estamos apenas verificando o estado inicial da tela, como fazemos para simular ações da pessoa usuária, como o clique em um botão?

testWidgets("O botão de incrementar deve somar 1 no contador.",
      (widgetTester) async {
  await widgetTester.pumpWidget(const MaterialApp(home: ContadorScreen()));

  expect(
    find.text("1"),
    findsNothing,
    reason: "Pois a tela deve estar mostrando '0' ao iniciar.",
  );

  final Finder addButtonFinder = find.text("+");

  await widgetTester.tap(addButtonFinder);
  await widgetTester.pumpAndSettle();

  expect(
    find.text("1"),
    findsOneWidget,
    reason: "Pois a tela deve agora mostrar '1'.",
  );
});

O código acima testa se o botão incrementar está funcionando. Vamos analisar as mudanças?

  • Em await widgetTester.pumpWidget(const MaterialApp(home: ContadorScreen())); nós construímos a tela no ambiente de testes assim como fizemos anteriormente;
  • Nossa primeira verificação é buscar por um texto "1" e esperar que não ache nenhum, isso faz sentido para garantirmos que o texto que mostra o valor do contador é iniciado como "0";
  • Em final Finder addButtonFinder = find.text("+"); procuramos nosso botão, que é um widget que contém o texto "+". Haveriam várias outras formas de buscar esse botão mas, para nossa aplicação que é muito simples, essa funciona bem;
  • Atenção agora! No widgetTester.tap(addButtonFinder); usamos o método .tap(Finder) que basicamente simula um clique na tela bem em cima do widget buscado no Finder. Nosso widgetTester possui vários tipos de interação como apertar e segurar, arrastar;
  • Atenção aqui também! Se a gente só pedir para o teste apertar o widget (o botão) ele apenas fará isso, mas não atualizará a tela! Para fazer isso podemos usar o método await widgetTester.pumpAndSettle();. Se não fizéssemos isso a tela continuaria “mostrando” "0";
  • Por fim, testamos se find.text("1") acha um widget, e acha!

O pilar do teste de widgets no Flutter é a classe WidgetTest que recebemos como parâmetro do callback de testWidget, entender essa classe significa explorar todas as possibilidades como construir telas e simular interações.

Para saber mais a respeito dessa classe, recomendo a leitura da documentação.

Conhecendo os testes de integração

Agora que já temos uma base nos testes de unidade e nos testes de widgets, chegou a hora de comentarmos a respeito dos testes que abrangem um escopo maior: os testes de integração!

Os Testes de Integração são uma etapa importante no processo de garantia de qualidade de um aplicativo, e não é diferente para o Flutter.

Eles se concentram em validar a interação entre diferentes partes do sistema para garantir que o comportamento geral do aplicativo seja consistente e correto.

Enquanto os testes de unidade se concentram na verificação do funcionamento isolado de unidades individuais de código, e os testes de widget examinam a interação entre widgets específicos da interface do usuário, os testes de integração lidam com a interação entre diferentes componentes.

Um exemplo bem significativo e usual: um teste de integração pode verificar se a navegação entre telas funciona corretamente ao clicar em um botão, não apenas trocando as telas, mas se toda a informação trocada ou processada é coerente.

É interessante, né? Primeiro testamos classes e funções, com o unitário. Depois o comportamento dessas classes em tela, com os testes de widget. E agora aumentamos o escopo para de fato um fluxo real!

Além disso, os testes de integração podem validar a integridade e a exibição adequada dos dados.

Por exemplo, um teste pode verificar se os dados são exibidos corretamente em uma lista após serem recuperados de um banco de dados.

Isso envolve verificar se os dados são carregados corretamente, se estão formatados conforme o esperado e se são exibidos de forma precisa na interface.

Em resumo, os testes de integração desempenham um papel fundamental na garantia de qualidade de um aplicativo Flutter, permitindo a validação do comportamento do aplicativo em níveis mais amplos e complexos.

Eles ajudam a identificar e corrigir problemas de integração entre componentes, garantindo uma experiência de usuário consistente e livre de erros.

Conhecendo os asserts

Paralelamente a tudo que já foi apresentado, há uma forma de manter o código íntegro e bem funcional que é o uso de asserts!

No Flutter, os asserts são declarações que ajudam a validar suposições sobre seu código durante o desenvolvimento.

Eles são úteis para garantir que certas condições sejam verdadeiras em pontos cruciais do seu código.

É usado para verificar se uma condição é verdadeira. Se a condição for falsa, uma exceção AssertionError será lançada. Vamos para um exemplo:

int idade = 10;
assert(idade >= 0, 'A idade deve ser maior ou igual a zero.');
assert(idade >= 0); //Versão curta sem descrição

Outro uso muito comum de assert é durante o construtor de uma classe, com o mesmo propósito de parar o código caso a classe tenha sido construída de forma incorreta:

class Pessoa {
  final String nome;
  final int idade;

  Pessoa(this.nome, this.idade)
      : assert(nome.isEmpty, 'O nome não pode ser vazio'),
        assert(idade >= 0, 'A idade não pode ser negativa');
}

void main() {
  // Criando uma instância de Pessoa
  Pessoa pessoa = Pessoa('João', 30);
  print('Nome: ${pessoa.nome}, Idade: ${pessoa.idade}');

  // Tentando criar uma Pessoa com nome nulo
  Pessoa pessoaComNomeVazio =
      Pessoa("", 25); // Isso lançará uma exceção AssertionError
}

Uma informação importante é que os asserts são desativados em builds de produção.

Quando você executa seu aplicativo no modo de depuração (debug), os asserts são ativados.

Isso significa que todas as declarações assert no seu código são verificadas e, se uma delas falhar (ou seja, a condição fornecida for falsa), uma exceção AssertionError será lançada.

Por outro lado, em builds de produção (release), os asserts são desativados. Isso ocorre porque as verificações adicionais dos asserts podem impactar negativamente o desempenho do aplicativo.

Em vez disso, em builds de produção, é suposto que seu código esteja livre de erros e pronto para ser distribuído para os usuários finais.

Portanto, ao usar assert no Flutter, você pode ter confiança de que suas verificações serão úteis durante o desenvolvimento para identificar problemas rapidamente, mas lembre-se de que essas verificações não estarão presentes em builds de produção, onde o desempenho é uma prioridade.

Embora distintos, os asserts e os testes são ferramentas complementares na garantia da qualidade do código.

Usar asserts adequadamente pode ajudar a manter o código conciso, identificando problemas rapidamente e permitindo que os testes se concentrem em casos mais abrangentes.

Conhecendo o TDD e como ele se aplica ao Flutter

Ao longo desse artigo conversamos algumas vezes sobre uma "filosofia" que trazia a escrita de testes para antes da escrita de código. Chegou a hora de falar dela!

TDD, ou Desenvolvimento Orientado por Testes (Test-Driven Development), é uma prática de desenvolvimento de software em que os testes automatizados são escritos antes mesmo da implementação do código de produção.

O processo geralmente segue três passos principais, conhecidos como "Red-Green-Refactor" (Vermelho-Verde-Refatoração):

  • Red (Vermelho): Nesta fase, um teste automatizado é escrito para descrever um novo recurso ou uma mudança de comportamento desejada. Como o código de produção correspondente ainda não foi escrito, o teste geralmente falha inicialmente.
  • Green (Verde): Nesta etapa, o código de produção é escrito para fazer o teste recém-criado passar. O(a) desenvolvedor(a) implementa a funcionalidade necessária para que o teste automatizado tenha sucesso. Isso geralmente envolve escrever o código mais simples possível que faça o teste passar.
  • Refactor (Refatoração): Após o teste passar, o código é revisado e refatorado conforme necessário para melhorar a qualidade, legibilidade e eficiência. Durante essa etapa, é importante garantir que todos os testes automatizados continuem passando, garantindo que as alterações não tenham introduzido regressões.

O ciclo é então repetido para cada nova funcionalidade ou alteração no código. O TDD é uma abordagem iterativa e incremental para o desenvolvimento de software, que promove a qualidade do código, a confiabilidade e a manutenibilidade, ao mesmo tempo em que fornece uma base sólida de testes automatizados.

Além disso, TDD ajuda os(as) desenvolvedores(as) a manter o foco nos requisitos do usuário, pois os testes são escritos com base nos comportamentos esperados do sistema.

E o que o Flutter tem a ver com isso?

Sabe nossa aplicação de contador? Imagina que agora chegou uma demanda para a implementação de um método que recebe diretamente um valor do teclado como String e deve adicioná-lo ao atributo valor.

Eu imagino que quando eu te apresento esse desafio, já vem na sua mente várias formas de como você resolveria esse problema, certo?

Mas você tem certeza que você pensou em todos os casos? Os que dão tudo certo e os que algo dá errado?

Você acha que facilitaria seu trabalho se eu já te desse esses casos todos bem descritos, e o seu trabalho fosse apenas fazer o método se encaixar neles?

Claro que sim! Então vamos tentar:

group("Método 'adicionarDoTeclado':", () {
  test("Se tudo der certo, o retorno deve ser nulo.", () {
    Contador contador = Contador();
    expect(contador.adicionarDoTeclado("5"), isNull);
  });

  test(
      "Se o teclado enviar um valor não numérico, deve-se retornar uma mensagem de erro.",
      () {
    Contador contador = Contador();
    expect(
        contador.adicionarDoTeclado("Cinco"), equals("Valor não numérico."));
  });

  test(
      "Se o teclado enviar um valor fracionado, deve-se adicionar apenas o chão inteiro.",
      () {
    Contador contador = Contador();
    contador.adicionarDoTeclado("5.5");
    expect(contador.valor, equals(5));
  });
});

Que tal esse desafio antes de continuarmos? Eu fiz etapa Red para você, agora preciso que você faça a Green e a Refactor! Quando tiver terminado, você pode checar o resultado do meu método aqui:

String? adicionarDoTeclado(String novoValor) {
    double? valorDouble = double.tryParse(novoValor);

    if (valorDouble != null) {
      valor = valorDouble.floor();
      return null;
    }

    return "Valor não numérico.";
}

Claro que esse foi um exemplo simples para que você perceba a potencialidade do uso do TDD no desenvolvimento com Flutter, mas é importante frisar outros aspectos que tornam o uso do TDD com Flutter recomendado:

  1. Código mais confiável: Ao escrever testes automatizados antes de implementar o código de produção, garante-se que cada funcionalidade seja testada em detalhes. Isso resulta em um código mais confiável, com menos bugs e comportamentos inesperados.
  2. Feedback rápido: O ciclo "Red-Green-Refactor" do TDD proporciona um feedback rápido sobre a implementação do código. Os testes falham inicialmente (Red), mas passam assim que a funcionalidade é implementada corretamente (Green). Esse ciclo rápido ajuda pessoas desenvolvedoras a identificarem problemas e corrigi-los prontamente.
  3. Documentação viva: Os testes servem como documentação viva do código, descrevendo o comportamento esperado das funcionalidades. Isso facilita a compreensão do código por parte dos(as) desenvolvedores(as), especialmente em projetos colaborativos ou durante a manutenção do código no futuro.
  4. Facilita a refatoração: Como os testes garantem que o comportamento do código não seja alterado inadvertidamente, eles permitem que os(as) desenvolvedores(as) realizem refatorações com confiança. Isso significa que o código pode ser reestruturado e otimizado sem medo de introduzir regressões.
  5. Promove o design modular: O TDD incentiva o desenvolvimento de código modular, onde as funcionalidades são divididas em unidades independentes e testáveis. Isso facilita a manutenção do código e a reutilização de componentes em diferentes partes do projeto.
  6. Reduz o tempo de depuração: Como os testes são executados automaticamente, os problemas são identificados precocemente, reduzindo a necessidade de depuração manual intensiva. Isso economiza tempo e recursos durante o ciclo de desenvolvimento.
  7. Maior confiança nas mudanças: Com uma suíte de testes abrangente, os(as) desenvolvedores(as) têm mais confiança ao introduzir novas funcionalidades ou realizar alterações no código existente. Eles(as) podem ter certeza de que as alterações não afetarão negativamente outras partes do sistema.

No geral, o uso do TDD leva a um código mais robusto, confiável e sustentável, reduzindo custos e aumentando a eficiência ao longo do ciclo de vida do projeto.

Integração Contínua no Testes com Flutter

A sinergia entre Testes e Integração Contínua (CI) desempenha um papel crucial na garantia da qualidade e na eficiência do ciclo de desenvolvimento, e com o Flutter não é diferente.

Os testes automatizados que tanto conversamos durante esse artigo, são usados pelas ferramentas de CI como parte de um fluxo de trabalho contínuo de construção, teste e entrega, onde cada alteração de código é verificada automaticamente.

Isso não apenas acelera o ciclo de feedback, mas também proporciona uma base sólida para implementações frequentes e seguras.

Se você quiser entender mais sobre Integração Contínua e Entrega Contínua no Flutter, recomendo a leitura do artigo CI/CD no Flutter: o que é? Como funciona e como usar?)

Conclusão

O Flutter é uma das melhores ferramentas da atualidade para desenvolvimento multiplataforma e, dentre os vários motivos para essa conclusão, está também seu ambiente de testes automatizados!

Essa habilidade levanta a régua de qualidade lá em cima, tornando o trabalho em equipe e o desenvolvimento de código mais seguro e livre dos temidos bugs.

E ainda poupa você de trabalhos manuais! O futuro é agora; e ele é automatizado.

Se você quiser ler e incrementar os códigos desenvolvidos nesse artigo, você pode baixar o código-fonte ou acessar o repositório.

Bateu o interesse pelo assunto? Estude Testes com Flutter com a gente:

E aí, já tinha ouvido falar de Testes Automatizados no Flutter? Já usou essas técnicas em algum projeto?

Conta para nós, marcando os perfis da Alura em suas redes sociais e usando a hashtag #AprendiNaAlura para compartilhar sua experiência de desenvolvimento.

Vejo você por aí no maravilhoso mundo do Flutter!

Referências

Continue seus estudos:

Ricarth Lima
Ricarth Lima

Acredito que educação e computação podem mudar o mundo para melhor, em especial, juntas. Por isso além de fazer parte do Grupo Alura, sou professor, desenvolvedor de jogos educativos e criador de conteúdo! Amo Flutter e Unity!

Veja outros artigos sobre Mobile