如何在Laravel外Mock Eloquent静态调用以测试共享模型?
问题描述
我有一个微服务架构系统,部分微服务基于Laravel搭建。为共享核心模型,我们通过Git/Packagist共享了Library C,该库包含共享模型——虽然它不在标准Laravel环境中,但已经通过composer引入了laravel/framework ^9.0。这些微服务由单体应用拆分而来,暂未完全解耦,需要共享模型功能,现在要对这些模型做单元测试。
具体场景:User、Customer等多个模型需要地址功能,暂未做数据归一化,使用AddressTrait实现。其中英国邮编需要通过Postcode模型校验,Trait中的setPostcodeAttribute方法会拦截赋值请求,调用Postcode::where(...)静态查询获取经纬度。
测试遇到的问题:单元测试时,赋值$user->postcode会触发真实数据库查询,报错“Call to a member function connection() on null”。尝试用Mockery Mock该静态调用,但未成功。
需求:在无Laravel环境、无数据库连接的情况下,如何Mock Laravel/Eloquent的静态调用完成单元测试?或如何重构代码提升可测试性?
解决方案
一、Mock Eloquent静态调用(无Laravel环境下)
因为Library C依赖laravel/framework,测试时可以初始化Laravel的基础组件(不需要完整框架或数据库),再结合Mockery来Mock静态查询:
初始化Eloquent基础环境
在测试文件的setUp方法中,初始化Eloquent的Capsule管理器,不需要配置真实数据库:use Illuminate\Database\Capsule\Manager; protected function setUp(): void { parent::setUp(); $capsule = new Manager(); // 只初始化Capsule,不配置真实数据库连接 $capsule->setAsGlobal(); $capsule->bootEloquent(); }这能避免
connection() on null的报错,因为Eloquent有了基础的容器支持。Mock Postcode模型的静态查询
使用Mockery的mockStatic方法来拦截Postcode::where的调用,返回预设的经纬度数据:use Mockery; use App\Models\Postcode; public function testPostcodeAssignment() { // Mock Postcode的静态where调用,返回模拟的查询构造器 $mockQuery = Mockery::mock(\Illuminate\Database\Eloquent\Builder::class); $mockQuery->shouldReceive('first')->andReturn((object)[ 'latitude' => 51.5074, 'longitude' => -0.1278 ]); Mockery::mockStatic(Postcode::class, function ($mock) use ($mockQuery) { $mock->shouldReceive('where')->with('postcode', 'SW1A 1AA')->andReturn($mockQuery); }); // 测试User模型的邮编赋值 $user = new User(); $user->postcode = 'SW1A 1AA'; $this->assertEquals(51.5074, $user->latitude); $this->assertEquals(-0.1278, $user->longitude); }注意:测试结束后要调用
Mockery::close()清理Mock,避免内存泄漏。
二、重构代码提升可测试性
如果Mock静态调用比较繁琐,更优雅的方式是通过依赖注入解耦静态调用:
抽象邮编查询逻辑
创建一个PostcodeLookupInterface接口,定义邮编查询方法:interface PostcodeLookupInterface { public function getCoordinates(string $postcode): ?array; }然后实现Laravel环境下的查询类:
class EloquentPostcodeLookup implements PostcodeLookupInterface { public function getCoordinates(string $postcode): ?array { $postcode = Postcode::where('postcode', $postcode)->first(); if (!$postcode) { return null; } return [ 'latitude' => $postcode->latitude, 'longitude' => $postcode->longitude ]; } }修改Trait注入依赖
把Trait中的静态调用替换为依赖注入的接口实例,通过构造函数或者属性注入:trait AddressTrait { protected PostcodeLookupInterface $postcodeLookup; // 可以通过构造函数注入,或者提供setter方法 public function setPostcodeLookup(PostcodeLookupInterface $postcodeLookup): void { $this->postcodeLookup = $postcodeLookup; } public function setPostcodeAttribute(string $postcode): void { $coordinates = $this->postcodeLookup->getCoordinates($postcode); if ($coordinates) { $this->attributes['latitude'] = $coordinates['latitude']; $this->attributes['longitude'] = $coordinates['longitude']; } $this->attributes['postcode'] = $postcode; } }单元测试时注入Mock实现
测试时,创建一个Mock的PostcodeLookupInterface实现,或者用Mockery生成Mock实例:public function testPostcodeAssignmentWithInjectedLookup() { // 创建Mock的邮编查询服务 $mockLookup = Mockery::mock(PostcodeLookupInterface::class); $mockLookup->shouldReceive('getCoordinates') ->with('SW1A 1AA') ->andReturn([ 'latitude' => 51.5074, 'longitude' => -0.1278 ]); // 初始化User模型并注入Mock服务 $user = new User(); $user->setPostcodeLookup($mockLookup); $user->postcode = 'SW1A 1AA'; $this->assertEquals(51.5074, $user->latitude); $this->assertEquals(-0.1278, $user->longitude); }这种方式彻底解耦了Eloquent静态调用,测试时不需要依赖Laravel的任何数据库组件,代码的可维护性和可测试性也更高。
内容的提问来源于stack exchange,提问作者elb98rm

