Laravel+Pest测试:如何断言已被业务代码捕获的异常?
问题场景
我用Laravel+Pest做测试,想断言方法执行时会抛出指定异常,但测试失败。业务代码里的try-catch已经捕获了异常,只做日志记录和事务回滚,没重新抛出。
业务代码
核心处理方法
public static function stuff(): void { DB::beginTransaction(); try { [$width, $height, $type] = $this->getImageSize($path); $model->update([ 'width' => $width, 'height' => $height, 'type' => image_type_to_mime_type($type), ]); DB::commit(); } catch (Exception $e) { Log::error($e->getMessage()); DB::rollBack(); } }
抛出异常的私有方法
private function getImageSize($path): ?array { if (! Storage::disk('images_admin')->exists($path)) { throw new Exception("Image not found: $path"); } $file = Storage::disk('images_admin')->path($path); $data = getimagesize($file); return [ $data[0], $data[1], $data[2], ]; }
测试代码
it('should not fill width, height and type of image if path is not found', function () { $model = Image::factory()->create([ 'path' => 'images/image.jpg', ]); Log::spy(); expect(SaveImageMetadata::run(fn () => $model, 'images/not-found.jpg'))->toThrow(Exception::class); // SaveImageMetadata::run($model, 'images/not-found.jpg'); Log::shouldHaveReceived('error') ->withArgs(function ($message) { return $message === "Image not found: images/not-found.jpg"; }) ->once(); expect($model->width)->toBeNull(); expect($model->height)->toBeNull(); expect($model->type)->toBeNull(); })
测试报错
Exception "Exception" not thrown.
需求:不修改业务代码的前提下,在Pest测试里验证这个被捕获的异常确实被抛出过。
解决方案
方法1:通过验证最终业务行为间接证明异常触发
既然业务代码捕获异常后会触发「记录错误日志」「回滚事务(模型无更新)」这两个确定行为,直接验证这两个结果就能反向证明异常发生了,这也是测试的核心目的——验证业务逻辑输出是否符合预期:
- 保留日志断言,确认错误信息被正确记录
- 保留模型字段断言,确认模型未被更新
- 移除
->toThrow()断言,因为异常确实不会向外抛出
调整后的测试代码:
it('should not fill width, height and type of image if path is not found', function () { $model = Image::factory()->create([ 'path' => 'images/image.jpg', ]); Log::spy(); SaveImageMetadata::run($model, 'images/not-found.jpg'); // 直接执行方法,无需用expect包裹 // 验证错误日志已记录 Log::shouldHaveReceived('error') ->withArgs(fn($message) => $message === "Image not found: images/not-found.jpg") ->once(); // 从数据库重新拉取模型,确保拿到最新状态 $model->refresh(); // 验证模型未被更新 expect($model->width)->toBeNull(); expect($model->height)->toBeNull(); expect($model->type)->toBeNull(); })
方法2:Mock私有方法,直接监听异常抛出动作
如果一定要验证getImageSize确实抛出了异常,可以用Mockery监听这个私有方法的行为:
it('should throw exception when image path is not found', function () { $model = Image::factory()->create([ 'path' => 'images/image.jpg', ]); // 创建SaveImageMetadata类的局部Mock,监听getImageSize方法 $service = Mockery::mock(SaveImageMetadata::class)->makePartial(); $service->shouldReceive('getImageSize') ->with('images/not-found.jpg') ->andThrow(new Exception("Image not found: images/not-found.jpg")); // 模拟静态run方法调用核心处理逻辑 SaveImageMetadata::shouldReceive('run') ->once() ->andReturnUsing(function () use ($service) { $service->stuff(); }); // 执行方法 SaveImageMetadata::run($model, 'images/not-found.jpg'); // 验证日志记录行为 Log::shouldHaveReceived('error') ->withArgs(fn($message) => $message === "Image not found: images/not-found.jpg") ->once(); })
方法3:临时注册全局异常监听器
在测试中临时替换Laravel的异常处理器,捕获所有抛出的异常:
it('should catch the image not found exception', function () { $caughtException = null; // 临时注册自定义异常处理器,捕获抛出的异常 app()->instance(ExceptionHandler::class, new class extends ExceptionHandler { public function report(Throwable $e) { global $caughtException; $caughtException = $e; parent::report($e); } public function render($request, Throwable $e) { return parent::render($request, $e); } }); $model = Image::factory()->create([ 'path' => 'images/image.jpg', ]); SaveImageMetadata::run($model, 'images/not-found.jpg'); // 验证捕获到的异常信息 expect($caughtException)->toBeInstanceOf(Exception::class); expect($caughtException->getMessage())->toBe("Image not found: images/not-found.jpg"); // 恢复默认异常处理器 app()->instance(ExceptionHandler::class, app(\App\Exceptions\Handler::class)); })
总结
最推荐方法1,它聚焦业务逻辑的最终结果,符合测试的核心目标;如果必须验证异常抛出的中间动作,方法2的Mock方案更简洁且无需修改业务代码。
内容的提问来源于stack exchange,提问作者Cordellito
相关产品推荐
相关产品推荐

