바로 시작하는 Laravel Feature Test #2

undefcat·2021년 10월 30일
0

Laravel TDD

목록 보기
2/3
post-thumbnail

Laravel Feature Test

게시글 CRUD

이번 포스팅에서는 게시글 CRUD중 C와 D만 구현해보겠습니다. 게시글은 Article로 표현하겠습니다.

$ sail artisan make:model Article -m
Model created successfully.
Created Migration: 2021_10_30_132020_create_articles_table

엘로퀀트 모델을 생성할 때, -m 플래그를 주면 마이그레이션까지 만들어집니다. 그럼 이제 테스트를 만들어봐야겠죠.

$ sail artisan make:test ArticleTest
Test created successfully.

게시글 CREATE

게시글 작성을 구현해보도록 하겠습니다. 게시글은 아주 간단하게

  • 작성자
  • 제목
  • 내용

위 3가지 정보만을 갖고 있다고 하겠습니다. 그렇다면 글을 작성할 때, 해당 작성자가 로그인이 되어있다고 가정해야겠죠. 라라벨에서는 어떤 특정한 사용자 세션을 테스트 할 때에는 actingAs 테스트 메서드를 사용합니다. 자세한 내용은 공식문서를 참고해주세요.

// tests/Feature/ArticleTest.php

<?php

namespace Tests\Feature;

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

class ArticleTest extends TestCase
{
    use RefreshDatabase;

    private const URL = '/api/articles';

    public function test_게시글_작성_201_성공()
    {
        $user = User::factory()->create();

        $formData = [
            'title' => 'title',
            'content' => 'content',
        ];

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

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

        $isExist = Article::where([
            ['user_id', '=', $user->id],
            ['title', '=', $formData['title']],
        ])->count() > 0;

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

당연하지만, 실패합니다. 이제 컨트롤러와 라우트를 정의해보겠습니다.

$ sail artisan make:controller ArticleController
Controller created successfully.
// app/Http/Controllers/ArticleController.php

<?php

namespace App\Http\Controllers;

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

class ArticleController extends Controller
{
    public function store(Request $request)
    {
        $rules = [
            'title' => ['required'],
            'content' => ['required'],
        ];

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

        $data = $validator->validated();
        $user = Auth::user();

        // Service

        $article = new Article();

        $article->user_id = $user->id;
        $article->title = $data['title'];
        $article->content = $data['content'];

        $article->save();

        return response()->json(null, Response::HTTP_CREATED);
    }
}
// routes/api.php

<?php

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

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

Route::post('/articles', [ArticleController::class, 'store']);

이제 마이그레이션에서 테이블 스키마를 정의하고 테스트를 돌려보도록 하겠습니다.

// database/migrations/2021_10_30_132020_create_articles_table.php

<?php

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

class CreateArticlesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('articles', function (Blueprint $table) {
            $table->id();
            $table->bigInteger('user_id')->unsigned();
            $table->string('title', 60);
            $table->longText('content');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('articles');
    }
}
$ sail test --filter ArticleTest

비로그인 사용자의 게시글 CREATE

비로그인 사용자가 게시글을 작성하면, 401 에러가 발생하도록 해야합니다. 우선 테스트 코드를 먼저 작성해봅시다.

<?php

namespace Tests\Feature;

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

class ArticleTest extends TestCase
{
    use RefreshDatabase;

    private const URL = '/api/articles';

    public function test_게시글_작성_201_성공()
    {
        // ...
    }

    public function test_게시글_작성_비회원_401_실패()
    {
        $formData = [
            'title' => 'title',
            'content' => 'content',
        ];

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

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

당연하지만 500 에러가 발생합니다. 저희의 코드에서는 사용자가 로그인을 했는지 안했는지 확인없이, 바로 Auth 파사드를 이용해서 사용자 정보를 가져왔기 때문입니다.

게시글을 읽는 것 빼고는 작성, 수정, 삭제는 모두 로그인된 사용자만 할 수 있어야 한다고 해봅시다. 그렇다면, 라라벨에서 제공하는 auth 미들웨어를 이용하면 됩니다. 이 때, 읽기 페이지에는 적용되지 말아야 하므로, route에서 미들웨어를 정의하지 말고, 컨트롤러의 생성자에서 미들웨어를 적용하되, 읽기 컨트롤러 메서드는 제외하도록 합시다.

미들웨어를 어디에 정의해야 할지는 정해진 규칙은 없습니다. 각자의 규칙대로 하면 될 것입니다. 저같은 경우에는

  1. 모든 라우트 그룹에 공통으로 적용되는 미들웨어의 경우, 그냥 라우트에서 선언합니다.
  • 이런 경우에는, 컨트롤러는 미들웨어 로직을 알 필요가 없는 경우일 것입니다.
  1. 특정 컨트롤러 메서드는 제외되어야 한다면, 컨트롤러 생성자에서 미들웨어를 정의합니다.
  • 이런 경우에는, 미들웨어가 해당 컨트롤러에서 실행되는 비즈니스 규칙과 연관이 있는 경우이므로, 이를 컨트롤러에서 바로 확인할 수 있는게 좀 더 직관적입니다.

웹MVC의 경우, 디버깅을 할 때 코드를 가장 먼저 살펴보게 되는 엔트리 포인트는 컨트롤러일 것이므로, 컨트롤러에서 확인하면 편한 정보는 컨트롤러에 있는게 맞다고 생각하기 때문에 저는 개인적으로 위와 같은 규칙을 적용하여 구현합니다.

보통 어떤 게시글을 읽는 컨트롤러 메서드는 라라벨에서 show라고 명명하므로, 이를 이용하여 미들웨어 코드를 작성해줍니다.

<?php

namespace App\Http\Controllers;

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

class ArticleController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth')->except(['show']);
    }

    public function store(Request $request)
    {
        // ...
    }
}

이제 테스트를 돌려보겠습니다.

게시글 DELETE

게시글 삭제는 본인만 할 수 있다고 해봅시다. 라라벨에서는 Authorization을 Policy로 처리합니다. 바로 생성해보도록 하겠습니다.

$ sail artisan make:policy ArticlePolicy
policy created successfully.

Policy는 권한 관련 처리를 하기 때문에, 기본적으로 로그인된 사용자 정보와 리소스 정보를 매개변수로 받습니다. 이 경우에는 Article이 리소스가 되겠죠.

// 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 destroy(User $user, Article $article)
    {
        return (string)$user->id === (string)$article->user_id;
    }
}

id값을 string으로 캐스팅하는 이유는, 이 값이 나중에 UUID 형식이 될 수도 있기 때문입니다. 또한, SELECT 쿼리를 할 때 엘로퀀트 모델에 cast attribute가 선언되어 있지 않으면, user_id값이 string타입을 기본값으로 가져오기 때문에 모든 상황에 대처할 수 있는 string으로 캐스팅합니다.

이제 이를 ServiceProvider에 등록해줘야 합니다. 라라벨에서는 기본적으로 AuthServiceProvider에서 합니다.

// app/Providers/AuthServiceProvider.php

<?php

namespace App\Providers;

use App\Models\Article;
use App\Policies\ArticlePolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;

class AuthServiceProvider extends ServiceProvider
{
    /**
     * The policy mappings for the application.
     *
     * @var array
     */
    protected $policies = [
        Article::class => ArticlePolicy::class,
    ];

    /**
     * Register any authentication / authorization services.
     *
     * @return void
     */
    public function boot()
    {
        $this->registerPolicies();

        //
    }
}

이제 삭제 테스트 코드를 작성해봅니다. 테스트코드는 아래 3개를 테스트할 것입니다.

  1. 작성자가 삭제하는 경우 204 성공
  2. 비로그인 사용자가 삭제하는 경우 401 실패
  3. 미작성자가 삭제하는 경우 403 실패

이 경우, Article을 우선 생성해줘야 하는데, 쉽게 생성하기 위해서 ArticleFactory를 이용합니다.

$ sail artisan make:factory ArticleFactory
Factory created successfully.

ArticleUser에 종속적이죠. 엘로퀀트 릴레이션을 설정해줘야 합니다.

// app/Models/Article.php

<?php

namespace App\Models;

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

class Article extends Model
{
    use HasFactory;

    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

// 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
{
    use HasApiTokens, HasFactory, Notifiable;
	
    // ...
    
    public function articles()
    {
        return $this->hasMany(Article::class);
    }
}

이제 ArticleFactory를 구현합니다.


// database/factories/ArticleFactory.php

<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

class ArticleFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array
     */
    public function definition()
    {
        return [
            'title' => $this->faker->title(),
            'content'=> $this->faker->text(),
        ];
    }
}

이제 테스트 코드를 작성합니다.

<?php

namespace Tests\Feature;

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

class ArticleTest extends TestCase
{
    use RefreshDatabase;

    private const URL = '/api/articles';

    public function test_게시글_작성_201_성공()
    {
        // ...
    }

    public function test_게시글_작성_비회원_401_실패()
    {
        // ...
    }

    public function test_게시글_삭제_작성자_204_성공()
    {
        $user = User::factory()->create();

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

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

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

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

    public function test_게시글_삭제_비로그인_사용자_401_실패()
    {
        $article = Article::factory()
            ->for(User::factory())
            ->create();

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

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

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

    public function test_게시글_삭제_미작성자_403_실패()
    {
        $other = User::factory()->create();

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

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

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

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

라라벨의 Factory에는 hasfor와 같은 릴레이션 관련 메서드가 존재합니다. 자세한 정보는 공식문서에서 확인해보시기 바랍니다.

테스트 결과는 아래와 같습니다.

이제 다시 컨트롤러를 구현하고, 라우트를 정의하면 되겠네요!

// app/Http/Controllers/ArticleController.php

<?php

namespace App\Http\Controllers;

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

class ArticleController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth')->except(['show']);
    }

    public function store(Request $request)
    {
        // ...
    }

    public function destroy(Request $request, int $id)
    {
        $article = Article::findOrFail($id);
        $user = Auth::user();

        if ($user->cannot('destroy', $article)) {
            return response()->json(null, Response::HTTP_FORBIDDEN);
        }

        $article->delete();

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

// routes/api.php

<?php

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

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

Route::post('/articles', [ArticleController::class, 'store']);

Route::delete('/articles/{id}', [ArticleController::class, 'destroy'])
    ->where('id', '[0-9]');

자, 이제 테스트를 돌려보겠습니다.

PASS 했네요!

정리

게시글 읽기, 게시글 수정 등 역시 위와 비슷하게 쉽게 구현할 수 있을 것입니다.

테스트의 핵심은 바로 테스트 코드를 먼저 작성하는 것입니다. 테스트 코드를 먼저 작성하게 되면, 테스트 코드를 작성하면서 요구사항이 명확해지기 때문에 구현이 쉬워진다는 장점이 있습니다. 목표에만 집중하여 구현하게 되어 간결해진다는 장점 또한 있죠.

하지만 가장 큰 장점은, 테스트 코드를 잘 작성하면 나중에 코드가 변경되더라도 혹여나 다른 코드에 영향을 주어 기능이 동작하지 않을까, 하는 우려가 없어진다는 점입니다. 기능을 변경하고, 테스트를 돌려 보면 되는 것이지요. 물론 어디까지나 전제조건은 테스트 코드를 잘 작성해야겠지만요.

다음 포스팅에서는 로그인 기능 구현 및 관리자 기능을 구현하며 마무리하도록 하겠습니다.

profile
undefined cat

0개의 댓글