Test Driven Laravel 07 - Book Checkout 테스트 코드 구현 (unit test)
05 Jan 2020 | PHP TDD Laravel coderstapeCoder’s tape의 Test Driven Laravel 강의 를 듣고 정리한 포스팅 입니다.
1. GET STARTED
도서관에서 책을 대여하는 checkout 구현하기 위해, 테스트 파일 생성
php artisan make:test BookReservationsTest
가장 기본적으로, 책을 체크아웃하면 시간이 찍히는 로직 구현. 기준이 되는 모델을 유저로 해도 되며, 책으로 해도 됨. 후자를 선택해서 진행
public function a_book_can_be_checked_out()
{
// $book->checkout($user);
// $user->checkout($book);
}
2. Factory
factory()
를 활용하여 Book, User 모델을 토대로 가상의 데이터를 생성하여 각각의 변수에 저장. Reservation
모델은 구현 전이나, user_id, book_id, check_out_at 등의 컬럼을 갖고 있을 예정.
use App\Book;
use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class BookReservationsTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function a_book_can_be_checked_out()
{
$book = factory(Book::class)->create();
$user = factory(User::class)->create();
$book->checkout($user);
$this->assertCount(1, Reservation::all());
$this->assertEquals($user->id, Reservation::first()->user_id);
$this->assertEquals($book->id, Reservation::first()->book_id);
$this->assertEquals(now(), Reservation::first()->checked_out_at);
}
}
이 때, 위의 테스트는 feature test/unit test 인지를 살펴보면, 우리가 체크아웃하는 특정한 책을 토대로 테스트를 하기 때문에 unit test라고 할 수 있음.
3. Feature Test vs Unit Test
Feature Test 중 하나인 an_author_can_be_created
를 살펴보면,
- The test actually hits a particular endpoint with some data.
- we are basically simulating the same exact thing that a user would do if they went through our browser, interacting with our website sent in some data.
- application did what it had to do
Unit Test인 a_book_can_be_checked_out
의 경우, “we are at no point doing anything that a user could do directly”. 따라서 해당 테스트의 파일 위치를 tests\Feature에서 tests\Unit으로 변경
/** @test */
public function an_author_can_be_created()
{
$this->withoutExceptionHandling();
$this->post('/author', [
'name' => 'Author Name',
'dob' => '05/14/1998'
]);
$author = Author::all();
$this->assertCount(1, $author);
$this->assertInstanceOf(Carbon::class, $author->first()->dob);
$this->assertEquals('1998/14/05', $author->first()->dob->format('Y/d/m'));
}
4. Factory 생성
테스트를 돌려보면 에러가 발생. 따라서 모델 Book에 대한 팩토리를 만듦.
InvalidArgumentException : Unable to locate factory with name [default] [App\Book].
php artisan make:factory BookFactory -m Book
마이그레이션에 명시된 컬럼명들을 토대로 코드를 작성. 이때 author_id에 대해서는 2가지 방법으로 구현이 가능함.
/** @var \Illuminate\Database\Eloquent\Factory $factory */
use App\Book;
use App\Author;
use Faker\Generator as Faker;
$factory->define(Book::class, function (Faker $faker) {
return [
'title' => $faker->sentence,
'author_id' => factory(Author::class)->create() // 방법 1
'author_id' => factory(Author::class) // 방법 2
];
});
후자의 방법의 경우,
- 테스트 코드에서 book을 생성할 때 값을 전달시킬 경우, 라라벨은 Author 객체를 생성하지 않음.
- 만약에 값을 전달시키지 않을 경우, 라라벨은 Author 객체를 생성시킴.
그러나 전자의 경우, book을 생성할 때마다 author 또한 생성 됨. 만약에 수동으로 author_id를 오버로딩하는 경우에도 author_id가 생성됨. 따라서 후자의 방법을 사용
/** @test */
public function a_book_can_be_checked_out()
{
$book = factory(Book::class)->create([
'author_id' => 123
]);
Author 팩토리가 생성되지 않았다는 에러가 발생하므로 팩토리 생성
InvalidArgumentException : Unable to locate factory with name [default] [App\Author].
/** @var \Illuminate\Database\Eloquent\Factory $factory */
use App\Author;
use Faker\Generator as Faker;
$factory->define(Author::class, function (Faker $faker) {
return [
'name' => $faker->name,
'dob' => now()->subYears(10),
];
});
3. Return to check out 테스트 코드 구현
Book 모델 내 checkout 메소드 구현
BadMethodCallException : Call to undefined method App\Book::checkout()
class Book extends Model
{
// 코드 중략
public function checkout()
{
}
}
Reservation 모델 생성 & 테스트 파일에 모델 import
Error : Class 'Tests\Unit\Reservation' not found
php artisan make:model Reservation -m
Book 모델에 checkout 메소드를 구현. a_book_can_be_checked_out
에서 Book@checkout
를 호출하여, Reservation 객체를 생성시킨 후, user_id, checked_out_at의 값을 적용시킴. 이때 book_id도 필요함 (관계 설정이 요구 됨)
Failed asserting that actual size 0 matches expected size 1.
// BookReservatinsTest.php
public function a_book_can_be_checked_out()
{
$book = factory(Book::class)->create();
$user = factory(User::class)->create();
$book->checkout($user);
// Book.php
public function checkout($user)
{
Reservation::create([
'user_id' => $user->id,
'checked_out_at' => now(),
]);
}
Reservation은 Book과 관계설정(Book: Reservation = 1: N) 이 적용되므로, 코드를 아래와 같이 수정
public function checkout($user)
{
$this->reservations()->create([
'user_id' => $user->id,
'checked_out_at' => now(),
]);
}
Book 모델에 관계설정 메소드 구현
BadMethodCallException : Call to undefined method App\Book::reservations()
public function reservations()
{
return $this->hasMany(Reservation::class);
}
Reservation 모델에 mass assignment 코드 적용
Illuminate\Database\Eloquent\MassAssignmentException : Add [user_id] to fillable property to allow mass assignment on [App\Reservation].
namespace App;
use Illuminate\Database\Eloquent\Model;
class Reservation extends Model
{
protected $guarded = [];
}
Reservation 모델 마이그레이션에 누락된 컬럼들을 추가
Illuminate\Database\QueryException : SQLSTATE[HY000]: General error: 1 table reservations has no column named user_id
public function up()
{
Schema::create('reservations', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('user_id');
$table->unsignedBigInteger('book_id');
$table->timestamp('checked_out_at');
$table->timestamps();
});
}