Como evitar erros lógicos no código, quando o TDD não ajudou?

67

Recentemente, eu estava escrevendo um pequeno código que indicaria, de uma maneira amigável ao homem, qual é a idade de um evento. Por exemplo, isso pode indicar que o evento aconteceu “Três semanas atrás” ou “Um mês atrás” ou “Ontem”.

Os requisitos eram relativamente claros e esse era um caso perfeito para o desenvolvimento orientado por testes. Eu escrevi os testes um por um, implementando o código para passar em cada teste, e tudo parecia funcionar perfeitamente. Até que um bug apareceu na produção.

Este é o código relevante:

now = datetime.datetime.utcnow()
today = now.date()
if event_date.date() == today:
    return "Today"

yesterday = today - datetime.timedelta(1)
if event_date.date() == yesterday:
    return "Yesterday"

delta = (now - event_date).days

if delta < 7:
    return _number_to_text(delta) + " days ago"

if delta < 30:
    weeks = math.floor(delta / 7)
    if weeks == 1:
        return "A week ago"

    return _number_to_text(weeks) + " weeks ago"

if delta < 365:
    ... # Handle months and years in similar manner.

Os testes foram verificar o caso de um evento que aconteceu hoje, ontem, há quatro dias, duas semanas atrás, há uma semana, etc., e o código foi construído de acordo.

O que eu perdi é que um evento pode acontecer um dia antes de ontem, sendo um dia atrás: por exemplo, um evento ocorrendo vinte e seis horas atrás seria um dia atrás, embora não exatamente ontem se agora é 01:00. é um ponto, mas como o delta é um inteiro, será apenas um. Nesse caso, o aplicativo exibe “Um dia atrás”, que é obviamente inesperado e não tratado no código. Pode ser corrigido adicionando:

if delta == 1:
    return "A day ago"

logo após calcular o delta .

Embora a única conseqüência negativa do bug seja que perdi meia hora imaginando como esse caso poderia acontecer (e acreditando que isso tenha a ver com fusos horários, apesar do uso uniforme do UTC no código), sua presença é me incomodando. Isso indica que:

  • É muito fácil cometer um erro lógico, mesmo em um código-fonte tão simples.
  • O desenvolvimento orientado a testes não ajudou.

Também é preocupante que eu não consiga ver como tais erros podem ser evitados. Além de pensar mais antes de escrever código, a única maneira que posso pensar é adicionar muitas afirmações para os casos que acredito que nunca aconteceriam (como eu acreditava que um dia atrás é necessariamente ontem), e então percorrer cada segundo para nos últimos dez anos, verificando qualquer violação de asserção, o que parece ser muito complexo.

Como eu poderia evitar criar esse bug em primeiro lugar?

    
por Arseni Mourzenko 12.07.2018 / 23:39
fonte

16 respostas

57

Estes são os tipos de erros que você normalmente encontra na etapa refator do red / green / refactor. Não esqueça esse passo! Considere um refator como o seguinte (não testado):

def pluralize(num, unit):
    if num == 1:
        return unit
    else:
        return unit + "s"

def convert_to_unit(delta, unit):
    factor = 1
    if unit == "week":
        factor = 7 
    elif unit == "month":
        factor = 30
    elif unit == "year":
        factor = 365
    return delta // factor

def best_unit(delta):
    if delta < 7:
        return "day"
    elif delta < 30:
        return "week"
    elif delta < 365:
        return "month"
    else:
        return "year"

def human_friendly(event_date):
    date = event_date.date()
    today = now.date()
    yesterday = today - datetime.timedelta(1)
    if date == today:
        return "Today"
    elif date == yesterday:
        return "Yesterday"
    else:
        delta = (now - event_date).days
        unit = best_unit(delta)
        converted = convert_to_unit(delta, unit)
        pluralized = pluralize(converted, unit)
        return "{} {} ago".format(converted, pluralized)

Aqui você criou 3 funções em um nível mais baixo de abstração, que são muito mais coesas e fáceis de testar isoladamente. Se você deixou de fora um intervalo de tempo que você pretendia, ele se destacaria como um polegar dolorido nas funções de ajuda mais simples. Além disso, ao remover a duplicação, você reduz o potencial de erro. Você teria que adicionar código para implementar seu caso quebrado.

Outros casos de teste mais sutis também vêm à mente quando se olha para uma forma refatorada como essa. Por exemplo, o que deve best_unit fazer se delta for negativo?

Em outras palavras, a refatoração não é apenas para torná-lo bonito. Isso torna mais fácil para os humanos detectar erros que o compilador não consegue.

    
por 13.07.2018 / 07:28
fonte
148

Test driven development didn't help.

Parece que ajudou, só porque você não fez um teste para o cenário "um dia atrás". Presumivelmente, você adicionou um teste depois que este caso foi encontrado; isso ainda é TDD, pois quando erros são encontrados, você escreve um teste de unidade para detectar o bug e, em seguida, conserta-o.

Se você esquecer de escrever um teste para um comportamento, o TDD não tem nada para ajudá-lo; você se esquece de escrever o teste e, portanto, não escreve a implementação.

    
por 12.07.2018 / 23:54
fonte
114

an event happening twenty six hours ago would be one day ago

Os testes não ajudarão muito se um problema estiver mal definido. Você está evidentemente misturando dias de calendário com dias contados em horas. Se você mantiver os dias do calendário, então, às 01:00, 26 horas atrás, não será ontem. E se você mantiver horas, então 26 horas atrás arredondadas para 1 dia atrás, independentemente do tempo.

    
por 13.07.2018 / 07:19
fonte
38

Você não pode. O TDD é ótimo para proteger você de possíveis problemas de que esteja ciente. Não ajuda se você tiver problemas que você nunca considerou. Sua melhor aposta é ter alguém testando o sistema, eles podem encontrar os casos de borda que você nunca considerou.

Leitura relacionada: É possível atingir o estado de erro zero absoluto para software de grande escala?

    
por 13.07.2018 / 02:38
fonte
35

Existem duas abordagens que eu normalmente uso e acho que podem ajudar.

Primeiro, procuro os casos de borda. Estes são lugares onde o comportamento muda. No seu caso, o comportamento muda em vários pontos ao longo da sequência de dias inteiros positivos. Existe um caso de borda no zero, em um, às sete, etc. Eu, então, escrevo casos de teste em e ao redor dos casos de borda. Eu teria casos de teste em -1 dias, 0 dias, 1 horas, 23 horas, 24 horas, 25 horas, 6 dias, 7 dias, 8 dias, etc.

A segunda coisa que eu procuro são padrões de comportamento. Na sua lógica há semanas, você tem tratamento especial por uma semana. Você provavelmente tem lógica semelhante em cada um dos seus outros intervalos não mostrados. Esta lógica não está presente por dias, no entanto. Eu olharia para isso com suspeita até que eu pudesse explicar de forma verificável por que esse caso é diferente, ou eu adiciono a lógica.

    
por 13.07.2018 / 05:36
fonte
14

Você não pode pegar erros lógicos presentes nos seus requisitos com o TDD. Mas ainda assim, o TDD ajuda. Você encontrou o erro, afinal, e adicionou um caso de teste. Mas, fundamentalmente, o TDD somente garante que o código esteja de acordo com o seu modelo mental. Se seu modelo mental é falho, os casos de teste não os detectarão.

Mas tenha em mente que, enquanto corrige o bug, os casos de teste que você já teve certeza de que nenhum comportamento existente estava funcionando. Isso é muito importante, é fácil consertar um bug, mas introduzir outro.

Para encontrar esses erros de antemão, você geralmente tenta usar casos de teste baseados em classe de equivalência. Usando esse princípio, você escolheria um caso de cada classe de equivalência e, em seguida, todos os casos de borda.

Você escolheria uma data de hoje, ontem, alguns dias atrás, há exatamente uma semana e várias semanas atrás, como exemplos de cada classe de equivalência. Ao testar datas, você também deve certificar-se de que seus testes não usam a data do sistema, mas usam uma data pré-determinada para comparação. Isso também destacaria alguns casos extremos: Você executaria seus testes em uma hora arbitrária do dia, você o executaria diretamente depois da meia-noite, diretamente antes da meia-noite e até diretamente à meia-noite. Isso significa que para cada teste, haveria quatro tempos de base em que é testado.

Em seguida, você adicionaria sistematicamente os casos de borda a todas as outras classes. Você tem o teste para hoje. Então adicione um tempo antes e depois do comportamento. O mesmo para ontem. O mesmo há uma semana atrás, etc.

As chances são de que, enumerando todos os casos de borda de uma maneira sistemática e anotando os casos de teste para eles, você descobre que sua especificação não possui alguns detalhes e a adiciona. Observe que as datas de manipulação são algo que as pessoas geralmente erram, porque as pessoas muitas vezes esquecem de escrever seus testes para que possam ser executadas em momentos diferentes.

Note, no entanto, que a maior parte do que escrevi tem pouco a ver com o TDD. Sua sobre como anotar classes de equivalência e certificando-se de suas próprias especificações são detalhadas o suficiente sobre eles. Esse é o processo com o qual você minimiza erros lógicos. O TDD apenas garante que seu código esteja de acordo com seu modelo mental.

A criação de casos de teste é difícil . O teste baseado em equivalência não é o fim de tudo e, em alguns casos, pode aumentar significativamente o número de casos de teste. No mundo real, adicionar todos esses testes muitas vezes não é economicamente viável (embora, em teoria, isso deva ser feito).

    
por 13.07.2018 / 09:17
fonte
12

The only way I can think of is to add lots of asserts for the cases that I believe would never happen (like I believed that a day ago is necessarily yesterday), and then to loop through every second for the past ten years, checking for any assertion violation, which seems too complex.

Por que não? Isso soa como uma boa ideia!

Adicionar contratos (asserções) ao código é uma maneira bastante sólida de melhorar sua correção. Geralmente, os adicionamos como condições prévias na entrada da função e pós-condições no retorno da função. Por exemplo, poderíamos adicionar uma pós-condição de que todos os valores retornados são do formulário "A [unidade] atrás" ou "[número] [unidade] s atrás". Quando feito de forma disciplinada, isso leva ao design por contrato , e é uma das formas mais comuns de escrever código de alta segurança.

Criticamente, os contratos não devem ser testados; Eles são exatamente as especificações do seu código, assim como seus testes. No entanto, você pode testar via os contratos: chame o código em seu teste e, se nenhum dos contratos gerar erros, o teste será aprovado. Loop em todos os segundos dos últimos dez anos é um pouco demais. Mas podemos aproveitar outro estilo de teste chamado teste baseado em propriedade .

Em PBT, em vez de testar saídas específicas do código, você testa que a saída obedece a alguma propriedade. Por exemplo, uma propriedade de uma função reverse() é aquela para qualquer lista l , reverse(reverse(l)) = l . A vantagem de escrever testes como esse é que você pode fazer com que o mecanismo PBT gere algumas centenas de listas arbitrárias (e algumas patológicas) e verifique se todas elas têm essa propriedade. Se qualquer não , o mecanismo "encolhe" o caso com falha para encontrar uma lista mínima que quebra seu código. Parece que você está escrevendo Python, que tem a Hipótese como a principal estrutura de PBT.

Portanto, se você quiser uma boa maneira de encontrar casos de borda mais complicados que talvez não pense, usar contratos e testes baseados em propriedade juntos ajudará muito. Isso não substitui os testes de unidade de escrita, é claro, mas aumenta, o que é realmente o melhor que podemos fazer como engenheiros.

    
por 13.07.2018 / 07:20
fonte
5

Este é um exemplo em que adicionar um pouco de modularidade teria sido útil. Se um segmento de código propenso a erros for usado várias vezes, é recomendável envolvê-lo em uma função, se possível.

def time_ago(delta, unit):
    delta_str = _number_to_text(delta) + " " + unit;
    if delta == 1:
        return delta_str + " ago"
    else:
        return delta_str = "s ago"

now = datetime.datetime.utcnow()
today = now.date()
if event_date.date() == today:
    return "Today"

yesterday = today - datetime.timedelta(1)
if event_date.date() == yesterday:
    return "Yesterday"

delta = (now - event_date).days

if delta < 7:
    return time_ago(delta, "day")

if delta < 30:
    weeks = math.floor(delta / 7)
    return time_ago(weeks, "week")

if delta < 365:
    months = math.floor(delta / 31)
    return time_ago(months, "month")
    
por 13.07.2018 / 00:05
fonte
5

Test driven development didn't help.

O TDD funciona melhor como técnica se a pessoa que escreve os testes é adversária. Isso é difícil se você não estiver programando em pares, então outra maneira de pensar sobre isso é:

  • Não escreva testes para confirmar se a função em teste funciona como você a fez. Escreva testes que deliberadamente a quebrem.

Esta é uma arte diferente, que se aplica a escrever código correto com ou sem TDD, e um talvez tão complexo (se não mais) do que realmente escrever código. É algo que você precisa praticar, e é algo que não existe uma resposta simples, fácil e simples.

A principal técnica para escrever softwares robustos é também a técnica principal para entender como escrever testes eficazes:

Entenda as condições prévias para uma função - os estados válidos (ou seja, quais suposições você está fazendo sobre o estado da classe da qual a função é um método) e intervalos de parâmetro de entrada válidos - cada tipo de dados tem um intervalo de valores possíveis um subconjunto do qual será tratado pela sua função.

Se você simplesmente não testar explicitamente essas suposições na entrada de função e garantir que uma violação seja registrada ou lançada e / ou a função seja eliminada sem tratamento adicional, você poderá saber rapidamente se o software está falhando na produção, torná-lo robusto e tolerante a erros, e desenvolver suas habilidades de escrita de testes adversários.

NB. Existe toda uma literatura sobre Pré e Pós-Condições, Invariantes e assim por diante, juntamente com bibliotecas que podem aplicá-las usando atributos. Pessoalmente eu não sou fã de ir tão formal, mas vale a pena investigar.

    
por 14.07.2018 / 16:51
fonte
1

Este é um dos fatos mais importantes sobre desenvolvimento de software: É absolutamente impossível escrever código livre de bugs.

O TDD não irá salvá-lo da introdução de erros correspondentes aos casos de teste em que você não pensou. Ele também não irá salvá-lo de escrever um teste incorreto sem perceber, em seguida, escrever código incorreto que acontece para passar no teste de bugs. E todas as outras técnicas de desenvolvimento de software já criadas têm buracos semelhantes. Como desenvolvedores, somos seres humanos imperfeitos. No final do dia, não há como escrever código 100% sem erros. Isso nunca aconteceu e nunca acontecerá.

Isso não quer dizer que você deva desistir da esperança. Embora seja impossível escrever código completamente perfeito, é muito possível escrever código que tenha tão poucos bugs que aparecem em casos de borda tão raros que o software é extremamente prático de usar. O software que não exibe comportamento de bugs na prática é muito possível escrever.

Mas escrevê-lo exige que aceitemos o fato de que produziremos software com bugs. Quase todas as práticas modernas de desenvolvimento de software são, em algum nível, construídas em torno de evitar que os bugs apareçam em primeiro lugar ou de nos proteger das consequências dos bugs que inevitavelmente produzimos:

  • A coleta de requisitos detalhados nos permite saber como é o comportamento incorreto em nosso código.
  • Escrever um código limpo e cuidadosamente arquitetado torna mais fácil evitar a introdução de bugs e facilitar a sua correção quando os identificamos.
  • Escrever testes nos permite produzir um registro do que acreditamos que muitos dos piores bugs possíveis em nosso software seriam e provar que evitamos pelo menos esses bugs. O TDD produz esses testes antes do código, o BDD deriva esses testes dos requisitos e o teste de unidade antiquado produz testes depois que o código é escrito, mas todos evitam as piores regressões no futuro.
  • Revisões entre pares significam que cada código de tempo é alterado, pelo menos dois pares de olhos viram o código, diminuindo a frequência com que os bugs aparecem no mestre.
  • Usar um rastreador de bugs ou um rastreador de histórias de usuário que trata erros como histórias de usuários significa que, quando bugs aparecem, eles são controlados e tratados, não esquecidos e deixados para entrar de forma consistente nos usuários.
  • O uso de um servidor intermediário significa que, antes de uma versão principal, qualquer erro de show-stopper tem a chance de aparecer e ser tratado.
  • O uso do controle de versão significa que, no pior cenário, onde o código com os principais bugs é enviado aos clientes, é possível realizar uma reversão de emergência e colocar um produto confiável nas mãos dos clientes enquanto você resolve as coisas.

A solução final para o problema que você identificou não é lutar contra o fato de que você não pode garantir que você vai escrever um código livre de erros, mas sim abraçá-lo. Adote as melhores práticas do setor em todas as áreas de seu processo de desenvolvimento, e você fornecerá consistentemente código aos seus usuários que, embora não seja perfeito, é mais do que robusto o suficiente para o trabalho.

    
por 13.07.2018 / 23:20
fonte
1

Você simplesmente não pensou neste caso antes e, portanto, não teve um caso de teste para ele.

Isso acontece o tempo todo e é apenas normal. É sempre um trade-off quanto esforço você coloca na criação de todos os possíveis casos de teste. Você pode gastar tempo infinito para considerar todos os casos de teste.

Para um piloto automático de avião, você gastaria muito mais tempo do que para uma ferramenta simples.

Geralmente ajuda a pensar nos intervalos válidos de suas variáveis de entrada e testar esses limites.

Além disso, se o testador for uma pessoa diferente do desenvolvedor, geralmente casos mais significativos serão encontrados.

    
por 14.07.2018 / 08:35
fonte
1

(and believing that it has to do with time zones, despite the uniform use of UTC in the code)

Esse é outro erro lógico no seu código para o qual você ainda não tem um teste de unidade :) - seu método retornará resultados incorretos para usuários em fusos horários que não sejam UTC. Você precisa converter "agora" e a data do evento no fuso horário local do usuário antes de calcular.

Exemplo: na Austrália, um evento acontece às 9h, horário local. Às 11h será exibido como "ontem" porque a data UTC mudou.

    
por 16.07.2018 / 05:34
fonte
0
  • Deixe alguém escrever os testes. Dessa forma, alguém que não esteja familiarizado com sua implementação pode verificar situações raras que você não pensou.

  • Se possível, injete casos de teste como coleções. Isso torna a adição de outro teste tão fácil quanto adicionar outra linha como yield return new TestCase(...) . Isso pode ir na direção de testes exploratórios , automatizando a criação de casos de teste: "Vamos ver o que o código retorna para todos os segundos de uma semana atrás ".

por 14.07.2018 / 12:54
fonte
0

Você parece estar sob o equívoco de que, se todos os seus testes forem aprovados, você não terá bugs. Na realidade, se todos os seus testes forem aprovados, todo o comportamento conhecido estará correto. Você ainda não sabe se o comportamento desconhecido está correto ou não.

Espero que você esteja usando a cobertura de código com seu TDD. Adicione um novo teste para o comportamento inesperado. Em seguida, você pode executar apenas o teste para o comportamento inesperado para ver o caminho que realmente leva através do código. Depois de conhecer o comportamento atual, você pode fazer uma alteração para corrigi-lo e, quando todos os testes passarem novamente, você saberá que fez isso corretamente.

Isso ainda não significa que seu código esteja livre de bugs, apenas que seja melhor do que antes, e mais uma vez todo o comportamento conhecido está correto!

Usar o TDD corretamente não significa que você escreverá código livre de bugs, isso significa que você escreverá menos bugs. Você diz:

The requirements were relatively clear

Isso significa que o comportamento de mais de um dia, mas não de ontem, foi especificado nos requisitos? Se você perdeu uma exigência por escrito, a culpa é sua. Se você percebeu que os requisitos estavam incompletos como você estava codificando, bom para você! Se todos que trabalharam nos requisitos perderam esse caso, você não é pior do que os outros. Todo mundo comete erros, e quanto mais sutis eles são, mais fáceis eles são de perder. O grande problema aqui é que o TDD não previne todos os erros!

    
por 16.07.2018 / 04:56
fonte
0

It's very easy to commit a logical mistake even in a such simple source code.

Sim. O desenvolvimento orientado por testes não altera isso. Você ainda pode criar erros no código real e também no código de teste.

Test driven development didn't help.

Oh, mas aconteceu! Primeiro de tudo, quando você percebeu o bug, você já tinha o framework de teste completo, e tinha que corrigir o bug no teste (e o código atual). Em segundo lugar, você não sabe quantos bugs adicionais você teria se não tivesse feito TDD no começo.

Also worrisome is that I can't see how could such bugs be avoided.

Você não pode. Nem a NASA encontrou uma maneira de evitar bugs; nós, seres humanos menores, certamente não o fazemos.

Aside thinking more before writing code,

Isso é uma falácia. Um dos maiores benefícios do TDD é que você pode codificar com menos pensamento, porque todos esses testes pelo menos capturam regressões muito bem. Além disso, mesmo, ou especialmente com o TDD, não é esperado que ele forneça código livre de bugs (ou sua velocidade de desenvolvimento irá simplesmente parar).

the only way I can think of is to add lots of asserts for the cases that I believe would never happen (like I believed that a day ago is necessarily yesterday), and then to loop through every second for the past ten years, checking for any assertion violation, which seems too complex.

Isso claramente entraria em conflito com o princípio de apenas codificar o que você realmente precisa no momento. Você pensou que precisava desses casos, e assim foi. Era um pedaço de código não crítico; como você disse, não houve nenhum dano, a não ser que você tenha pensado nisso por 30 minutos.

Para o código de missão crítica, você realmente pode fazer o que você disse, mas não para o seu código padrão diário.

How could I avoid creating this bug in the first place?

Você não faz. Você confia em seus testes para encontrar a maioria das regressões; você continua no ciclo vermelho-verde-refatorador, escrevendo testes antes / durante a codificação real e (importante!) você implementa a quantidade mínima necessária para fazer o comutador vermelho-verde (não mais, não menos). Isso terminará com uma ótima cobertura de teste, pelo menos, uma positiva.

Quando, não se, você encontrar um bug, você escreve um teste para reproduzir esse bug, e conserta o bug com o mínimo de trabalho para fazer o teste passar de vermelho para verde.

    
por 16.07.2018 / 18:04
fonte
-2

Você acabou de descobrir que não importa o quanto você tente, você nunca conseguirá capturar todos os erros possíveis em seu código.

Então, o que isto significa é que, mesmo tentando pegar todos os bugs, é um exercício de futilidade, então você deve usar apenas técnicas como o TDD como uma maneira de escrever código melhor, código que tenha menos bugs, e não bugs. / p>

Isso, por sua vez, significa que você deve gastar menos tempo usando essas técnicas e gastar esse tempo economizado trabalhando em formas alternativas de encontrar os erros que passam pela rede de desenvolvimento.

alternativas como teste de integração ou equipe de teste, testes de sistema e registro e análise desses registros.

Se você não pode pegar todos os bugs, então você deve ter uma estratégia para mitigar os efeitos dos bugs que passam por você. Se você tiver que fazer isso de qualquer maneira, colocar mais esforço nisso faz mais sentido do que tentar (em vão) pará-los em primeiro lugar.

Afinal de contas, é inútil gastar uma fortuna no tempo escrevendo testes e no primeiro dia em que você dá seu produto a um cliente ele cai, especialmente se você não tiver idéia de como encontrar e resolver esse bug. A resolução de bugs pós-morte e pós-entrega é tão importante e precisa de mais atenção do que a maioria das pessoas gasta escrevendo testes de unidade. Salve o teste de unidade para os bits complicados e não tente a perfeição na frente.

    
por 15.07.2018 / 14:54
fonte