Os testes unitários ajudaram o Citigroup a evitar esse erro caro?

85

Eu li sobre essa confusão: O bug de programação custa ao Citigroup $ 7m depois de transações legítimas confundidas com dados de teste para 15 anos .

When the system was introduced in the mid-1990s, the program code filtered out any transactions that were given three-digit branch codes from 089 to 100 and used those prefixes for testing purposes.

But in 1998, the company started using alphanumeric branch codes as it expanded its business. Among them were the codes 10B, 10C and so on, which the system treated as being within the excluded range, and so their transactions were removed from any reports sent to the SEC.

(Eu acho que isso ilustra que usar um indicador de dados não explícito é ... sub-ótimo. Teria sido muito melhor preencher e usar uma propriedade Branch.IsLive semanticamente explícita.)

Além disso, minha primeira reação foi "Testes de unidade teriam ajudado aqui" ... mas seria?

Li recentemente Porque a maioria dos testes unitários é desperdiçada com interesse e, então, minha pergunta é: como seriam os testes de unidade que teriam falhado na introdução de códigos de ramificação alfanuméricos?

    
por Matthew Evans 14.07.2016 / 05:40
fonte

11 respostas

18

Você está realmente perguntando, "os testes de unidade ajudaram aqui?", ou você está perguntando, "algum tipo de teste poderia ter ajudado aqui?".

A forma mais óbvia de testar que teria ajudado, é uma afirmação de pré-condição no próprio código, que um identificador de ramificação consiste apenas em dígitos (supondo que essa é a suposição usada pelo codificador ao escrever o código). / p>

Isso poderia ter falhado em algum tipo de teste de integração e, assim que os novos IDs de ramificação alfanuméricos forem introduzidos, a asserção será ativada. Mas isso não é um teste de unidade.

Como alternativa, pode haver um teste de integração do procedimento que gera o relatório da SEC. Esse teste garante que todo identificador de ramificação real relate suas transações (e, portanto, requer entrada no mundo real, uma lista de todos os identificadores de ramificação em uso). Então, isso não é um teste de unidade.

Não consigo ver nenhuma definição ou documentação das interfaces envolvidas, mas pode ser que os testes de unidade não possam ter detectado o erro porque a unidade não estava com defeito . Se a unidade tiver permissão para assumir que os identificadores de ramificação consistem apenas em dígitos, e os desenvolvedores nunca tomaram uma decisão sobre o que o código deve fazer no caso contrário, eles não devem escrever um teste unitário para impor um comportamento específico no caso de identificadores não digitalizados porque o teste rejeitaria uma implementação válida hipotética da unidade que manipulava identificadores de ramificação alfanuméricos corretamente, e geralmente não se deseja gravar um teste de unidade que impeça futuras implementações e extensões válidas. Ou talvez um documento escrito há 40 anos implicitamente definido (por meio de um intervalo lexicográfico em EBCDIC bruto, em vez de uma regra de agrupamento mais humana) que 10B é um identificador de teste porque, de fato, fica entre 089 e 100. Mas então 15 anos atrás, alguém decidiu usá-lo como um identificador real, então a "falha" não está na unidade que implementa corretamente a definição original: ela está no processo que não percebeu que 10B é definido como um identificador de teste e, portanto, não deve ser atribuído a um ramo. O mesmo aconteceria em ASCII se você definisse 089 - 100 como um intervalo de teste e, em seguida, introduzisse um identificador 10 $ ou 1.0. Acontece que em EBCDIC os dígitos vêm depois das letras.

Um teste unitário (ou, possivelmente, um teste funcional) que concebivelmente pode ter salvo o dia, é um teste da unidade que gera ou valida novos identificadores de ramificação. Esse teste afirmaria que os identificadores devem conter apenas dígitos e seriam escritos para permitir que os usuários dos identificadores de ramificação assumam o mesmo. Ou talvez exista uma unidade em algum lugar que importe identificadores de ramificação reais, mas nunca veja os de teste, e que possa ser testada em unidade para garantir que ele rejeite todos os identificadores de teste (se identificadores são apenas três caracteres, podemos enumerá-los e comparar o comportamento de o validador ao do filtro de teste para garantir que eles correspondam, o que lida com a objeção usual aos testes spot). Então, quando alguém mudou as regras, o teste de unidade teria falhado, uma vez que contradiz o comportamento recém-exigido.

Como o teste estava lá por um bom motivo, o ponto em que você precisa removê-lo devido a requisitos de negócios alterados se torna uma oportunidade para alguém receber o trabalho ", encontre todos os lugares no código que dependem do comportamento queremos mudar ". Claro que isso é difícil e, portanto, não confiável, de modo algum garantiria salvar o dia. Mas se você capturar suas suposições nos testes das unidades que você está assumindo propriedades, então você se deu uma chance e então o esforço não é totalmente desperdiçado.

Concordo, claro, que se a unidade não tivesse sido definida em primeiro lugar com uma entrada de "formato engraçado", então não haveria nada para testar. Divisões de namespace complicadas podem ser difíceis de serem testadas corretamente porque a dificuldade não está em implementar sua definição engraçada, mas sim em garantir que todos entendam e respeitem sua definição engraçada. Isso não é uma propriedade local de uma unidade de código. Além disso, alterar alguns tipos de dados de "uma cadeia de dígitos" para "uma cadeia de alfanuméricos" é semelhante a fazer um programa baseado em ASCII manipular Unicode: não será simples se seu código estiver strongmente acoplado à definição original e o tipo de dados é fundamental para o que o programa faz, então ele é strongmente acoplado.

it’s a bit disturbing to think it’s largely wasted effort

Se os testes de sua unidade falharem (enquanto você estiver refatorando, por exemplo) e, ao fazer isso, fornecer informações úteis (a alteração está errada, por exemplo), o esforço não será desperdiçado. O que eles não fazem é testar se o seu sistema funciona. Então, se você está escrevendo testes de unidade ao invés de ter testes funcionais e de integração, então você pode estar usando seu tempo abaixo do ideal.

    
por 15.07.2016 / 12:23
fonte
120

Testes de unidade poderiam ter detectado que os códigos de ramificação 10B e 10C foram incorretamente classificados como "branches de teste", mas acho improvável que os testes para essa classificação de ramificação tenham sido extensos o suficiente para detectar esse erro.

Por outro lado, as verificações pontuais dos relatórios gerados poderiam ter revelado que os 10B e 10C ramificados estavam constantemente ausentes dos relatórios muito antes dos 15 anos em que o bug agora podia permanecer presente.

Por fim, este é um bom exemplo porque é uma má idéia misturar dados de teste com os dados de produção reais em um banco de dados. Se eles tivessem usado um banco de dados separado que contivesse os dados de teste, não haveria necessidade de filtrar isso dos relatórios oficiais e seria impossível filtrar demais.

    
por 14.07.2016 / 08:50
fonte
75

O software teve que lidar com certas regras de negócios. Se houvesse testes de unidade, os testes de unidade teriam verificado se o software manipulava as regras de negócios corretamente.

As regras de negócios foram alteradas.

Aparentemente, ninguém percebeu que as regras de negócios haviam mudado e ninguém alterou o software para aplicar as novas regras de negócios. Se houvesse testes unitários, esses testes de unidade teriam que ser alterados, mas ninguém teria feito isso porque ninguém percebeu que as regras de negócios haviam mudado.

Então, não, os testes de unidade não teriam percebido isso.

A exceção seria se os testes de unidade e o software tivessem sido criados por equipes independentes, e a equipe que fazia os testes de unidade alterasse os testes para aplicar as novas regras de negócios. Então os testes de unidade teriam falhado, o que esperançosamente resultaria em uma mudança do software.

É claro que, no mesmo caso, se apenas o software foi alterado e não os testes da unidade, os testes de unidade também falharão. Sempre que um teste de unidade falha, isso não significa que o software está errado, isso significa que o software ou o teste de unidade (às vezes ambos) estão errados.

    
por 14.07.2016 / 10:07
fonte
30

Não. Este é um dos grandes problemas com testes unitários: eles induzem você a uma falsa sensação de segurança.

Se todos os seus testes passarem, isso não significa que seu sistema está funcionando corretamente; isso significa que todos os seus testes estão passando . Significa que as partes do seu projeto que você conscientemente pensou e escreveu testes estão funcionando como você conscientemente pensou que fariam, o que realmente não é muito importante, de qualquer maneira: essa era a coisa que você estava realmente prestando atenção para, então é muito provável que você acertou de qualquer maneira! Mas não faz nada para captar casos que você nunca pensou, como este, porque você nunca pensou em escrever um teste para eles. (E se você tivesse, você estaria ciente de que isso significava alterações de código eram necessárias e você as teria alterado.)

    
por 14.07.2016 / 14:31
fonte
10

Não, não necessariamente.

O requisito original era usar códigos de ramificação numéricos, portanto, um teste de unidade teria sido produzido para um componente que aceitasse vários códigos e rejeitasse qualquer um como 10B. O sistema teria sido passado como funcionando (o que era).

Em seguida, o requisito teria sido alterado e os códigos atualizados, mas isso significaria que o código de teste de unidade que forneceu os dados incorretos (que agora são bons dados) teria que ser modificado.

Agora, presumimos que as pessoas que gerenciam o sistema saberiam que esse era o caso e mudariam o teste de unidade para lidar com os novos códigos ... mas, se soubessem que isso estava ocorrendo, também saberiam alterar o código que lidou com esses códigos de qualquer maneira .. e eles não fizeram isso. Um teste de unidade que originalmente rejeitou o código 10B teria dito felizmente "está tudo bem aqui" quando executado, se você não souber atualizar esse teste.

O teste de unidade é bom para o desenvolvimento original, mas não para o teste do sistema, especialmente 15 anos após os requisitos serem esquecidos há muito tempo.

O que eles precisam nesse tipo de situação é um teste de integração de ponta a ponta. Um onde você pode passar os dados que espera trabalhar e ver se isso acontece. Alguém teria notado que seus novos dados de entrada não produziram um relatório e, em seguida, investigariam mais.

    
por 14.07.2016 / 09:57
fonte
9

Teste de tipo (o processo de testar invariantes usando dados válidos gerados aleatoriamente, conforme exemplificado pela biblioteca de testes Haskell QuickCheck e vários portos / alternativas inspirados por ele em outros idiomas) podem ter percebido esse problema, o teste de unidade quase certamente não teria sido feito.

Isso ocorre porque, quando as regras de validade dos códigos de ramificação foram atualizadas, é improvável que alguém tenha pensado em testar esses intervalos específicos para garantir que funcionassem corretamente.

No entanto, se o teste de tipo estiver em uso, alguém deve no momento em que o sistema original foi implementado tiver escrito um par de propriedades, uma para verificar se os códigos específicos dos ramos de teste foram tratados como dados de teste e um para verificar que nenhum outro código estava ... quando a definição de tipo de dados para o código de ramificação foi atualizada (o que seria necessário para permitir testar se alguma das alterações do código de ramificação do dígito ao numérico funcionou ), este teste teria começado a testar valores no novo intervalo e provavelmente teria identificado a falha.

Naturalmente, o QuickCheck foi desenvolvido pela primeira vez em 1999, então já era tarde demais para resolver esse problema.

    
por 14.07.2016 / 10:56
fonte
5

Eu realmente duvido que o teste de unidade faria diferença para esse problema. Parece uma daquelas situações de visão de túnel porque a funcionalidade foi alterada para suportar novos códigos de ramificação, mas isso não foi realizado em todas as áreas do sistema.

Usamos o teste de unidade para projetar uma classe. A repetição de um teste de unidade só é necessária se o design foi alterado. Se uma determinada unidade não mudar, os testes de unidade inalterados retornarão os mesmos resultados de antes. Os testes unitários não mostrarão os impactos das mudanças em outras unidades (se você não estiver escrevendo testes de unidade).

Você pode detectar esse problema apenas de maneira razoável:

  • Testes de integração - mas você teria que adicionar especificamente os novos formatos de código para alimentar várias unidades no sistema (isto é, eles só mostrariam o problema se os testes originais incluíssem as ramificações agora válidas)
  • Teste de ponta a ponta - a empresa deve executar um teste de ponta a ponta que incorporou formatos antigos e novos de código de filial

Não ter testes de ponta a ponta suficientes é mais preocupante. Você não pode confiar no teste de unidade como seu teste SOMENTE ou MAIN para alterações no sistema. Parece que só é necessário alguém para executar um relatório sobre os formatos de código de ramificação recém-suportados.

    
por 14.07.2016 / 14:06
fonte
2

A conclusão é Falha rápida .

Não temos o código, nem temos muitos exemplos de prefixos que são ou não prefixos de ramificação de teste de acordo com o código. Tudo o que temos é isto:

  • 089 - 100 = > ramo de teste
  • 10B, 10C = > ramo de teste
  • < 088 = > ramos presumivelmente reais
  • > 100 = > ramos presumivelmente reais

O fato de o código permitir números e strings é mais do que um pouco estranho. Obviamente, 10B e 10C podem ser considerados números hexadecimais, mas se os prefixos forem todos tratados como números hexadecimais, 10B e 10C ficarão fora do intervalo de teste e serão tratados como ramos reais.

Isso provavelmente significa que o prefixo é armazenado como uma string, mas tratado como um número em alguns casos. Aqui está o código mais simples que posso imaginar que replica esse comportamento (usando C # para propósitos ilustrativos):

bool IsTest(string strPrefix) {
    int iPrefix;
    if(int.TryParse(strPrefix, out iPrefix))
        return iPrefix >= 89 && iPrefix <= 100;
    return true; //here is the problem
}

Em inglês, se a string é um número e está entre 89 e 100, é um teste. Se não é um número, é um teste. Caso contrário, não é um teste.

Se o código seguir esse padrão, nenhum teste de unidade teria detectado isso no momento em que o código foi implantado. Aqui estão alguns exemplos de testes unitários:

assert.isFalse(IsTest("088"))
assert.isTrue(IsTest("089"))
assert.isTrue(IsTest("095"))
assert.isTrue(IsTest("100"))
assert.isFalse(IsTest("101"))
assert.isTrue(IsTest("10B")) // <--- business rule change

O teste unitário mostra que "10B" deve ser tratado como um ramo de teste. O usuário @ gnasher729 acima diz que as regras de negócios mudaram e é isso que a última afirmação acima mostra. Em algum momento, a afirmação deveria ter mudado para isFalse , mas isso não aconteceu. Testes de unidade são executados no tempo de desenvolvimento e de compilação, mas depois em nenhum momento.

Qual é a lição aqui? O código precisa de alguma forma de sinalizar que recebeu entrada inesperada. Aqui está uma maneira alternativa de escrever esse código que enfatiza que ele espera que o prefixo seja um número:

// Alternative A
bool TryGetIsTest(string strPrefix, out bool isTest) {
    int iPrefix;
    if(int.TryParse(strPrefix, out iPrefix)) {
        isTest = iPrefix >= 89 && iPrefix <= 100;
        return true;
    }
    isTest = true; //this is just some value that won't be read
    return false;
}

Para quem não conhece o C #, o valor de retorno indica se o código conseguiu ou não analisar um prefixo da string dada. Se o valor de retorno for true, o código de chamada poderá usar a variável isTest out para verificar se o prefixo de ramificação é um prefixo de teste. Se o valor de retorno for falso, o código de chamada deve informar que o prefixo fornecido não é esperado e a variável isTest out não tem sentido e deve ser ignorada.

Se você está bem com exceções, você pode fazer isso:

// Alternative B
bool IsTest(string strPrefix) {
    int iPrefix = int.Parse(strPrefix);
    return iPrefix >= 89 && iPrefix <= 100;
}

Esta alternativa é mais direta. Nesse caso, o código de chamada precisa capturar a exceção. Em ambos os casos, o código deve ter alguma forma de relatar ao chamador que ele não esperava um strPrefix que não pudesse ser convertido em um inteiro. Desta forma, o código falha rapidamente e o banco pode encontrar rapidamente o problema sem o constrangimento da SEC.

    
por 15.07.2016 / 20:45
fonte
1

Uma afirmação embutida no tempo de execução pode ter ajudado; por exemplo:

  1. Crie uma função como bool isTestOnly(string branchCode) { ... }
  2. Use esta função para decidir quais relatórios filtrar
  3. Reutilize essa função em uma declaração, no código de criação de ramificação, para verificar ou afirmar que uma ramificação não é (não pode ser) criada usando esse tipo de código de ramificação‼
  4. Ter esta asserção ativada no tempo de execução real (e não "otimizada, exceto na versão de desenvolvedor apenas de depuração do código")‼

Veja também:

por 14.07.2016 / 17:47
fonte
1

Tantas respostas e nem mesmo uma citação de Dijkstra:

Testing shows the presence, not the absence of bugs.

Portanto, isso depende. Se o código foi testado corretamente, muito provavelmente este bug não existiria.

    
por 18.07.2016 / 12:10
fonte
-1

Eu acho que um teste de unidade aqui teria assegurado que o problema nunca existisse em primeiro lugar.

Considere, você escreveu a função bool IsTestData(string branchCode) .

O primeiro teste de unidade que você escreve deve ser para string nula e vazia. Então, para seqüências de comprimento incorretas, em seguida, para seqüências não inteiras.

Para fazer todos esses testes passarem, você terá que adicionar a verificação de parâmetros à função.

Mesmo se você só testar dados "bons" 001 - > 999 não está pensando na possibilidade de 10A a verificação de parâmetro forçará você a reescrever a função quando você começar a usar alfanuméricos para evitar as exceções que ele jogará

    
por 14.07.2016 / 20:57
fonte