Testes unitários são códigos que tem como responsabilidade avaliar o funcionamento de uma parte especifica do código, a qual iremos nos referir como sendo uma unidade de código.

Por verificarem apenas uma unidade de código, eles nos ajudam a identificar rapidamente qualquer problema ou regressão no código, facilitando a manutenção e o desenvolvimento de novas funcionalidades.

Te falar que gosto de encara-los como uma corrente, uma vez que definem o comportamento esperado dessa unidade de código, estabelecendo um “contrato” de funcionamento e garantindo a consistência do projeto.

Agora que você entende a sua importância e a sua contribuição para a qualidade e consistência do projeto, que tal vermos isso tudo na prática? Tá, pera lá que ainda tem um pouco de teoria. Porém, prometo que não vamos só ficar nela! Bora lá para a próxima seção?

Estruturando um teste unitário

Para começarmos a colocar a mão na massa, precisamos adicionar o pacote test em nosso arquivo pubspec.yaml. Como iremos utilizá-lo apenas durante o desenvolvimento, adicionaremos como uma dependência de desenvolvimento (dev_dependencies).

Ah! Eu esqueci de mencionar a estrutura do projeto. Vamos considerar a seguinte organização de pastas como exemplo:”

root/
├── lib/
│   └── calculadora.dart
└── test/
    └── calculadora_test.dart

O próximo passo é criar um ponto de entrada e importar o package test.

import 'package:test/test.dart';
 
void main() {
  // Digite eu código
}

A função group()

É algo bem comum que nossos testes sejam agrupados por uma determinada ação ou por uma determinada lógica. Por exemplo, você pode querer verificar todos os cenários de sucesso de uma determinada função e, depois, verificar os cenários de erro dela.

Aí que entra a função group(). Ao utilizarmos ela, podemos fornecer uma descrição daquele grupo de testes e, em seguida, adicioná-los. Isso torna mais fácil identificar e encontrar testes específicos durante a sua execução.

Além disso, ao trabalhar com grupos de testes, podemos usar as funções setUp e tearDown para a configuração daquele determinado cenário. Essas duas funções não diferem muito do que já estamos acostumados na codificação de testes, a função setUp é executada antes de cada teste dentro do grupo e é útil para preparar o ambiente de teste, como inicializar objetos ou definir variáveis. Já a função tearDown é executada após cada teste e serve para limpar o ambiente de teste, como descartar objetos ou resetar variáveis.

Bom, agora que já sabemos agrupar um conjunto de testes, que tal vermos como definimos um teste?

A função test()

A função ´test()´ aceita vários parâmetros, mas vamos reduzir apenas dois:

  • description: Um texto responsável por descrever o que aquele código está testando;
  • function: Uma função responsável por conter todo o código que testará uma unidade de código.

Levando para o código, temos:

import 'package:test/test.dart';
 
main() {
  test(
    'Assegurando comportamentos do TopLocalDataSource',
    () {
      // Test code here
    },
  );
}

A estrutura apresentada é a base para criar nossos testes unitários. Agora que a conhecemos, podemos focar na escrita deles. Uma boa estrutura de teste nos ajuda a garantir que os testes sejam fáceis de ler, manter e entender.

Antes de irmos para a próxima seção, gostaria de deixar uma observação: A teoria de teste é uma área bem vasta e o que será proposto a seguir são apenas boas práticas, não levando em conta toda a base teórica existente.

Agora que já tirei o meu da reta, bora lá dar uma olhada nessa forma de escrever teste?

Uma boa estrutura para teste

É composta por três partes:

  • Arrange: Etapa responsável por configurar os objetos, variáveis e condições necessárias para a execução do teste. Gosto de pensar que é o momento que deixamos o sistema no estado descrito pelo teste.
  • Act: Aqui, realizamos a ação que queremos testar. É onde iremos provocar uma ação no sistema, a qual levará para um próximo estado.
  • Assert: Finalmente, verificamos se os resultados obtidos na última fase estão de acordo com o que era esperado. Através das asserções, comparamos se o estado final é igual ao estado desejado.

Incrementando nosso código com essas três etapas:

import 'package:test/test.dart';
 
main() {
  test(
    'Assegurando comportamentos do TopLocalDataSource',
    () {
      // Arrange
      // Act
      // Assert
    },
  );
}

Vale ressaltar que há a possibilidade de existir mais de um assert nessa estrutura. Porém, é uma boa prática sempre tentarmos manter apenas uma asserção, visando torná-la clara. Mas ó, temos de lembrar que o objetivo principal é garantir que os testes sejam:

  • Claros;
  • Eficientes;
  • Fáceis de manter.

Logo, é importante avaliar cada situação e determinar qual abordagem funciona melhor para o seu contexto.

Falando em contexto, bora praticar tudo isso que foi escrito até agora?

Testes Unitários na prática

Contexto

Um belo dia, você fica encarregado de criar um DataSource local que retorna uma lista de “Atalhos”.

Para facilitar minha vida, iremos definir um atalho como sendo:

class Atalho {
  const Atalho({
    required this.nome,
    required this.deeplink,
    required this.icone,
  });
 
  final String nome;
  final String deeplink;
  final String icone;
 
  @override
  bool operator ==(covariant Atalho other) {
    if (identical(this, other)) return true;
    return
      other.nome == nome &&
      other.deeplink == deeplink &&
      other.icone == icone;
  }
 
  @override
  int get hashCode => nome.hashCode ^ deeplink.hashCode ^ icone.hashCode;
}

Além disso, o DataSource precisa registrar toda invocação de suas funções. A classe responsável por fazer isso é:

abstract class Logger {
  void log(String functionName);
}

Por último, mas não menos importante, o código que iremos testar:

abstract class TopLocalDataSource {
  Future<List<Atalho>> fetch();
  void blowUpTheWorld();
  Stream<int> countDown(int from);
}
 
class TopLocalDataSourceImpl implements TopLocalDataSource {
  TopLocalDataSourceImpl(this.logger);
 
  final Logger logger;
 
  @override
  Future<List<Atalho>> fetch() async {
    logger.log("Chamando fetch");
    return Future.value([
      Atalho(
        nome: "Atalho",
        deeplink: "deeplink",
        icone: "icone",
      ),
      Atalho(
        nome: "Atalho 1",
        deeplink: "deeplink",
        icone: "icone",
      )
    ]);
  }
 
  @override
  Stream<int> countDown(int from) async* {
    for (int i = from; i >= 0; i--) {
      yield i;
    }
  }
 
  @override
  void blowUpTheWorld() {
    throw Exception("Isso daqui era só pra poder testar");
  }
}

Testando uma função

O objetivo dessa seção é aplicar tudo que vimos até agora e testarmos a função uma função, no nosso caso a função fetch. Logo:

void main() {
  group(
    'Assegurando comportamentos do TopLocalDataSource',
    () {
      late TopLocalDataSource dataSource;
      late Logger logger;
 
      setUp(
        () {
          logger = MockLogger();
          dataSource = TopLocalDataSourceImpl(logger);
        },
      );
 
      test(
        'Quando chamarmos a função fetch(), então devemos retornar uma lista de objetos "Atalho"',
        () async {
          const expectedValue = [
            Atalho(nome: 'Atalho', deeplink: 'deeplink', icone: 'icone'),
            Atalho(nome: 'Atalho 1', deeplink: 'deeplink', icone: 'icone'),
          ];
 
          final currentValue = await dataSource.fetch();
 
          expect(currentValue, equals(expectedValue));
        },
      );
    },
  );
}

Testando se uma dependência foi chamada

Depois de testarmos uma função, você pode se perguntar:

Será que há uma maneira de garantir que, ao chamar uma função do nosso objeto em teste (SUT), estamos realmente chamando uma função de uma dependência dessa classe?

É claro! Bora ver isso agora. Para tal:

void main() {
  group(
    'Assegurando comportamentos do TopLocalDataSource',
    () {
      late TopLocalDataSource dataSource;
      late Logger logger;
 
      setUp(
        () {
          logger = MockLogger();
          dataSource = TopLocalDataSourceImpl(logger);
        },
      );
 
      test(
        'Quando chamarmos a função fetch(), então devemos retornar uma lista de objetos "Atalho"',
        () async {
          const expectedValue = [
            Atalho(nome: 'Atalho', deeplink: 'deeplink', icone: 'icone'),
            Atalho(nome: 'Atalho 1', deeplink: 'deeplink', icone: 'icone'),
          ];
 
          final currentValue = await dataSource.fetch();
 
          expect(currentValue, equals(expectedValue));
        },
      );
 
      test(
        'Quando chamarmos a função fetch(), então devemos assegurar que a função log foi chamada com a mensagem "Chamando fetch"',
        () async {
          await dataSource.fetch();
          verify(logger.log("Chamando fetch")).called(1);
        },
      );
    },
  );
}

O trecho de código fornecido nos apresenta uma nova função, a verify(). Essa função recebe como argumento o método que se deseja verificar se houve uma interação com ele. Logo após essa verificação, concatenamos a chamada called(n), que é utilizada pra verificar quantas vezes esperamos que o método em questão tenha sido chamado. No exemplo, usamos called(1) para garantir que o método log() foi chamado exatamente uma vez.

Testando streams

void main() {
  group(
    'Assegurando comportamentos do TopLocalDataSource',
    () {
      late TopLocalDataSource dataSource;
      late Logger logger;
 
      setUp(
        () {
          logger = MockLogger();
          dataSource = TopLocalDataSourceImpl(logger);
        },
      );
 
      test(
        'Quando chamarmos a função fetch(), então devemos retornar uma lista de objetos "Atalho"',
        () async {
          const expectedValue = [
            Atalho(nome: 'Atalho', deeplink: 'deeplink', icone: 'icone'),
            Atalho(nome: 'Atalho 1', deeplink: 'deeplink', icone: 'icone'),
          ];
 
          final currentValue = await dataSource.fetch();
 
          expect(currentValue, equals(expectedValue));
        },
      );
 
      test(
        'Quando chamarmos a função fetch(), então devemos assegurar que a função log foi chamada com a mensagem "Chamando fetch"',
        () async {
          await dataSource.fetch();
          verify(logger.log("Chamando fetch")).called(1);
        },
      );
 
      test(
        'Quando chamarmos a função countDown(), então devemos emitir uma sequência decrescente a partir do número fornecido',
        () async {
          final expectedValue = [5, 4, 3, 2, 1, 0];
 
          final currentValue = await dataSource.countDown(5).toList();
 
          expect(currentValue, equals(expectedValue));
        },
      );
    },
  );
}

Há outros tipos de testes relacionados a streams:

  • Verificar se a stream emite os elementos esperados na ordem desejada;
  • Verificar se ela completa corretamente;
  • Verificar se há a emissão de um erro específico;

Mas, pera lá. Vimos que conseguimos testar várias coisas, mas será que é possível testar aquele cenário caótico que vai levar a uma exceção?

Testando exceção

void main() {
  group(
    'Assegurando comportamentos do TopLocalDataSource',
    () {
      late TopLocalDataSource dataSource;
      late Logger logger;
 
      setUp(
        () {
          logger = MockLogger();
          dataSource = TopLocalDataSourceImpl(logger);
        },
      );
 
      test(
        'Quando chamarmos a função fetch(), então devemos retornar uma lista de objetos "Atalho"',
        () async {
          const expectedValue = [
            Atalho(nome: 'Atalho', deeplink: 'deeplink', icone: 'icone'),
            Atalho(nome: 'Atalho 1', deeplink: 'deeplink', icone: 'icone'),
          ];
 
          final currentValue = await dataSource.fetch();
 
          expect(currentValue, equals(expectedValue));
        },
      );
 
      test(
        'Quando chamarmos a função fetch(), então devemos assegurar que a função log foi chamada com a mensagem "Chamando fetch"',
        () async {
          await dataSource.fetch();
          verify(logger.log("Chamando fetch")).called(1);
        },
      );
 
      test(
        'Quando chamarmos a função countDown(), então devemos emitir uma sequência decrescente a partir do número fornecido',
        () async {
          final expectedValue = [5, 4, 3, 2, 1, 0];
 
          final currentValue = await dataSource.countDown(5).toList();
 
          expect(currentValue, equals(expectedValue));
        },
      );
 
      test(
        'Quando chamarmos a função blowUpTheWorld(), então devemos verificar se houve o lançamento de uma exceção',
        () async {
          expect(() => dataSource.blowUpTheWorld(), throwsA(isA<Exception>()));
        },
      );
    },
  );
}

‘Além de verificar se a exceção foi lançada, poderíamos utilizar a estrutura try {} catch(e) {} para verificar se a mensagem dentro de nossa Exception é igual aquela esperada.

E agora?

Ao longo deste artigo, exploramos o conceito de testes unitários e aprendemos como estruturar e organizar nossos testes. Vimos como utilizar as funções group() e test() para criar agrupamentos de testes e defini-los individualmente.

Por meio de exemplos práticos, demonstramos como testar funções, dependências, streams e exceções. Esses conceitos e práticas são fundamentais para garantir a sua sexta-feira e a qualidade da sua aplicação!

Embora este artigo tenha abordado os conceitos básicos dos testes unitários, ainda há muitos outros tipos de testes, como testes de integração e de interface do usuário, que também são importantes para garantir seu sono e uma tranquila jornada para o usuário.

Espero que este guia tenha sido útil para você tanto quanto foi para mim! Estou migrando de área e colocar as ideias no papel acaba me forçando a estudar alguns conceitos que passam batidos no dia a dia.

Até a próxima!