白盒测试

高质量程序的开发意味着该程序及其各个部分都经过了测试。 经典的单元测试涉及将大型程序分解为便于测试的小块。 或者,如果测试的开发与代码的开发并行进行,或者测试是在程序之前进行的(TDD-测试驱动的开发),那么程序最初是按照适合测试要求的小块形式开发的。


单元测试的种类之一可以认为是基于属性的测试(例如,在QuickCheck和ScalaCheck库中实现了这种方法)。 该方法基于查找对于任何输入数据都应有效的通用属性。 例如, 序列化后反序列化应产生相同的对象 。 或者, 重新排序不应更改列表中项目的顺序 。 为了验证这种通用属性,上述库支持一种用于生成随机输入数据的机制。 这种方法对于基于数学定律的程序特别有效,这些定律可用作适用于各种程序的通用属性。 甚至还有一个现成的数学属性库- 学科 -可让您在新程序中检查这些属性的性能(重用测试的一个很好的例子)。


有时,事实证明,必须测试复杂的程序而不将其解析为可独立验证的部分。 在这种情况下,测试程序是 黑色的 白色框(白色-因为我们有机会研究程序的内部结构)。


在切口下,描述了几种测试复杂程序的方法,这些复杂程序的一个输入具有不同程度的复杂性(参与度)和不同程度的覆盖率。


* 在本文中,我们假设被测程序可以表示为没有内部状态的纯函数。 (如果存在内部状态,则可以应用下面给出的一些注意事项,但是可以将此状态重置为固定值。)


试验台


首先,由于仅测试了一个函数,其调用代码始终相同,因此我们无需创建单独的单元测试。 所有这些测试都是相同的,对于输入和检查都是准确的。 循环传输源数据( input )并检查结果( expectedOutput )就足够了。 为了在检测到错误的情况下识别出测试数据的问题集,必须标记所有测试数据。 因此,一组测试数据可以表示为三元组:


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

一次运行的结果可以表示为TestCaseResult


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

(我们使用“ Try捕获可能的异常”来显示启动的结果。)


为了简化通过被测程序的所有测试数据的运行,可以使用一个辅助函数,该函数将为每个输入值调用该程序:


 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)) 

此辅助函数将返回有问题的数据和结果与预期不同。


为方便起见,您可以格式化测试结果。


 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") 

并仅在出现错误的情况下显示报告:


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

输入准备


在最简单的情况下,您可以手动创建测试数据来测试程序,将其直接写入测试代码中,然后使用它,如上所示。 通常会发现有趣的测试数据案例有很多共同点,可以作为一些基本实例进行细微的更改。


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

当使用嵌套的不可变数据结构时,例如, Monocle库中的镜头提供了很大的帮助:


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

镜头使您可以优雅地“修改”数据结构的深层嵌套部分:每个镜头都是一个属性的吸气剂和吸气剂。 可以组合镜头以生产“聚焦”到下一个层次的镜头。


使用DSL呈现更改


接下来,我们将通过更改一些初始输入对象来考虑测试数据的形成。 通常,要获得我们需要的测试对象,我们需要进行一些更改。 同时,在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-  ???) ) 

然后,我们将始终知道要执行哪些测试数据。


为了使更改的文本列表不会偏离实际更改,您必须遵循“真理的单一版本”的原则。 (如果在多个点上需要/使用相同的信息,则应该有唯一的唯一信息的主要来源,并且应该通过必要的转换将信息自动分发给所有其他使用点。如果违反了该原理,则不可避免地要手动复制信息。在不同的点,换言之在测试数据,我们看到一个,和测试数据的描述差异版本信息, -另一个例子中,复制的变化field2 = "456" ,并在调整它field3 = "789"我们毛格 一不小心忘了更正的说明。其结果是,该说明将只反映三两个变化。)


在我们的案例中,信息的主要来源是更改本身,或者更确切地说,是进行更改的程序的源代码。 我们想从他们那里得出描述这些变化的文字。 另外,作为第一种选择,您可以建议使用一个宏,该宏将捕获更改的源代码,并将该源代码用作文档。 显然,这是记录实际更改的一种很好且相对简单的方法,并且可能会在某些情况下应用。 不幸的是,如果我们以纯文本形式显示更改,我们将无法对更改列表进行有意义的转换。 例如,检测并消除重复或重叠的更改,以方便最终用户的方式绘制更改列表。


为了能够处理更改,您必须具有结构化的模型。 该模型应具有足够的表现力,以描述我们感兴趣的所有变化。 例如,该模型的一部分将是对象字段,常量,赋值操作的寻址。


变更模型应允许解决以下任务:


  1. 生成变更模型实例。 (也就是说,实际上是创建特定的更改列表。)
  2. 形成对变更的文字描述。
  3. 将更改应用于域对象。
  4. 在模型上执行优化转换。

如果使用通用编程语言进行更改,则可能很难在模型中表示这些更改。 程序的源代码可以使用模型不支持的复杂构造。 这样的程序可以使用辅助模式(例如镜头或copy方法)来更改对象的字段,这些字段是相对于更改模型级别的较低级别的抽象。 结果,可能需要对这种模式进行额外的分析,以输出变化的实例。 因此,最初使用宏的好选择不是很方便。


创建变更模型实例的另一种方法可以是专用语言(DSL),它使用一组扩展方法和辅助运算符来创建变更模型对象。 好了,在最简单的情况下,可以通过构造函数直接创建变更模型的实例。


更改语言详细信息

变更语言是一个相当复杂的结构,包含几个组成部分,而这些组成部分又是不平凡的。


  1. 数据结构模型。
  2. 更改模型。
  3. 实际上是嵌入式(?)DSL-辅助结构,扩展方法,用于方便地进行更改。
  4. 更改的解释器,使您可以实际“修改”对象(当然,实际上是创建修改后的副本)。

这是使用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")) 

也就是说,使用扩展方法\:=PropertyAccessSetProperty对象由先前创建的targetfield1subobjectfield2 。 同样,由于(危险的)隐式转换,字符串“ 123”被打包到LiftedString (您可以不进行隐式转换而显式地调用相应的方法: lift("123") )。


可以将类型化的本体用作数据模型(请参阅https://habr.com/post/229035/https://habr.com/post/222553/ )。 (简而言之:声明了代表任何类型域的属性的名称对象: val field1: Property[Target, String] 。)在这种情况下,实际数据可以以JSON的形式存储。 在我们的案例中,类型化本体的便利性在于这样的事实,即更改模型通常以对象的单个属性运行,而本体只是提供了一种处理属性的合适工具。


为了表示更改,您需要与上述SetProperty类具有相同计划的一组类:


  • Modify -功能的应用,
  • Changes -按顺序应用多个更改
  • ForEach将更改应用于集合中的每个项目,

更改语言解释器是基于PatternMatching的正则递归表达式评估器。 类似于:


 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) } 

要直接对对象的属性进行操作,必须为更改模型中使用的每个属性指定getter和setter。 这可以通过填充本体属性及其对应的镜头之间的映射来实现。


这种方法总体上是有效的,并且确实允许您一次描述更改,但是逐渐需要表示越来越复杂的更改,并且更改模型在不断发展。 例如,如果您需要使用同一对象的另一个属性的值来更改属性(例如, field1 = field2 + 1 ),则需要在DSL级别上支持变量。 而且,如果更改属性并非易事,那么在DSL级别上,就需要支持算术表达式和函数。


分支测试


测试代码可以是线性的,然后从总体上讲,一组测试数据足以了解其是否有效。 如果有一个分支( if-then-else ),则必须使用不同的输入数据至少运行白盒两次,以便两个分支都被执行。 足以覆盖所有分支的输入数据集的数量在数值上显然等于具有分支的代码的圈复杂度。


如何形成所有输入数据集? 由于我们正在处理白盒,因此我们可以隔离分支条件并修改输入对象两次,以便在一种情况下执行一个分支,在另一种情况下执行另一个分支。 考虑一个例子:


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

有了这样的条件,我们可以形成两个测试用例:


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

(如果无法创建其中一种测试场景,那么我们可以假定已检测到无效代码,并且可以安全地删除该条件以及相应的分支。)


如果在几个分支中检查对象的独立属性,那么形成一组详尽的修改后的测试对象非常简单,它完全涵盖了所有可能的组合。


DSL构成所有变更组合

让我们更详细地考虑允许形成所有可能的更改列表的机制,以提供所有分支的完整覆盖。 为了在测试期间使用更改列表,我们需要将所有更改组合到一个对象中,然后将其提交给测试代码的输入,也就是说,需要对组合的支持。 为此,您可以使用上面的DSL来建模更改,然后简单的更改列表就足够了,或者可以将一个更改作为修改函数T => T呈现:


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

那么变更链将仅仅是功能的组合:


 val changes = change1 compose change2 

或者,对于更改列表:


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

要紧凑地记录与所有可能的分支相对应的所有更改,可以使用以下抽象级别的DSL,该DSL模拟被测试的白盒的结构:


 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) 

在这里, tests集合包含与所有可能的分支组合相对应的汇总更改。 类型为String的参数将包含条件的所有名称以及对形成汇总更改函数的更改的所有描述。 一对类型T => T的第二个元素只是由于个体变化的组成而获得的变化的聚合函数。


要获取更改的对象,您需要将所有汇总的更改功能应用于基准对象:


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

结果,我们得到了一个对的集合,该行将描述所应用的更改,并且该对的第二个元素将是所有这些更改组合在一起的对象。


基于树形式的被测试代码的模型结构,更改列表将表示从树的根到表的路径。 因此,大部分更改将被复制。 您可以使用DSL选项摆脱这种重复,该选项在您沿着分支移动时将更改直接应用于基线对象。 在这种情况下,将执行较少的不必要的计算。


自动生成测试数据


由于我们正在处理一个白框,因此可以看到所有分支。 这样就可以构建白盒中包含的逻辑模型,并使用该模型生成测试数据。 如果测试代码是用Scala编写的,则可以使用scalameta读取代码,然后再转换为逻辑模型。 同样,正如前面讨论的对变更逻辑建模的问题一样,我们很难对通用语言的所有可能性进行建模。 此外,我们将假定使用语言的有限子集或最初受限制的另一种语言或DSL来实现测试代码。 这使我们可以专注于我们感兴趣的语言方面。


考虑一个包含单个分支的代码示例:


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

该条件将field1值的集合分为两个等效类: == "123"!= "123" 。 因此,就此条件而言,整个输入数据集也分为两个等效类ClassCondition1IsTrueClassCondition1IsFalse 。 从覆盖的完整性的角度来看,我们足以从这两个类中至少取一个例子来覆盖分支A和分支B 对于第一类,我们可以在某种意义上以独特的方式构建示例:随机对象,但将field1更改为"123" 。 而且,该对象肯定会ClassCondition1IsTrue在等价类ClassCondition1IsTrue ,并且计算将沿着分支A 第二类还有更多示例。 生成第二类示例的一种方法是生成任意输入对象,并丢弃field1 == "123"那些输入对象。 另一种方法:获取随机对象,但将field1更改为"123" + "*" (进行修改,可以使用控制行中的任何更改来确保新行不等于控制行)。


ScalaCheck库中的 ArbitraryGen非常适合作为随机数据 Arbitrary


本质上,我们调用 if使用布尔函数。 也就是说,我们找到此布尔函数对其求值的输入对象的所有值ClassCondition1IsTrue ,以及对它接受false的输入对象的所有值ClassCondition1IsFalse


以类似的方式,可以生成适合于由简单条件运算符使用常量(大于或小于常量,包含在集合中,从常量开始)生成的约束的数据。 这种情况很容易逆转。 即使在测试代码中调用了简单的函数,我们也可以用其定义(内联)替换它们的调用,并且仍然可以反转条件表达式。


硬可逆功能


当条件使用难以逆转的功能时,情况就不同。 例如,如果使用哈希函数,则似乎无法自动生成给出所需哈希码值的示例。


在这种情况下,尽管违反了功能连接,您仍可以向输入对象添加一个代表函数计算结果的附加参数,用对此参数的调用替换函数调用,并更新此参数:


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

附加参数允许在分支内执行代码,但是很显然,它可能导致实际错误的结果。 也就是说,测试程序将产生实际上无法观察到的结果。 尽管如此,检查本来无法访问的部分代码仍然有用,并且可以视为单元测试的一种形式。 毕竟,即使在单元测试期间,也可能会使用在程序中永远不会使用的参数来调用子函数。


通过这种操作,我们替换(替换)了测试对象。 但是,从某种意义上说,新建程序包括旧程序的逻辑。 确实,如果将新人工参数的值作为计算用参数替换的函数的结果,则程序将产生相同的结果。 显然,测试修改后的程序仍然很有趣。 您只需要记住在什么条件下更改的程序的行为将与原始程序相同。


相关条件


, . , , , . , . (, , 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 . . . 举个例子


 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% . , , . 嗯 . , , , ? , , - .


:


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

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


结论


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


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


, , . , , - .


致谢


@mneychev .

Source: https://habr.com/ru/post/zh-CN422283/


All Articles