PHpullh

PHP · 심층 가이드

PHP 테스트 완전 정리

직접 만든 assert 헬퍼에서 시작해 PHPUnit 데이터 제공자, 익명 클래스 스텁, 메모리 SQLite 통합 테스트까지 12가지 예제로 실제 돌아가는 PHP 테스트를 짭니다.

주제 12개 · 예제 코드 포함 · 최종 수정 2026-09-03 · 작성 pullh 편집팀

PHP의 테스트는 다른 언어와 출발점이 다릅니다. 요청 하나가 끝나면 프로세스 상태가 통째로 사라지는 실행 모델 덕분에, 개발자는 오래 살아 있는 객체 그래프를 신경 쓰지 않아도 되는 대신 전역 함수와 superglobal에 기대는 코드를 아주 쉽게 쓰게 됩니다. 그런 코드는 돌아갈 때는 멀쩡하지만 테스트에서는 손댈 구멍이 없습니다. 테스트 가능한 PHP를 쓴다는 것은 결국 입력을 인자로 받고 결과를 반환값으로 돌려주는 함수 경계를 만든다는 뜻이고, 이 가이드의 예제들은 대부분 그 경계를 어디에 그을지에 대한 이야기입니다.

순서대로 읽으면 도구가 아니라 구조가 쌓입니다. 간단한 assert 헬퍼는 테스트 프레임워크가 실제로 하는 일이 조건 확인과 실패 보고뿐임을 보여주고, PHPUnit 기본 테스트가 그 위에 라이프사이클과 리포팅을 얹습니다. 같은 검증 로직을 입력만 바꿔 반복해야 할 때는 데이터 제공자로 케이스를 표처럼 펼치고, 협력 객체가 끼어들면 무거운 목 라이브러리 대신 익명 클래스로 스텁 만들기로 그 자리에서 가짜를 만듭니다. 마지막에 임시 SQLite 통합 테스트가 붙으면 단위 테스트가 못 잡는 쿼리 오타까지 걸러집니다.

가장 많이 물리는 지점은 비교 연산입니다. PHPUnit의 assertEquals는 느슨한 비교라 "2"와 2, 빈 배열과 false 같은 조합을 통과시키므로, 타입까지 확인하려면 assertSame을 써야 합니다. PHP 8부터 숫자와 비숫자 문자열의 비교 규칙이 바뀌어 예전 코드의 0 == "abc" 같은 표현이 다르게 동작한다는 점도 기억해 둘 만합니다. 또 하나, 메모리 SQLite는 연결 단위로 만들어지므로 커넥션을 새로 열면 스키마가 없는 빈 DB를 만나게 됩니다.

01간단한 assert 헬퍼

기대값과 실제값을 !==로 견주어 다르면 예외를 던지는 열 줄짜리 검증기입니다. 테스트 프레임워크를 붙이기 전 작은 스크립트를 확인할 때 쓸 수 있습니다. 언어 내장 assert()는 zend.assertions 설정에 따라 통째로 무시될 수 있지만, 이 함수는 언제나 실행된다는 차이가 있습니다.

PHP code

<?php
function assertSame(mixed $expected, mixed $actual): void {
    if ($expected !== $actual) {
        throw new RuntimeException('assert failed');
    }
}

assertSame(3, 1 + 2);
echo 'ok';
알아두면 좋은 점

실패 메시지에 기대값과 실제값을 담아 두면 어디가 어긋났는지 바로 보입니다. 배열이나 객체를 견줄 때는 var_export()로 찍어 두는 편이 읽기 좋습니다.

자주 하는 실수

엄격 비교라 부동소수 결과에는 그대로 쓸 수 없습니다. 0.1과 0.2를 더한 값이 0.3과 다르게 나오므로 허용 오차를 둔 비교가 따로 필요합니다.

02PHPUnit 기본 테스트

TestCase를 상속한 클래스에 test로 시작하는 메서드를 두고 assertSame으로 결과를 확인합니다. PHPUnit이 찾아 실행하는 최소 구조가 이것입니다. assertSame은 타입까지 비교해 '1'과 1을 다른 값으로 보므로, 느슨한 비교가 필요하면 assertEquals를 써야 합니다.

PHP code

<?php
use PHPUnit\Framework\TestCase;

final class SluggerTest extends TestCase {
    public function testItCreatesSlug(): void {
        $this->assertSame('hello-php', str_replace(' ', '-', strtolower('Hello PHP')));
    }
}
알아두면 좋은 점

테스트 클래스는 final로 두고 메서드에 #[Test] 어트리뷰트를 붙이면 이름 규칙에 얽매이지 않습니다. 준비 코드는 setUp에, 정리는 tearDown에 둡니다.

자주 하는 실수

네임스페이스와 autoload 설정 없이 바로 실행하려 하면 PHPUnit이 클래스를 찾지 못할 수 있습니다.

03데이터 제공자

#[DataProvider]가 가리키는 정적 메서드가 입력과 기대값 쌍을 돌려주고, 테스트 메서드가 케이스마다 한 번씩 실행됩니다. 같은 로직을 값만 바꿔 검증할 때 씁니다. 제공자는 테스트 실행에 앞서 평가되기 때문에 정적이어야 하고, setUp에서 만든 객체는 여기서 쓸 수 없습니다.

PHP code

<?php
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class PriceFormatterTest extends TestCase {
    #[DataProvider('cases')]
    public function testFormat(int $input, string $expected): void {
        $this->assertSame($expected, number_format($input) . ' KRW');
    }

    public static function cases(): array {
        return [[1000, '1,000 KRW'], [12000, '12,000 KRW']];
    }
}
알아두면 좋은 점

제공자가 돌려주는 배열의 키에 케이스 이름을 붙이면 실패 보고에 그 이름이 표시돼 어느 입력에서 깨졌는지 바로 압니다. 제공자는 제너레이터를 돌려줘도 됩니다.

자주 하는 실수

제공자 메서드를 static으로 만들지 않으면 실행 자체가 거부됩니다. 안에서 $this나 setUp이 만든 객체를 참조하려 해도 그 시점에는 아직 존재하지 않습니다.

04익명 클래스로 스텁 만들기

Clock을 구현하면서 언제 불러도 같은 시각을 돌려주는 객체를 그 자리에서 만듭니다. 시간에 기대는 로직을 결정적으로 검증할 때 넣습니다. 목 프레임워크와 달리 호출 횟수 검증 같은 기능은 없지만, 반환값만 필요할 때는 설정 코드 없이 동작이 눈에 보인다는 이점이 있습니다.

PHP code

<?php
interface Clock {
    public function now(): string;
}

$clock = new class implements Clock {
    public function now(): string { return '2026-03-28T09:00:00+09:00'; }
};

echo $clock->now();
알아두면 좋은 점

인터페이스만 있으면 스텁을 만들 수 있어 아직 구현하지 않은 협력 객체를 두고도 테스트를 먼저 쓸 수 있습니다. 반환값을 생성자로 받게 하면 케이스마다 다른 시각을 넣을 수 있습니다.

자주 하는 실수

스텁이 늘어 인터페이스가 바뀔 때마다 여러 파일을 고치게 되면 오히려 부담이 됩니다. 그럴 때는 공용 페이크 구현을 하나 만들어 두는 편이 낫습니다.

05예외 테스트

expectException으로 어떤 예외를 기대하는지 먼저 선언한 뒤 예외를 발생시켜, 그것이 나와야 통과하게 만듭니다. 잘못된 입력을 막는 검증 코드를 확인할 때의 형태입니다. 선언은 반드시 예외가 터지는 코드보다 앞에 와야 하고, 그 뒤 줄은 실행되지 않아 단언을 붙여도 소용없습니다.

PHP code

<?php
use PHPUnit\Framework\TestCase;

final class EmailTest extends TestCase {
    public function testInvalidEmailThrows(): void {
        $this->expectException(InvalidArgumentException::class);
        throw new InvalidArgumentException('invalid');
    }
}
알아두면 좋은 점

메시지나 코드를 함께 기대하도록 지정하면 어떤 실패인지까지 못 박을 수 있습니다. 기대한 예외가 나지 않으면 테스트는 실패로 처리됩니다.

자주 하는 실수

예외를 던지는 줄 뒤에 단언을 이어 적으면 그 줄은 실행되지 않는데 테스트는 통과해 검증한 것처럼 보입니다. 예외 이후 상태를 보려면 try-catch로 직접 감싸야 합니다.

06임시 SQLite 통합 테스트

메모리 SQLite에 테이블을 만들고 한 행을 넣은 뒤 개수를 세어, 실제 서버 없이 SQL까지 검증합니다. 저장소 코드를 통합해 확인하고 싶을 때 쓰는 방식입니다. sqlite::memory:는 연결이 닫히면 사라져 테스트끼리 격리되지만, 문법 차이 탓에 MySQL 전용 쿼리는 이렇게 검증할 수 없습니다.

PHP code

<?php
$pdo = new PDO('sqlite::memory:');
$pdo->exec('CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT)');
$pdo->exec("INSERT INTO posts (title) VALUES ('PHP')");

echo $pdo->query('SELECT COUNT(*) FROM posts')->fetchColumn();
알아두면 좋은 점

메모리 SQLite는 연결마다 새 데이터베이스라 테스트끼리 상태가 섞이지 않습니다. 스키마 생성 비용이 부담되면 파일 DB를 만들어 두고 복사해 쓰는 방법도 있습니다.

자주 하는 실수

SQLite로만 검증하면 운영에서 쓰는 엔진의 타입 처리나 제약 조건 차이를 놓칩니다. 방언에 기대는 쿼리는 실제 엔진으로 한 번은 확인해야 합니다.

07JSON 스냅샷 비교

JSON_PRETTY_PRINT로 만든 문자열을 기대 문자열과 ===로 맞춰 봅니다. 응답 형태가 언제 바뀌었는지 한눈에 잡고 싶을 때 쓰는 접근입니다. 문자열 비교라 들여쓰기나 키 순서만 달라져도 실패하니, 구조만 보고 싶다면 디코드한 배열끼리 비교하는 편이 안정적입니다.

PHP code

<?php
$actual = json_encode(['lang' => 'php', 'ok' => true], JSON_PRETTY_PRINT);
$expected = "{
    "lang": "php",
    "ok": true
}";

var_dump($actual === $expected);
알아두면 좋은 점

스냅샷은 형식이 언제 바뀌었는지는 빠르게 알려 주지만 무엇을 검증하는지는 드러내지 않습니다. 중요한 필드는 개별 단언으로 따로 확인해 두는 편이 안전합니다.

자주 하는 실수

기대 문자열을 소스에 직접 적으면 들여쓰기나 개행이 조금만 달라져도 실패합니다. 스냅샷을 파일로 두고 비교하는 편이 유지하기 쉽습니다.

08CLI 커맨드 테스트

proc_open으로 PHP 하위 프로세스를 띄우고 표준출력을 읽어 기대 문자열과 견줍니다. 콘솔 명령이 실제로 무엇을 출력하는지 확인할 때 쓰입니다. 여기서는 stdout 파이프만 열어 두어 오류 메시지는 잡히지 않으므로, 실패 원인을 보려면 stderr 파이프도 함께 열어야 합니다.

PHP code

<?php
$process = proc_open('php -r "echo 42;"', [1 => ['pipe', 'w']], $pipes);
$output = trim(stream_get_contents($pipes[1]));
proc_close($process);

var_dump($output === '42');
알아두면 좋은 점

명령 경로에 PHP_BINARY 상수를 쓰면 테스트를 돌리는 것과 같은 실행 파일을 확실히 고를 수 있습니다. 종료 코드는 proc_close()의 반환값으로 확인합니다.

자주 하는 실수

인자를 문자열로 이어 붙이면 공백이나 따옴표가 든 값에서 명령이 깨집니다. escapeshellarg()를 쓰거나 명령을 배열 형태로 넘겨야 합니다.

09HTTP 핸들러 테스트 구조

요청을 배열로 받아 상태와 본문을 배열로 돌려주는 함수라, 웹서버를 띄우지 않고 그냥 호출해 검증할 수 있습니다. 슈퍼전역과 얽힌 코드를 테스트 가능하게 바꿀 때의 형태입니다. $_GET을 함수 안에서 직접 읽으면 테스트마다 전역을 손대야 하지만, 인자로 받으면 그럴 일이 없습니다.

PHP code

<?php
function handle(array $request): array {
    return ['status' => 200, 'body' => ['path' => $request['path']]];
}

$response = handle(['path' => '/health']);
var_dump($response['status'] === 200);
알아두면 좋은 점

요청을 값으로 받으면 같은 함수에 여러 입력을 넣어 표 형태로 검증할 수 있습니다. 응답도 배열이라 상태와 본문을 각각 나눠 단언하기 쉽습니다.

자주 하는 실수

함수 안에서 $_GET이나 header()를 직접 부르면 테스트가 전역 상태와 출력에 얽매입니다. 부작용은 경계 바깥으로 밀어내야 이 형태가 유지됩니다.

10로거 테스트 더블

ArrayLogger가 받은 메시지를 public 배열에 쌓아 두어 무엇이 기록됐는지 나중에 그대로 비교합니다. 로그를 남기는지 자체가 요구사항인 코드를 검증할 때 씁니다. 진짜 로거를 쓰면 파일이나 표준출력을 뒤져야 하지만, 이 더블은 확인할 내용이 메모리 안에 남아 있습니다.

PHP code

<?php
interface Logger {
    public function info(string $message): void;
}

class ArrayLogger implements Logger {
    public array $messages = [];
    public function info(string $message): void { $this->messages[] = $message; }
}

$logger = new ArrayLogger();
$logger->info('saved');
var_dump($logger->messages === ['saved']);
알아두면 좋은 점

메시지뿐 아니라 레벨과 컨텍스트까지 담아 두면 무엇을 어떤 수준으로 기록했는지 검증할 수 있습니다. PSR-3 인터페이스를 따르면 실제 로거와 그대로 바꿔 끼워집니다.

자주 하는 실수

로그 문구 자체를 단언하면 문구를 다듬을 때마다 테스트가 깨집니다. 검증은 기록 여부나 핵심 식별자 정도로 좁히는 편이 낫습니다.

11엄격 비교로 버그 막기

문자열 '1'과 정수 1을 ===와 ==로 각각 비교해 결과가 갈리는 것을 드러냅니다. 폼이나 JSON에서 넘어온 값은 문자열이라 이 차이가 곧 버그가 됩니다. 숫자 문자열끼리는 ==가 수로 바꿔 비교하므로, 값 검사에서는 먼저 타입을 캐스팅하고 ===를 쓰는 편이 안전합니다.

PHP code

<?php
$payload = ['count' => '1'];
var_dump($payload['count'] === 1);
var_dump($payload['count'] == 1);
알아두면 좋은 점

PHP 8부터는 숫자가 아닌 문자열과 숫자를 비교할 때 숫자를 문자열로 바꿔 견줍니다. 그래서 예전과 달리 0 == 'abc'가 이제는 거짓입니다.

자주 하는 실수

== 로 테스트를 쓰면 버그를 놓칠 가능성이 크므로 가능하면 === 를 기본값으로 두는 것이 좋습니다.

12간단한 랜덤 입력 테스트

random_int로 다섯 번 값을 뽑아 범위를 벗어나면 예외를 던지는, 속성 기반 검사의 축소판입니다. 특정 입력이 아니라 항상 지켜져야 할 조건을 확인하려는 출발점입니다. 실패해도 어떤 값에서 깨졌는지 남지 않으니, 실무에서는 예외 메시지에 값을 담거나 시드를 고정해 재현할 수 있게 합니다.

PHP code

<?php
for ($i = 0; $i < 5; $i++) {
    $value = random_int(1, 100);
    if ($value < 1 || $value > 100) {
        throw new RuntimeException('out of range');
    }
}

echo 'ok';
알아두면 좋은 점

실패한 값을 예외 메시지에 담거나 사용한 입력을 출력해 두면 같은 케이스를 다시 재현할 수 있습니다. random_int는 암호학적으로 안전한 난수원을 씁니다.

자주 하는 실수

만든 범위와 똑같은 범위로 검사하고 있어 이 테스트는 어떤 버그도 잡지 못합니다. 검증 대상은 난수 생성기가 아니라 그 값을 쓰는 코드여야 합니다.

정리하며

  • 테스트가 어려운 코드는 대개 전역 상태에 의존하는 코드입니다. 먼저 인자와 반환값으로 경계를 만드세요.
  • 타입까지 검증해야 하는 자리에서는 assertEquals 대신 assertSame을 기본값으로 두는 편이 안전합니다.
  • 반복되는 케이스는 테스트 메서드를 복사하지 말고 데이터 제공자로 표처럼 정리하면 누락이 눈에 보입니다.
  • 메모리 SQLite 통합 테스트는 연결을 재사용해야 하며, 픽스처는 테스트마다 새로 만들어 격리를 유지합니다.

더 깊이 들어가고 싶다면 PHP 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.