바로 시작하는 Laravel Feature Test #3

undefcat·2021년 10월 31일
0

Laravel TDD

목록 보기
3/3
post-thumbnail

Laravel Feature Test

이번 포스팅에서는 로그인 기능과 관리자 회원의 권한기능을 구현하고, 마무리하도록 하겠습니다.

Login

이제는 어떤 기능을 구현하고자 할 때, 테스트를 먼저 작성해야겠다는 생각이 듭니다. 바로 만들어봅니다.

$ sail artisan make:test LoginTest
Test created successfully.
// tests/Feature/LoginTest.php

<?php

namespace Tests\Feature;

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

class LoginTest extends TestCase
{
    use RefreshDatabase;

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

    public function test_사용자_로그인_204_성공()
    {
        $user = User::factory()->create();

        $credential = [
            'email' => $user->email,
            'password' => 'password',
        ];

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

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

컨트롤러를 구현하고 라우트를 설정해야겠죠? 아직은 어플리케이션이 크지 않으니, 사용자 관련 기능은 UserController에 구현하겠습니다.

// app/Http/Controllers/UserController.php

<?php

namespace App\Http\Controllers;

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

class UserController extends Controller
{
    public function signUp(Request $request)
    {
        // ...
    }

    public function signIn(Request $request)
    {
        $rules = [
            'email' => ['required', 'email'],
            'password' => ['required'],
        ];

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

        $credential = $validator->validated();

        // Service

        if (!Auth::attempt($credential)) {
            return response()->json([
                'error' => true,
                'messages' => [
                    'login' => ['이메일이 잘못되었거나, 비밀번호가 잘못되었습니다.'],
                ],
            ], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

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

컨트롤러에 로그인 기능을 구현하다 보니, 아이디와 비밀번호가 잘못된 경우에도 오류가 발생할 수 있다는 것을 알게 되었습니다. 테스트에 바로 추가해줍니다.

<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Str;
use Illuminate\Testing\Fluent\AssertableJson;
use Symfony\Component\HttpFoundation\Response;
use Tests\TestCase;

class LoginTest extends TestCase
{
    use RefreshDatabase;

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

    public function test_사용자_로그인_204_성공()
    {
        $user = User::factory()->create();

        $credential = [
            'email' => $user->email,
            'password' => 'password',
        ];

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

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

    public function test_사용자_로그인_존재하지_않는_계정_422_실패()
    {
        $credential = [
            'email' => Str::random(10).'@email.com',
            'password' => 'password',
        ];

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

        $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
        $response->assertJson(fn (AssertableJson $json) =>
            $json
                ->has('messages.login')
                ->etc()
        );
    }

    public function test_사용자_로그인_잘못된_비밀번호_422_실패()
    {
        $user = User::factory()->create();

        $credential = [
            'email' => $user->email,
            'password' => 'nope',
        ];

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

        $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
        $response->assertJson(fn (AssertableJson $json) =>
            $json
                ->has('messages.login')
                ->etc()
        );
    }
}

존재하지 않는 계정으로 로그인했을 때와, 계정은 존재하지만 비밀번호는 틀렸을 때 모두를 테스트해야 합니다.

assertJson에 대한 자세한 정보는 역시 공식문서에서 확인해주시기 바랍니다.

권한정보 생성

이제, 사용자에 권한정보를 설정해보도록 하겠습니다. 어떤 시스템이든 권한은 정말로 중요한데요. 라라벨에서는 Guard라는 기능이 있는데, 이는 인증체계를 아예 다르게 가져갈 수 있는 기능입니다.

라라벨의 기본 Guardweb이고, 이는 세션기반으로 동작합니다.

// config/auth.php

<?php

return [
    // ....
    
    'defaults' => [
        'guard' => 'web',
        'passwords' => 'users',
    ],

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
    ],
    
    // ....
];

자세한 정보는 공식문서에서 확인하실 수 있습니다.

일반사용자 계정과 관리자 계정을 아예 구분해야 한다면 애초에 인증체계부터 나눌 필요가 있습니다. 이 포스팅에서는 그렇게까지 하진 않겠습니다. 다같은 사용자지만, 특정 사용자에게 관리 권한이 부여되는 상황을 가정하겠습니다.

그렇다면 사용자에게 권한 정보를 줄 필요가 있습니다. 일반적으로 권한에 관한 기능은 RolePrivilege라는 속성으로 구현을 하는데요. 스프링에서도 이렇게 구현하는 것으로 알고 있습니다. (참고 사이트)

이 포스팅에서는 Role만 구현하도록 하겠습니다.

Role

$ sail artisan make:model Role -m
Model created successfully.
Created Migration: 2021_10_31_031425_create_roles_table

$ sail artisan make:factory RoleFactory
Factory created successfully.

바로 테스트 코드를 작성합니다. 게시글을 삭제할 때, 관리자 권한이 있는 경우에도 삭제할 수 있도록 합니다.

// tests/Feature/ArticleTest.php

// ...

class ArticleTest extends TestCase
{
    // ...
    
    public function test_게시글_삭제_관리자_204_성공()
    {
        $admin = User::factory()
            ->has(Role::factory()->admin())
            ->create();

        $article = Article::factory()
            ->for(User::factory())
            ->create();

        $response = $this
            ->actingAs($admin)
            ->deleteJson(self::URL."/{$article->id}");

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

        $found = Article::find($article->id);
        $this->assertEmpty($found);
    }
}

UserRole의 관계가 필요합니다. 일반적으로 하나의 사용자는 여러 Role을 가질 수 있고, 하나의 Role역시 여러 User와 관계되어 있으므로 M:N으로 설정해야 합니다. 이를 위해 릴레이션 테이블을 만들어줍니다.

M:N관계를 위한 테이블을 생성할 때, 라라벨의 기본 네이밍 규칙(사전순)이 존재하는데 UserRole의 관계의 경우, role_user로 테이블을 생성해야 하지만 저는 user_role로 생성하겠습니다.

$ sail artisan make:migration create_user_role_table --create=user_role
Created Migration: 2021_10_31_101823_create_user_role_table

이제 모델들과 테이블의 스키마를 다 정의합니다. 이 때, Role과 릴레이션 테이블에는 굳이 created_at 정보와 updated_at 정보가 필요없기 때문에, 제거하였습니다. 이렇게 제거하면 Role 모델에서 $timestamps 프로퍼티를 false로 오버라이드를 해야합니다.

// app/Models/User.php

<?php

namespace App\Models;

use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    // ....
    
    public function roles()
    {
        return $this->belongsToMany(Role::class, 'user_role');
    }
}

// app/Models/Role.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Role extends Model
{
    use HasFactory;

    public $timestamps = false;
    
    public function users()
    {
        return $this->belongsToMany(User::class, 'user_role');
    }
}

// database/migrations/2021_10_31_031425_create_roles_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateRolesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('roles', function (Blueprint $table) {
            $table->id();
            $table->string('name');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('roles');
    }
}


// database/migrations/2021_10_31_101823_create_user_role_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateUserRoleTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('user_role', function (Blueprint $table) {
            $table->id();
            $table->bigInteger('user_id')->unsigned();
            $table->bigInteger('role_id')->unsigned();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('user_role');
    }
}

RoleFactory에서 관리자 권한을 가진 state를 설정하는 메서드를 정의합니다.

// database/Factories/RoleFactory.php

<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

class RoleFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array
     */
    public function definition()
    {
        return [
            'name' => 'normal',
        ];
    }

    public function admin()
    {
        return $this->state(fn (array $attribute) => [
            'name' => 'admin',
        ]);
    }
}

여기까지 설정하고 테스트를 실행해보겠습니다.

403 에러가 발생하네요. 403 에러가 발생한다는 뜻은, 관리자 권한을 체크하지 않는다는 뜻입니다. ArticlePolicy에서 권한을 검사하도록 합니다.

이 때, Useradmin Role을 갖고 있는지 검사하는 코드를 작성해야하므로, 이를 User 모델의 메서드로 추가해줍니다.

// app/Modles/User.php

<?php

// ...

class User extends Authenticable
{
    // ....
    
    public function isAdmin()
    {
        return $this->roles()
            ->where('name', '=', 'admin')
            ->count() > 0;
    }

// app/Policies/ArticlePolicy.php

<?php

namespace App\Policies;

use App\Models\Article;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;

class ArticlePolicy
{
    use HandlesAuthorization;

    public function before(User $user, string $ability)
    {
        if ($user->isAdmin()) {
            return true;
        }

        return null;
    }

    public function destroy(User $user, Article $article)
    {
        return (string)$user->id === (string)$article->user_id;
    }
}

beforeArticlePolicy의 권한 검사 전, 가장 먼저 수행되는 메서드입니다. 라라벨에서는 이를 Policy Filter라고 합니다. 이제, 테스트를 다시 돌려보겠습니다.

정리

지금까지 라라벨에서 어떻게 Feature Test를 진행하는지 그 기본 방법을 알아보았습니다. TDD를 도입하면서 느낀 점은, 우선 개발이 평소보다 더 즐겁다는 것입니다.

테스트 코드를 작성하고, 실패하고, 기능을 구현하고, 성공하는 사이클에서 저희가 추구하는 즐거움, 즉 어떤 것이 동작할 때의 그 희열을 빠르게 느낄 수 있어서 그런지 TDD없이 그냥 기능을 단순히 구현할 때보다 훨씬 더 즐거웠던 것 같습니다.

또한 새로운 기능을 추가하거나 어떤 기능을 변경할 때 테스트 코드가 있을 때의 그 안정감은 직접 경험해보지 않으면 말로만 들었을 때에는 그게 어떤 안정감인지 잘 와닿지 않는 것 같습니다.

예를 들어, 회원가입시 새로운 정보를 받도록 테이블에 컬럼을 추가하고 Validation 규칙에도 해당 필드를 추가하였는데, 해당 값을 모델에는 할당하지 않았습니다. 테스트를 실행하니 당연하게도 FAIL이 나왔고, 그제서야 해당 값을 넣는 코드를 작성하지 않았다는 것을 깨닫게 되었습니다.

이런 테스트 코드 없이 개발했을 때에는 기능을 구현하면 다시 회원가입할 때 필드를 채우고 가입을 해보고 하는 일을 했을 것이고, 단순한 기능을 추가했다고 생각하여 아마 테스트를 안했을 수도 있었을 겁니다.

개발을 하다보면 당연하게 빼먹는 일들이 있을 수 있는데, 테스트 코드가 있으니 기능 변경에도 버그를 줄일 수 있겠구나 하는 생각이 들었습니다.

라라벨은 생각보다 테스트 환경이 너무나도 잘 되어있고, 단위 테스트까진 아니더라도 Feature 테스트로 가볍게 시작하는 것도 좋을 것이라는 생각이 듭니다.

이번에 새로운 서비스를 개발하면서 점진적으로 단위 테스트도 도입해야겠다는 생각이 들었고, 기존에 진행했던 프로젝트들에도 테스트를 추가해야겠다는 생각이 들었습니다.

사람들이 좋다고 하는 것에는 언제나 그러한 이유가 있다는 생각을 하며, 만약 라라벨을 사용하시면서 테스트를 해보고는 싶었으나 뭔가 자료찾는게 번거로워 도입을 미뤘던 분들이 계시다면 한 번 도전해보시는게 어떨까요?

profile
undefined cat

0개의 댓글