IEC 61131-3:单元测试
单元测试是每个程序员确保其软件正常运行的重要工具。软件错误会浪费时间和金钱,因此您需要一种自动化的解决方案来发现这些错误,最好是在软件使用之前。在专业开发软件的地方都应使用单元测试。
本文旨在提供快速介绍,并让您了解单元测试的好处。
编写测试用例太繁琐?博途 Block-Sim插件 可以根据你的需求规格和实现代码自动生成测试用例并运行云端仿真!
1、动机
通常会编写单独的测试程序来测试功能块。在这样的测试程序中,创建所需功能块的实例并调用它。观察输出变量并手动检查其正确性。如果这些与预期值不匹配,则调整功能块直到其按预期工作。
但仅测试一次软件是不够的。程序的修改和扩展通常会导致以前经过测试且没有错误的功能或功能块突然不再正常工作。同样,程序错误的修正也可能影响程序的其他部分,并导致代码其他部分出现故障。因此,必须手动重复以前执行和完成的测试。
改善这种情况的一种可能方法是自动化测试。为此,开发了一个测试程序,该程序调用要测试的程序的功能并检查返回值。编写一次的测试程序提供了许多优势:
- 测试是自动化的,因此可以在任何时间以相同的框架条件(时间等)重复进行。
- 为其他团队成员保留了编写好的测试。
2、单元测试
单元测试检查软件中非常小且自给自足的部分(单元)。在IEC 61131-3中,这是一个单独的功能块或函数。每个测试使用测试数据(参数)调用要测试的单元(功能块、方法或函数),并检查其对测试数据的反应。如果提供的结果与预期结果匹配,则测试被视为通过。测试通常由一系列测试用例组成,这些测试用例不仅检查一个目标/实际对,而且检查多个对。
开发人员自行决定实现哪些测试场景。但是,使用在实践中调用时通常出现的值进行测试是有意义的。考虑限制值(极大或极小的值)或特殊值(零指针、空字符串)也是有用的。如果所有这些测试场景都按预期提供正确的值,开发人员可以假设其实现是正确的。
一个积极的副作用是,它让开发人员在对代码进行复杂更改时减少头痛。毕竟,他们可以在进行此类更改后随时检查系统。如果在进行此类更改后没有发生错误,则很可能已经成功。
但是,不能忽略测试实现不当的风险。如果这些测试不充分甚至是错误的,但产生积极的结果,这种欺骗性的确定性迟早会导致重大问题。
3、单元测试框架TcUnit
单元测试框架提供了快速有效地创建单元测试的必要功能。它们提供了进一步的优势:
- 团队中的每个人都可以快速轻松地扩展测试。
- 每个人都可以启动测试并检查测试结果的正确性。
单元测试框架TcUnit是在一个项目中开发的。实际上,它是一个PLC库,提供用于验证变量的方法(断言方法)。如果检查不成功,将在输出窗口中显示状态消息。断言方法包含在功能块FB_Assert中。
每种数据类型都有一个方法,其结构始终相似。始终有一个参数包含实际值,一个参数用于设定值。如果两者匹配,该方法返回TRUE,否则返回FALSE。参数sMessage指定在发生错误时要显示的输出文本。这允许您将消息分配给各个测试用例。断言方法的名称始终以AreEqual开头。
例如,此方法检查类型为integer的变量的有效性。
一些方法包含其他参数。
所有标准数据类型(BOOL、BYTE、INT、WORD、STRING、TIME等)都有相应的断言方法。也支持一些特殊数据类型,例如AreEqualMEM用于检查内存区域或AreEqualGIUD。
4、第一个示例
单元测试用于独立于其他组件检查单个功能块。这些功能块可以位于PLC库或PLC项目中。
对于第一个示例,要测试的FB应该位于PLC项目中。这是功能块FB_Foo。
定义如果未向bSwitch施加更多正沿,则输出bOut保持置位的时间。
| 参数 | 说明 |
|---|---|
| bSwitch | 正沿将输出 bOut 设置为 TRUE。这在时间 tDuration 内保持有效。如果输出已经置位,则重新启动时间 tDuration。 |
| bOff | 正沿立即重置输出 bOut。 |
| tDuration | 定义如果未向 bSwitch 施加更多正沿,则输出 bOut 保持置位的时间。 |
单元测试旨在证明FB_Foo功能块按预期运行。测试代码直接在TwinCAT项目中实现。
5、项目设置
为了将测试代码与应用程序分离,创建了文件夹TcUnit_Tests。POU P_Unit_Tests存储在此文件夹中,从该文件夹调用各个测试用例。
为每个FB创建相应的测试FB。它具有相同的名称加上后缀_Tests。在我们的示例中,名称是FB_Foo_Tests。
在P_Unit_Tests中,创建FB_Foo_Tests的实例并调用它。
PROGRAM P_Unit_Tests
VAR
fbFoo_Tests : FB_Foo_Tests;
END_VAR
fbFoo_Tests();
FB_Foo_Tests包含用于检查FB_Foo的全部测试代码。在FB_Foo_Tests中,为每个测试用例创建FB_Foo的实例。使用不同的参数调用这些实例,并使用断言方法验证返回值。
各个测试用例的执行在状态机中进行,该状态机也由PLC库TcUnit管理。这意味着,例如,一旦检测到错误,测试就会自动终止。
6、测试用例的定义
首先必须定义各个测试用例。每个测试用例占据状态机中的某个区域。
对于各个测试用例的命名,已经证明了多种命名规则有助于使测试设置更加透明。
要检查FB_Foo收件箱的测试用例的名称由[输入名称]_[测试条件]_[预期行为]组成。测试FB_Foo方法的测试用例类似地命名,即[方法名称]_[测试条件]_[预期行为]。
根据此方案定义以下测试用例:
- Switch_RisingEdgeAndDuration1s_OutIsTrueFor1s
测试如果tDuration设置为t#1s,则bSwitch上的正沿是否将输出bOut设置为1秒。
- Switch_RisingEdgeAndDuration1s_OutIsFalseAfter1100ms
测试如果tDuration设置为t#1s,则bSwitch上的正沿是否导致输出bOut在1100毫秒后再次变为FALSE。
- Switch_RetriggerSwitch_OutKeepsTrue
测试新的bSwitch正沿是否重新启动时间tDuration。
- Off_RisingEdgeAndOutIsTrue_OutIsFalse
测试bOff上的正沿是否将输出bOut设置为FALSE。
7、测试用例实现
每个测试用例在状态机中至少占据一个步骤。在本示例中,各个测试用例之间的增量为16#0100。第一个测试用例从16#0100开始,第二个从16#0200开始,依此类推。在步骤16#0000中,将执行初始化,而步骤16#FFFF必须可用,因为一旦断言方法检测到错误,就会由状态机启动该步骤。如果测试没有错误运行,将在16#FF00中显示一条消息,并且FB_Foo的单元测试完成。
pragma区域对于简化源代码中的导航非常有帮助。
FUNCTION_BLOCK FB_Foo_Tests
VAR_INPUT
END_VAR
VAR_OUTPUT
bError : BOOL;
bDone : BOOL;
END_VAR
VAR
Assert : FB_ASSERT('FB_Foo');
fbFoo_0100 : FB_Foo;
fbFoo_0200 : FB_Foo;
fbFoo_0300 : FB_Foo;
fbFoo_0400 : FB_Foo;
END_VAR
CASE Assert.State OF
{region 'start'}
16#0000:
bError := FALSE;
bDone := FALSE;
Assert.State := 16#0100;
{endregion}
{region 'Switch_RisingEdgeAndDuration1s_OutIsTrueFor1s'}
16#0100:
fbFoo_0100(...
...
Assert.State := 16#0200;
{endregion}
{region 'Switch_RisingEdgeAndDuration1s_OutIsFalseAfter1100ms'}
16#0200:
fbFoo_0200(...
...
Assert.State := 16#0300;
{endregion}
{region 'Switch_RetriggerSwitch_OutKeepsTrue'}
16#0300:
fbFoo_0300(...
...
Assert.State := 16#0400;
{endregion}
{region 'Off_RisingEdgeAndOutIsTrue_OutIsFalse'}
16#0400:
fbFoo_0400(...
...
Assert.State := 16#FF00;
{endregion}
{region 'done'}
16#FF00:
Assert.PrintPassed('Done');
Assert.State := 16#FF10;
16#FF10:
bDone := TRUE;
{endregion}
{region 'error'}
16#FFFF:
bError := TRUE;
{endregion}
ELSE
Assert.StateMachineError();
END_CASE
每个测试用例都有一个单独的FB_Foo实例。这确保了每个测试用例都使用新初始化的FB_Foo实例工作。这避免了测试用例之间的相互影响。
16#0100:
fbFoo_0100(bSwitch := TRUE, tDuration := T#1S);
Assert.AreEqualBOOL(TRUE, fbFoo_0100.bOut, 'Switch_RisingEdgeAndDuration1s_OutIsTrueFor1s');
tonDelay(IN := TRUE, PT := T#900MS);
IF (tonDelay.Q) THEN
tonDelay(IN := FALSE);
Assert.State := 16#0200;
END_IF
被测块被调用900毫秒。在此期间,bOut必须为TRUE,因为bSwitch已设置为TRUE且tDuration为1秒。断言方法AreEqualBOOL检查输出bOut。如果它没有预期的状态,则输出错误消息。900毫秒后,通过设置FB_Assert的State属性切换到下一个测试用例。
一个测试用例也可以由多个步骤组成:
16#0300:
fbFoo_0300(bSwitch := TRUE, tDuration := T#500MS);
Assert.AreEqualBOOL(TRUE, fbFoo_0300.bOut, 'Switch_RetriggerSwitch_OutKeepsTrue');
tonDelay(IN := TRUE, PT := T#400MS);
IF (tonDelay.Q) THEN
tonDelay(IN := FALSE);
fbFoo_0300(bSwitch := FALSE);
Assert.State := 16#0310;
END_IF
16#0310:
fbFoo_0300(bSwitch := TRUE, tDuration := T#500MS);
Assert.AreEqualBOOL(TRUE, fbFoo_0300.bOut, 'Switch_RetriggerSwitch_OutKeepsTrue');
tonDelay(IN := TRUE, PT := T#400MS);
IF (tonDelay.Q) THEN
tonDelay(IN := FALSE);
Assert.State := 16#0400;
END_IF
在第7行和第12行执行bSwitch的触发。第3行和第13行检查输出是否保持置位。
8、消息输出
执行FB_Foo的所有测试用例后,将输出一条消息(步骤16#FF00)。
如果断言方法检测到错误,它也将作为消息显示。
如果FB_Assert的AbortAfterFail属性设置为TRUE,则在发生错误时调用步骤16#FFFF,并且测试终止。
断言方法防止在单个步骤中多次发出相同的消息。因此,抑制了相同消息的多次输出,例如在循环中。通过将MultipleLog属性设置为TRUE,将禁用此过滤器,并且输出每条消息。
由于上述结构,单元测试与实际应用程序清晰分离。FB_Foo保持完全不变。
此TwinCAT解决方案与PLC库的TwinCAT解决方案一起存储在源代码管理中(如TFS或Git)。因此,测试对项目的所有团队成员都可用。借助单元测试框架,任何人都可以扩展测试,并且可以启动现有测试并轻松评估它们。
尽管单元测试框架这个术语对于PLC库TcUnit来说有些复杂,但很明显,仅使用少数几个工具,IEC 61131-3也可以进行自动化测试。商业单元测试框架远不止PLC库所能做的。因此,它们包含用于启动测试和显示结果的对话框。此外,通常会标记各个测试用例已遍历的源代码区域。
GitHub上的TcUnit库(TwinCAT 3.1.4022)
9、提示
单元测试中最大的障碍通常是自己较弱的自我。一旦克服了这一点,单元测试几乎可以自动编写。第二个障碍是必须测试软件的哪些部分的问题。想要测试一切没有多大意义。相反,您应该专注于软件的基本区域,并彻底测试构成应用程序基础的功能块。
基本上,如果在执行期间可能遍历许多分支,则认为单元测试相当有质量。编写单元测试时,应选择测试用例,以便尽可能多地遍历功能块的分支。
如果在实践中仍然发生错误,则为该错误情况编写测试可能是有利的。这确保了发生一次的错误不会再次发生。
仅仅两个或更多功能块正常工作并且通过单元测试得到证明这一事实并不意味着应用程序也正确使用了这些功能块。单元测试绝不替代集成和验收测试。此类测试方法验证整个系统并对其进行整体评估。即使应用了单元测试,也有必要继续测试整个工作。但是,通过单元测试消除了很大一部分潜在错误,最终节省了时间和金钱。
汇智网翻译整理,转载请标明出处