Skip to main content
Summary

테스트는 개발 속도를 늦추는 도구가 아니다

지금까지 게시판 애플리케이션은 꽤 많은 일을 하게 되었다. 사용자는 글을 보고, 로그인한 사용자는 글을 작성하고, 작성자만 수정하거나 삭제할 수 있다. 컨트롤러는 모델을 사용하고, 모델은 데이터베이스와 연결되며, 일부 로직은 서비스 클래스로 분리되기 시작했다.

이 단계부터는 작은 수정도 불안해진다. 검증 규칙을 바꿨는데 글 저장이 깨질 수 있다. 권한 검사를 정리했는데 다른 사용자의 수정 요청이 통과할 수 있다. 서비스 클래스를 리팩터링했는데 컨트롤러가 더 이상 기대한 값을 받지 못할 수 있다.

테스트는 이 불안감을 줄이는 장치다. 테스트가 모든 버그를 막아 주지는 않는다. 대신 중요한 흐름이 계속 동작하는지 반복해서 확인해 준다. 특히 배포 전과 리팩터링 중에 효과가 크다. 사람이 브라우저에서 매번 목록, 작성, 검증 실패, 수정, 삭제를 확인하는 대신, 테스트가 같은 요청을 빠르게 실행하고 결과를 비교한다.

Laravel 프로젝트에는 테스트를 작성하고 실행할 수 있는 구조가 기본으로 들어 있다. tests/Feature는 라우트, 미들웨어, 컨트롤러, 모델, 데이터베이스가 함께 움직이는 기능 테스트를 두는 곳이다. tests/Unit은 하나의 계산 로직이나 서비스 메서드처럼 작은 단위를 빠르게 확인하는 곳이다. Laravel은 Pest와 PHPUnit을 모두 사용할 수 있으며, phpunit.xml을 통해 테스트 환경도 미리 구성한다.1

테스트 실행은 보통 다음 명령으로 시작한다.

php artisan test

필요하면 특정 테스트 묶음만 실행할 수 있다.

php artisan test --testsuite=Feature --stop-on-failure

테스트 파일은 Artisan 명령으로 만들 수 있다.

php artisan make:test PostFeatureTest
php artisan make:test ReadingTimeCalculatorTest --unit

프로젝트를 만들 때 Pest를 선택했다면 같은 테스트도 Pest 문법으로 작성할 수 있다. PHPUnit을 쓰더라도 핵심은 같다. 요청을 보내고, 응답을 확인하고, 데이터베이스 상태를 검증한다. 이 장의 예제는 짧고 읽기 쉬운 Pest 문법을 사용한다.

테스트로 안전하게 개발하기AI 생성 이미지

Feature 테스트로 사용자의 흐름 확인하기

Feature 테스트는 사용자가 실제로 브라우저에서 하는 행동을 코드로 표현한다. 예를 들어 게시글 목록에 접속한다. 로그인하지 않고 작성 화면에 들어가 본다. 잘못된 폼 값을 보낸다. 올바른 값을 보내고 데이터베이스에 저장됐는지 확인한다.

테스트가 데이터베이스를 사용한다면 RefreshDatabase를 함께 사용하는 것이 안전하다. 이 기능은 테스트가 만든 데이터가 다음 테스트에 영향을 주지 않도록 데이터베이스 상태를 정리한다. 또한 Factory를 사용하면 테스트에 필요한 사용자나 게시글을 매번 긴 배열로 만들지 않아도 된다.2

먼저 게시글 목록이 정상적으로 보이는지 확인해 보자.

<?php

use AppModelsPost;
use AppModelsUser;
use IlluminateFoundationTestingRefreshDatabase;

uses(RefreshDatabase::class);

it('shows the post list', function () {
    $user = User::factory()->create();

    Post::factory()
        ->for($user)
        ->create([
            'title' => '테스트로 작성한 글',
        ]);

    $this->get(route('posts.index'))
        ->assertOk()
        ->assertSee('테스트로 작성한 글');
});

이 테스트는 세 가지를 확인한다. 첫째, posts.index 라우트가 정상 응답을 돌려준다. 둘째, 컨트롤러가 데이터베이스에서 게시글을 조회한다. 셋째, Blade 화면에 제목이 출력된다. 라우트, 컨트롤러, 모델, 뷰가 한 번에 연결되어야 통과하는 테스트다.

이제 인증이 필요한 흐름을 확인하자. 게시판 예제에서 작성 화면과 저장 요청에 auth 미들웨어를 적용했다면, 로그인하지 않은 사용자는 작성 화면으로 바로 들어갈 수 없어야 한다.

it('requires login to open the create form', function () {
    $this->get(route('posts.create'))
        ->assertRedirect(route('login'));
});

프로젝트에 로그인 라우트 이름이 없다면 assertRedirect('/login')처럼 실제 경로에 맞춰 작성하면 된다. 중요한 것은 "로그인하지 않은 사용자가 보호된 기능에 접근할 수 없는가"를 테스트로 고정하는 것이다.

폼 검증 실패도 반드시 테스트해야 한다. 브라우저의 required 속성은 편의를 위한 안내일 뿐이다. 서버는 사용자가 보낸 값을 다시 검증해야 한다.

it('rejects invalid post data', function () {
    $user = User::factory()->create();

    $this->actingAs($user)
        ->post(route('posts.store'), [
            'title' => '',
            'body' => '제목이 없는 본문입니다.',
        ])
        ->assertSessionHasErrors('title');

    $this->assertDatabaseMissing('posts', [
        'body' => '제목이 없는 본문입니다.',
    ]);
});

actingAs($user)는 테스트 요청을 로그인한 사용자로 실행하게 한다. 실제 로그인 화면을 거치지 않아도 세션에 인증 상태가 있는 것처럼 요청을 보낼 수 있다. 이 테스트는 제목이 비어 있을 때 검증 오류가 생기고, 잘못된 데이터가 데이터베이스에 저장되지 않는지 확인한다.

성공 흐름도 필요하다.

it('stores a post for the logged-in user', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user)
        ->post(route('posts.store'), [
            'title' => '저장 성공 글',
            'body' => '테스트가 확인한 본문입니다.',
        ]);

    $response->assertRedirect(route('posts.index'));

    $this->assertDatabaseHas('posts', [
        'title' => '저장 성공 글',
        'user_id' => $user->id,
    ]);
});

여기서 핵심은 user_id다. 사용자가 폼에서 user_id를 보내지 않아도 서버가 현재 로그인한 사용자를 기준으로 작성자를 저장해야 한다. 테스트는 이 규칙을 문서처럼 남긴다.

권한 테스트도 같은 방식으로 작성할 수 있다. 다른 사용자가 내 글을 수정할 수 없어야 한다면 다음 흐름을 고정한다.

it('blocks update by another user', function () {
    $owner = User::factory()->create();
    $intruder = User::factory()->create();

    $post = Post::factory()
        ->for($owner)
        ->create([
            'title' => '원래 제목',
        ]);

    $this->actingAs($intruder)
        ->patch(route('posts.update', $post), [
            'title' => '바뀌면 안 되는 제목',
            'body' => '권한이 없는 수정 요청입니다.',
        ])
        ->assertForbidden();

    $this->assertDatabaseHas('posts', [
        'id' => $post->id,
        'title' => '원래 제목',
    ]);
});

화면에서 수정 버튼을 숨기는 것만으로는 부족하다. 악의적인 사용자는 직접 PATCH 요청을 보낼 수 있다. Feature 테스트는 그런 요청까지 서버가 막는지 확인한다.

Unit 테스트로 작은 로직을 빠르게 검증하기

모든 테스트가 HTTP 요청일 필요는 없다. 계산, 분기, 문자열 가공처럼 프레임워크 전체가 필요 없는 로직은 Unit 테스트가 더 적합하다. Unit 테스트는 보통 데이터베이스나 세션을 사용하지 않는다. 그래서 빠르고 실패 원인을 찾기 쉽다.

예를 들어 게시글 본문을 기준으로 예상 읽기 시간을 계산하는 서비스가 있다고 하자.

<?php

namespace AppServices;

class ReadingTimeCalculator
{
    public function minutes(string $body, int $wordsPerMinute = 400): int
    {
        $plainText = trim(strip_tags($body));

        if ($plainText === '') {
            return 1;
        }

        $words = preg_split('/s+/u', $plainText, -1, PREG_SPLIT_NO_EMPTY);

        return max(1, (int) ceil(count($words) / $wordsPerMinute));
    }
}

이 로직을 확인하기 위해 라우트나 컨트롤러를 실행할 필요는 없다. 서비스 객체를 직접 만들고 결과만 검증하면 된다.

<?php

use AppServicesReadingTimeCalculator;

it('returns at least one minute', function () {
    $calculator = new ReadingTimeCalculator();

    expect($calculator->minutes('짧은 글'))->toBe(1);
});

it('rounds up reading time', function () {
    $calculator = new ReadingTimeCalculator();
    $body = implode(' ', array_fill(0, 401, 'word'));

    expect($calculator->minutes($body, 400))->toBe(2);
});

Unit 테스트는 이런 질문에 답한다.

  • 입력이 비어 있으면 어떤 기본값을 반환하는가?

  • 경계값에서 올림 처리가 맞는가?

  • 특정 조건에서 어떤 분기로 이동하는가?

  • 외부 상태 없이 같은 입력에 항상 같은 결과를 내는가?

반대로 라우트 응답, 로그인 상태, 데이터베이스 저장 여부를 확인하려면 Feature 테스트를 사용한다. 두 테스트의 역할을 섞지 않는 것이 중요하다.

테스트하기 어려운 코드는 구조를 알려 준다

테스트를 작성하다 보면 유난히 다루기 어려운 코드가 보인다. 대개 이런 특징을 가진다.

  • 컨트롤러 메서드 하나가 검증, 권한, 계산, 저장, 외부 API 호출, 응답 생성을 모두 처리한다.

  • 메서드 안에서 new로 외부 클라이언트를 직접 만든다.

  • 현재 시간, 랜덤 값, 인증 사용자, HTTP 요청, 데이터베이스가 계산 로직 안에 뒤섞여 있다.

  • 메일 발송, 큐 등록, 파일 저장 같은 부작용이 테스트 중에도 실제로 실행될 수 있다.

예를 들어 게시글 저장 과정에서 외부 서비스로 본문 점수를 계산한다고 하자. 아래 코드는 동작할 수는 있지만 테스트하기 어렵다.

public function store(Request $request)
{
    $validated = $request->validate([
        'title' => ['required', 'string', 'max:255'],
        'body' => ['required', 'string'],
    ]);

    $client = new ExternalContentScoreClient(config('services.score.key'));
    $score = $client->score($validated['body']);

    $post = $request->user()->posts()->create([
        ...$validated,
        'score' => $score,
    ]);

    return redirect()->route('posts.show', $post);
}

이 코드는 테스트할 때 외부 점수 서비스가 필요하다. 네트워크가 느리거나 API 키가 없으면 테스트도 실패한다. 문제는 점수 계산 서비스 자체가 아니라, 컨트롤러가 외부 객체를 만드는 방식까지 알고 있다는 점이다.

지난 장에서 배운 의존성 주입을 사용하면 경계를 나눌 수 있다.

interface ContentScoreClient
{
    public function score(string $body): int;
}
use AppModelsPost;
use AppModelsUser;

class StorePostAction
{
    public function __construct(
        private ContentScoreClient $scoreClient,
    ) {
    }

    public function execute(User $author, array $data): Post
    {
        $score = $this->scoreClient->score($data['body']);

        return $author->posts()->create([
            ...$data,
            'score' => $score,
        ]);
    }
}

실제 서비스에서는 ContentScoreClient 인터페이스를 HTTP 구현체에 연결한다. 테스트에서는 가짜 구현체로 바꿔 끼운다.

$this->app->instance(ContentScoreClient::class, new class implements ContentScoreClient {
    public function score(string $body): int
    {
        return 80;
    }
});

이제 Feature 테스트는 외부 API를 호출하지 않고도 저장 흐름을 확인할 수 있다. 컨트롤러는 요청을 받고 검증한 뒤 액션에 일을 맡긴다. 액션은 게시글 생성 규칙을 담당한다. 외부 점수 서비스는 인터페이스 뒤에 숨는다. 테스트하기 쉬워졌다는 것은 책임이 더 분명해졌다는 뜻이다.

단, 모든 코드를 무조건 인터페이스로 감쌀 필요는 없다. 단순한 Post::query() 조회나 한 줄짜리 저장 로직까지 억지로 분리하면 오히려 읽기 어려워진다. 테스트가 자주 깨지거나, 외부 시스템 때문에 느리거나, 분기가 많아서 실수하기 쉬운 부분부터 분리하면 충분하다.

배포 전 확인할 최소 테스트 목록

처음부터 완벽한 테스트 묶음을 만들려고 하면 오래가지 못한다. 게시판 애플리케이션이라면 다음 순서로 시작하는 편이 현실적이다.

  1. 공개 페이지가 정상 응답을 반환하는지 확인한다.

  2. 로그인이 필요한 페이지가 게스트를 막는지 확인한다.

  3. 폼 검증 실패 시 오류가 세션에 담기는지 확인한다.

  4. 폼 검증 성공 시 데이터베이스에 올바른 값이 저장되는지 확인한다.

  5. 다른 사용자의 수정과 삭제 요청이 403으로 차단되는지 확인한다.

  6. 계산이나 분기가 있는 서비스 클래스는 Unit 테스트로 확인한다.

버그가 발견되면 바로 코드를 고치기 전에 가능하면 실패하는 테스트를 먼저 만든다. 그리고 코드를 수정해 테스트가 통과하게 한다. 이렇게 하면 같은 버그가 다시 들어왔을 때 테스트가 먼저 알려 준다.

테스트는 별도의 세계가 아니다. 라우트, 미들웨어, 컨트롤러, Eloquent, 서비스 컨테이너가 제대로 연결됐는지 반복해서 확인하는 방법이다. 수동 확인은 눈앞의 한 번을 안심시킨다. 테스트는 다음 수정과 다음 배포까지 안심의 범위를 넓힌다.

참고

1Laravel Testing: Getting Started

2Laravel Database Testing

책 목록

라라벨 학습서: 웹 기초부터 배포까지 배우는 Laravel 실전 입문

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기