深入解读 hamcrest-php:Laravel 项目中的 Hamcrest 匹配器 PHP 移植版
【免费下载链接】sql-server-samplesAzure Data SQL Samples - Official Microsoft GitHub Repository containing code samples for SQL Server, Azure SQL, Azure Synapse, and Azure SQL Edge项目地址: https://gitcode.com/gh_mirrors/sq/sql-server-samples
导读
本篇技术指南以 hamcrest-php 官方 README 为骨架,结合其在 sql-server-samples 仓库的 Laravel 示例(vendor/hamcrest/hamcrest-php/)中的完整源码实现,系统讲解 Hamcrest 匹配器(Matchers)在 PHP 中的用法、与原始 Java API 的差异,以及assertThat断言机制的底层原理。读完本文,你将掌握:如何用可读性极强的自然语言式断言编写 PHP 单元测试、如何组合多个匹配器表达复杂校验逻辑、以及如何从源码层面理解匹配失败时的描述信息是如何生成的。
一、Hamcrest 与 hamcrest-php:从 Java 到 PHP 的官方移植
Hamcrest 是一个最初为 Java 编写的匹配(matching)库,其核心思想是用“匹配器对象”代替传统断言中的布尔表达式,从而让测试断言像自然语言一样可读、可复用、可组合。随后 Hamcrest 被移植到多种语言,而hamcrest-php 是 Hamcrest 的官方 PHP 移植版。
根据 hamcrest-php README 的说明,hamcrest-php 基本遵循对原始 Java API 的直译(literal translation),只在少数 PHP 语言限制导致的例外处做了调整。其 composer.json 表明该库通过classmap自动加载整个hamcrest目录,并额外加载hamcrest/Hamcrest.php,仅要求 PHP >= 5.3.2,是一个零依赖的轻量测试库。
在 sql-server-samples 仓库的 Laravel 示例 中,hamcrest-php 位于vendor/hamcrest/hamcrest-php/,是 Laravel 5.1 项目开发依赖链的组成部分。项目根 composer.json 的require-dev声明了phpunit/phpunit: ~4.0、phpspec/phpspec: ~2.1、mockery/mockery: 0.9.*等测试相关依赖,而 PHPUnit、Mockery 等框架正是 Hamcrest 匹配器最常见的消费方。也就是说,凡是在这个 Laravel 项目里通过 PHPUnit 编写测试,都可以直接使用Hamcrest\Matchers提供的匹配器工厂。
二、从一行代码开始:assertThat基本用法
README 给出了一个最简洁的用法示例:
Hamcrest_MatcherAssert::assertThat('a', Hamcrest_Matchers::equalToIgnoringCase('A'));即:断言字符串'a'与'A'忽略大小写后相等。这一行代码同时展示了 hamcrest-php 的两个核心入口类:
Hamcrest_MatcherAssert(即Hamcrest\MatcherAssert):静态断言入口;Hamcrest_Matchers(即Hamcrest\Matchers):全部匹配器的静态工厂。
2.1assertThat的三种调用形态
查看 MatcherAssert.php 的源码,assertThat()通过func_get_args()接收可变参数,根据参数个数分三种情况处理:
| 参数个数 | 语义 | 失败行为 |
|---|---|---|
| 1 个 | 直接断言布尔表达式为真 | 抛出无消息的AssertionError |
2 个(第二个是Matcher) | 断言第一个值匹配该匹配器 | 通过doAssert生成带描述的AssertionError |
| 2 个(第二个是普通值) | 视为布尔表达式,false则失败,消息为第一个参数 | 抛出AssertionError($args[0]) |
| 3 个 | 形如assertThat($identifier, $actual, $matcher),第三个参数若不是匹配器则自动包装为equalTo | 失败消息中带上$identifier |
第三种形态在源码注释中给出了完整示例:
// With an identifier assertThat("apple flavour", $apple->flavour(), equalTo("tasty")); // Without an identifier assertThat($apple->flavour(), equalTo("tasty")); // Evaluating a boolean expression assertThat("some error", $a > $b); assertThat($a > $b);其中“第三个参数若不是匹配器则自动包装为equalTo”这一行为,由Util::wrapValueWithIsEqual($args[2])实现——这意味着你甚至可以省略equalTo()而直接传一个期望值。
2.2 失败消息的生成机制
当匹配失败时,doAssert()(MatcherAssert.php)会构建一条极具可读性的失败描述:
Expected: <匹配器对期望的描述> but: <匹配器对实际值的失配描述>其实现是:用StringDescription依次追加标识符(可选)、Expected:、appendDescriptionOf($matcher)(即匹配器的describeTo输出)、换行后的but:,最后调用$matcher->describeMismatch($actual, $description)描述实际值的失配原因。这也是 Hamcrest 与普通assertTrue最直观的差异——失败信息本身就是可读的英文句子,便于快速定位问题。
此外,MatcherAssert 还维护了一个静态断言计数器$_count,提供getCount()与resetCount()两个静态方法,可统计/重置已执行的断言次数。
三、与 Java API 的六点差异:PHP 移植版的适配细节
README 中最重要的技术内容,是 hamcrest-php 相对原始 Java API 的六点差异。逐一结合仓库源码展开:
1.instanceOf($theClass)改名为anInstanceOf($theClass)
Java 中的instanceOf是保留字,PHP 的instanceof同样是语言关键字,无法用作方法名,因此移植时改名为anInstanceOf。对应实现位于 Core/IsInstanceOf.php,并有配套测试 tests/Hamcrest/Core/IsInstanceOfTest.php 验证其行为。
2.both(...)->and(...)改为both(...)->andAlso(...)
Java 中and是关键字,PHP 中and也是运算符,不能作为方法名,因此逻辑“与”组合改为andAlso。见 Core/CombinableMatcher.php:
/** Diversion from Hamcrest-Java... Logical "and" not permitted */ public function andAlso(Matcher $other) { return new self(new AllOf($this->_templatedListWith($other))); }源码注释直言这是“对 Hamcrest-Java 的偏离”(Diversion from Hamcrest-Java)。andAlso将当前匹配器与新匹配器组合成一个AllOf(全满足)匹配器,继续包装回CombinableMatcher,从而支持链式连续组合。
3.either(...)->or(...)改为either(...)->orElse(...)
同理,or也是 PHP 运算符,逻辑“或”组合改名orElse,底层包装为AnyOf(任一满足)匹配器:
/** Diversion from Hamcrest-Java... Logical "or" not permitted */ public function orElse(Matcher $other) { return new self(new AnyOf($this->_templatedListWith($other))); }CombinableMatcher的类注释中还给出了两个组合用法的官方示例:
assertThat($string, both(containsString("a"))->andAlso(containsString("b"))); assertThat($string, either(containsString("a"))->orElse(containsString("b")));4. 允许“PHP 式”动态类型,但语义攸关的匹配器除外
除非某个匹配器的语义本身与类型强相关,否则 hamcrest-php 允许对输入采用 PHP 的动态类型(dynamic typing)处理。README 点名的两个例外是:
stringContains():字符串包含匹配器,输入必须是字符串语义;greaterThan():数值比较匹配器,依赖类型比较。
后者的类型约束在 Number/OrderingComparison.php 中有明确体现:其构造函数调用parent::__construct(self::TYPE_NUMERIC),继承自类型安全匹配器TypeSafeMatcher,只对数值类型执行matchesSafely()。该文件还完整实现了五个数值比较工厂:
comparesEqualTo($value) // 等于 greaterThan($value) // 大于 greaterThanOrEqualTo($value) // 大于等于(@factory atLeast) lessThan($value) // 小于 lessThanOrEqualTo($value) // 小于等于(@factory atMost)内部通过_compare()返回-1 / 0 / 1并夹在[$minCompare, $maxCompare]区间内判定,失配时还能给出“was greater than / equal to / less than”的英文描述。
5. 四个未移植的官方匹配器
README 明确列出以下 Java 匹配器因“在 PHP 中无意义或不适用”而未移植:
| Java 匹配器 | 未移植原因(README 语境) |
|---|---|
typeCompatibleWith($theClass) | 依赖 Java 的类型系统语义 |
eventFrom($source) | 面向 Java 事件对象,PHP 无对应概念 |
hasProperty($name) | 依赖 JavaBean 属性约定(PHP 侧用 POPO 类比不成立) |
samePropertyValuesAs($obj) | 同上,依赖 JavaBean 属性约定 |
其中hasProperty、samePropertyValuesAs的注释还带了一个开发者的幽默说明:除非把 PHP 的 POPO(Plain Old PHP Objects)类比成 Java 的 POJO/JavaBeans——而这在 PHP 中并不成立,因此没有移植。
6. 集合匹配器未来将提供 PHP 专属别名
README 说明:由于 Java 的 Arrays、Collections、Sets、Maps 与 PHP 的数组在命名习惯上的差异,当大部分集合匹配器最终移植完成后,大概率会为它们创建 PHP 特有的别名。实际上,从 Matchers.php 的静态工厂可以看到,这种“一义两名”的别名机制已经开始落地,例如:
anArray()与arrayContainingInAnyOrder()对应的别名containsInAnyOrder();arrayContaining()对应的别名contains();hasItemInArray()对应的别名hasValue();hasKeyInArray()对应的别名hasKey()。
这些别名都只是转发到同一个底层实现(如hasValue内部调用IsArrayContaining::hasItemInArray),体现了“Java 风格命名 + PHP 风格别名”并存的策略。
四、匹配器工厂体系:Hamcrest\Matchers的分类目录
Matchers.php(共 713 行)是所有匹配器的静态工厂集合,文件头注释说明它是“从静态方法@factorydoctag 自动生成的”。结合hamcrest/Hamcrest/目录的源码组织,可以将全部匹配器按语义分类如下:
数组(Arrays)
anArray()、arrayContaining()/contains()(按序包含)、arrayContainingInAnyOrder()/containsInAnyOrder()(乱序包含)、hasItemInArray()/hasValue()、hasKeyInArray()/hasKey()、hasKeyValuePair()、arrayWithSize()。- 实现位于 Arrays/ 下的 8 个类,其中
MatchingOnce、SeriesMatchingOnce负责“每个元素只匹配一次”的乱序语义。
集合(Collection)
empty()(对应IsEmptyTraversable)、nonEmpty()、hasSize()(对应IsTraversableWithSize)、hasItem()/hasItems()(对应IsCollectionContaining,且非匹配器参数会自动降级为equalTo,见 Matchers.php 中的示例assertThat(array('a', 'b'), hasItem('b')))。
核心(Core)
allOf()、anyOf()、both()/either()(组合)、describedAs()、everyItem()、is()、anything()、not()、nullValue()/notNullValue()、equalTo()、identicalTo()、sameInstance()、anInstanceOf()/anyOf()等。- 其中
is($value)在$value不是匹配器时自动包装为equalTo($value),因此assertThat($cheese, is(equalTo($smelly)))与assertThat($cheese, is($smelly))等价(Matchers.php)。
数值(Number)
closeTo($value, $delta)(近似相等,IsCloseTo)、comparesEqualTo()、greaterThan()、greaterThanOrEqualTo()、lessThan()、lessThanOrEqualTo()(OrderingComparison,类型安全)。
文本(Text)
isEmptyString()、equalToIgnoringCase()、equalToIgnoringWhiteSpace()、matchesPattern()、containsString()、containsStringIgnoringCase()、stringContainsInOrder()、startsWith()、endsWith()。- 实现类位于 Text/,
SubstringMatcher是各字符串包含类别的公共抽象基类。
类型(Type)
arrayValue()、booleanValue()、callableValue()、doubleValue()、integerValue()/intValue()、numericValue()、objectValue()、resourceValue()、scalarValue()、stringValue()。- 以 Type/IsInteger.php 为例,它继承
IsTypeOf并在构造时传入'integer',integerValue()工厂方法上标注@factory intValue,说明Matchers::intValue()与Matchers::integerValue()是同一匹配器的两个入口。
XML
hasXPath()(Xml/HasXPath.php),配合 XPath 表达式校验 XML 内容。
五、匹配器与描述机制的架构:Matcher 接口与基类
要真正理解 hamcrest-php,需要掌握其最核心的接口与抽象类:
Matcher接口:核心方法是matches($item)(返回布尔)、describeTo(Description $description)(描述“期望什么”)、describeMismatch($item, Description $description)(描述“实际是什么”)。BaseMatcher(BaseMatcher.php):所有匹配器的默认基类,提供默认的describeMismatch(输出was <值>)以及__toString()(借助StringDescription::toString将自身描述转为字符串)。TypeSafeMatcher/TypeSafeDiagnosingMatcher:类型安全匹配器基类,先校验类型再执行matchesSafely(),OrderingComparison、IsInteger等均继承自它们。FeatureMatcher:用于“先提取对象某个特征值、再用子匹配器断言该特征值”的组合型匹配器。Description/StringDescription/NullDescription/BaseDescription/SelfDescribing:描述机制的完整实现,负责将匹配器与失配信息渲染成人类可读文本。AssertionError:断言失败时抛出的异常类型,扩展自 PHP 内置ErrorException或类似错误类。
这一架构保证了:任何自定义匹配器只要实现Matcher接口(通常继承BaseMatcher或TypeSafeMatcher),即可无缝接入assertThat、allOf、hasItem等所有组合场景,并获得一致的失败消息输出。
六、测试与验证:仓库中的配套测试套件
hamcrest-php 在 tests/ 目录提供了与实现一一对应的 PHPUnit 测试套件,是学习每个匹配器行为的最佳参考资料:
tests/Hamcrest/Core/:覆盖AllOf、AnyOf、CombinableMatcher、IsEqual、IsInstanceOf、IsNot、IsNull、IsTypeOf、Set等核心匹配器;其中SampleBaseClass.php/SampleSubClass.php用于anInstanceOf的继承关系验证。tests/Hamcrest/Text/:覆盖IsEmptyString、IsEqualIgnoringCase、IsEqualIgnoringWhiteSpace、MatchesPattern、StringContains(含 IgnoringCase、InOrder)、StringStartsWith、StringEndsWith等文本匹配器。tests/Hamcrest/Type/:覆盖全部 10 个类型匹配器(IsArrayTest、IsIntegerTest、IsNumericTest等)。tests/Hamcrest/Number/:IsCloseToTest、OrderingComparisonTest验证数值比较语义。tests/Hamcrest/Array/与Collection/:验证数组与可遍历集合的匹配行为。tests/Hamcrest/Xml/HasXPathTest.php:验证 XPath 匹配。- 基础设施:
tests/AbstractMatcherTest.php(自定义匹配器的测试基类)、tests/MatcherAssertTest.php、tests/StringDescriptionTest.php、tests/UtilTest.php,以及tests/phpunit.xml.dist与tests/bootstrap.php。
以 CombinableMatcherTest 为例,它直接验证了 README 中第 2、3 点差异的行为:both(...)->andAlso(...)必须两个条件同时成立,either(...)->orElse(...)只要一个成立即可。这些测试即为“直译 Java API 但调整命名”这一事实的最直接证据。
七、在 Laravel 测试栈中的落地方式
回到本仓库的 Laravel 示例项目:hamcrest-php 作为 vendor 依赖随 Composer 安装到vendor/hamcrest/hamcrest-php/。虽然项目根 composer.json 未直接声明 hamcrest,但它通过phpunit/phpunit、phpspec/phpspec、mockery/mockery等require-dev依赖被传递引入。实际使用时可遵循以下路径:
- 安装:
composer require --dev hamcrest/hamcrest-php(本仓库已随 vendor 自带,无需重复安装)。 - 引入入口文件:Composer 的
filesautoload 会自动加载hamcrest/Hamcrest.php,其中定义了Hamcrest_MatcherAssert、Hamcrest_Matchers这类下划线风格的全局别名类,因此即使不写use语句也能直接使用Hamcrest_Matchers::equalToIgnoringCase(...)。 - 在 PHPUnit 测试中使用:在 Laravel 的
tests/目录下,直接调用assertThat($actual, Matchers::xxx(...))即可获得与 Java JUnit/Hamcrest 一致的断言体验;也可以与 Mockery 的shouldReceive()->with(Matchers::xxx())结合,用匹配器描述 mock 方法的期望参数。
一个典型的完整示例:
use Hamcrest\Matchers; // 组合断言:字符串同时包含 'a' 与 'b'(对应差异 2 的 andAlso) assertThat($string, Matchers::both(Matchers::containsString("a")) ->andAlso(Matchers::containsString("b"))); // 集合断言:数组中包含某个元素(非匹配器值自动降级为 equalTo) assertThat(array('a', 'b'), Matchers::hasItem('b')); // 类型断言:值必须是整数 assertThat($count, Matchers::integerValue()); // 数值断言:值必须大于 10 assertThat($total, Matchers::greaterThan(10));结语
hamcrest-php 虽然只是 sql-server-samples Laravel 示例中的一个第三方测试依赖,但它完整继承了 Hamcrest“用匹配器描述期望、用可读消息报告失败”的设计哲学,并针对 PHP 语言特性做出了六处关键适配。理解这些适配(anInstanceOf、andAlso、orElse、动态类型边界、未移植匹配器、集合别名)与 MatcherAssert 的底层实现,你就能在 Laravel/PHPUnit 测试中写出既接近自然语言、又可灵活组合的强表达力断言,同时具备阅读和自定义匹配器源码的能力。
【免费下载链接】sql-server-samplesAzure Data SQL Samples - Official Microsoft GitHub Repository containing code samples for SQL Server, Azure SQL, Azure Synapse, and Azure SQL Edge项目地址: https://gitcode.com/gh_mirrors/sq/sql-server-samples
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考