franiglesias / golden
PHP中用于快照测试的库。
Requires
- php: ^7.4 || ^8.0
- ext-json: *
- galbar/jsonpath: ^3.0
Requires (Dev)
- mikey179/vfsstream: ^1.6
- phpcompatibility/php-compatibility: *
- phpunit/phpunit: ^9.0 || ^10.0 || ^11.0
- squizlabs/php_codesniffer: *
README
一个用于PHP快照📸测试的库。
⚠️️ 注意 ⚠️:此README正在进行中。我们正在将原始的Golden README翻译成PHP版本。也许,我们有一些错别字或对Go版本的引用。
食谱:食谱和教程(WIP)🚧
TL;DR
快照测试是一种技术,通过将测试对象的输出与先前生成的输出进行比较,该输出是通过运行相同的测试对象获得的。基本思想是确保对代码所做的更改没有影响其行为。
这很有用
- 测试复杂或大的输出,如对象、文件、生成的代码等。
- 理解和测试遗留代码。
- 在开始重构遗留代码或没有测试的代码时,获得高代码覆盖率。
当前状态:v0.2.x 主要稳定。
路线图/待办功能:
以下功能已就绪
- 验证 ✅
- 批准模式 ✅
- 黄金大师 ✅
snapshot()
、folder()
和extension()
选项用于命名测试 ✅- Scrubbers支持 ✅
- 适用于所有测试的默认选项 ✅
- Scrubbers支持JSON内容,使用路径。 ✅
- 通常与原始Golden的功能同步 ✅
未来版本
- 使用自定义报告器的能力和API。
- 使用自定义规范器的能力和API。
使用建议:可用的稳定API。可能存在一些行为问题。
安装
您可以使用标准的composer require
composer require --dev franiglesias/golden
基本用法:与自动生成的快照进行验证
编写快照测试相当简单。将测试对象的输出捕获到变量中,并将其传递给$this->verify()
。Golden
会处理所有事情。您可以使用任何适合您的类型。
在PHP中,Golden是一个Trait,因此您必须在PHPUnit测试中声明您正在使用Golden trait。就是这样。现在,您有一个verify
方法。
class ParrotTest extends TestCase { use Golden; public function testSpeedOfEuropeanParrot(): void { $parrot = $this->getParrot(ParrotTypeEnum::EUROPEAN, 0, 0, false); $this->verify($parrot->getSpeed()); } }
有时,您可能希望将输出转换为string
,以便为人类提供更易于阅读的快照。
工作原理
第一次运行测试时,将在测试同一目录下生成快照文件 __snapshots/TestSomething/test_speed_of_european_parrot.snap
。并且测试 会通过。查看 快照命名规则 了解在 Golden 中名称是如何生成的。
该文件将包含被测试主题生成的输出,前提是它可以序列化为JSON。
如果您认为快照没有问题,请将其与代码一起提交,以便在未来的运行中作为比较标准,并防止回归。如果不合适,请删除文件,修改代码或测试中需要更改的部分,然后再次运行。
但是,如果您不确定当前输出是否正确,您可以尝试审批模式。
基本用法:审批模式
审批模式在编写新代码时非常有用。在此模式下,快照会生成和更新,但测试永远不会通过。为什么?您需要检查快照,直到您对其满意,然后您或领域专家 批准 它。
将 waitApproval()
选项传递给测试以运行审批模式。
class ParrotTest extends TestCase { use Golden; public function testSpeedOfEuropeanParrot(): void { $parrot = $this->getParrot(ParrotTypeEnum::EUROPEAN, 0, 0, false); $this->verify($parrot->getSpeed(), waitApproval()); } }
一旦您或领域专家批准了快照,请移除 waitApproval()
选项。这就完成了。最后生成的快照将用作标准。
class ParrotTest extends TestCase { use Golden; public function testSpeedOfEuropeanParrot(): void { $parrot = $this->getParrot(ParrotTypeEnum::EUROPEAN, 0, 0, false); $this->verify($parrot->getSpeed()); } }
工作原理
第一次运行测试时,将在测试同一包的 __snapshots/TestSomething/test_speed_of_european_parrot.snap
目录下生成快照文件。并且测试 不会通过。除非您移除 waitApproval()
选项,否则测试的后续运行都不会通过,即使快照与当前输出之间没有差异。
该文件将包含被测试主题生成的输出,前提是它可以序列化为JSON。
如果您认为快照没有问题,请移除 waitApproval()
选项,以便在未来的运行中作为比较标准。如果不合适,请修改代码并重新运行,直到快照正常。
基本用法:黄金大师模式
黄金大师 模式在您想生成大量测试,结合被测试主题参数的不同值时非常有用。它将生成所有可能的组合,创建一个包含所有结果的详细快照。
您需要创建一个包装函数来执行被测试主题,同时管理参数和所有返回值,包括错误。您需要能够返回 SUT 的结果字符串表示。
以下是一个 GildedRose kata 测试的例子。
class GildedRoseTest extends TestCase { use Golden; public function testFoo(): void { $sut = function(...$params): string { $items = [new Item($params[0], $params[1], $params[2])]; $gildedRose = new GildedRose($items); ; $gildedRose->updateQuality(); return $items[0]->__toString(); }; $names = [ 'foo', 'Aged Brie', 'Sulfuras, Hand of Ragnaros', 'Backstage passes to a TAFKAL80ETC concert', 'Conjured' ]; $sellIns = [ -1, 0, 1, 10, 20, 30 ]; $qualities = [ 0, 1, 10, 50, 80, 100 ]; $this->master($sut, Combinations::of($names, $sellIns, $qualities)); } }
工作原理
第一次运行测试时,将在测试同一目录下生成快照文件 __snapshots/GildedRoseTest/test_foo.snap.json
。这将是一个包含每个生成测试的输入和输出的描述的 JSON 文件。除了使用 waitApproval()
选项外,测试将通过。这里有一个快照的片段,您可以看到参数和生成的输出。
[ { "id": 1, "params": [ "foo", -1, 0 ], "output": "foo, -2, 0" }, { "id": 2, "params": [ "Aged Brie", -1, 0 ], "output": "Aged Brie, -2, 2" }, { "id": 3, "params": [ "Sulfuras, Hand of Ragnaros", -1, 0 ], "output": "Sulfuras, Hand of Ragnaros, -1, 0" }, ... ]
作为奖励,您可以在审批模式下使用 GoldenMaster 测试。实际上,您可以通过所有常见的选项。
已知限制
组合测试中的路径覆盖率不准确
由于 Golden 创建和执行组合测试的方式,如果您尝试使用路径覆盖率,您会看到覆盖率报告中只有每行一次命中。
什么是黄金?
Golden 是一个受像 Approval Tests 这样的项目启发的库。还有一些其他类似库,如 Approval Tests、Go-snaps 或 Cupaloy,它们提供类似的功能。
那么……为什么要重造轮子呢?
首先,为什么不做呢?我愿意开始一个小型的项目来学习和实践一些在日常工作中没有时间或机会去学习的Golang技术。例如,创建一个用于分发的库,解决一些关于状态管理的问题,创建友好的API,管理未知类型等。
其次,我发现我在使用的库(主要是Approval tests)中存在一些限制,这使我的工作有些不舒服。所以,我开始寻找替代方案。我希望有更多的灵活性和定制性。最终,我决定创建自己的库。
对于go来说,Golden库对我工作效果很好,所以我决定按照类似的原则将库移植到PHP。
快照测试
快照测试是一种提供替代断言测试的测试技术。在断言测试中,你将执行某个单元代码的输出与你对它的期望进行比较。例如,你可以期望输出等于某个值,或者包含某些文本等。
这里有一个典型的等式断言
$this->assertEqual("Expected", output)
这对于TDD和测试简单的输出效果很好。然而,如果需要测试复杂对象、生成的文件和其他大型输出,这会变得相当繁琐。此外,它并不总是测试不熟悉或没有考虑测试的代码的最佳工具。
在快照测试中,相反,你首先获取并持久化当前测试主题的行为输出。这就是我们所说的快照。这为我们提供了该段代码的回归测试。因此,如果你做了某些更改,你可以确信行为没有受到影响。
这就是你如何做到这一点
$this->verify(subject)
如你所见,与断言相比,我们没有指定任何关于主题的期望。第一次运行时,verify
将subject
的值存储在文件中,并在后续运行中用作比较的预期值。
Golden在第一次运行测试时自动化快照创建过程,并在后续运行中使用相同的快照作为标准。我们称这种工作流程为“验证模式”:测试的目的是验证输出与第一个快照相同。正如你容易猜到的那样,这就像一个回归测试。
快照测试是将遗留代码置于测试或在不带测试的代码库中引入测试的一个非常好的入门方法。你不需要知道代码是如何工作的。你只需要一种捕获现有代码输出的一种方法。
然而,测试遗留或未知代码并不是快照测试的唯一用途。
快照测试是测试复杂对象或输出(如生成的HTML、XML、JSON、代码等)的一个非常好的选择。只要你能创建一个包含响应序列化表示的文件,你就可以将快照与相同单元代码的后续执行进行比较。假设你需要生成包含大量数据的API响应。与其试图弄清楚如何检查每个可能的字段值,不如生成一个包含数据的快照。之后,你将使用该快照来验证执行。
但这种测试方式对于开发新功能来说很奇怪...我如何知道我的输出在特定时刻是正确的呢?
批准测试
审批测试是快照测试的一种变体。通常,在快照测试中,第一次执行测试时,将创建一个新的快照,并且测试自动通过。然后,你更改代码并使用快照来确保没有行为变化。这是Golden的默认行为,正如你所看到的。
但是,当我们创建新功能时,我们并不希望这种行为。我们构建代码并定期检查输出。必须对输出进行审查,以确保它包含所需的内容。在这种情况下,最好是等到专家审查了生成的输出后才进行测试。
在批准测试中,第一次测试执行不是自动通过的。相反,会创建快照,但你应该显式地审查和批准它。这一步骤可以确保输出是您想要的或所需的。您可以将其展示给业务人员、客户、您的API用户或任何可以审查它的人。一旦您获得了快照的批准并重新运行测试,它就会通过。
您可以随时进行更改并重新运行测试,直到您对输出满意并获得批准。
我认为批准测试最初是由Llewellyn Falco提出的。您可以在他们的网站上了解有关此技术的更多信息,了解如何用它来开发。
如何使用Golden进行批准测试
想象一下,您正在编写一些代码,生成一个复杂的结构、JSON对象或另一个长而复杂的文档。您需要该对象被领域专家审查,以确保它包含所需的内容。
如果您像我一样工作,您可能首先会生成一个空对象,并逐个添加不同的元素,每次迭代都运行一次测试,以确保一切进展顺利。使用Golden意味着使用
$this->verify(theOutput)
别忘了在测试中使用Golden特质。
但是,如果您在“验证模式”下工作,您将不得不删除在运行测试时创建的每个快照。相反,您可以使用批准模式。这很简单:您只需将waitApproval()
函数作为选项传递,直到快照反映您或领域专家想要的精确内容。
class ParrotTest extends TestCase { use Golden; public function testSpeedOfEuropeanParrot(): void { $parrot = $this->getParrot(ParrotTypeEnum::EUROPEAN, 0, 0, false); // Verify waiting for approval so the test will always fail $this->verify($parrot->getSpeed(), waitApproval()); } }
这样,测试将始终失败,并且它将更新快照以反映输出中的更改。这是因为在这种情况下,您不希望测试通过。您可以将其视为构建快照的迭代方式:您进行更改,运行测试,并将一些内容添加到快照中,直到您对结果满意。
因此,我如何声明快照已被批准?很简单:只需将测试改回验证模式,一旦您确认最后一个快照是正确的,就可以通过删除golden.WaitApproval()
选项。
class ParrotTest extends TestCase { use Golden; public function testSpeedOfEuropeanParrot(): void { $parrot = $this->getParrot(ParrotTypeEnum::EUROPEAN, 0, 0, false); // Back to standard verification mode $this->verify($parrot->getSpeed()); } }
其他库要求您使用某种外部工具重命名或标记快照为已批准。Golden将此区分放在测试本身中。即使在获得批准后它仍然失败,这也让您记得您需要对测试进行一些操作。
鉴于批准流程的性质,即使快照和当前输出没有差异,测试也始终会失败。这意味着您没有添加更多更改以修改输出。通常,这可以作为一个经验法则,将快照视为已批准,并移除waitApproval()
选项。
黄金大师
快照测试还有另一种变体。Golden Master是由Michael Feathers为处理您不理解的遗留代码而引入的技术。这种测试的另一种命名方式是特征测试。
使用这种技术,您可以快速实现高覆盖率,因此您可以确信重构是安全的,因为您始终会知道代码的行为是否因您引入的更改而损坏。最好的是,您不需要理解代码就可以对其进行测试。一旦您开始重构和抽象代码,引入经典的断言测试甚至TDD就会变得更容易,最终可以移除金母测试。
这种技术需要为同一代码单元创建大量测试,使用需要传递给该单元的参数的组合。原始的Approval Tests包包括Combinations,这是一个帮助您生成这些组合测试的库。
有几种技术可以帮助您猜测应使用的最佳值。例如,您可以研究代码并在图形覆盖工具的帮助下查找条件中的显著值,该工具显示根据值执行或不执行代码的哪些部分。
完成每个参数的可能值的收集后,您将使用组合工具为您生成一批测试。测试数量是每个参数值的数量的乘积。您可以轻松地为相同的代码单元实现数十个甚至数百个测试。
如您所猜,金母之所以得名于此技术...并且因为它是从“Go”开始的。无论如何,我发现许多包都在使用相同的名称,甚至是在标准库中。
如何使用 Golden 进行黄金大师测试
组合测试比纯快照稍微复杂一点。不过,难点在于提供一个简单的API,但也许金母有一些很好的东西可以提供。
我将使用GildedRose重构kata中的一个测试作为示例。这是该类的基本API。为了将此类作为受测试的主题,我们需要在构造时传递一个Item对象的数组,并调用updateQuality
方法。Item对象的数组将被更新,这是我们将要测试的结果。
final class GildedRose { /** * @var Item[] */ private $items; public function __construct(array $items) { $this->items = $items; } public function updateQuality(): void { // A bunch of nested conditionals } }
将测试对象封装起来
我们首先需要一个包装器变长函数,它接受任何类型和数量的参数并返回某种东西。我们将把这个函数传递给master
方法。我们可以使用匿名函数来做到这一点。
$sut = function(...$params): string { // ... };
包装器的主体必须将接收到的参数转换回SUT所需的类型。您需要通过位置识别适当的数据。在这个特定的例子中,我们不需要对$params
项应用转换或类型转换。
包装器函数可以返回任何类型的输出。但如果您发现这样做有困难,请尝试将其转换为string
。这是一个安全的选择,也是我们在这里的首选。
$sut = function(...$params): string { $name = $params[0]; $sellIn = $params[1]; $quality = $params[2]; $items = [new Item($name, $sellIn, $quality)]; $gildedRose = new GildedRose($items); ; $gildedRose->updateQuality(); return $items[0]->__toString(); };
如果SUT抛出异常怎么办?最好的办法是使用try/catch
捕获异常并返回一些可以提供信息的东西,比如异常消息。它应该像这样出现在快照中,这样您就会知道什么输入组合生成了它。
让我们假设GildedRose类可能因任何原因抛出异常。
$sut = function(...$params): string { $name = $params[0]; $sellIn = $params[1]; $quality = $params[2]; $items = [new Item($name, $sellIn, $quality)]; $gildedRose = new GildedRose($items); try { $gildedRose->updateQuality(); } catch (\Exception $e) { return $e->getMessage(); } return $items[0]->__toString(); };
我们在捕获块中捕获一个通用异常并返回消息字符串。就是这样。消息将作为输入组合的输出出现。如果您愿意,可以捕获特定的异常,或使用更多信息自定义返回的字符串。这取决于您。
如果SUT不返回输出怎么办?为此建议的技术是添加一些日志记录功能,您可以在测试受测试的主题之后检索该日志输出。实际上,这正是这个例子中发生的事情:SUT本身不返回任何内容,我们以另一种方式捕获结果。
为每个参数准备值列表
接下来我们需要做的是为每个参数准备值列表。您将用一个数组填充所有想要测试的值。请记住,为SUT的签名使用有效的类型。
$names = [ 'foo', 'Aged Brie', 'Sulfuras, Hand of Ragnaros', 'Backstage passes to a TAFKAL80ETC concert', 'Conjured' ]; $sellIns = [ -1, 0, 1, 10, 20, 30 ]; $qualities = [ 0, 1, 10, 50, 80, 100 ];
您将使用方便函数Combinations::of()
传递值集合。
$this->master($sut, Combinations::of($names, $sellIns, $qualities));
实际上,Combinations::of()
是一个对象构造函数,该对象将管理和生成所有可能的值组合。
我应该选择哪些值?这是一个有趣的问题。在这个例子中,具体的值并不重要,因为代码只有一个执行流程。在许多情况下,您可以在控制执行流程的条件中找到有趣的值,从而让您执行所有可能的分支,以获得最大的代码覆盖率。这些值的先前和后续值也很有趣。如果您不确定,甚至可以使用包含多个随机值的批次。记住,一旦设置好测试,添加或删除值都非常容易。
整合一切
这就是如何使用Golden
运行金测试。
public function testFoo(): void { // wrapper function that exercise the subject under test and return a result for each combination $sut = function(...$params): string { $name = $params[0]; $sellIn = $params[1]; $quality = $params[2]; $items = [new Item($name, $sellIn, $quality)]; $gildedRose = new GildedRose($items); try { $gildedRose->updateQuality(); } catch (\Exception $e) { return $e->getMessage(); } return $items[0]->__toString(); }; // define lists of values for each parameter $names = [ 'foo', 'Aged Brie', 'Sulfuras, Hand of Ragnaros', 'Backstage passes to a TAFKAL80ETC concert', 'Conjured' ]; $sellIns = [ -1, 0, 1, 10, 20, 30 ]; $qualities = [ 0, 1, 10, 50, 80, 100 ]; // generates all combinations and run the wrapper function for each of them $this->master($sut, Combinations::of($names, $sellIns, $qualities)); }
此示例将生成180个测试:5个产品 * 6个sellIn * 6个品质。
master
方法将隐式调用verify
,使用执行所有组合的结果作为主题创建快照。顺便说一下,这是一个非常特殊的快照。首先,它是一个包含JSON对象的JSON文件,每个对象代表一个示例。如下所示
[ { "id": 1, "params": [ "foo", -1, 0 ], "output": "foo, -2, 0" }, { "id": 2, "params": [ "Aged Brie", -1, 0 ], "output": "Aged Brie, -2, 2" }, { "id": 3, "params": [ "Sulfuras, Hand of Ragnaros", -1, 0 ], "output": "Sulfuras, Hand of Ragnaros, -1, 0" }, // ... ]
我认为这会帮助您理解快照,轻松识别有趣的案例,甚至在需要时进行后处理。
自定义行为
您可以在任何测试模式下传递以下选项的任何组合。
自定义快照名称
您可以通过传递选项snapshot()
来自定义快照名称。
class ParrotTest extends TestCase { use Golden; public function testSpeedOfEuropeanParrot(): void { $parrot = $this->getParrot(ParrotTypeEnum::EUROPEAN, 0, 0, false); $this->verify($parrot->getSpeed(), snapshot("european_snapshot")); } }
这将生成快照到同一测试文件夹中的__snapshots/ParrotTest/european_snapshot.snap
。
如果您需要
- 同一测试中的多个快照
- 使用外部生成的文件作为快照。例如,如果您想使代码复制另一个系统的输出,前提是您有一个示例。将文件放入
__snapshots
文件夹。
自定义存储快照的文件夹
您可以通过传递选项folder()
来自定义快照文件夹。
class ParrotTest extends TestCase { use Golden; public function testSpeedOfEuropeanParrot(): void { $parrot = $this->getParrot(ParrotTypeEnum::EUROPEAN, 0, 0, false); $this->verify($parrot->getSpeed(), folder("__parrots")); } }
这将生成快照到同一测试文件夹中的__parrots/ParrotTest/test_speed_of_european_parrot.snap
。
您可以使用这两个选项同时使用
class ParrotTest extends TestCase { use Golden; public function testSpeedOfEuropeanParrot(): void { $parrot = $this->getParrot(ParrotTypeEnum::EUROPEAN, 0, 0, false); $this->verify($parrot->getSpeed(), snapshot("european_snapshot"), folder("__parrots")); } }
这将生成快照到同一测试包中的__parrots/ParrotTest/european_snapshot.snap
。
自定义快照文件的扩展名
您可以通过传递extension()
来自定义快照名称。
class ParrotTest extends TestCase { use Golden; public function testSpeedOfEuropeanParrot(): void { $parrot = $this->getParrot(ParrotTypeEnum::EUROPEAN, 0, 0, false); $this->verify($parrot->getSpeed(), extension('.data')); } }
这将生成快照到同一测试包中的__snapshots/ParrotTest/test_speed_of_european_parrot.data
。
此选项在您的快照可以是某些类型的文件时很有用,例如CSV、JSON、HTML或类似类型。大多数IDE将自动根据扩展名应用语法着色和其他好处来检查这些文件。此外,使用正确的扩展名打开它们或将其传递作为示例将更容易。
设置自己的默认值
文件夹。您可以通过将folder()
选项传递给defaults()
来自定义默认快照文件夹。
class ParrotTest extends TestCase { use Golden; protected function setUp(): void { $this->defaults(folder("__parrots")); } public function testSpeedOfEuropeanParrot(): void { $parrot = $this->getParrot(ParrotTypeEnum::EUROPEAN, 0, 0, false); $this->verify($parrot->getSpeed()); } }
这将生成TestCase的所有快照到同一测试文件夹中的__parrots/
文件夹。
扩展。您可以通过将extension()
传递给defaults()
来自定义默认快照扩展。
class ParrotTest extends TestCase { use Golden; protected function setUp(): void { $this->defaults(extension(".json")); } public function testSpeedOfEuropeanParrot(): void { $parrot = $this->getParrot(ParrotTypeEnum::EUROPEAN, 0, 0, false); $this->verify($parrot->getSpeed()); } }
这将生成TestCase中所有快照的.json
扩展。
作用域默认值。您可以通过在setUp
中添加对$this->defaults(extension(".json"));
的调用来为整个TestCase
指定defaults
。
如果您想要一次设置多个测试用例的默认值(甚至所有测试用例),最佳选择似乎是为TestCase创建一个扩展类并在setUp
方法中包含调用。请记住,您所有的测试用例都必须扩展新创建的TestCase,并且设置必须调用其父类,如下所示
abstract class JsonTestCase extends TestCase { use Golden; protected function setUp(): void { $this->defaults(extension(".json")); } }
final class MyTestCase extends TestCase { use Golden; protected function setUp(): void { parent::setUp(); } }
处理非确定性的输出
这不仅仅是一个快照测试的问题。管理非确定性输出始终是一个问题。在断言测试中,您可以引入基于属性的测试:您不需要查找精确值,而可以查找输出所需属性。
在快照测试中,事情要复杂一些。检查输出特定部分的属性并忽略该特定值很困难。无论如何,一种解决方案是寻找模式并对它们进行处理:用固定但具有代表性的值替换它们或用占位符替换它们。也许可以忽略与快照比较的那部分输出。
在Golden中,就像在其他类似库中一样,我们可以使用Scrubbers
。Scrubber封装了一个正则表达式匹配和替换,因此您使用正则表达式来描述应该替换的受试片段,并提供合理的替代方案。
您现在就可以看到一个示例。在下面的测试中,受试对象具有非确定性内容,因为它在测试执行时获取当前时间,所以每次运行都会不同。我们想通过用永远不变的值替换时间数据来避免测试失败。
在这个例子中,scrubber
匹配任何格式为"15:04:05.000"的时间,并将其替换为"<Current Time>"。
#[Test] /** @test */ public function shouldScrubNonDeterministicData(): void { $scrubber = new RegexScrubber("/\\d{2}:\\d{2}:\\d{2}.\\d{3}/", "<Current Time>"); $subject = sprintf("Current time is: %s", (new \DateTimeImmutable())->format("H:i:s.v")); $this->verify($subject, scrubbers($scrubber)); }
您可以使用任何替换字符串。在先前的示例中,我们使用了一个占位符。但您可以选择使用任意时间,这样在检查快照时您可以看到真实数据。如果尝试获得快照的批准,这很有用,只要它避免了需要解释真实事物将显示真实时间或其他软件生成的非确定性数据。
使用PathScrubbers替换Json文件中的字段
如果您正在测试Json文件,您可能希望清理输出中的特定字段。您不仅要搜索文件中的模式,还要搜索字段的路由。我们为您提供了PathScrubber。
PathScrubber允许您指定一个路径并无条件替换其内容。如果找不到路径,则不会执行替换。
#[Test] /** @test */ public function shouldReplaceInnerPath(): void { $subject = '{"object":{"id":"12345","name":"My Object","count":1234,"validated":true,"other":{"remark":"accept"}}}'; $scrubber = new PathScrubber("object.other.remark", "<Replacement>"); $expected = /** @lang JSON */ <<<'EOF' { "object": { "id": "12345", "name": "My Object", "count": 1234, "validated": true, "other": { "remark": "<Replacement>" } } } EOF; assertEquals($expected, $scrubber->clean($subject)); }
注意事项
Scrubbers很有用,但建议不要在同一个测试中使用太多。需要使用大量Scrubbers意味着输出中有很多非确定性数据,因此替换将使测试变得相当无用,因为快照中的数据大部分将是占位符或替换值。
检查是否可以通过以下方式避免这种情况:
- 避免使用随机测试数据。如果您需要一些 Dummy对象,请使用固定数据创建它们。了解Object Mother模式来管理您的测试示例。
- 通常只有时间、日期、标识符和随机生成的事物(如密码生成器中的事物)是非确定性的。Scrubbing应仅限于它们,并且仅当它们在受试者内部生成时。例如,如果您的测试代码创建一个随机标识符,引入并scrubber。但如果您有一个密码字段传递给受试代码,设置任何任意有效的值。
- 考虑软件设计:避免SUT中的全局状态依赖关系,例如系统时钟或随机生成器。将这些封装在对象或函数中,您可以将它们注入SUT并在测试中进行双重处理,提供可预测的输出。
创建自定义Scrubbers
如果您发现您正在多次使用相同的正则表达式和替换,请考虑创建一个自定义Scrubber。
例如,在先前的测试中,我们使用了
$scrubber = new RegexScrubber("/\\d{2}:\\d{2}:\\d{2}.\\d{3}/", "<Current Time>");
这完全没问题,但如果您真的需要在多个测试中重复使用逻辑,最佳方式是创建自己的专用scrubber,通过创建一个实现Scrubber接口的类,将行为委托给RegexpScrubber
或PathScrubber
。
class MyTimeScrubber implements Scrubber { private RegexScrubber $scrubber; public function __construct(callable ...$options) { $this->scrubber = new RegexScrubber( "/\\d{2}:\\d{2}:\\d{2}.\\d{3}/", "<Current Time>", ...$options ); } public function clean(string $subject): string { return $this->scrubber->clean($subject); } public function setContext(string $context) { $this->scrubber->setContext($context); } public function setReplacement(string $replacement) { $this->scrubber->setReplacement($replacement); } }
编写Scruber时,应支持如前例中的callable ...$options
参数。这将允许您的Scruber使用scrubber选项,以便您可以根据需要修改替换内容或上下文。
这将允许您强制执行清理快照的策略,引入对您的领域需求有用的Scruber。
预定义Scrubbers
CreditCard
:混淆信用卡号码
class CreditCardScrubberTest extends TestCase { #[Test] /** @test */ public function shouldObfuscateCreditCard(): void { $scrubber = new CreditCard(); $subject = "Credit card: 1234-5678-9012-1234"; assertEquals("Credit card: ****-****-****-1234", $scrubber->clean($subject)); } }
ULID
:替换一个全局唯一按字典顺序可排序的标识符
final class ULIDScrubberTest extends TestCase { #[Test] /** @test */ public function shouldReplaceULID(): void { $scrubber = new ULID(); $subject = "This is an ULID: 01HNAZ89E30JHFNJGQ84QFJBP3"; assertEquals("This is an ULID: <ULID>", $scrubber->clean($subject)); } }
Scrubbers选项
Replacement
:允许您自定义支持选项的任何scrubber的替换。一些scruber会使用占位符作为替换,但在某些情况下,您可能更喜欢不同的占位符。
#[Test] /** @test */ public function shouldReplaceULIDWithCustomReplacement(): void { $scrubber = new ULID(replacement("[[Another thing]]")); $subject = "This is an ULID: 01HNAZ89E30JHFNJGQ84QFJBP3"; assertEquals("This is an ULID: [[Another thing]]", $scrubber->clean($subject)); }
值的固定示例将有助于让非技术人员更好地理解生成的快照。
#[Test] /** @test */ public function shouldReplaceULIDWithAnotherULID(): void { $scrubber = new ULID(replacement("01HNB9N6T6DEB1XN10C58DT1WE")); $subject = "This is an ULID: 01HNAZ89E30JHFNJGQ84QFJBP3"; assertEquals("This is an ULID: 01HNB9N6T6DEB1XN10C58DT1WE", $scrubber->clean($subject)); }
Format
:允许您传递一个格式字符串,为替换提供一些上下文,以便它们只应用于输出中的特定部分。在下面的示例中,我们只想对信用卡字段应用混淆,而不是对其他可能相似的代码。
#[Test] /** @test */ public function shouldObfuscateOnlyFieldCreditCard(): void { $scrubber = new CreditCard(format("Credit card: %s")); $subject = "Credit card: 1234-5678-9012-1234, Another code: 4561-1234-4532-6543"; assertEquals("Credit card: ****-****-****-1234, Another code: 4561-1234-4532-6543", $scrubber->clean($subject)); }
快照的命名方式
默认情况下,测试名称用于自动生成快照文件名。TestCase的名称将用于创建一个文件夹,在该文件夹中为每个测试创建快照。这些文件的名称将是测试名称的snake_case版本。
这个测试
final class NonDeterministicTest extends TestCase { #[Test] /** @test */ public function shouldScrubNonDeterministicData(): void { // ... } }
将生成快照:__snapshots/NonDeterministicTest/should_scrub_non_deterministic_data.snap
您可以通过传递选项snapshot("new_snapshot_name")
来自定义快照文件名。如果您想在同一个测试中拥有两个或更多不同的快照,则必须这样做。第一个可以使用默认名称,但后续的将重用该名称。您也可以使用此功能使不同的测试使用相同的快照。