Teste da caixa branca

O desenvolvimento de programas de alta qualidade implica que o programa e suas partes sejam testados. O teste de unidade clássico envolve dividir um programa grande em pequenos blocos convenientes para o teste. Ou, se o desenvolvimento de testes ocorrer paralelamente ao desenvolvimento de código ou se os testes forem desenvolvidos antes do programa (TDD - test driven development), o programa será inicialmente desenvolvido em pequenos blocos adequados para os requisitos dos testes.


Uma das variedades de teste de unidade pode ser considerada teste baseado em propriedade (essa abordagem é implementada, por exemplo, nas bibliotecas QuickCheck , ScalaCheck ). Essa abordagem é baseada na localização de propriedades universais que devem ser válidas para qualquer dado de entrada. Por exemplo, a serialização seguida pela desserialização deve produzir o mesmo objeto . Ou re-classificar não deve alterar a ordem dos itens na lista . Para verificar essas propriedades universais, as bibliotecas acima suportam um mecanismo para gerar dados de entrada aleatórios. Essa abordagem funciona especialmente bem para programas baseados em leis matemáticas que servem como propriedades universais válidas para uma ampla classe de programas. Existe até uma biblioteca de propriedades matemáticas prontas - disciplina - que permite verificar o desempenho dessas propriedades em novos programas (um bom exemplo de reutilização de testes).


Às vezes, é necessário testar um programa complexo sem poder analisá-lo em partes verificáveis ​​de forma independente. Nesse caso, o programa de teste é preto caixa branca (branca - porque temos a oportunidade de estudar a estrutura interna do programa).


Sob o corte, são descritas várias abordagens para testar programas complexos com uma entrada com graus variados de complexidade (envolvimento) e graus variados de cobertura.


* Neste artigo, assumimos que o programa em teste pode ser representado como uma função pura sem um estado interno. (Algumas das considerações fornecidas abaixo podem ser aplicadas se o estado interno estiver presente, mas é possível redefinir esse estado para um valor fixo.)


Banco de ensaio


Antes de tudo, como apenas uma função é testada, cujo código de chamada é sempre o mesmo, não precisamos criar testes de unidade separados. Todos esses testes seriam os mesmos, precisos para as entradas e verificações. É o suficiente para transmitir os dados de origem ( input ) em um loop e verificar os resultados (resultado expectedOutput ). Para identificar um conjunto de problemas de dados de teste em caso de detecção de erros, todos os dados de teste devem ser rotulados. Assim, um conjunto de dados de teste pode ser representado como um triplo:


 case class TestCase[A, B](label: String, input: A, expectedOutput: B) 

O resultado de uma execução pode ser representado como TestCaseResult :


 case class TestCaseResult[A, B](testCase: TestCase[A, B], actualOutput: Try[B]) 

(Apresentamos o resultado do lançamento usando o Try para capturar possíveis exceções.)


Para simplificar a execução de todos os dados de teste através do programa em teste, você pode usar uma função auxiliar que chamará o programa para cada valor de entrada:


 def runTestCases[A, B](cases: Seq[TestCase[A, B])(f: A => B): Seq[TestCaseResult[A, B]] = cases .map{ testCase => TestCaseResult(testCase, Try{ f(testCase.input) } ) } .filter(r => r.actualOutput != Success(r.testCase.expectedOutput)) 

Essa função auxiliar retornará os dados e resultados problemáticos diferentes do esperado.


Por conveniência, você pode formatar os resultados do teste.


 def report(results: Seq[TestCaseResult[_, _]]): String = s"Failed ${results.length}:\n" + results .map(r => r.testCase.label + ": expected " + r.testCase.expectedOutput + ", but got " + r.actualOutput) .mkString("\n") 

e exiba um relatório apenas em caso de erros:


 val testCases = Seq( TestCase("1", 0, 0) ) test("all test cases"){ val testBench = runTestCases(testCases) _ val results = testBench(f) assert(results.isEmpty, report(results)) } 

Preparação de entrada


No caso mais simples, você pode criar dados de teste manualmente para testar o programa, gravá-los diretamente no código de teste e usá-los, como mostrado acima. Muitas vezes acontece que casos interessantes de dados de teste têm muito em comum e podem ser apresentados como uma instância básica, com pequenas alterações.


 val baseline = MyObject(...) //        val testCases = Seq( TestCase("baseline", baseline, ???), TestCase("baseline + (field1 = 123)", baseline.copy(field1 = "123"), ???) ) 

Ao trabalhar com estruturas de dados imutáveis ​​aninhadas, as lentes são de grande ajuda, por exemplo, na biblioteca Monocle :


 val baseline = ??? val testObject1 = (field1 composeLens field2).set("123")(baseline) //    : val testObject1 = baseline.copy(field1 = baseline.field1.copy(field2 = "123")) 

As lentes permitem que você "modifique" elegantemente partes profundamente aninhadas das estruturas de dados: cada lente é um método de obtenção e configuração de uma propriedade. As lentes podem ser combinadas para produzir lentes que "focam" no próximo nível.


Usando DSL para apresentar alterações


A seguir, consideraremos a formação dos dados de teste fazendo alterações em algum objeto de entrada inicial. Normalmente, para obter o objeto de teste de que precisamos, precisamos fazer algumas alterações. Ao mesmo tempo, é muito útil incluir uma lista de alterações na descrição de texto do TestCase:


 val testCases = Seq( TestCase("baseline", baseline, ???), TestCase("baseline + " + "(field1 = 123) + " + //  1-  "(field2 = 456) + " + // 2- "(field3 = 789)", // 3- baseline .copy(field1 = "123") // 1-  .copy(field2 = "456") // 2-  .copy(field3 = "789"), // 3-  ???) ) 

Sempre saberemos para quais dados de teste o teste é realizado.


Para que a lista textual de alterações não diverja das mudanças reais, você deve seguir o princípio de "uma única versão da verdade". (Se as mesmas informações forem necessárias / usadas em vários pontos, deve haver uma única fonte primária de informações exclusivas, e as informações devem ser distribuídas automaticamente para todos os outros pontos de uso, com as transformações necessárias. Se esse princípio for violado, a cópia manual de informações será inevitável. . informações sobre a versão discrepância em pontos diferentes em outras palavras na descrição dos dados de teste, vemos um, e dados de ensaios -. outro exemplo, copiar uma mudança field2 = "456" e ajustando-o na field3 = "789" que Mauger acidentalmente esquecer de corrigir a descrição. Como resultado, a descrição só irá refletir duas mudas de três.)


No nosso caso, a principal fonte de informação são as próprias alterações, ou melhor, o código-fonte do programa que faz as alterações. Gostaríamos de deduzir deles um texto descrevendo as alterações. De antemão, como primeira opção, você pode sugerir o uso de uma macro que irá capturar o código fonte das alterações e usar o código fonte como documentação. Aparentemente, essa é uma maneira boa e relativamente simples de documentar mudanças reais e pode muito bem ser aplicada em alguns casos. Infelizmente, se apresentarmos as alterações no texto sem formatação, perderemos a capacidade de fazer transformações significativas na lista de alterações. Por exemplo, detecte e elimine alterações duplicadas ou sobrepostas, elabore uma lista de alterações de maneira conveniente para o usuário final.


Para poder lidar com as mudanças, você deve ter um modelo estruturado delas. O modelo deve ser expressivo o suficiente para descrever todas as mudanças que nos interessam. Parte deste modelo, por exemplo, será o endereçamento de campos de objetos, constantes e operações de atribuição.


O modelo de mudança deve permitir resolver as seguintes tarefas:


  1. Gerar instâncias de modelo de mudança. (Ou seja, na verdade, criando uma lista específica de alterações.)
  2. Formação de uma descrição textual das alterações.
  3. Aplicando alterações nos objetos do domínio.
  4. Executando transformações de otimização no modelo.

Se uma linguagem de programação universal for usada para fazer alterações, pode ser difícil representar essas alterações no modelo. O código fonte do programa pode usar construções complexas que não são suportadas pelo modelo. Esse programa pode usar padrões secundários, como lentes ou o método de copy , para alterar os campos de um objeto, que são abstrações de nível inferior em relação ao nível do modelo de mudança. Como resultado, análises adicionais de tais padrões podem ser necessárias para gerar instâncias de alterações. Portanto, inicialmente uma boa opção usando uma macro não é muito conveniente.


Outra maneira de criar instâncias do modelo de mudança pode ser uma linguagem especializada (DSL), que cria objetos do modelo de mudança usando um conjunto de métodos de extensão e operadores auxiliares. Bem, nos casos mais simples, instâncias do modelo de mudança podem ser criadas diretamente através dos construtores.


Alterar detalhes do idioma

A linguagem de mudança é uma construção bastante complexa que inclui vários componentes, que são, por sua vez, não triviais.


  1. Modelo de estrutura de dados.
  2. Alterar modelo.
  3. DSL atualmente incorporado (?) - construções auxiliares, métodos de extensão, para a construção conveniente de alterações.
  4. Um intérprete de alterações que permite "modificar" um objeto (de fato, é claro, criar uma cópia modificada).

Aqui está um exemplo de um programa escrito usando DSL:


 val target: Entity[Target] // ,     val updateField1 = target \ field1 := "123" val updateField2 = target \ subobject \ field2 := "456" // ,   DSL: val updateField1 = SetProperty(PropertyAccess(target, Property(field1, typeTag[String])), LiftedString("123")) val updateField2 = SetProperty(PropertyAccess(PropertyAccess(target, Property(subobject, typeTag[SubObject])), Property(field2, typeTag[String])), LiftedString("456")) 

Ou seja, usando os métodos de extensão \ e := , PropertyAccess , SetProperty , os objetos são formados a partir dos objetos de target , field1 , subobject , field2 criados anteriormente. Além disso, devido a conversões implícitas (perigosas), a string "123" é compactada em um LiftedString (você pode fazer isso sem conversões implícitas e chamar o método correspondente explicitamente: lift("123") ).


Uma ontologia digitada pode ser usada como modelo de dados (consulte https://habr.com/post/229035/ e https://habr.com/post/222553/ ). (Em resumo: são declarados objetos de nome que representam as propriedades de qualquer tipo de domínio: campo val field1: Property[Target, String] .) Nesse caso, os dados reais podem ser armazenados, por exemplo, no formato JSON. A conveniência de uma ontologia digitada, no nosso caso, reside no fato de que o modelo de mudança geralmente opera com propriedades individuais de objetos, e a ontologia apenas fornece uma ferramenta adequada para abordar propriedades.


Para representar as alterações, você precisa de um conjunto de classes do mesmo plano que a classe SetProperty acima:


  • Modify - aplicação da função,
  • Changes - aplicando várias alterações sequencialmente
  • ForEach - aplique alterações a cada item da coleção,
  • etc.

O intérprete de mudança de idioma é um avaliador de expressão recursiva regular baseado no PatternMatching. Algo como:


 def eval(expression: DslExpression, gamma: Map[String, Any]): Any = expression match { case LiftedString(str) => str case PropertyAccess(obj, prop) => Getter(prop)(gamma).get(obj) } def change[T] (expression: DslChangeExpression, gamma: Map[String, Any], target: T): T = expression match { case SetProperty(path, valueExpr) => val value = eval(valueExpr, gamma) Setter(path)(gamma).set(value)(target) } 

Para operar diretamente nas propriedades dos objetos, você deve especificar getter e setter para cada propriedade usada no modelo de mudança. Isso pode ser alcançado preenchendo o mapa entre as propriedades ontológicas e as lentes correspondentes.


Essa abordagem como um todo funciona e, de fato, permite que você descreva as mudanças uma vez, mas gradualmente há a necessidade de representar mudanças cada vez mais complexas e o modelo de mudanças está crescendo um pouco. Por exemplo, se você precisar alterar uma propriedade usando o valor de outra propriedade do mesmo objeto (por exemplo, field1 = field2 + 1 ), precisará suportar variáveis ​​no nível DSL. E se a alteração de uma propriedade não for trivial, no nível DSL, será necessário suporte para expressões e funções aritméticas.


Teste de ramificação


O código de teste pode ser linear e, em geral, um conjunto de dados de teste é suficiente para entender se funciona. Se houver uma ramificação ( if-then-else ), você deverá executar a caixa branca pelo menos duas vezes com dados de entrada diferentes para que ambas as ramificações sejam executadas. O número de conjuntos de dados de entrada suficientes para cobrir todas as ramificações é aparentemente numericamente igual à complexidade ciclomática do código com ramificações.


Como formar todos os conjuntos de dados de entrada? Como estamos lidando com uma caixa branca, podemos isolar as condições de ramificação e modificar o objeto de entrada duas vezes para que, em um caso, uma ramificação seja executada, no outro, outra. Considere um exemplo:


 if (object.field1 == "123") A else B 

Tendo essa condição, podemos formar dois casos de teste:


 val testCase1 = TestCase("A", field1.set("123")(baseline), /* result of A */) val testCase2 = TestCase("B", field1.set(/*  "123", , , */"123" + "1">)(baseline), /*result of B*/) 

(Caso um dos cenários de teste não possa ser criado, podemos assumir que o código morto foi detectado e a condição, juntamente com a ramificação correspondente, pode ser removida com segurança.)


Se propriedades independentes de um objeto são verificadas em várias ramificações, é bastante simples formar um conjunto exaustivo de objetos de teste modificados que cubram completamente todas as combinações possíveis.


DSL para formar todas as combinações de alterações

Vamos considerar com mais detalhes o mecanismo que permite formar todas as listas possíveis de alterações que fornecem cobertura completa de todos os ramos. Para usar a lista de alterações durante o teste, precisamos combinar todas as alterações em um único objeto, que enviaremos à entrada do código testado, ou seja, é necessário suporte para a composição. Para fazer isso, você pode usar o DSL acima para modelar alterações e, em seguida, basta uma lista simples de alterações ou pode apresentar uma alteração como uma função de modificação T => T :


 val change1: T => T = field1.set("123")(_) // val change1: T => T = _.copy(field1 = "123") val change2: T => T = field2.set("456") 

então a cadeia de mudanças será simplesmente uma composição de funções:


 val changes = change1 compose change2 

ou, para uma lista de alterações:


 val rawChangesList: Seq[T => T] = Seq(change1, change2) val allChanges: T => T = rawChangesList.foldLeft(identity)(_ compose _) 

Para registrar compactamente todas as alterações correspondentes a todas as ramificações possíveis, você pode usar o DSL do seguinte nível de abstração, que simula a estrutura da caixa branca testada:


 val tests: Seq[(String, T => T)] = IF("field1 == '123'") //  ,    THEN( field1.set("123"))( //  target \ field1 := "123" IF("field2 == '456') THEN(field2.set("456"))(TERMINATE) ELSE(field2.set("456" + "1"))(TERMINATE) ) ELSE( field1.set("123" + "1") )(TERMINATE) 

Aqui, a coleção de tests contém alterações agregadas correspondentes a todas as combinações possíveis de ramificações. Um parâmetro do tipo String conterá todos os nomes das condições e todas as descrições das mudanças das quais a função de mudança agregada é formada. E o segundo elemento de um par do tipo T => T é apenas a função agregada das mudanças obtidas como resultado da composição das mudanças individuais.


Para obter os objetos alterados, você precisa aplicar todas as funções de alteração agregadas ao objeto de linha de base:


 val tests2: Seq[(String, T)] = tests.map(_.map_2(_(baseline))) 

Como resultado, obtemos uma coleção de pares e a linha descreve as alterações aplicadas, e o segundo elemento do par será o objeto no qual todas essas alterações são combinadas.


Com base na estrutura do modelo do código testado na forma de uma árvore, as listas de alterações representam o caminho da raiz para as planilhas dessa árvore. Assim, uma parte significativa das alterações será duplicada. Você pode se livrar dessa duplicação usando a opção DSL, na qual as alterações são aplicadas diretamente ao objeto de linha de base à medida que você se move pelas ramificações. Nesse caso, menos cálculos desnecessários serão realizados.


Geração automática de dados de teste


Como estamos lidando com uma caixa branca, podemos ver todos os galhos. Isso torna possível construir um modelo de lógica contido em uma caixa branca e usar o modelo para gerar dados de teste. Se o código de teste estiver escrito em Scala, você poderá, por exemplo, usar scalameta para ler o código, com a conversão subsequente em um modelo lógico. Novamente, como na questão discutida anteriormente sobre modelagem da lógica das mudanças, é difícil modelar todas as possibilidades de uma linguagem universal. Além disso, assumiremos que o código testado é implementado usando um subconjunto limitado do idioma, ou em outro idioma ou DSL, inicialmente limitado. Isso nos permite focar nos aspectos da linguagem que nos interessam.


Considere um exemplo de código que contém uma única ramificação:


 if(object.field1 == "123") A else B 

A condição divide o conjunto de valores do field1 em duas classes de equivalência: == "123" e != "123" . Portanto, todo o conjunto de dados de entrada também é dividido em duas classes de equivalência com relação a essa condição - ClassCondition1IsTrue e ClassCondition1IsFalse . Do ponto de vista da abrangência, é suficiente que tomemos pelo menos um exemplo dessas duas classes para cobrir os ramos A e B Para a primeira classe, podemos construir um exemplo, de certo modo, de uma maneira única: pegue um objeto aleatório, mas altere o field1 para "123" . Além disso, o objeto certamente ClassCondition1IsTrue na classe de equivalência ClassCondition1IsTrue e os cálculos seguirão a ramificação A Existem mais exemplos para a segunda classe. Uma maneira de gerar algum exemplo da segunda classe é gerar objetos de entrada arbitrários e descartar aqueles com o field1 == "123" . Outra maneira: pegar um objeto aleatório, mas altere o field1 para "123" + "*" (para modificação, você pode usar qualquer alteração na linha de controle para garantir que a nova linha não seja igual à linha de controle).


Arbitrary e Gen da biblioteca ScalaCheck são bastante adequados como Arbitrary dados aleatórios.


Basicamente, chamamos a função booleana usada na if . Ou seja, encontramos todos os valores do objeto de entrada para os quais essa função booleana avalia como true - ClassCondition1IsTrue , e todos os valores do objeto de entrada para os quais ele aceita false - ClassCondition1IsFalse .


De maneira semelhante, é possível gerar dados adequados para as restrições geradas por operadores condicionais simples com constantes (mais / menos que uma constante, incluída em um conjunto, começa com uma constante). Tais condições são fáceis de reverter. Mesmo que funções simples sejam chamadas no código de teste, podemos substituir a chamada pela definição (inline) e ainda inverter expressões condicionais.


Funções reversíveis rígidas


A situação é diferente quando a condição usa uma função difícil de reverter. Por exemplo, se uma função hash for usada, não seria possível gerar automaticamente um exemplo fornecendo o valor desejado do código hash.


Nesse caso, você pode adicionar um parâmetro adicional ao objeto de entrada que representa o resultado do cálculo da função, substituir a chamada de função por uma chamada para esse parâmetro e atualizar esse parâmetro, apesar da violação da conexão funcional:


 if(sha(object.field1)=="a9403...") ... //     ==> if(object.sha_field1 == "a9403...") ... 

Um parâmetro adicional permite a execução de código dentro da ramificação, mas, obviamente, pode levar a resultados realmente incorretos. Ou seja, o programa de teste produzirá resultados que nunca podem ser observados na realidade. No entanto, verificar parte do código que, de outra forma, é inacessível para nós ainda é útil e pode ser considerado como uma forma de teste de unidade. Afinal, mesmo durante o teste de unidade, uma subfunção é chamada com argumentos que nunca podem ser usados ​​no programa.


Com essas manipulações, substituímos (substituímos) o objeto de teste. No entanto, de certa forma, o programa recém-criado inclui a lógica do programa antigo. De fato, se como os valores dos novos parâmetros artificiais obtivermos os resultados do cálculo das funções que substituímos pelos parâmetros, o programa produzirá os mesmos resultados. Aparentemente, o teste do programa modificado ainda pode ser interessante. Você só precisa se lembrar sob quais condições o programa alterado se comportará da mesma forma que o original.


Condições dependentes


, . , , , . , . (, , x > 0 , — x <= 1 . , — (-∞, 0] , (0, 1] , (1, +∞) , — .)


, , , true false . , , " " .



, , :


 if(x > 0) if(y > 0) if (y > x) 

( > 0 , — y > x .)
"", , , , , . , " " .
, "", ( y == x + 1 ), , .
"" ( y > x + 1 && y < x + 2 ), , .



, , - , "c " ( Symbolic Execution , ), . ( field1 = field1_initial_value ). , . :


 val a = field1 + 10 //    a = field_initial_value + 10 val b = a * 3 //    b = 3 * field_initial_value + 30 

true false . . . Por exemplo


 if(a > 0) A else B //   A ,  field_initial_value + 10 > 0 //   B ,  field_initial_value + 10 <= 0 

, , , , . , (, , ).



. , , . , . , .


, . . , , . , , , . , , , , , ?


Y- ( " " , stackoverflow:What is a Y-combinator? (2- ) , habr: Y- 7 ). , . ( , , .) . , . , "" . Y- " " ( ).


( ). , . , . , . , , , TestCase '. , , ( throw Nothing bottom , ). .


, . . , , . , . , , . , , , , . , . , .



, , , , 100% . , , . Hum. . , , , ? , , - .


:


  1. .
  2. ( ).
  3. , .
  4. , .

, , . -, , , , . -, , , ( , ), , , "" . / , .


Conclusão


" " " ". , , , , . , .


, , , , . -, , ( ), . -, -, . DSL, , . -, , . -, , ( , , ). .


, , . , , - .


Agradecimentos


@mneychev .

Source: https://habr.com/ru/post/pt422283/


All Articles