외부 API와 연동되는 비즈니스 로직을 테스트할 때, 실제 네트워크 요청을 전송하다가 응답 지연이나 외부 서버 장애로 인해 테스트 빌드가 실패하는 상황을 경험해봤을 것이다.
다만 대부분의 개발자들은 이 문제를 해결하기 위해 외부 API 테스트 자체를 포기하거나, 테스트 실행 시 실제 API를 매번 호출하며 불안정한 테스트 환경을 그대로 방치한다.
이번에는 외부 의존성을 완벽하게 격리하고 네트워크 연결 없이도 빠르게 검증할 수 있는 PHPUnit과 Mockery 기반 모킹(Mocking) 기법을 완벽하게 정리해서 소개하겠다.
단위 테스트(Unit Test)의 핵심 목적은 외부 환경의 상태와 상관없이 '검증하려는 단 하나의 로직'이 올바르게 동작하는지 확인하는 것이다.
데이터베이스, 결제 게이트웨이, 소셜 로그인 API 등 외부 시스템과 직접 통신하는 코드가 테스트 대상에 포함되면 여러 문제가 발생한다.
첫째, 테스트 실행 속도가 현저히 느려진다.
둘째, 외부 API 서비스가 점검 중이거나 네트워크가 불안정하면 내 코드가 정상이라도 테스트가 실패한다.
셋째, 실제 결제나 문자 발송 같은 작업이 테스트 실행 중에 실제로 일어날 수 있다.
따라서 외부 서비스와 통신하는 객체는 진짜 객체 대신 동일한 인터페이스를 구현한 가짜 객체(Mock)로 교체하여 테스트를 수행해야 한다.
| 비교 항목 | PHPUnit 내장 Mock | Mockery Library |
|---|---|---|
| 체이닝 가독성 | 기본적인 형태만 지원하여 코드가 길어짐 | natural language에 가까운 뛰어난 가독성 제공 |
| 유연한 메서드 모킹 | 복잡한 조건부 모킹 구현이 까다로움 | allows(), shouldReceive() 등 유연한 표현식 지원 |
| 정적 메서드/Spies 지원 | 정적 메서드 모킹 제약이 큼 | Alias Mock 및 Spy 객체 손쉽게 생성 가능 |
| 적용 편의성 | 프레임워크 독립적 사용 시 설정 번거로움 | Laravel 등 주요 PHP 프레임워크와 완벽 호환 |
Mockery는 PHP 생태계에서 가장 널리 사용되는 모킹 라이브러리다.
Composer를 통해 개발 의존성으로 간편하게 설치할 수 있다.
composer require --dev mockery/mockeryPHPUnit 실행 후 각 테스트가 끝날 때마다 Mockery가 설정한 기대값(Expectation)이 올바르게 충족되었는지 검증하고 메모리를 해제해야 한다.
이를 위해 테스트 클래스의 tearDown() 메서드에서 Mockery::close()를 반드시 호출해야 한다.
외부 결제 서비스(PaymentGateway)를 이용해 주문을 처리하는 PaymentService 클래스를 예시로 들어보겠다.
테스트 대상은 PaymentService의 processOrder() 메서드다.
아래 코드는 실제 외부 결제 서버로 네트워크 요청을 전송하는 테스트 사례다.
네트워크 상태에 따라 테스트 결과가 달라지며, 실제 API 시크릿 키가 필요하고 응답 시간이 수초 이상 소요되는 문제가 발생한다.
<?php
use PHPUnit\Framework\TestCase;
use GuzzleHttp\Client;
class PaymentServiceTest extends TestCase
{
public function testProcessOrderSuccess(): void
{
// ✗ 실제 HTTP 클라이언트를 생성하여 외부 API 서버로 직접 요청을 보냄
$httpClient = new Client(['base_uri' => 'https://api.payment-provider.com']);
$paymentService = new PaymentService($httpClient);
$result = $paymentService->processOrder('ORDER_123', 50000);
// 외부 네트워크 상태나 API 서버 작동 여부에 테스트 결과가 종속됨
$this->assertTrue($result->isSuccess());
}
}
HTTP 클라이언트를 Mockery 가짜 객체로 대체하여, 외부 API 서버를 직접 호출하지 않고도 성공 및 실패 시나리오를 완벽하게 검증한다.
<?php
use PHPUnit\Framework\TestCase;
use Mockery as m;
use GuzzleHttp\Client;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
class PaymentServiceTest extends TestCase
{
protected function tearDown(): void
{
// ✓ 각 테스트 종료 후 Mockery 검증 및 정리 수행
m::close();
}
public function testProcessOrderSuccessWithMock(): void
{
// 1. PSR-7 Response 및 Stream 가짜 객체 생성
$mockStream = m::mock(StreamInterface::class);
$mockStream->shouldReceive('getContents')
->once()
->andReturn(json_encode(['status' => 'APPROVED', 'transaction_id' => 'TX_9999']));
$mockResponse = m::mock(ResponseInterface::class);
$mockResponse->shouldReceive('getStatusCode')
->once()
->andReturn(200);
$mockResponse->shouldReceive('getBody')
->once()
->andReturn($mockStream);
// 2. HTTP Client 가짜 객체 생성 및 행위 정의
$mockHttpClient = m::mock(Client::class);
$mockHttpClient->shouldReceive('request')
->once()
->with('POST', '/v1/payments', m::type('array'))
->andReturn($mockResponse);
// 3. 테스트 대상 서비스에 Mock 주입 후 검증
$paymentService = new PaymentService($mockHttpClient);
$result = $paymentService->processOrder('ORDER_123', 50000);
$this->assertTrue($result->isSuccess());
$this->assertEquals('TX_9999', $result->getTransactionId());
}
}
PHPUnit을 통해 테스트를 실행하면 네트워크 연결 없이 0.01초 만에 테스트가 성공적으로 통과하는 것을 확인할 수 있다.
./vendor/bin/phpunit tests/PaymentServiceTest.php
PHPUnit 10.5.0 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 00:00.012, Memory: 8.00 MB
OK (1 test, 2 assertions)
모킹을 과도하게 사용하면 실제 운영 환경과 괴리가 생겨 테스트의 신뢰성이 크게 떨어질 수 있다.
✗ 잘못된 것: 테스트 대상 클래스 내부의 구현 세부사항까지 전부 Mock으로 대체하는 행위
클래스 내부의 private 메서드나 연산 로직까지 모킹하면, 리팩토링 시 로직 변화가 전혀 없어도 테스트가 실패하는 결합도 높은 코드가 된다.
✓ 올바른 것: 시스템의 외부 경계(HTTP 요청, 데이터베이스 접근, 파일 I/O)만 격리하는 행위
도메인 비즈니스 로직과 데이터 가공 알고리즘은 가짜 객체 없이 실제로 동작하게 하고, 입출력을 담당하는 외부 인터페이스만 Mocking해야 안전하다.
외부 API 격리 및 Mocking 전략은 단위 테스트의 속도와 신뢰성을 결정짓는 핵심 요소다.
작은 단위 테스트 습관이 모여서 안정적인 CI/CD 파이프라인을 만든다는 점을 잊지 말자.
이 글의 Mockery 활용법을 참고해 외부 의존성을 완벽히 격리하면, 네트워크 장애나 외부 서비스 변경에 흔들리지 않는 견고한 테스트 환경을 얻을 수 있을 것이다.