바로 시작하는 Laravel Feature Test #1

undefcat·2021년 10월 29일
0

Laravel TDD

목록 보기
1/3
post-thumbnail

Laravel Feature Test

최근에 회사에서 새로운 서비스를 개발하게 되었습니다. 이번에는 TDD를 적용해보고 싶었는데요. 단위테스트까진 아니더라도, 라라벨에서 정의하는 Feature 테스트로 시작해보았는데, 너무나 만족스러워서 한 번 정리해보고자 합니다.

이 포스팅 시리즈에서 구현해볼 것은 다음과 같습니다.

  • 회원가입 기능
  • 로그인 기능
  • 게시글 CRUD
  • 게시글 관리
  • GatePolicy를 이용한 Authorization

뷰는 구현하지 않으며, 말 그대로 바로 따라서 시작해볼 수 있는 간단한 수준으로 구현을 해보고자 합니다. 저도 현재 공부하면서 처음으로 실무에 적용해보고 있고, 아직 기초적인 수준이라 앞으로 시행착오를 거치면서 경험을 쌓으면 나중에 좀 더 자세하게 포스팅을 작성해보도록 하겠습니다. (그 날을 위해!)

참고로, 이 포스팅은 라라벨에서 TDD를 빠르게 시작할 수 있는 방법을 설명하고 있기 때문에, 라라벨과 TDD의 개념을 알고 계셔야 합니다.

sail

saildocker를 이용한 라라벨 개발환경입니다. 이를 이용하여 진행하도록 하겠습니다.

$ curl -s "https://laravel.build/start-laravel-tdd-1" | bash

앞으로 포스팅이 진행되는 동안, ./vendor/bin/sail을 많이 실행해야 합니다. 따라서 이를 공식문서에서 설명하는 것처럼, alias를 주도록 합니다.

저는 MacOS 환경이고, iTerm2를 사용하고 있는데요. ~/.zshrc에서 ~/.bash_profile을 로드하도록 하고, alias~/.bash_profile에 설정하였습니다.

# ~/.zshrc

# ...
# ...

source ~/.bash_profile

# ~/.bash_profile

alias sail='[ -f sail ] && bash || bash vendor/bin/sail'

설정이 끝나면 source ~/.bash_profile을 이용하여 alias를 현재 터미널 세션에서 로드하도록 합니다.

이제, 프로젝트로 들어가서 시작하도록 합니다.

$ cd start-laravel-tdd-1
$ sail up -d

phpunit.xml 설정

프로젝트 루트에 있는 phpunit.xml파일에는 아래와 같이 데이터베이스 설정에 주석처리가 되어있습니다.

이 주석을 해제해줍니다. 그러면 테스트용 데이터베이스를 sqlite로 사용하는데, 메모리DB처럼 사용하게 됩니다.

회원가입

우선 테스트를 먼저 만들어보겠습니다. TDD에서 중요한 점은, 우선 테스트를 먼저 작성하고 그 뒤에 구현하는 것입니다. 테스트코드를 먼저 작성하게 되면 얻을 수 있는 몇가지 장점이 있는데

  • 테스트를 할 수 있는 코드로 작성하려면, 자연스럽게 고립된 객체를 구현하게 되어 불필요한 의존성을 가진 객체를 구현하지 않게 된다.
  • 객체 메서드 인터페이스를 좀 더 자연스럽게 정의할 수 있다.
  • 테스트에 동작하는 코드를 작성하게 되므로, 불필요한 생각이 들기 전에 필요한 코드만 작성하게 되어 좀 더 간결해진다.

이러한 장점들은 단위 테스트에서 좀 더 두드러지게 나타나게 됩니다. 아무튼, 우선 회원가입 성공 테스트 코드를 먼저 작성해보도록 하겠습니다.

$ sail artisan make:test UserSignUpTest
Test created successfully.
// tests/Feature/UserSignUpTest.php
<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class UserSignUpTest extends TestCase
{
    /**
     * A basic feature test example.
     *
     * @return void
     */
    public function test_example()
    {
        $response = $this->get('/');

        $response->assertStatus(200);
    }
}

위와 같이 테스트 파일이 생성되었습니다. RefreshDatabase Trait를 사용해야 하는데, 이는 각 테스트가 실행될 때마다 데이터베이스를 초기화해주는 역할을 합니다. 각 테스트 코드들은 다른 테스트 코드에 의존적이지 않아야 하므로, 이는 반드시 필요합니다.

우선 이 Trait를 사용합니다.

기본 템플릿에는 use RefreshDatabase; 값이 없으므로, 앞으로 테스트 파일을 생성할 때마다 이를 적어줘야 합니다. 매번 하기 귀찮으므로, 이를 템플릿에 추가하도록 하겠습니다.

$ sail artisan stub:publish
Stubs published successfully.

artisan 명령어로 생성되는 각종 파일들의 기본 템플릿을 변경할 수 있습니다. stubs/test.stub 파일을 열고 수정해줍니다.

회원가입 성공

회원가입 성공 테스트부터 작성해보도록 하겠습니다.

// tests/Feature/UserSignUpTest.php

<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Symfony\Component\HttpFoundation\Response;
use Tests\TestCase;

class UserSignUpTest extends TestCase
{
    use RefreshDatabase;

    private const URL = '/api/users/sign-up';

    public function test_회원가입_201_성공()
    {
        $formData = [
            'email' => 'email@email.com',
            'name' => 'name',
            'password' => 'password',
            'password_confirmation' => 'password',
        ];

        $response = $this->postJson(self::URL, $formData);

        $response->assertStatus(Response::HTTP_CREATED);

        $isExist = User::where('email', '=', $formData['email'])
            ->count() > 0;

        $this->assertTrue($isExist);
    }
}

회원가입은 간단하게 이메일과 비밀번호만 받도록 하겠습니다. 바로 테스트코드를 실행하면, 실패할 것입니다.

$ sail test --filter UserSignUpTest

결과가 404입니다. 당연합니다. 아직 저희는 라우트도, 컨트롤러도 정의하지 않았기 때문입니다. 이제, 만들어보겠습니다.

$ sail artisan make:controller UserController
Controller created successfully.

signUp 컨트롤러 메서드를 먼저 정의하도록 하겠습니다.

// app/Http/Controllers/UserController.php

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\Response;

class UserController extends Controller
{
    public function signUp(Request $request)
    {
        $rules = [
            'email' => ['required', 'email'],
            'name' => ['required'],
            'password' => ['required', 'confirmed'],
        ];

        $validator = Validator::make($request->all(), $rules);
        if ($validator->fails()) {
            return response()->json([
                'error' => true,
                'messages' => $validator->errors(),
            ], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        $data = $validator->validate();

        // Service

        $user = new User();

        $user->email = $data['email'];
        $user->name = $data['name'];
        $user->password = bcrypt($data['password']);

        $user->save();

        return response()->json(null, Response::HTTP_CREATED);
    }
}

저는 실제로 현재 진행중인 프로젝트에서도, 컨트롤러 메서드 안에 모든 코드를 다 넣고 있습니다. 지금은 빠른 MVP를 만들고 있고, 최초 단계에서는 정말 아주 기본적인 CRUD만을 수행하는 어플리케이션이기 때문에 이렇게 하기로 결정했습니다.

다만 그렇다고 마구자비로 코드를 작성하는 것은 아닙니다. 코드 안에서도 레이어를 나눠서 작성하여 나중에 리팩토링 하기 쉽도록 배치하고 있습니다.

저는 웹MVC에서 컨트롤러의 역할은 HTTP요청어플리케이션 요청으로 변환하는 역할을 수행한다고 생각하고 있습니다. 따라서 나중에 컨트롤러는 어플리케이션 서비스를 호출하는 클라이언트가 되고, 그에 따라 해야될 역할은

  • Authorization 처리
  • 요청 데이터의 물리적 검증
  • 어플리케이션 레이어를 위한 데이터 변환

위와 같은 일들을 컨트롤러가 해야 할 일로 생각하고 있기 때문에, 나중에 어플리케이션 서비스 레이어로 추출될 부분에는 컨트롤러에서 처리해야할 코드들이 들어가 있으면 안됩니다.

아무튼, 이렇게 컨트롤러 메서드를 가장 간단하게 구현했으니 라우트에 추가해줍니다.

// routes/api.php

<?php

use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;

Route::post('/users/sign-up', [UserController::class, 'signUp']);

이제 다시 테스트 코드를 실행해보겠습니다.

$ sail test --filter UserSignUpTest

PASS 됐습니다!

이메일 중복 회원가입 실패 테스트

이메일은 유일해야합니다. 이메일이 중복되는 경우, 회원가입이 실패하는 테스트를 작성해보겠습니다.

<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Symfony\Component\HttpFoundation\Response;
use Tests\TestCase;

class UserSignUpTest extends TestCase
{
    use RefreshDatabase;

    private const URL = '/api/users/sign-up';

    public function test_회원가입_201_성공()
    {
    	// ...
    }

    public function test_회원가입_이메일_중복_422_실패()
    {
        $user = User::factory()->create();

        $formData = [
            'email' => $user->email,
            'name' => 'name',
            'password' => 'password',
            'password_confirmation' => 'password',
        ];

        $response = $this->postJson(self::URL, $formData);

        $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
    }
}

409 CONFLICT 에러를 발생시킬 수도 있지만, 저는 어떤 리소스에 대한 오류들은 그냥 422로 처리하는 편입니다. 테스트를 실행시켜보면

길고 긴 Stack trace값 위에 오류를 보면 아시겠지만, 데이터베이스에서 unique 제약이 걸려있는 이메일 값을 중복해서 넣었기 때문에 데이터베이스에서 예외가 발생했고, 이에 따라 500 에러가 발생한 것입니다.

이런 데이터의 중복은 물리적 검증이 아닌 논리적 검증이므로, 어플리케이션 레이어에서 처리하도록 코드를 수정하겠습니다.

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\Response;

class UserController extends Controller
{
    public function signUp(Request $request)
    {
        // ...
        
        $data = $validator->validate();

        // Service

        $isExistUser = User::where('email', '=', $data['email'])
            ->count() > 0;

        // 이 부분은 나중에 서비스에서 예외를 던지도록 구현하고
        // 컨트롤러에서 예외를 잡거나
        // 전역 예외처리기를 통해 처리하도록 수정해야한다.
        if ($isExistUser) {
            return response()->json([
                'error' => true,

                // 이 에러메세지의 형식은
                // 라라벨의 $validator->errors()로 얻어지는
                // MessageBag의 형태이므로
                // 이를 통일하기 위해 이런 구조를 갖게 한다.
                'messages' => [
                    'email' => ['이미 존재하는 이메일입니다.'],
                ],
            ], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        $user = new User();

        // ...
    }
}

위와 같이 이메일이 이미 존재하는지 확인하도록 합니다. 이제 다시 테스트를 돌려보겠습니다.

PASS 됐습니다!

정리

위의 테스트 말고도, 각 폼 데이터의 validation rule들에 따른 유효성 검증 코드도 하나씩 다 작성해봐야 합니다. 예를 들어, 현재 제가 개발중인 서비스에서는 회원가입에 대해서 아래와 같은 테스트 코드들이 있습니다.

<?php

// ...

class UserJoinTest extends TestCase
{
    use RefreshDatabase;

    private const URL = '...';

    public function test_회원가입_201_성공(): void
    {
        // ...
    }

    public function test_회원가입_이메일_필수값_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_이메일_중복_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_이메일_형식_오류_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_이메일_글자수_제한_5_미만_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_이메일_글자수_제한_60_초과_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_비밀번호_필수값_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_비밀번호_글자수_제한_4_미만_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_비밀번호_글자수_제한_20_초과_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_비밀번호_확인_필수값_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_비밀번호_확인_다름_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_이름_필수값_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_이름_글자수_제한_2_미만_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_이름_글자수_제한_30_초과_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_닉네임_필수값_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_닉네임_글자수_제한_2_미만_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_닉네임_글자수_제한_30_초과_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_연락처_필수값_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_연락처_글자수_제한_6_미만_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_연락처_글자수_제한_60_초과_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_성별_필수값_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_성별_글자수_제한_1_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_성별_형식_M_또는_W_아님_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_생년월일_필수값_422_실패(): void
    {
        // ...
    }

    public function test_회원가입_생년월일_형식_오류_422_실패(): void
    {
        // ...
    }

    // ...
}

테스트 코드에서도 공통되는 코드 부분을 따로 메서드로 추출하면, 반복적인 작업을 줄일 수 있습니다.

앞으로 남은 포스팅에서는

  • 게시글 CRUD
  • 로그인 기능 구현
  • 게시글 CRUD에 권한 부여하여 코드 수정

위의 과정을 거치면서 시리즈를 마무리 해 나가보도록 하겠습니다.

profile
undefined cat

0개의 댓글