对控制台应用程序进行单元测试

2020-12-10 18:53:41

EndBASIC 0.3中最显着的功能是其新的基于控制台的全屏文本编辑器。它比我开始开发它所花的时间要长,部分原因是我忙于移动,部分原因是我担心必须对文本编辑器进行单元测试。 (是的,EndBASIC是一个个人项目,我在业余时间开发它,但这并不意味着我不希望它得到适当的设计!)

最后,我卷起袖子,开始工作,并取得了合理的测试范围。实际上,通过发现各种错误和效率低下的问题,这些测试已经获得了回报,因此付出了巨大的努力。但是,开发这些测试并非易事,因此,这里概述了为什么值得对全屏控制台应用程序进行单元测试以及如何进行测试。

毫不奇怪,关键的见识是针对可测试性进行设计。我知道我从一开始就想对文本编辑器进行单元测试,所以我不得不设计一个允许这样做的设计。将测试改造为从未考虑过测试的代码非常困难。

和往常一样,虽然我要向您展示的特定代码是在Rust中,但您可以轻松地将这些想法应用到您喜欢的语言。您所需要的只是一种机制,用于在您的应用程序和控制台操作代码之间表达一个抽象层,您甚至可以在外壳中做到这一点。

命令行应用程序通常通过从标准输入(stdin)中读取并写入标准输出(stdout)与控制台进行交互。 stdin和stdout都是“流”:您可以读写它们,但是这些I / O操作是顺序的。流不知道有关光标在哪里或如何更改颜色的任何信息。这是有道理的,因为如果stdin和stdout连接到文件,“清除屏幕”是什么意思?

但是,如果是这种情况,控制台程序如何操作屏幕以例如清除屏幕并将光标定位在任意位置?好吧,答案是……这取决于。

在类似Unix的系统上,应用程序将特殊的字节集合(称为转义序列)写入stdout。转义序列的外观及其含义完全取决于附加的stdout。同样,如果将stdout附加到文件,则这些序列只不过是字节序列而已。相反,如果stdout连接到终端,则终端将对其进行解释并执行某些操作。

您可以想到的最明显的转义序列是行终止符或\ n。 \ n本身就是ASCII字节10。但是,当终端看到该字节时,终端便知道必须将光标移至下一行并将其移至第一列。但是\ n所做的语义在整个系统中各不相同:Windows将\ n解释为仅将光标向下移动一行,而不是将其回滚到第一列。

当然,还有更复杂的转义序列。这就是为什么您可以轻松地(假设您知道要与之对话的终端类型)将转义序列嵌入到printf或echo -e调用中以控制外观的原因。本质上,您可以使用转义序列作为标记文本的一种方式:

为了完整起见,我还将提到终端仿真器通常位于内核中,这使您可以在文本模式控制台中使用全屏文本应用程序,但也可以在硬件中实现(概念起源于此)。 ,因此也在用户空间中,这是xterm之类的图形程序在PTY之前曾经做过的事情(这就是为什么将它们称为终端仿真器的原因)。

如果您还不了解问题,那就恰恰是古老的TERM环境变量所要解决的:它告诉控制台应用程序正在与哪个特定终端通信,以便应用程序本身可以生成正确的转义序列来对其进行控制。如今,ANSI转义序列几乎是通用的,但并不总是如此。 terminfo / termcap和(n)curses是将所有这些详细信息抽象出来的库,并为程序员提供了一个通用的控制台操作界面,该界面可在TERM变体中使用。

不过,这全都与Unix有关。在Windows上,情况有所不同(并且我的知识非常有限)。命令行应用程序通过类似ioctl的调用与控制台窗口主机(conhost.exe)通信。换句话说:控制台操作是带外进行的,不是stdout的一部分。或者至少这是过去的唯一方法:新的控制台主机也支持ANSI转义序列,大概是为了促进与WSL的互操作。

无论如何。从测试的角度来看,我们不在乎控制台是如何更新的:我们想知道控制台是如何变化的,而不是操作系统是如何完成的。因此,在下面的文本中,我将把发送到控制台的转义序列和/或ioctl称为控制台操纵命令或控制台命令。

测试全屏控制台应用程序(某些人简称为文本用户界面或TUI)与测试GUI基本上具有相同的困难:

我们正在尝试为响应用户输入的内容编写测试,并且每次用户交互都会导致本质上是视觉变化并需要人眼才能进行解释的更改。

让我们将这些问题分解为几部分,看看如何解决。

首先,我们必须确保输入结果(按键)对程序状态具有正确的影响。假设我们正在测试文本编辑器,这很容易:我们可以用一些文本填充编辑器的缓冲区,让编辑器处理一组按键,然后验证编辑器缓冲区的内容是否与金色文本匹配。与其他更传统的测试没有太大区别。

其次,我们需要担心用户输入。毕竟,TUI是交互式的,并且对用户按键操作有反应,因此我们的测试将必须驱动TUI。这比处理可视控制台更新要容易得多,因为我们要做的就是代表要发送到应用程序的按键序列,然后以某种方式将其输入给应用程序。同样,这与其他测试没有太大不同:我们有一个算法,并注入了一些输入。

第三,我们必须担心情况如何,这是此问题中最有趣的部分。因为,毕竟……我们可以编写测试来验证一段代码选择蓝色,然后清除屏幕,但是除非看到结果,否则我们不知道屏幕是否全部清空了蓝色背景或不。

好吧,我们可以。大概,我们可以捕获原始屏幕内容(如果我们处于DOS时代,则可以对它们进行戳戳;是否为0xB8000?),并在每次按键后将它们与金色的“屏幕截图”进行比较。这样就可以解决问题,并导致测试与屏幕更新的方式完全脱钩……实际上听起来是个好主意。这种比较屏幕内容的方法的问题在于,我们需要一个终端仿真器来从应用程序发出的控制台命令“渲染”控制台,而终端仿真器并不是一件简单的软件。

比较屏幕内容的想法的另一种选择是捕获应用程序发出的控制台命令,并将其与期望进行比较。这将更易于实现,但在保真度方面会有不同,如下所述。

如果遵循上述思想,我们将进行一堆将按键按下到TUI中的测试,并且针对每个按键,我们将捕获发出了哪些控制台操作命令,并将其与期望值进行比较。

哪个……听起来很脆弱,但不是特别有用,不是吗?如前所述,除非终端仿真器处理了命令,否则我们看不到视觉结果,否则命令序列是没有意义的。然后,您可以说这些测试毫无意义。但是这些测试提供了三个单独的好处:

特殊情况和回归验证。在我们正在观察的场景中,许多编辑器的行为都是显而易见的:如果按向右箭头键,则知道如果文本行允许,光标必须向右移动一个位置。如果我们破坏了它的工作方式,那么一旦我们进行任何类型的手动测试,破损就会非常明显。

但是……如果光标位于文本的最后可见行的中间,并且视口滚动到右侧(因为该行过长),然后按Enter键将其拆分,该怎么办?那不是您通常要做的事,那么那里的预期行为是什么?我们需要确保,一旦按预期运行,就不会意外中断,并且我们不想每次更改编辑器逻辑时都必须手动进行验证。

行为文档。例如,如果进行任何类型的重构,那么TUI的测试用例的收集将作为我们在代码中必须关注的所有用例的文档。如上所示,有很多情况需要处理,除非在某个地方进行跟踪,否则很容易忘记它们。

效率措施。这些测试给我们带来的最后一个好处是一种衡量效率的方法。通过捕获TUI逻辑发出的命令序列,我们可以查看这些命令是否最少。因为如果没有,则TUI将闪烁。

例如:实现TUI的一种简单方法是在每次按键后刷新整个屏幕,尽管这将产生看起来正确的更新(并且将通过我们验证屏幕内容的测试模型),但应用程序会执行太多的工作无法更新屏幕。我们只需要担心只进行部分屏幕更新(清除一行,使用终端滚动功能等),为此,捕获命令序列就可以做到这一点。

再次,这种测试方法的缺点是,我们对测试中的屏幕外观没有直观的了解。因此,进行测试不足以验证TUI行为:如果我们更改了编辑器代码,则将必须手动并目视检查我们的新更改是否相应地起作用。但是我们的想法是我们只需要对新行为进行一次最少的检查。之后,我们可以放心,我们的测试将捕获其他地方发生的意外更改。

让我们开始实践这些想法,对EndBASIC文本编辑器进行单元测试。

我们需要的第一件事是编辑器(TUI)和控制台之间的抽象层,以便我们可以挂接到I / O操作以进行测试。

幸运的是,EndBASIC已经有了一个这样的抽象,这有两个原因:首先,因为我是为可测试性而预先设计的;其次,因为我有意要使该语言的核心与任何控制台操作无关(支持Unix系统,Windows和Web,因此都必须脱离)。

pub枚举键{ArrowUp,ArrowDown,ArrowLeft,ArrowRight,Backspace,Enter,Eof,Char(char),...} pub trait控制台{... fn read_key(& mut self)-> io ::结果<键> ; ...}

我们有一个Key枚举,可独立于其按键代码来代表高级按键。我们具有控制台特性(如果您不熟悉Rust,可以将其视为抽象基类)以抽象方式表示控制台操作。控制台特性提供了一个read_key()挂钩来等待单个按键。

有了这个接口,我们的编辑器会根据Console :: read_key()的返回值实现一个事件循环,并使用通用的Key表示来处理控制操作(例如,移动光标)和编辑操作(例如,实际的字符插入)。

有了这个接口,向我们的TUI提供虚假的输入数据就变得微不足道了。我们需要做的就是声明一个带有模拟read_key的MockConsole实现,对于每个按键,它都会从一系列金键按下中产生一个预先记录的值:

struct MockConsole {... golden_in:VecDeque<键> ,...}用于MockConsole {... fn read_key(& mut self)->的impl控制台。 io ::结果<键> {匹配自我。 golden_in。 pop_front(){Some(ch)=>确定(ch),无=>确定(密钥:Eof),}} ...}

现在...考虑到已经存在控制台抽象层,您可能会认为这太过分了。实际上,EndBASIC已经使用crossterm crate来支持Unix和Windows,因此,大概该库可以提供一种模拟控制台进行测试的方法。不幸的是,事实并非如此。即使这样做:我也需要自己的抽象以使语言的核心保持最小化,并与繁重的库分离。而且我需要绕过无法在Web(WASM)上下文中建立交叉项的条件。

现在我们已经放置了一个基本的控制台抽象来读取按键,我们也可以使用它来操纵控制台本身。让我们用TUI所需的一些原语扩展控制台特性:

pub trait console {... fn clear(& mut self)-> io ::结果< ()> ; fnlocate(& mut self,row:u16,col:u16)-> io ::结果< ()> ; fn write(& mut self,字节:& [u8])-> io ::结果< ()> ; ...}

请注意,这些是“高级”原语:它们告诉控制台该怎么做,但调用者并不关心它是如何发生的。该实现可以自由使用ANSI代码(可能通过crossterm,也许不能),并在不使用时直接进行Windows conhost操作调用。

或者,您知道,我们可以实现一个记录器,该记录器捕获这些调用以在测试中进行进一步的验证,而对真正的控制台不做任何事情:

枚举CapturedOut {清除,找到(u16,u16),写(Vec< u8>),...} struct MockConsole {... capture_out:Vec< CapturedOut> ,...}用于MockConsole {... fn clear(& mut self)->的impl控制台。 io ::结果< ()> {自我。 capture_out。推送(CapturedOut :: Clear);确定(())} fn定位(& mut self,行:u16,列:u16)-> io ::结果< ()> {自我。 capture_out。推(CapturedOut ::找到(行,列)); OK(())} fn write(& mut self,字节数:& [u8])-> io ::结果< ()> {自我。 capture_out。推(CapturedOut ::写(字节。to_owned()));好 (()) } ... }

现在,MockConsole将钩住编辑器发出的所有控制台命令,并将它们记录在capture_out数组中,我们以后可以将其与黄金数据进行比较。

使所有这些都变得可口的最后一步是使用构建器模式来简化表达测试数据。我不会遵循我以前的建议,即使用构建器模式来定义测试场景,但是我们已经足够接近了。目的是以声明性的方式定义我们的测试方案,并且还可以交错原因(按键)和效果(控制台更改),以便轻松推断正在发生的事情。

MockConsoleBuilder:一个构建器,用于构造MockConsole包含的黄金输入。此构建器将使我们能够使用单独的调用来积累输入数据,无论哪种表示形式对手头的数据更有意义:使用add_input_chars()记录较长的字符序列而无需使用Key :: Char样板,或者使用use_input_keys()来精确注入Key实例。

OutputBuilder:一个构建器,用于构建我们希望MockConsole捕获的控制台命令。同样,这将使我们可以通过add()累积“原始” CapturedOut命令,并且还为我们提供了更高层次的操作,这些操作封装了编辑器发出的常见命令序列。例如,对于编辑器来说,重绘整个屏幕或屏幕的一部分是很普遍的,因此我们将执行此操作所需的所有命令分别封装在refresh()和quick_refresh()之后。

有了这些,我们现在掌握了所有必要的知识和知识,可以将测试用例放在一起。

为了说明测试的样子,我逐字复制/粘贴了其中一个编辑器测试,并插入了详细的注释来指导您进行操作:

#[test] fn test_insert_in_empty_file(){//为MockConsole和我们期望的CapturedOut值集合创建一个生成器。这两个构建器使我们可以交错因果关系,///从而更容易理解正在发生的事情。 let mut cb = MockConsoleBuilder :: new()。 with_size(rowcol(10,40)); let mut ob = OutputBuilder :: new(rowcol(10,40));复制代码//编辑器启动后要做的第一件事是清除屏幕,//写入状态行,然后定位光标:又名全屏刷新。这是一个非常常见的操作,因此//而不是使每个测试用例都杂乱无章,而是将其抽象化到刷新辅助函数的后面,该函数将这些操作插入到OutputBuilder中。 ob = ob。刷新(rowcol(0,0),& [""],rowcol(0,0)); //编辑器现在已启动并正在运行,等待按键。我们注入//三个不同的字母,然后为每个字母添加期望值://即,我们希望看到字母出现在终端中,//我们希望看到状态栏以反映新的位置,并且希望光标//移动到特定位置。这是//全屏刷新的更快版本,因此我在quick_refresh之后将这些序列抽象了。 cb = cb。 add_input_chars(" abc"); ob = ob。添加(CapturedOut ::写(b" a"。to_vec())); ob = ob。 quick_refresh(rowcol(0,1),rowcol(0,1)); ob = ob。添加(CapturedOut ::写(b" b"。to_vec())); ob = ob。 quick_refresh(rowcol(0,2),rowcol(0,2)); ob = ob。添加(CapturedOut ::写(b" c"。to_vec())); ob = ob。 quick_refresh(rowcol(0,3),rowcol(0,3)); //现在有一行文字。让我们按两种形式按Enter,并//确保光标和编辑器状态相应地向下移动两行。 cb = cb。 add_input_keys(& [Key :: NewLine]); ob = ob。 quick_refresh(rowcol(1,0),rowcol(1,0)); cb = cb。 add_input_keys(& [Key :: CarriageReturn]); ob = ob。 quick_refresh(rowcol(2,0),rowcol(2,0)); //我们处于空白行,因此在其中添加最后一个字符。 cb = cb。 add_input_chars(" 2"); ob = ob。添加(CapturedOut ::写(b" 2"。to_vec())); ob = ob。 quick_refresh(rowcol(2,1),rowcol(2,1)); //我们的测试场景已准备就绪。现在,我们调用run_editor帮助器,它//构建Editor对象,并将其连接到MockConsole,该MockConsole //可以从cb.build()获得。然后run_editor启动编辑器,//将根据从中获得的按键顺序//使用命令更新MockConsole。一旦编辑器完成,我们将做两件事://我们验证编辑器的原始文本内容是否与我们在下面传递的字符串匹配//,并将捕获的命令与我们在ob中预记录的命令进行比较。 run_editor(""," abc \ n \ n2 \ n",cb,ob); }

就是这样。编辑器逻辑是模拟控制台的驱动程序,因此我们可以完全观察屏幕上发生的情况。请记住:这并不能告诉我们我们所看到的是正确的,但是快速的手动抽查会告诉我们它是正确的。并且一旦我们知道视觉行为是好的,将其包含在代码中将有助于我们呈现棘手的极端情况并防止回归。

虽然听起来不错,但是这些测试并没有

......