Devin.KR

PHP · 심화

설계와 보안으로 깊어지는 PHP

테스트 - 검증 가능한 코드 만들기

테스트하기 쉬운 구조, 작은 assert 함수로 테스트 러너 만들기, 테스트 대역(가짜 시계·가짜 저장소), PHPUnit 구조와 대응(설명만)

개발자KR · 원고 갱신

이 장에서 배우는 것

코드를 고친 뒤에 "예전처럼 동작하는지"를 확인하는 방법은 손으로 다시 실행해 보는 것이다. 코드가 커지면 이 방법은 금방 한계에 부딪힌다. 이 장에서는 같은 확인을 몇 초 안에 반복할 수 있는 테스트(test)를 만든다. 도구는 PHP 내장 기능뿐이다. 수강 신청 규칙을 검증하는 작은 테스트 러너를 직접 짜고, 시간과 저장소처럼 실행할 때마다 달라지는 요소를 가짜로 바꿔 끼우는 방법을 익힌다. PHPUnit 은 직접 설치하지 않고, 이 장에서 만든 러너와 어떻게 대응하는지만 설명한다.

  • 테스트하기 어려운 코드의 원인(숨은 입력)을 찾고, 생성자로 주입받는 구조로 바꾼다.
  • assert 함수 몇 개와 try/catch 로 테스트 러너를 만든다.
  • 가짜 시계와 메모리 저장소 같은 테스트 대역(test double)을 만들어 쓴다.
  • 경계값, 거부 경로, 실패 보고까지 테스트에 담는다.
  • 이 장의 러너가 PHPUnit 의 어느 부분에 해당하는지 설명할 수 있다.

문제 상황

학원 시스템에 수강 신청 규칙이 생겼다. 마감 시각이 지나면 신청을 받지 않고, 정원이 차면 거부하며, 같은 학생의 중복 신청도 막는다. 처음 작성한 함수는 다음과 같았다.

function enrollBad(int $courseId, string $student): bool
{
    $now = new DateTimeImmutable();                       // 실행 시각에 따라 결과가 바뀐다
    $pdo = new PDO('sqlite:/var/data/academy.db');        // 운영 파일에 직접 연결한다
    // ... 마감 비교, 정원 조회, INSERT ...
    return true;
}

이 함수를 검증하려면 마감 직전과 직후에 각각 실행해 봐야 한다. 그러려면 실제로 마감 시각이 지나기를 기다리거나 시스템 시계를 바꿔야 한다. 운영 데이터베이스 파일을 건드리므로 실행할 때마다 데이터도 달라진다. 정원 초과 상황을 만들려면 학생 데이터를 직접 채워야 하고, 테스트가 끝난 뒤 지우는 일도 따로 필요하다. 결국 사람이 눈으로 확인하게 되고, 규칙을 하나 고칠 때마다 다른 규칙이 깨졌는지 확인할 방법이 없어진다.

원인은 규칙 자체가 아니라 구조에 있다. 함수가 현재 시각과 저장소를 스스로 만들어 쓰기 때문에 바깥에서 값을 정해 줄 수 없다. 이 장은 그 구조를 바꾸는 데서 시작한다.

테스트하기 쉬운 구조와 테스트 대역

숨은 입력과 숨은 출력

함수의 인자로 보이지 않는데 결과를 좌우하는 값을 숨은 입력이라 부른다. 현재 시각, 난수, 환경 변수, 전역 상태, 파일과 데이터베이스의 내용이 해당한다. 반대로 반환값에 나타나지 않는데 바깥 세계를 바꾸는 쓰기, 즉 INSERT 나 파일 저장은 숨은 출력이다. 테스트하기 쉬운 코드는 이 둘을 코드 가장자리로 밀어내고, 규칙을 담은 부분은 받은 값만으로 판단하게 만든다.

방법은 앞서 기본서에서 다룬 인터페이스를 쓰는 것이다. 시간은 Clock 인터페이스 뒤로, 저장은 EnrollmentStore 인터페이스 뒤로 숨긴다. 서비스는 구현체가 무엇인지 모른 채 생성자로 받는다. 운영에서는 진짜 시계와 데이터베이스 저장소를 넣고, 테스트에서는 가짜를 넣는다. 의존성을 밖에서 넣어 주는 방식은 다음에 이어지는 의존성 주입 장에서 따로 다룬다. 여기서는 생성자 인자로 받는다는 점만 쓴다.

서비스가 인터페이스에만 의존하므로 운영 구현과 테스트 대역을 바꿔 끼울 수 있다.

테스트 대역의 종류

진짜 의존 대상을 대신하는 객체를 통틀어 테스트 대역이라 한다. 용도에 따라 이름이 나뉜다.

테스트 대역의 네 가지 종류와 쓰임
종류하는 일이 장의 예확인하는 것
스텁(stub)정해진 값을 돌려준다FixedClock입력에 따른 결과
페이크(fake)단순하지만 실제로 동작하는 대체 구현InMemoryEnrollmentStore저장 후 상태
스파이(spy)호출 내역을 기록한다연습 문제의 CountingStore호출 횟수와 인자
목(mock)기대한 호출이 일어났는지 스스로 검증한다직접 만들지 않는다상호작용 방식

가짜 시계는 현재 시각을 고정해 돌려주고, 필요하면 advance() 로 시각을 앞으로 보낼 수 있다. 메모리 저장소는 배열에 신청 내역을 쌓을 뿐이지만 "넣은 것을 다시 셀 수 있다"는 저장소의 약속은 지킨다. 그래서 테스트에서 신청 뒤의 상태를 그대로 확인할 수 있다.

대역에는 주의할 점이 있다. 대역이 진짜 구현과 다르게 동작하면 테스트는 통과하는데 운영에서 깨진다. 페이크에 복잡한 규칙을 넣지 않고, 인터페이스가 정한 약속만 지키게 두는 편이 안전하다.

작은 테스트 러너

테스트 러너가 하는 일은 단순하다. 검사 본문을 실행하고, 예외가 없으면 통과, 단언(assertion) 실패 예외가 나면 실패, 그 밖의 예외가 나면 오류로 기록한 뒤 다음 검사로 넘어간다. 이 장의 러너는 이 흐름만 구현한다.

러너는 본문을 try 안에서 실행하고 예외의 종류에 따라 PASS, FAIL, ERROR 로 나눠 기록한다.

단언 함수는 조건이 틀리면 AssertionFailed 예외를 던진다. 일반 오류와 구분하려고 전용 예외 클래스를 둔다. 검사 하나가 실패해도 다른 검사는 계속 돌아야 하므로 러너가 예외를 잡아 기록한다. 검사 하나는 준비, 실행, 검증 순서로 쓴다. 준비에서 대상과 대역을 만들고, 실행에서 규칙을 한 번 호출하며, 검증에서 결과와 상태를 단언한다.

값 비교는 assertSame 이 !== 로 한다. == 는 타입을 바꿔 비교하기 때문에 '1e3' == '1000' 처럼 의도하지 않은 통과를 만든다. 예외를 기대하는 검사는 assertThrows 로 따로 만든다. 이 함수는 예외 클래스와 메시지 일부를 함께 확인한다.

PHPUnit 과의 대응

PHPUnit 은 PHP 에서 가장 널리 쓰는 테스트 프레임워크다. Composer 로 설치하는 방식이라 이 책의 실행 환경 밖이므로 설명만 한다. 이 장의 러너를 이해했다면 구조는 낯설지 않다. 같은 검사를 PHPUnit 으로 쓰면 대략 다음 모양이다. 아래 코드는 설명용이며 이 장의 실행 대상이 아니다.

final class EnrollmentServiceTest extends TestCase
{
    private FixedClock $clock;
    private InMemoryEnrollmentStore $store;

    protected function setUp(): void
    {
        $this->clock = new FixedClock(/* ... */);
        $this->store = new InMemoryEnrollmentStore();
    }

    public function testRejectsAfterDeadline(): void
    {
        $this->expectException(DomainException::class);
        // ... 서비스 호출 ...
    }
}
이 장의 러너와 PHPUnit 의 대응
이 장의 러너PHPUnit비고
$runner->test('이름', 본문)test 로 시작하는 메서드PHPUnit 은 속성으로도 표시할 수 있다
assertSame, assertTrue같은 이름의 단언 메서드$this->assertSame() 로 호출한다
assertThrowsexpectException호출 전에 기대를 먼저 선언한다
fixture() 함수setUp()검사마다 새로 만든다
직접 만든 대역createStub, createMock인터페이스로 대역을 자동 생성한다
PASS/FAIL/ERROR 출력점, F, E 와 요약실패와 오류를 나누는 점이 같다

PHPUnit 이 추가로 주는 것은 파일과 디렉터리 단위로 테스트를 찾아 실행하는 기능, 데이터 제공자(data provider)로 같은 검사를 여러 입력에 돌리는 기능, 코드 커버리지 보고 같은 부가 기능이다. 규칙을 검증하는 방식 자체는 이 장에서 만든 것과 다르지 않다. 자세한 사용법은 PHPUnit 공식 문서에서 확인할 수 있다.

완성 코드

파일은 main.php 하나다. 도메인 코드, 테스트 대역, 러너, 검사를 위에서 아래로 나눠 두었다. 실제 프로젝트에서는 이 구분을 파일로 나누는 것이 보통이다.

<?php

declare(strict_types=1);

// ---- 1. 도메인 ----

interface Clock
{
    public function now(): DateTimeImmutable;
}

interface EnrollmentStore
{
    public function add(Enrollment $enrollment): void;

    public function countFor(int $courseId): int;

    public function has(int $courseId, string $student): bool;
}

final class SystemClock implements Clock
{
    public function now(): DateTimeImmutable
    {
        return new DateTimeImmutable();
    }
}

final class Course
{
    public function __construct(
        public readonly int $id,
        public readonly string $title,
        public readonly int $capacity,
        public readonly DateTimeImmutable $deadline,
    ) {
    }
}

final class Enrollment
{
    public function __construct(
        public readonly int $courseId,
        public readonly string $student,
        public readonly DateTimeImmutable $enrolledAt,
    ) {
    }
}

final class EnrollmentService
{
    public function __construct(
        private Clock $clock,
        private EnrollmentStore $store,
    ) {
    }

    public function enroll(Course $course, string $student): Enrollment
    {
        $now = $this->clock->now();

        if ($now > $course->deadline) {
            throw new DomainException('신청 기간이 끝났다');
        }
        if ($this->store->has($course->id, $student)) {
            throw new DomainException('이미 신청한 학생이다');
        }
        if ($this->store->countFor($course->id) >= $course->capacity) {
            throw new DomainException('정원이 찼다');
        }

        $enrollment = new Enrollment($course->id, $student, $now);
        $this->store->add($enrollment);

        return $enrollment;
    }
}

// ---- 2. 테스트 대역 ----

final class FixedClock implements Clock
{
    public function __construct(private DateTimeImmutable $current)
    {
    }

    public function now(): DateTimeImmutable
    {
        return $this->current;
    }

    public function advance(string $modifier): void
    {
        $this->current = $this->current->modify($modifier);
    }
}

final class InMemoryEnrollmentStore implements EnrollmentStore
{
    /** @var list<Enrollment> */
    private array $rows = [];

    public function add(Enrollment $enrollment): void
    {
        $this->rows[] = $enrollment;
    }

    public function countFor(int $courseId): int
    {
        $count = 0;
        foreach ($this->rows as $row) {
            if ($row->courseId === $courseId) {
                $count++;
            }
        }

        return $count;
    }

    public function has(int $courseId, string $student): bool
    {
        foreach ($this->rows as $row) {
            if ($row->courseId === $courseId && $row->student === $student) {
                return true;
            }
        }

        return false;
    }
}

// ---- 3. 작은 테스트 러너 ----

final class AssertionFailed extends Exception
{
}

function show(mixed $value): string
{
    return var_export($value, true);
}

function assertSame(mixed $expected, mixed $actual): void
{
    if ($expected !== $actual) {
        throw new AssertionFailed(sprintf('기대 %s, 실제 %s', show($expected), show($actual)));
    }
}

function assertTrue(bool $condition): void
{
    if (!$condition) {
        throw new AssertionFailed('true 를 기대했지만 false 였다');
    }
}

function assertThrows(string $class, callable $body, ?string $messagePart = null): void
{
    try {
        $body();
    } catch (Throwable $e) {
        if (!$e instanceof $class) {
            throw new AssertionFailed(sprintf('%s 를 기대했지만 %s 가 던져졌다', $class, $e::class));
        }
        if ($messagePart !== null && !str_contains($e->getMessage(), $messagePart)) {
            throw new AssertionFailed(sprintf('메시지에 %s 가 없다: %s', show($messagePart), show($e->getMessage())));
        }

        return;
    }

    throw new AssertionFailed($class . ' 를 기대했지만 예외가 없었다');
}

final class TinyRunner
{
    /** @var list<string> */
    private array $lines = [];
    private int $passed = 0;
    private int $failed = 0;

    public function test(string $name, callable $body): void
    {
        try {
            $body();
            $this->passed++;
            $this->lines[] = 'PASS  ' . $name;
        } catch (AssertionFailed $e) {
            $this->failed++;
            $this->lines[] = 'FAIL  ' . $name;
            $this->lines[] = '      ' . $e->getMessage();
        } catch (Throwable $e) {
            $this->failed++;
            $this->lines[] = 'ERROR ' . $name;
            $this->lines[] = '      ' . $e::class . ': ' . $e->getMessage();
        }
    }

    public function passed(): int
    {
        return $this->passed;
    }

    public function failed(): int
    {
        return $this->failed;
    }

    /** @return list<string> */
    public function lines(): array
    {
        return $this->lines;
    }

    public function report(): void
    {
        foreach ($this->lines as $line) {
            echo $line, "\n";
        }
        printf("%d개 중 %d개 통과, %d개 실패\n", $this->passed + $this->failed, $this->passed, $this->failed);
    }
}

// ---- 4. 검사 ----

function at(string $time): DateTimeImmutable
{
    return new DateTimeImmutable($time, new DateTimeZone('Asia/Seoul'));
}

/** @return array{FixedClock, InMemoryEnrollmentStore, EnrollmentService, Course} */
function fixture(string $now = '2025-03-03 09:00:00', int $capacity = 2): array
{
    $clock = new FixedClock(at($now));
    $store = new InMemoryEnrollmentStore();
    $service = new EnrollmentService($clock, $store);
    $course = new Course(1, '저녁 코딩 기초', $capacity, at('2025-03-07 18:00:00'));

    return [$clock, $store, $service, $course];
}

$runner = new TinyRunner();

$runner->test('마감 전 신청은 성공하고 저장소에 남는다', function (): void {
    [, $store, $service, $course] = fixture();

    $enrollment = $service->enroll($course, '김하늘');

    assertSame('김하늘', $enrollment->student);
    assertSame('2025-03-03 09:00', $enrollment->enrolledAt->format('Y-m-d H:i'));
    assertSame(1, $store->countFor($course->id));
});

$runner->test('마감 시각 정각까지는 신청할 수 있다', function (): void {
    [, $store, $service, $course] = fixture('2025-03-07 18:00:00');

    $service->enroll($course, '김하늘');

    assertTrue($store->has($course->id, '김하늘'));
});

$runner->test('마감 1초 뒤에는 거부한다', function (): void {
    [$clock, , $service, $course] = fixture('2025-03-07 18:00:00');
    $clock->advance('+1 second');

    assertThrows(DomainException::class, fn () => $service->enroll($course, '김하늘'), '신청 기간');
});

$runner->test('정원이 차면 거부한다', function (): void {
    [, $store, $service, $course] = fixture();
    $service->enroll($course, '김하늘');
    $service->enroll($course, '이도윤');

    assertThrows(DomainException::class, fn () => $service->enroll($course, '박서연'), '정원');
    assertSame(2, $store->countFor($course->id));
});

$runner->test('같은 학생의 중복 신청은 거부하고 저장하지 않는다', function (): void {
    [, $store, $service, $course] = fixture();
    $service->enroll($course, '김하늘');

    assertThrows(DomainException::class, fn () => $service->enroll($course, '김하늘'), '이미 신청');
    assertSame(1, $store->countFor($course->id));
});

$runner->test('거부된 신청은 저장소에 남지 않는다', function (): void {
    [, $store, $service, $course] = fixture('2025-03-08 10:00:00');

    assertThrows(DomainException::class, fn () => $service->enroll($course, '김하늘'), '신청 기간');
    assertSame(0, $store->countFor($course->id));
});

$runner->test('러너는 실패한 검사를 실패로 센다', function (): void {
    $inner = new TinyRunner();
    $inner->test('일부러 틀림', fn () => assertSame(3, 4));
    $inner->test('일부러 예외', fn () => throw new LogicException('boom'));
    $inner->test('통과', fn () => assertTrue(true));

    assertSame(1, $inner->passed());
    assertSame(2, $inner->failed());
    assertSame('FAIL  일부러 틀림', $inner->lines()[0]);
    assertSame('      기대 3, 실제 4', $inner->lines()[1]);
});

$runner->report();
exit($runner->failed() === 0 ? 0 : 1);

줄별 해설

도메인 부분. Clock 과 EnrollmentStore 는 서비스가 바깥 세계에 요구하는 약속이다. SystemClock 은 운영에서 쓰는 진짜 시계로, 이 파일의 검사는 일부러 쓰지 않는다. 실행 시각에 따라 결과가 달라지기 때문이다. Course 와 Enrollment 는 readonly 속성만 가진 값 묶음이다. 만든 뒤 바뀌지 않으므로 검사 중에 값이 몰래 변할 걱정이 없다.

enroll(). 첫 줄에서 시각을 한 번만 읽어 지역 변수에 담는다. 같은 호출 안에서 시각을 두 번 읽으면 그 사이에 값이 달라질 수 있기 때문이다. 마감은 > 로 비교하므로 마감 정각은 허용하고 1초라도 지나면 거부한다. 세 가지 거부 조건을 모두 확인한 뒤에야 add() 를 부른다. 그래서 거부된 신청은 저장소에 남지 않는다. 이 순서는 뒤의 마지막 두 검사가 확인한다.

대역 부분. FixedClock 은 생성자로 받은 시각을 그대로 돌려준다. advance() 는 modify() 가 돌려준 새 객체로 현재 값을 바꾼다. DateTimeImmutable 은 원본을 바꾸지 않으므로 반환값을 다시 대입해야 한다. InMemoryEnrollmentStore 는 배열에 쌓고 순회하며 센다. 속도는 중요하지 않고, 인터페이스의 약속을 지키는 것이 목적이다.

러너 부분. assertSame 은 var_export() 로 값을 문자열로 바꿔 메시지에 넣는다. 문자열은 따옴표가 붙어 나오므로 숫자 3 과 문자열 '3' 이 구분된다. assertThrows 에서 예외를 잡은 뒤의 throw 는 catch 안에 있으므로 같은 try 에 다시 잡히지 않는다. 마지막 throw 는 본문이 예외 없이 끝난 경우를 위한 것이다. TinyRunner::test() 는 AssertionFailed 를 먼저 잡고 그 밖의 Throwable 을 나중에 잡는다. 순서를 바꾸면 모든 실패가 오류로 분류된다. 출력은 곧바로 하지 않고 $lines 에 모아 두므로, 러너를 검사하는 검사가 소리 없이 러너를 하나 더 만들 수 있다.

검사 부분. at() 은 시간대를 Asia/Seoul 로 고정한다. 실행하는 컴퓨터의 기본 시간대에 결과가 좌우되면 안 되기 때문이다. fixture() 는 검사마다 시계, 저장소, 서비스, 강좌를 새로 만든다. 한 검사의 신청 내역이 다른 검사로 새지 않는다. 배열 구조 분해에서 [, $store, ...] 처럼 쓰는 빈 자리는 그 값을 쓰지 않겠다는 표시다. 둘째 검사는 마감 정각을 직접 시계에 넣고, 셋째 검사는 정각에서 1초를 보낸다. 경계값은 이렇게 양쪽에서 확인한다. 마지막 검사는 러너를 검사한다. 틀린 단언과 예상 밖의 예외가 모두 실패로 집계되는지 확인한다. 러너가 실패를 놓치면 다른 모든 통과 표시를 믿을 수 없기 때문이다.

끝의 exit 은 실패가 하나라도 있으면 종료 코드 1 을 돌려준다. 셸 스크립트나 자동화 도구가 테스트 성공 여부를 이 값으로 판단한다.

실행 결과

$ php main.php
PASS  마감 전 신청은 성공하고 저장소에 남는다
PASS  마감 시각 정각까지는 신청할 수 있다
PASS  마감 1초 뒤에는 거부한다
PASS  정원이 차면 거부한다
PASS  같은 학생의 중복 신청은 거부하고 저장하지 않는다
PASS  거부된 신청은 저장소에 남지 않는다
PASS  러너는 실패한 검사를 실패로 센다
7개 중 7개 통과, 0개 실패

실패했을 때의 모습도 확인해 두면 좋다. 서비스의 마감 비교를 >= 로 잘못 고쳤다고 하자. 그러면 마감 정각 검사에서 규칙이 정원 이전에 거부를 던지므로 다음과 같이 보고된다. 이 출력은 설명을 위한 예이며 위 실행 결과에는 나타나지 않는다.

ERROR 마감 시각 정각까지는 신청할 수 있다
      DomainException: 신청 기간이 끝났다
...
7개 중 6개 통과, 1개 실패

테스트 이름과 오류 메시지만 읽어도 어느 규칙이 깨졌는지 짐작할 수 있다. 이름을 "무엇을 하면 어떻게 된다" 형태로 쓰는 이유다.

실무에서 자주 틀리는 것

서비스 안에서 현재 시각을 직접 만든다

틀린 코드는 new DateTimeImmutable() 을 규칙 안에서 부른다. 테스트할 때 시각을 정할 방법이 없다.

final class EnrollmentService
{
    public function enroll(Course $course, string $student): void
    {
        if (new DateTimeImmutable() > $course->deadline) {
            throw new DomainException('신청 기간이 끝났다');
        }
    }
}

고친 코드는 시계를 생성자로 받는다.

final class EnrollmentService
{
    public function __construct(private Clock $clock)
    {
    }

    public function enroll(Course $course, string $student): void
    {
        if ($this->clock->now() > $course->deadline) {
            throw new DomainException('신청 기간이 끝났다');
        }
    }
}

느슨한 비교로 단언한다

!= 는 타입을 바꿔 비교하므로 서로 다른 값도 같다고 본다. 단언이 통과해서는 안 되는 경우에 통과한다.

function assertSame(mixed $expected, mixed $actual): void
{
    if ($expected != $actual) {   // '1e3' 과 '1000' 은 같다고 판단한다
        throw new AssertionFailed('다르다');
    }
}

고친 코드는 !== 를 쓴다. 타입까지 같아야 통과한다.

function assertSame(mixed $expected, mixed $actual): void
{
    if ($expected !== $actual) {
        throw new AssertionFailed(sprintf('기대 %s, 실제 %s', show($expected), show($actual)));
    }
}

검사들이 상태를 공유한다

저장소를 파일 맨 위에서 한 번만 만들면, 앞 검사가 넣은 신청 내역이 뒤 검사의 결과를 바꾼다. 검사 순서를 바꾸면 통과 여부도 바뀐다.

$store = new InMemoryEnrollmentStore();   // 모든 검사가 함께 쓴다

$runner->test('정원이 찼다', function () use ($store): void {
    // 앞 검사에서 넣은 학생이 이미 들어 있을 수 있다
});

고친 코드는 검사마다 fixture() 로 새로 만든다.

$runner->test('정원이 찼다', function (): void {
    [, $store, $service, $course] = fixture();
    // 이 검사만의 저장소이므로 다른 검사의 영향을 받지 않는다
});

예외 클래스만 확인한다

같은 DomainException 이라도 거부 이유는 셋이다. 클래스만 확인하면 의도와 다른 이유로 던져진 예외에도 통과한다.

// 정원 초과를 검사하려는데 실제로는 마감 때문에 거부되어도 통과한다
assertThrows(DomainException::class, fn () => $service->enroll($course, '박서연'));

고친 코드는 메시지의 일부를 함께 확인하고, 거부 뒤의 상태도 단언한다.

assertThrows(DomainException::class, fn () => $service->enroll($course, '박서연'), '정원');
assertSame(2, $store->countFor($course->id));

메시지 문자열을 여러 곳에서 비교하는 방식은 문구를 바꿀 때 검사가 함께 깨진다는 약점이 있다. 예외 클래스를 이유별로 나누는 방법은 예외 계층 설계에서 다룬다.

한눈에 보기

테스트하기 쉬운 코드를 만드는 요령
상황하는 일이 장의 예얻는 것
현재 시각을 읽는다인터페이스 뒤로 숨기고 주입한다Clock, FixedClock시각을 정해 두고 경계 확인
저장과 조회가 있다저장소 인터페이스로 분리한다InMemoryEnrollmentStore데이터베이스 없이 상태 확인
값이 같은지 본다!== 로 비교한다assertSame타입이 다른 값을 걸러냄
예외를 기대한다클래스와 메시지를 함께 본다assertThrows엉뚱한 이유의 통과 방지
검사가 여러 개다검사마다 새 대상을 만든다fixture()순서에 무관한 결과
러너의 세 가지 판정
판정조건의미
PASS본문이 예외 없이 끝남모든 단언이 맞았다
FAILAssertionFailed기대와 실제가 달랐다
ERROR그 밖의 Throwable검사 도중 예상하지 못한 예외가 났다

연습 문제

  1. 가짜 시계를 5일 보내면 마감이 지난다. advance('+5 days') 를 써서 "마감 5일 뒤 신청은 거부한다" 검사를 추가하라.
  2. 문자열에 부분 문자열이 들어 있는지 확인하는 단언 assertContains(string $needle, string $haystack): void 를 만들어라. 실패 메시지에는 두 값을 모두 담는다.
  3. 저장소를 감싸 add() 호출 횟수를 세는 스파이 CountingStore 를 만들고, 마감 뒤 신청이 거부되면 add() 가 0 번 불렸음을 확인하라.
  4. EnrollmentStore 에 신청 취소를 위한 remove(int $courseId, string $student): void 를 추가한다고 하자. 코드의 어느 부분을 고쳐야 하는지, 고치지 않으면 무슨 일이 생기는지 설명하라.

정답과 해설

1번. 시계만 앞으로 보내면 된다. 새로 만든 서비스나 저장소는 필요 없다.

$runner->test('마감 5일 뒤 신청은 거부한다', function (): void {
    [$clock, , $service, $course] = fixture();
    $clock->advance('+5 days');

    assertThrows(DomainException::class, fn () => $service->enroll($course, '김하늘'), '신청 기간');
});

시작 시각이 3월 3일 오전 9시이므로 5일 뒤는 3월 8일 오전 9시이고, 마감인 3월 7일 오후 6시를 지난다.

2번. 실패 메시지에 두 값을 show() 로 담으면 어떤 문자열에서 무엇을 찾았는지 알 수 있다.

function assertContains(string $needle, string $haystack): void
{
    if (!str_contains($haystack, $needle)) {
        throw new AssertionFailed(sprintf('%s 안에 %s 가 없다', show($haystack), show($needle)));
    }
}

str_contains() 는 빈 문자열 $needle 에 항상 true 를 돌려준다. 빈 문자열을 찾는 검사는 의미가 없으니 호출 쪽에서 피한다.

3번. 진짜 저장소를 안에 두고 호출을 전달하면서 횟수만 센다.

final class CountingStore implements EnrollmentStore
{
    public int $adds = 0;

    public function __construct(private EnrollmentStore $inner)
    {
    }

    public function add(Enrollment $enrollment): void
    {
        $this->adds++;
        $this->inner->add($enrollment);
    }

    public function countFor(int $courseId): int
    {
        return $this->inner->countFor($courseId);
    }

    public function has(int $courseId, string $student): bool
    {
        return $this->inner->has($courseId, $student);
    }
}

$runner->test('마감 뒤 거부되면 add 는 호출되지 않는다', function (): void {
    $store = new CountingStore(new InMemoryEnrollmentStore());
    $service = new EnrollmentService(new FixedClock(at('2025-03-08 10:00:00')), $store);
    $course = new Course(1, '저녁 코딩 기초', 2, at('2025-03-07 18:00:00'));

    assertThrows(DomainException::class, fn () => $service->enroll($course, '김하늘'));
    assertSame(0, $store->adds);
});

저장소의 현재 상태만 보는 검사로는 "저장했다가 지웠다"와 "처음부터 저장하지 않았다"를 구분할 수 없다. 호출 자체를 확인해야 할 때 스파이를 쓴다.

4번. 먼저 인터페이스에 remove() 선언을 추가한다. 그다음 이를 구현하는 모든 클래스, 즉 InMemoryEnrollmentStore, 연습 문제의 CountingStore, 운영용 저장소에 메서드를 만든다. 하나라도 빠뜨리면 그 클래스를 불러오는 시점에 추상 메서드가 구현되지 않았다는 치명적 오류(fatal error)가 난다. 이 오류는 해당 클래스가 선언될 때 발생한다. 이 장의 단일 파일에서는 파일을 읽는 즉시 터진다. 대역과 진짜 구현을 같은 인터페이스에 맞춰 두는 구조의 장점은 이런 누락을 실행 초기에 드러낸다는 것이다. 이어서 취소 규칙을 서비스에 만들고, 그 규칙을 검사하는 테스트를 같이 추가한다.

다음 장에서는 이 메모리 저장소를 PDO 기반 저장소로 바꾸고, 여러 쓰기를 트랜잭션으로 묶는 방법을 다룬다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.