在开发与外部服务、API 或复杂功能交互的应用程序时,测试几乎总是很困难。简化测试的一种方法是使用存根类。以下是我通常使用它们的方法。
福利简介
存根是接口或类的伪实现,用于模拟真实服务的行为。它们允许您:
无需调用外部服务即可测试代码
无需 API 密钥即可在本地工作
通过避免昂贵的 API 调用来加速测试
创建可预测的测试场景
外部会计服务示例
让我们看一个外部会计服务的简单接口。实际上,你甚至不需要接口来实现这一点,但它可以更轻松地切换实现并保持同步。
interface ExternalAccountingInterface
{
public function createRecord(array $data): string;
}
以下是调用外部 API 的实际实现:
class ExternalAccounting implements ExternalAccountingInterface
{
public function __construct(
private readonly HttpClient $client,
private readonly string $apiKey,
) {}
public function createRecord(array $data): string
{
$response = $this->client->post("https://api.accounting-service.com/v1/records", [
'headers' => [
'Authorization' => "Bearer {$this->apiKey}",
'Content-Type' => 'application/json',
],
'json' => $data,
]);
$responseData = json_decode($response->getBody(), true);
return $responseData['record_id'];
}
}
现在,这里有一个用于测试的虚假实现:
class FakeExternalAccounting implements ExternalAccountingInterface
{
private array $createdRecords = [];
private bool $hasEnoughCredits = true;
public function createRecord(array $data): string
{
if (! $this->hasEnoughCredits) {
throw new InsufficientCreditsException("Not enough credits to create a record");
}
$recordId = Str::uuid();
$this->createdRecords[$recordId] = $data;
return $recordId;
}
// Edge case simulation
public function withNotEnoughCredits(): self
{
$this->hasEnoughCredits = false;
return $this;
}
// Helper methods for assertions
public function assertRecordsCreated(array $eventData): void
{
Assert::assertContains(
$eventData,
$this->createdRecords,
'Failed asserting that the record was created with the correct data.'
);
}
public function assertNothingCreated(): void
{
Assert::assertEmpty($this->createdRecords, 'Records were created unexpectedly.');
}
}
之前和之后:重构以使用存根
之前:使用 Mockery
public function testCreateAccountingRecord(): void
{
// Create a mock using Mockery
$accountingMock = $this->mock(ExternalAccountingInterface::class);
// Set expectations
$accountingMock->shouldReceive('createRecord')
->once()
->with(Mockery::on(function ($data) {
return isset($data['type']) && $data['type'] === 'invoice' &&
isset($data['amount']) && $data['amount'] === 99.99;
}))
->andReturn('rec_123456');
// Bind the mock
$this->swap(ExternalAccountingInterface::class, $accountingMock);
// Execute the test
$response = $this->post('/api/invoices', [
'product_id' => 'prod_123',
'amount' => 99.99,
]);
// Assert the response
$response->assertStatus(200);
$response->assertJson(['success' => true]);
}
之后:使用存根
public function testCreateAccountingRecord(): void
{
// Create an instance of our custom stub
$fakeAccounting = new FakeExternalAccounting;
// Bind the stub
$this->swap(ExternalAccountingInterface::class, $fakeAccounting);
// Execute the test
$response = $this->post('/api/invoices', [
'product_id' => 'prod_123',
'amount' => 99.99,
]);
// Assert the response
$response->assertStatus(200);
$response->assertJson(['success' => true]);
// Assert that records were created with the expected data
$fakeAccounting->assertRecordsCreated([
'type' => 'invoice',
'amount' => 99.99,
]);
}
自定义存根可以轻松测试边缘情况和错误场景:
public function testInvoiceFailsWhenNotEnoughCredits(): void
{
// Create an instance of our custom stub
$fakeAccounting = new FakeExternalAccounting;
// Configure the stub to simulate not enough credits
$fakeAccounting->withNotEnoughCredits();
// Bind the stub
$this->swap(ExternalAccountingInterface::class, $fakeAccounting);
// Execute the test expecting a failure
$response = $this->post('/api/invoices', [
'product_id' => 'prod_123',
'amount' => 99.99,
]);
// Assert the response handles the failure correctly
$response->assertStatus(422);
$response->assertJson(['error' => 'Insufficient credits']);
// Assert that no records were created
$fakeAccounting->assertNothingCreated();
}
通过此设置,您的本地开发环境将使用虚假实现,让您无需 API 密钥即可工作,也不用担心速率限制。当部署到暂存区或生产环境时,应用程序将使用真实的实现