Em testes de software, frequentemente nos deparamos com a necessidade de comparar o valor atual com o valor esperado. É nesse momento que os matchers se tornam essenciais. Um matcher é, basicamente, um objeto que segue uma interface específica para realizar essa comparação.
O principal objetivo dos matchers é criar testes mais descritivos, legíveis e robustos. Isso nos permite expressar as condições que esperamos de uma forma muito mais clara e fluída.
Por exemplo, em vez de usar um expect
para verificar se uma variável é igual a 5, poderíamos utilizar um matcher para confirmar se a variável “é igual a 5”, “é maior que 2” ou “é um número inteiro”.
Pô, Caique, isso dá muito trabalho para comparar apenas dois valores. Isso vai atrasar minha entrega.
Calma lá! A gente só gasta horas em uma tarefa quando não compreendemos como realizá-la. Para evitar que os testes se tornem um fardo, vamos nos aprofundar um pouco mais nesse tipo de objeto.
Agora que sabemos o que é um matcher, vamos explorar como eles funcionam. Para isso, vamos dar uma olhada no código dessa classe:
/// The base class for all matchers.
///
/// [matches] and [describe] must be implemented by subclasses.
///
/// Subclasses can override [describeMismatch] if a more specific description is
/// required when the matcher fails.
abstract class Matcher {
const Matcher();
/// Does the matching of the actual vs expected values.
///
/// [item] is the actual value. [matchState] can be supplied
/// and may be used to add details about the mismatch that are too
/// costly to determine in [describeMismatch].
bool matches(dynamic item, Map matchState);
/// Builds a textual description of the matcher.
Description describe(Description description);
/// Builds a textual description of a specific mismatch.
///
/// [item] is the value that was tested by [matches]; [matchState] is
/// the [Map] that was passed to and supplemented by [matches]
/// with additional information about the mismatch, and [mismatchDescription]
/// is the [Description] that is being built to describe the mismatch.
///
/// A few matchers make use of the [verbose] flag to provide detailed
/// information that is not typically included but can be of help in
/// diagnosing failures, such as stack traces.
Description describeMismatch(
dynamic item,
Description mismatchDescription,
Map matchState,
bool verbose,
) => mismatchDescription;
}
Essa classe descreve um contrato que deve ser seguido pelas suas especializações. Os principais métodos são:
-
matches: Este método contém a lógica de um match. Ele recebe um item para ser comparado e um
matchState
. O retorno é um booleano que indica se o item satisfaz todas as condições impostas pelo Matcher. -
describe: Responsável por gerar a descrição do que o Matcher está realizando o match.
-
describeMismatch: Oferece mais contexto sobre o mismatch que ocorreu. Esse método é invocado quando o método matches retorna falso.
Compreendendo as Funções matches
e describeMismatch
Um dos parâmetros que a função matches
recebe é um Map
, que tem como finalidade enriquecer as informações que o matches
irá passar para o describeMismatch
.
Quando Utilizar o describeMismatch
-
Validações complexas: Se o Matcher é responsável por realizar múltiplas validações, é interessante indicar a razão pela qual o teste falhou.
-
Dados adicionais: Às vezes, o código sendo validado depende de algum dado computado. Podemos indicar isso através do
matchState
.
Visualizando o Processo
Agora que sabemos o que cada função faz, vamos entender como elas interagem entre si. Para isso, iremos desenhar um diagrama de sequência com Mermaid:
sequenceDiagram
participant Teste
participant FrameworkDeTeste
participant MatcherPersonalizado
participant Descrição
Teste ->> FrameworkDeTeste: expect(valorAtual, matcher)
FrameworkDeTeste ->> MatcherPersonalizado: matches(item, estadoDaCorrespondência)
alt correspondência é false
MatcherPersonalizado ->> Descrição: describe(descrição)
Descrição -->> MatcherPersonalizado: textoDaDescrição
MatcherPersonalizado ->> Descrição: add(textoDaDescrição)
Descrição -->> FrameworkDeTeste: textoDoErro
FrameworkDeTeste -->> Teste: Lança erro de asserção com textoDoErro
else correspondência é true
FrameworkDeTeste -->> Teste: Teste passa
end
Colocando o Matcher para Trabalhar
Finalmente, a teoria acabou e chegou a hora da prática. Vamos entender como criar um Matcher para facilitar nossa vida?
O Cenário
Imagine que você está desenvolvendo uma tela em Flutter e precisa testar um widget que recebe um e-mail como entrada. Para facilitar nossa vida, poderíamos criar um matcher da seguinte forma:
class IsValidEmail extends Matcher {
@override
bool matches(
dynamic item,
Map matchState,
) {
if (item is! String) {
matchState['reason'] = 'Not a string';
return false;
}
if (!item.contains('@')) {
matchState['reason'] = 'Missing @ symbol';
return false;
}
List<String> parts = item.split('@');
if (parts.length != 2) {
matchState['reason'] = 'Multiple @ symbols';
return false;
}
if (parts[0].isEmpty || parts[1].isEmpty) {
matchState['reason'] = 'Local or domain part is empty';
return false;
}
if (!parts[1].contains('.')) {
matchState['reason'] = 'Missing domain extension';
return false;
}
return true;
}
@override
Description describe(Description description) {
return description.add('is a valid email');
}
@override
Description describeMismatch(
item,
Description mismatchDescription,
bool verbose,
Map matchState,
) {
return mismatchDescription.add('Failed because ${matchState['reason']}');
}
}
E Agora?
No nosso segundo artigo sobre testes, vimos o funcionamento de um Matcher. A partir daí, reconhecemos o matcher como uma ferramenta que pode transformar a maneira como escrevemos e lemos nossos testes.
No exemplo prático, demonstramos como podemos reduzir a duplicidade de código em testes através do uso de matchers, otimizando o processo e tornando tudo mais claro.
Espero que este artigo tenha ajudado no entendimento dos matchers. Nos próximos artigos, vamos entender como funciona o processo de um teste de widget e como os matchers podem nos ajudar nesse contexto.
Até a próxima, pessoal!