Share via


Visual Studio 2015

Criar software melhor com testes de unidade inteligentes

Pratap Lakshman

O mundo do desenvolvimento de software está indo em direção a ciclos de lançamento cada vez mais curtos. O momento em que as equipes de desenvolvimento de software podiam sequenciar estritamente as funções de especificação, implementação e teste em um modelo de cascata é coisa do passado. Desenvolver software de alta qualidade é difícil em um mundo movimentado e invoca uma reavaliação das metodologias de desenvolvimento existentes.

Para reduzir o número de bugs em um produto de software, todos os membros da equipe devem concordar com o que o sistema de software deve para fazer e isso é um desafio importante. A especificação, a implementação e os testes normalmente têm ocorrido em silos, com nenhum meio comum de comunicação. Diferentes idiomas ou artefatos usados para cada dificultam sua coevolução à medida que a atividade de implementação de software progride e, enquanto um documento de especificação deva conectar o trabalho e todos os membros da equipe, é raramente o caso na realidade. A especificação original e a implementação atual podem divergir e a única coisa mantendo tudo junto é eventualmente o código, que acaba incluindo a última especificação e as várias decisões de design tomadas a caminho. Os testes tentam reconciliar esta divergência recorrendo ao teste de somente alguns cenários de ponta a ponta bem compreendidos.

Essa situação pode ser melhorada. É necessário um meio comum para especificar o comportamento desejado do sistema de software, um que pode ser compartilhado pelo design, implementação e teste e isso é fácil de evoluir. A especificação deve estar diretamente relacionada com o código e o meio ser codificado como um conjunto completo de testes. As técnicas baseadas em ferramentas permitidas por Testes de Unidade Inteligentes podem ajudar a satisfazer esta necessidade.

Testes de Unidade Inteligentes

Os Testes de Unidade Inteligentes, um recurso do Visual Studio 2015 Preview (consulte a Figura 1), são um assistente inteligente para desenvolvimento de software, ajudando equipes de desenvolvimento a encontrar bugs no início e reduzir os custos de manutenção do teste. Baseia-se no Microsoft Research anterior chamado “Pex”. Seu mecanismo usa análises de código de caixa branca e restringe a resolução à sintetização de valores de entrada de teste precisos para utilizar todos os caminhos de código no código em teste, persiste como um conjunto compacto das unidades de teste tradicionais com alta cobertura e evolui automaticamente o pacote de testes à medida que o código evolui.

Testes de Unidade Inteligentes totalmente integrados no Visual Studio 2015 Preview
Figura 1 Testes de Unidade Inteligentes totalmente integrados no Visual Studio 2015 Preview

Além disso, e é vivamente incentivado, as propriedades de exatidão especificadas como declarações no código podem ser usadas para orientar a geração do caso de teste.

Por padrão, se nada mais fizer do que somente executar Testes de Unidade Inteligentes em uma parte de código, os casos de teste gerados capturam o comportamento observado do código em teste para cada valor de entrada sintetizado. Nesse estágio, exceto para os casos de teste que estão causando erros de tempo de execução, os restante são considerados como testes aprovados — afinal de contas, é o comportamento observado.

Além disso, se gravar declarações especificando as propriedades de exatidão do código em teste, então, os Testes de Unidade Inteligentes surgirão com valores de entrada de teste que podem fazer as asserções falhar, bem como, cada valor de entrada detectando um bug no código e, portanto, um caso de teste reprovado. Os Testes de Unidade Inteligentes não podem surgir com essas propriedades de exatidão por si mesmos; teria de os gravar com base em seu conhecimento de domínio.

Geração de caso de teste

Em geral, as técnicas de análise de programa ficam entre os seguintes dois extremos:

  • As técnicas de análise estática verificam que uma propriedade mantém todos os caminhos de execução. Como o objetivo é a verificação de programa, estas técnicas são usualmente bastante conservadoras e sinalizam possíveis violações como erros, levando a falsos positivos.
  • As técnicas de análise dinâmica verificam que uma propriedade mantém alguns dos caminhos de execução. O teste pega na abordagem de análise dinâmica que visa detectar bugs, mas usualmente não consegue comprovar a ausência de erros. Portanto, estas técnicas muitas vezes não conseguem detectar todos os erros.

Pode não ser possível detectar bugs precisamente quando da aplicação somente de análises estatísticas ou empregando uma técnica de teste que não está ciente da estrutura do código. Analise, por exemplo, o código a seguir:

int Complicated(int x, int y)
{
  if (x == Obfuscate(y))
    throw new RareException();
  return 0;
}
int Obfuscate(int y)
{
  return (100 + y) * 567 % 2347;
}

As técnicas de análise estática tendem a ser conservadoras, pelo que a aritmética de inteiros não linear presente em Obfuscate faz com que a maioria das técnicas de análise estática emita um aviso sobre um potencial erro em Complicated. Além disso, as técnicas de teste aleatórias têm pouca probabilidade de encontrar um par de valores x e y que dispara a exceção.

Os Testes de Unidade Inteligentes implementam uma técnica de análise que fica entre estes dois extremos. Semelhante às técnicas de análise estática, prova que uma propriedade é mantida para caminhos mais viáveis. Semelhante às técnicas de análise dinâmica, relata somente erros reais e sem falsos positivos.

A geração do caso de teste envolve o seguinte:

  • Detectar dinamicamente todas as ramificações (explícitas e implícitas) no código em teste.
  • Sintetizar os valores de entrada de teste precisos que usam essas ramificações.
  • Registrar a saída do código em teste para as entradas em questão.
  • Persistir nos mesmos como um pacote de testes compacto com alta cobertura.

A Figura 2 mostra como funciona usando instrumentação e monitoramento de tempo de execução e eis as etapas envolvidas:

  1. O código em teste é o primeiro instrumentado e os retornos de chamada são instalados e irão permitir que o mecanismo de teste monitore a execução. O código é executado com o valor de entrada concreto relevante mais simples (com base no tipo de parâmetro). Isso representa o caso de teste inicial.
  2. O mecanismo de teste monitora a execução, calcula a cobertura de cada caso de teste e controla como o valor de entrada flui através do código. Se todos os caminhos forem abordados, o processo para; todos os comportamentos excepcionais são considerados como ramificações, assim como ramificações explícitas no código. Se todos os caminhos ainda não tiverem sido abordados, o mecanismo de teste escolhe um caso de teste que atinge um ponto do programa a partir do qual uma ramificação não descoberta sai e determina como a condição de ramificação depende do valor de entrada.
  3. O mecanismo constrói um sistema de restrição representando a condição sob a qual o controle alcança para esse ponto de programa e continuaria ao longo da ramificação anteriormente não descoberta. Em seguida, consulta o solucionador de restrições para sintetizar um novo valor de entrada concreto baseado nesta restrição.
  4. Se o solucionador de restrições puder determinar um valor de entrada concreto para a restrição, o código em teste é executado com o novo valor de entrada concreto.
  5. Se a cobertura aumentar, é emitido um caso de teste.

Como a geração do caso de teste funciona de modo subjacente
Figura 2 Como a geração do caso de teste funciona de modo subjacente

As etapas 2 a 5 são repetidas até que todas as ramificações são abordadas ou até os limites de exploração pré-configurados serem excedidos.

Esse processo é chamado de uma "exploração". Em uma exploração, o código em teste pode ser “executado” várias vezes. Algumas destas execuções aumentam a cobertura e somente as execuções que aumentam de cobertura emitem casos de teste. Portanto, todos os testes que são gerados usam caminhos viáveis.

Exploração limitada

Se o código em teste não contiver loops ou recursões não limitadas, tipicamente a exploração para rapidamente, pois há somente um número finito (pequeno) de caminhos de execução a analisar. No entanto, os programas mais interessantes contêm loops ou recursão não limitada. Nesses casos, o número de caminhos de execução é (praticamente) infinito e é geralmente indecidível se uma instrução é acessível. Em outras palavras, uma exploração poderia demorar uma eternidade a analisar todos os caminhos de execução do programa. Como a geração de teste envolve executar realmente o código em teste, como se protege de uma exploração de fuga? É onde a exploração limitada desempenha uma função importante. Garante que as explorações param após uma quantidade razoável de tempo. Há vários limites de exploração configuráveis e em camadas que são usados:

  • Os limites de solucionador de restrições limitam a quantidade de tempo e a memória que o solucionador pode usar na procura do próximo valor de entrada concreto.
  • Os limites do caminho de exploração limitam a complexidade do caminho de execução sendo analisada em termos do número de ramificações feitas, o número de condições sobre as entradas que precisam ser verificadas e a profundidade do caminho de execução em termos de quadros da pilha.
  • Os limites de exploração limitam o número de “execuções” que não produzem um caso de teste, o número total de execuções permitidas e o limite de tempo geral após o qual a exploração para.

Um aspecto importante de qualquer abordagem de teste baseada em ferramentas sendo eficaz é um feedback rápido e todos estes limites foram pré-configurados para permitir o uso interativo rápido.

Além disso, o mecanismo de teste usa heurística para alcançar alta cobertura de código rapidamente, adiando a resolução de sistemas de restrição difíceis. É possível deixar o mecanismo gerar rapidamente alguns testes para código nos quais você está trabalhando. No entanto, para resolver os restantes problemas de geração de entrada de teste difíceis, é possível ajustar os limites para permitir o mecanismo de teste fragmente os sistemas de restrição complicados.

Teste de unidade parametrizada

Todas as técnicas de análise de programa tentam validar ou contestar certas propriedades especificadas de um determinado programa. Há diferentes técnicas para especificar propriedades de programa:

  • Os contratos de API especificam o comportamento de ações de API individuais da perspectiva da implementação. Sua meta é garantir resistência, no sentido de que as operações não falham e as invariáveis de dados são preservadas. Um problema comum dos contratos de API é sua vista restrita sobre as ações de API individuais, que dificultam a descrição de protocolos em todo o sistema.
  • Os Testes de Unidade incorporam cenários de uso da perspectiva de um cliente da API. Sua meta é garantir a exatidão funcional, no sentido de que a interação de várias operações se comporta conforme o esperado. Um problema comum de testes de unidade é que eles estão desanexados dos detalhes da implementação da API.

Os Testes de Unidade Inteligentes permitem o teste de unidade parametrizada, que une ambas as técnicas. Suportada por um mecanismo de geração de entrada de teste, esta metodologia combina as perspectivas do cliente e da implementação. As propriedades de exatidão funcional (testes de unidade parametrizada) são verificadas na maioria dos casos de implementação (geração da entrada de teste).

Um teste de unidade parametrizada (PUT) é a generalização direta de um teste de unidade através do uso de parâmetro. Um PUT cria instruções sobre o comportamento do código para um conjunto inteiro de valores de entrada possíveis, ao invés de somente um valor de entrada exemplar. Expressa suposições sobre as entradas do teste, executa uma sequência de ações e avalia propriedades que deve manter no estado final; ou seja, atua como a especificação. Tal especificação não requer ou introduz qualquer nova linguagem ou artefato. É gravado ao nível das APIs atuais implementadas pelo produto de software e na linguagem de programação do produto de software. Os projetistas podem usá-los para expressar o comportamento esperado das APIs de software, os desenvolvedores podem usá-los para promover o teste do desenvolvedor automatizado e os testadores aproveitam-nos para geração de teste automático detalhado. Por exemplo, o seguinte PUT afirma que depois de adicionar um elemento a uma lista não nula, o elemento está de fato contido na lista:

void TestAdd(ArrayList list, object element)
{
  PexAssume.IsNotNull(list);
  list.Add(element);
  PexAssert.IsTrue(list.Contains(element));
}

PUTs separa as seguintes duas preocupações:

  1. A especificação das propriedades de exatidão do código em teste para todos os argumentos de teste possíveis.
  2. Os casos de teste “fechados” atuais com os argumentos concretos.

O mecanismo emite stubs para a primeira preocupação e é incentivado a defini-los com base em seu conhecimento de domínio. As invocações subsequentes dos Testes de Unidade Inteligentes irão gerar automaticamente e atualizar os casos de teste fechados individuais.

Aplicativo

As equipes de desenvolvimento de software podem ser estabelecidas em várias metodologias e é ilusório esperar que elas adotem uma nova durante a noite. De fato, os Testes de Unidade Inteligentes não devem ser uma substituição de quaisquer práticas de teste que as equipes podem estar seguindo; em vez disso, devem incrementar quaisquer práticas existentes. É provável que a adoção comece com uma aceitação gradual, com equipes aproveitando a geração de teste automático padrão e capacidades de manutenção primeiro e, em seguida, avançando para gravar as especificações no código.

Comportamento observado por teste Imagine ter de efetuar alterações a um corpo de código sem cobertura de teste. Talvez queira apontar seu comportamento em termos de um pacote de testes de unidade antes de começar, mas é mais fácil dizer que fazer:

  • O código (código de produto) pode não ser adaptado para ser testável a nível de unidade. Pode ter dependências sólidas com o ambiente externo que necessitará ser isolado e, se não as conseguir identificá-las, pode nem saber onde começar.
  • A qualidade dos testes também pode ser um problema e existem muitas medidas de qualidade. Há a medição de cobertura – quantas ramificações ou caminhos de código ou outros artefatos de programa no código do produto os testes contatam? Há a medida de declarações que expressa se o código está fazendo a coisa certa. Contudo, nenhuma destas medidas por si só é suficiente. Em vez disso, o que poderia ser bom é uma alta densidade de declarações sendo validada com alta cobertura de código. Mas não é fácil fazer este tipo de análises de qualidade em sua mente à medida que grava os testes e, como uma consequência, pode acabar com testes que manipulam os mesmos caminhos de código repetidamente; talvez somente testando o "caminho da felicidade" e nunca saberá se o código de produto pode mesmo lidar com todos esses casos extremos.
  • E, de forma frustrante, você talvez nem saiba que declarações colocar. Imagine que está sendo chamado para fazer alterações em uma base de código não familiar!

A capacidade de geração de teste automático de Testes de Unidade Inteligentes é especialmente útil nesta situação. Pode tornar o comportamento observado atual na linha de base de seu código como um pacote de teste para usar como pacote de regressão.

Teste baseado na especificação As equipes de software podem usar PUTs como a especificação para promover a geração de caso de teste exaustivo para revelar violações de declarações de teste. Estando livres de grande parte do trabalho manual necessário para gravar casos de teste que alcançam alta cobertura de código, as equipes podem concentrar-se em trabalhos que os Testes de Unidade Inteligentes não podem automatizar, tais como gravar cenários mais interessantes como PUTs e desenvolver testes de integração que vão além do escopo dos PUTs.

Localização automática de bus As declarações expressando propriedades de exatidão podem ser mencionadas de múltiplas formas: como instruções assert, como contratos de código e mais. A boa notícia é que essas são todas compiladas em ramificações – uma instrução if com uma ramificação then e uma ramificação else representando o resultado do predicado sendo declarado. Como os Testes de Unidade Inteligentes calculam entradas que manipulam todas as ramificações, se tornam em uma ferramenta de localização de bugs, qualquer entrada que surja com isso pode disparar a ramificação else que representa um bug no código em teste. Assim, todos os bugs que são relatados são bugs reais.

Manutenção de caso de teste reduzida Na presença de PUTs, é preciso manter um número significativamente reduzido de casos de teste. Em um mundo onde casos de teste fechados individuais foram gravados manualmente, o que aconteceria quando o código em teste evoluísse? Teria de adaptar o código de todos os testes individualmente, que representaria um custo significativo. Mas ao gravar PUTs em vez disso, somente os PUTs precisam ser mantidos. Então, os Testes de Unidade Inteligentes podem regenerar automaticamente os casos de teste individuais.

Desafios

Limitações da ferramenta A técnica de usar análises de código de caixa branca com resolução de restrições funciona muito bem em um código no nível de unidade que está bem isolado. No entanto, o mecanismo de teste tem algumas limitações:

  • Linguagem: Em princípio, o mecanismo de teste pode analisar programas .NET arbitrários, gravados em qualquer linguagem .NET. No entanto, o código de teste é gerado somente em C#.
  • Não-determinismo: O mecanismo de teste assume que o código em teste é determinístico. Caso contrário, ele irá remover caminhos de execução não-determinísticos ou pode vir em ciclos até que ele atinja os limites de exploração.
  • Simultaneidade: O mecanismo de teste não trata de programas multithread.
  • Código nativo ou código .NET que não é instrumentado: O mecanismo de teste não compreende código nativo, ou seja, instruções x86 invocada através do recurso Platform Invoke (P/Invoke) do Microsoft .NET Framework. O mecanismo de teste não sabe como traduzir essas chamadas em restrições que podem ser resolvidas por um solucionador de restrições. E mesmo para o código .NET, o mecanismo somente pode analisar código que instrumenta.
  • Aritmético de ponto flutuante: O mecanismo de teste usa um solucionador de restrições automático para determinar que valores são relevantes para o caso de teste e o código em teste. No entanto, as habilidades do solucionador de restrições são limitadas. Em particular, não pode justificar precisamente o aritmético de ponto flutuante.

Nestes casos, o mecanismo de teste alerta o desenvolvedor emitindo um aviso e o comportamento do mecanismo na presença dessas limitações pode ser controlado usando atributos personalizados.

Gravar bons testes de unidade parametrizada Gravar bons PUTs pode ser desafiador. Há duas perguntas principais a responder:

  • Cobertura: Quais são os bons cenários (sequências de chamadas de método) para manipular o código em teste?
  • Verificação: O que são boas declarações que podem ser expressas facilmente se reimplementar o algoritmo?

Um PUT é útil somente se der respostas a ambas as perguntas.

  • Sem cobertura suficiente; ou seja, se o cenário for restringido para alcançar o código em teste, a extensão do PUT é limitado.
  • Sem verificação suficiente dos resultados calculados; ou seja, se o PUT não contiver declarações suficientes, não pode verificar se o código está fazendo a coisa correta. Tudo o que o PUT faz é verificar se o código em teste não falha ou tem erros de tempo de execução.

No teste de unidade tradicional, o conjunto de perguntas inclui mais uma: Quais são as entradas de teste relevantes? Com PUTs, esta questão é tratada pelas ferramentas. No entanto, o problema de localizar boas declarações é mais fácil no teste de unidade tradicional: As declarações tendem a ser mais simples, pois são gravadas para entradas de teste particulares.

Conclusão

O recurso Testes de Unidade Inteligentes no Visual Studio 2015 Preview permite que você especifique o comportamento desejado do software em termos de seu código-fonte e usa a análise de código automático de caixa branca em conjunto com um solucionador de restrições para gerar e manter um conjunto compacto de testes relevantes com alta cobertura para seu código .NET. As funções de alcance de benefícios – os projetistas podem usá-las para especificar o comportamento esperado das APIs de software; os desenvolvedores podem usá-los para promover o teste do desenvolvedor automatizado e os testadores aproveitam-nos para geração de teste automático detalhado.

Os ciclos de lançamento cada vez mais curtos no desenvolvimento de software estão a promover que muitas das atividades relacionadas com planejamento, especificação, implementação e teste continuem acontecendo. Este mundo movimentado está desafiando-nos para reavaliar práticas existentes em torno dessas atividades. Os ciclos de lançamento curtos, rápidos e iterativos requerem levar a colaboração entre estas funções para um novo nível. Os recursos tais como Testes de Unidade Inteligentes podem ajudar as equipes de desenvolvimento de software a alcançar esses níveis mais facilmente.


Pratap Lakshman trabalha na Divisão dos Desenvolvedores onde é atualmente um gerente de programas sênior na equipe do Visual Studio, trabalhando em ferramentas de teste.

Agradecemos ao seguinte especialista técnico da Microsoft pela revisão deste artigo: Nikolai Tillmann