해리의 데브로그

Object-Oriented Bootcamp 01 - Class / Encapsulation / Inheritance / Abstract class

|

Laracasts - Object-Oridented Bootcamp 강의를 듣고 정리한 포스팅 입니다.

1. Classes

PHP의 클래스는 기본적으로 2가지로 구성되어 있음

  • Property: 멤버변수. 클래스의 변수를 정의
  • public : 클래스 외부에서도 프로퍼티에 접근할 수 있게 함.
  • Method: 클래스의 특정 함수(동작)을 정의
    • __construct : 생성자 메서드로, 클래스 인스턴스가 생성될때 자동으로 실행됨.

클래스 인스턴스를 생성할때는 new 를 사용하며, 프로퍼티 & 메서드를 호출할 때는 -> 를 사용함. 또한, 클래스 내의 $this 는 클래스의 인스턴스를 의미함.

<?php

class Task {
    // public: outside of this class, anyone can access to the property
    public $description;

    public $completed = false;

    // method
    // __construct: immediately run when instantiating the class
    public function __construct($description)
    {
        $this->description = $description;
    }

    public function complete()
    {
        $this->completed = true;
    }
}

$task = new Task('Learn OOP');
$task->complete();
var_dump($task->description);
var_dump($task->completed);

2. Getters and Setters

이름과 나이에 대한 프로퍼티 & 메서드를 구현한 후, 클래스의 인스턴스를 생성하여 호출하면 인스턴스의 이름과 나이를 쉽게 알 수 있음. 그렇다면 getter과 setter의 역할을 무엇일까?

<?php

class Person {

    public $name;

    public $age;

    public function __construct($name)
    {
        $this->name = $name;
    }
}

$harry = new Person('harry lee');
$harry->age = 30;

var_dump($harry); // name: harry, age: 30

$harry->age++;
var_dump($harry); // name: harry, age: 31

getter과 setter를 쓰는 이유는 (little litte bit of ) protection 과 security 때문.

예를 들어 18살 미만인 사람은 허용하지 않는 어플리케이션을 만든다고 하자. 문제는 우리가 age라는 프로퍼티를 사용하므로, 나이와 관계없이 인스턴스의 프로퍼티에 값을 저장할 수 있음(나이 15, 심지어 -5와 같이…) 이처럼 프로퍼티로 바로 접근하는 경우 어떠한 Protection이 존재하지 않음. 이러한 맥락으로 setter & getter를 사용함.

Setters & Getters are behaviors associated with setting or getting particular property. In this case, the bahavior is that if you are younger than 18, then that’s not allowed.

Convention: set/get + value

public function setAge($age)
{
  if ($age < 18)
  {
    throw new Exception("Person is not old enough");
  }

  $this->age = $age;
}

$harry = new Person('harry lee');
$harry->setAge(17); // throw Exception

getter도 같은 방식으로 메서드 구현 가능

public function getAge()
{
  return $this->age;
}

$harry = new Person('harry lee');
var_dump($harry->getAge());

에러를 던지는 로직을 작성하였지만, 여전히 setAge 를 통해 18살 미만일 경우 setAge 를 거치지 않고 프로퍼티에 바로 접근하여 인스턴스에 값을 저장시킬 수 있음. 이러한 이유로 Encapulation 을 사용함.

3. Encapulation

프로퍼티를 정의할 때 public 외에도 privateprotected 가 있음. private 메서드를 호출할 경우 에러 메세지가 발생함 (me only exclusively access from within the class)

PHP Fatal error: Uncaught Error: Call to private method LightSwitch::connect() from context ‘’ in /Users/…..”

protected 도 동일하게 에러 메세지를 발생시키지만, 클래스를 extend 하여 서브클래스에서 해당 메서드를 사용 할 수 있음. 따라서, 이전 강의에서 보았듯이 setter 를 거치지 않고 바로 프로퍼티에 접근하는 것을 막기 위해서는 프로퍼티를 private 으로 변경하면 됨.

3-1. 객체가 private/protected 멤버를 갖게 하세요

  • public 메소드와 프로퍼티는 변경에 가장 취약합니다. 그 이유는 어떤 외부 코드가 쉽게 의존할 수 있고, 어떤 코드가 의존하고 있는지 제어할 수 없기 때문입니다. 클래스의 수정은 클래스의 모든 사용자에게 위험합니다.
  • protected 제어자는 public 만큼이나 위험합니다. 자식 클래스 범위내에서 사용할 수 있기 때문입니다. 이는 public과 protected의 차이점은 접근 매커니즘에만 있다는 것을 의미하나, 캡슐화 보증은 동일하게 유지됩니다. 클래스의 수정은 모든 하위 클래스에 위험합니다.
  • private 제어자는 코드가 단일 클래스의 경계에서만 수정하는 것이 위험함 을 보증합니다(변경하는 것이 안전하며 젠가 효과를 갖지 않을 것 입니다.).

그러므로 private 을 기본으로 사용하고 외부 클래스에 대한 접근 권한을 제공해야 할 때 public/protected를 사용하세요. 더 많은 정보를 원하면 이 주제에 대해서 Fabien Potencier가 작성한 블로그 포스트를 읽어볼 수 있습니다.

출처: Clean Code PHP 한글판

4. Inheritance

서브 클래스는 상속을 통해 부모 클래스의 메서드, 프로퍼티에 접근이 가능함. 서브 클래스에서 다른 클래스를 상속할 때는 extends 를 사용함.

class Mother {

    public function getEyeCount()
    {
        return 2;
    }
}

class Child extends Mother {
}

(new Child)->getEyeCount(); // 2

라라벨에서는 Eloqeunt 클래스를 상속받음으로써, 라라벨 ORM 문법을 사용할 수 있게 됨.

class Post extends Eloquent {
}

$post->save();
$post->update();

클래스를 상속할 때는, 부모 클래스의 functionality/behavior를 오버라이딩 할 수 있음

class Shape {
    protected $length = 4;

    public function getArea()
    {
        return pow($this->length, 2);
    }
}

class Triangle extends Shape{
    protected $base = 4;
    protected $height = 7;

    // override the method of parent class
    public function getArea()
    {
        return .5 * $this->base * $this->height;
    }
}

Shape@getArea 는 부모 클래스에 구현되어있으나, 사실상 로직이 사각형에서만 사용이 가능함(삼각형, 오각형 등에 사용 불가). 따라서 해당 프로퍼티와 매서드를 서브 클래스인 Sqaure 으로 옮기는 것이 더 적합함.

그렇다면 비어있는 Shape 클래스를 사용하는 이유 및 Benefit은 무엇일까?

  1. will there ever be any attributes or behaviors that would be shared across every shape? 만약 이 질문의 답이 “그렇다” 라면, functionality를 부모 클래스에 두는 것이 맞음.
  2. we can use as a sole contract
class Shape {
}

class Square extends Shape {
    protected $length = 4;

    public function getArea()
    {
        return pow($this->length, 2);
    }
}

Circle 이라는 새로운 클래스를 생성하여 Shape를 상속 받은 후 getArea 메서드를 호출할 경우, 에러가 발생함. “언제 어디서든 Shape을 상속받은 서브클래스에서 getArea 메서드를 사용하고 싶다” 라는 contract가 포함되어야함. 이를 위해서는 2가지 방법이 있음.

  1. Abstract Class
  2. Interface
class Circle extends Shape {
}

echo (new Circle)->getArea();

// Fatal error: Uncaught Error: Call to undefined method Circle::getArea()

4-1. Abstract class

기본 클래스인 Shape 의 인스턴스를 생성하는 것은 어떠한 에러도 발생시키지 않음. new Shape; 또한, 일반적인 shape이 없으므로 항상 서브 클래스를 instantiate 하자고 결정내릴 수 있음 (그리고 기본 클래스는 유지하고 싶지 않음).

이러한 경우 abstract 를 사용. 추상화 클래스를 사용하면, 이 클래스의 인스턴스를 만드는 것이 불가능해짐 (에러 발생). 추상화 클래스를 사용하면 여전히 상속은 가능하면서, 기본 클래스의 인스턴스를 만드는것을 막을 수 있음.

abstract class Shape {
}

부모클래스인 Shape 에서 color 라는 프로퍼티의 값을 부여하는 생성자 메서드를 만든 후, 서브클래스에서 어떠한 파라미터도 넣지 않은 채 인스턴스를 만들어 출력을 해보면, 에러가 발생하는 것을 알 수 있음.

abstract class Shape {
  
    protected $color;

    public function __construct($color)
    {
        $this->color = $color;
    }
}

echo new Square;
// Fatal error: Uncaught ArgumentCountError: Too few arguments to function Shape::

color 프로퍼티에 default 값(red)을 주는 경우, 그 값을 서브 클래스들이 그대로 사용할 수 있게 됨. 이때 getter를 사용하여 프로퍼티에 접근.

getColor 는 모든 서브 클래스들이 공유하는 shared behavior임. 이후, 각 서브 클래스의 인스턴스를 만들면서 getter 메서드를 호출하는 경우, color를 지정하지 않으면 red가 출력되거나, 인자로 받은 색상이 출력된다.

abstract class Shape {
  
    protected $color;

    public function __construct($color = 'red')
    {
        $this->color = $color;
    }

    public function getColor()
    {
        return $this->color;
    }
}

echo (new Square())->getColor() ; // red
echo (new Square('green'))->getColor() ; // green

서브클래스인 CirclegetArea() 메서드를 호출하면 에러가 발생함. 만약 각각의 서브클래스가 고유의 메서드를 정의해야 한다면, 추상 메서드를 호출하면 됨. 추상 메서드는 특별히 body ( {} )가 필요하지 않음.

abstract class Shape {
    protected $color;

    public function __construct($color = 'red')
    {
        $this->color = $color;
    }

    public function getColor()
    {
        return $this->color;
    }
		
    // 추상화 메서드 정의
    abstract protected function getArea();
}

class Circle extends Shape {

}

echo (new Circle('green'))->getArea() ; 

최종적으로 서브 클래스 Circle 에서 getArea 메서드를 구현

class Circle extends Shape {
    protected $radius = 5;

    public function getArea()
    {
        return pi() * pow($this->radius, 2);
    }
}

$circle = new Circle;
echo $circle->getArea(); // 78.539..

**For the purpose of extract common behavior, we use abstract class. **

추상 클래스는 상속을 강제하기 위한 것임. 부모 클래스에는 메서드의 시그니처만 정의해놓고 그 메소드의 실제 동작 방법은 메소드를 상속 받은 하위 클래스의 책임으로 위임하고 있음.

추상메소드를 정의하면 서브클래스는 반드시 그 메소드를 구현해야함.

5. Messages 101

사람들은 직업을 갖고 있으며 비즈니스를 위해 일을 함. 비즈니스는 사람들을 고용함. 고용된 사람들은 스태프로써 일을 함

  • Person , Business , Staff 라는 클래스로 구성 가능

비즈니스 클래스는 사람을 고용해야 하므로 hire 메소드 구현. 이때 당연히 사람을 고용하므로 $person을 인자로 넣을 수 있음. 이 개념을 확장하여 PHP에서 지원하는 Type hinting 을 사용하여 오브젝트를 인자로 넣을 수 있음 Person $person

class Person {

    protected $name;

    public function _construct($name)
    {
        $this->name = $name;
    }

}

class Business {

    protected $staff;

    public function __construct(Staff $staff)
    {
        $this->staff = $staff;
    }

    public function hire(Person $person)
    {
        $this->staff->add($person);
    }
}

class Staff {
  
    protected $members = [];

    public function add(Person $person)
    {
        $this->members[] = $person;
    }
}

$harry = new Person('harry lee');
$staff = new Staff([$harry]);
$laracasts = new Business($staff);

메세지를 보냄으로써 클래스간 상호작용을 하는 것이 가능함. 스태프를 추가하고싶을때는 스태프에 add 메세지를 보내며, 모든 스태프의 리스트를 보고 싶을 때는 스태프에 members 메세지를 보냄

// Business Class
public function hire(Person $person)
{
  $this->staff->add($person);
}

public function getStaffMembers()
{
  return $this->staff->members();
}

// Staff Class
public function add(Person $person)
{
  $this->members[] = $person;
}

public function Members()
{
  return $this->members;
}

최종 결과 코드

<?php

class Person {

    protected $name;

    public function __construct($name)
    {
        $this->name = $name;
    }
}

class Business {

    protected $staff;

    public function __construct(Staff $staff)
    {
        $this->staff = $staff;
    }

    public function hire(Person $person)
    {
        $this->staff->add($person);
    }

    public function getStaffMembers()
    {
        return $this->staff->members();
    }
}

class Staff {
    
    protected $members = [];

    public function __construct($members = [])
    {
        $this->members = $members;
    }

    public function add(Person $person)
    {
        $this->members[] = $person;
    }

    public function Members()
    {
        return $this->members;
    }
}

$harry = new Person('harry lee');
$staff = new Staff([$harry]);
$laracasts = new Business($staff);

$laracasts->hire(new Person('Ron Wizlie'));

//$laracasts->hire($harry);

var_dump($staff);
var_dump($laracasts->getStaffMembers());

Test Driven Laravel 11 - 최종 리팩토링

|

Coder’s tape의 Test Driven Laravel 강의 를 듣고 정리한 포스팅 입니다.

1. Feature Test: Author - create(store) 테스트 코드 리팩토링

기존 코드

class AuthorManagementTest extends TestCase
{
    use RefreshDatabase;
    /** @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'));
    }
}

엔드포인트를 복수형으로 변경 (/author -> /authors ) & 라우트도 그에 맞게 수정

$this->post('/authors', [
  'name' => 'Author Name',
  'dob' => '05/14/1998'
]);

Route::post('/authors', 'AuthorsController@store');

데이터와 함께 엔드포인트에 도달하는 로직을 추출하여 새로운 메소드로 분리시킴

public function an_author_can_be_created()
{
  $this->withoutExceptionHandling();

  $this->post('/authors', $this->data());

  $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'));
}

private function data()
{
  return [
    'name' => 'Author Name',
    'dob'  => '05/14/1998'
  ];
}

2. Feature Test - Author 유효성 검증(작가 이름 필요)

기본 코드 작성

/** @test */
public function a_name_is_required()
{
  $response = $this->post('/authors', array_merge($this->data(), ['name' => '']));

  $response->assertSessionHasErrors('name');
}

에러발생. AuthorsController@store 는 현재 유효성 검증을 하고 있지 않음. 따라서 유효성 검증 코드 작성한 후, 테스트를 돌리면 정상적으로 테스트가 동작함.

// Session is missing expected key [errors].

class AuthorsController extends Controller
{
    public function store()
    {
        $data = request()->validate([
           'name' => 'required',
           'dob' => '',
        ]);

        Author::create($data);
    }
}

3. Feature Test - Author 유효성 검증(Date Of Birth 필요)

/** @test */
public function a_dob_is_required()
{
  $response = $this->post('/authors', array_merge($this->data(), ['dob' => '']));

  $response->assertSessionHasErrors('dob');
}

동일한 에러가 발생함. 테스트가 동작할 수 있도로 유효성 검증을 통해 해당 필드가 필수가 되도록 수정

// Session is missing expected key [errors].

class AuthorsController extends Controller
{
    public function store()
    {
        $data = request()->validate([
           'name' => 'required',
           'dob' => 'required',
        ]);

        Author::create($data);
    }
}

4. 유효성 검증 코드 리팩토링

BooksController@validateRequest 메소드를 통해 유효성 검증하는 부분을 추출하여 따로 뺀 것처럼, AuthorController@validateRequest 메소드를 동일한 방식으로 구현하여 리팩토링을 진행

class AuthorsController extends Controller
{
    public function store()
    {
        Author::create($this->validateRequest());
    }

    private function validateRequest()
    {
        return request()->validate([
            'name' => 'required',
            'dob'  => 'required',
        ]);
    }
}

Test Driven Laravel 10 - Book Checkin 테스트 코드 구현 (Feature test)

|

Coder’s tape의 Test Driven Laravel 강의 를 듣고 정리한 포스팅 입니다.

1. Checkin 테스트 코드 작성

기본적인 코드는 a_book_can_be_checked_out_by_a_signed_in_user 메소드를 토대로 만드나, reservation 객체를 생성하려면 Book@checkout이 선행되어야함.

/** @test */
public function a_book_can_be_checked_in_by_a_signed_in_user()
{
  $book = factory(Book::class)->create();
  $user = factory(User::class)->create();
  
  // Reservation 객체를 생성하기 위해 우선 체크아웃 로직 동작시킴
  $this->actingAs($user)->post('/checkout/' . $book->id); 

  $this->actingAs($user)->post('/checkin/' . $book->id);

  $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_in_at);
}

아래 코드에서 에러가 발생함.

// null does not match expected type "object".
$this->assertEquals(now(), Reservation::first()->checked_in_at);

withoutExceptionHandling으로 살펴보면 라우트 설정이 되지 않은 것이 확인 가능. 라우트를 생성한 후, CheckinBookController 컨트롤러를 생성 & store 메소드를 구현함.

NotFoundHttpException : POST http://localhost/checkin/1
Route::post('/checkin/{book}', 'CheckinBookController@store');

class CheckinBookController extends Controller
{
    public function store()
    {
    }
}

정확한 상세 정보를 확인할 수 없는 에러가 발생하나, Unit test를 통해 어떤 로직을 구현해야하는지를 알 수 있음.

  • $book->checkin($user);
null does not match expected type "object".
use App\Book;

class CheckinBookController extends Controller
{
    public function store(Book $book)
    {
        $book->checkin(auth()->user());
    }
}

나머지 코드는 Unit Test 때와 그대로 동일하게 작성

public function a_book_can_be_checked_in_by_a_signed_in_user()
{
  $this->withoutExceptionHandling();

  $book = factory(Book::class)->create();
  $user = factory(User::class)->create();
  $this->actingAs($user)->post('/checkout/' . $book->id);

  $this->actingAs($user)->post('/checkin/' . $book->id);

  $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_in_at);
}

2. Checkin 테스트 케이스 확장 - 인증된 유저만 체크아웃 가능

“인증된 유저만 체크아웃 가능” - only_signed_in_users_can_checkout_a_book() 를 기반으로 코드 작성

public function only_signed_in_users_can_checkin_a_book()
{
  $this->withoutExceptionHandling();

  $book = factory(Book::class)->create();

  $this->post('/checkout/' . $book->id)->assertRedirect('/login');
  $this->post('/checkin/' . $book->id)->assertRedirect('/login');

  $this->assertCount(0, Reservation::all());
}

테스트를 실행시켜보면 500에러(unauthorized) 발생. 기존의 코드는 로그인이 되어있지 않은 경우에 로그인 엔드포인트로 리다이렉트시키는 코드인데, 지금의 테스트 코드는 체크인을 테스트하는 것임. 그러므로 우선, 로그인은 정상적으로 동작해야함. actingAs 메소드와 factory 메소드를 활용하여 로그인이 동작하도록 코드 수정

$this->post('/checkout/' . $book->id)->assertRedirect('/login');
public function only_signed_in_users_can_checkin_a_book()
{
  $book = factory(Book::class)->create();
  $this->actingAs(factory(User::class)->create())
    ->post('/checkout/' . $book->id);

  $this->post('/checkin/' . $book->id)
    ->assertRedirect('/login');

  $this->assertCount(0, Reservation::all());
}

CheckinBookController의 생성자 메소드로 auth 미들웨어를 추가시킴

class CheckinBookController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth');
    }

에러가 발생. 로그인 코드를 통해 로그인 상태가 유지되고 있어서 200 코드를 반환시키고 있음. 따라서, 로그 아웃 코드를 추가로 삽입해야함.

//Response status code [200] is not a redirect status code.
//Failed asserting that false is true.

// 문제가 되는 부분
$this->post('/checkin/' . $book->id)->assertRedirect('/login');
use Illuminate\Support\Facades\Auth;

/** @test */
public function only_signed_in_users_can_checkin_a_book()
{
  $book = factory(Book::class)->create();

  $this->actingAs(factory(User::class)->create())
    ->post('/checkout/' . $book->id);

  Auth::logout();

  $this->post('/checkin/' . $book->id)
    ->assertRedirect('/login');

  $this->assertCount(0, Reservation::all());
}

Reservation 객체가 생성되었으므로 assertCount의 첫번째 인수에는 1을 넣어주며, 체크인이 실제로 되지 않았으므로 그 값이 null이라는 것을 확인시켜주는 코드를 추가로 작성함

$this->assertCount(1, Reservation::all());
$this->assertNull(Reservation::first()->checked_in_at);

최종적으로 완성된 코드는 아래와 같음.

public function only_signed_in_users_can_checkin_a_book()
{
  $book = factory(Book::class)->create();

  $this->actingAs(factory(User::class)->create())
    ->post('/checkout/' . $book->id);

  Auth::logout();

  $this->post('/checkin/' . $book->id)
    ->assertRedirect('/login');

  $this->assertCount(1, Reservation::all());
  $this->assertNull(Reservation::first()->checked_in_at);
}

3. Checkin 테스트 케이스 확장 - 체크아웃 되지 않은 책을 체크인

체크아웃되지 않은 책을 체크인할 경우 404에러를 반환하는 것이 일반적임.

  • checkin 테스트 코드를 기반으로 작성하되, Book@checkout 을 동작시키는 코드는 삭제시킴.
  • Reservation 객체는 생성되지 않으므로 0으로 만들며 나머지 코드는 삭제시킴
  • 그리고 /checkin/ 엔드포인트에 대하여 404를 반환하는 assert 코드 구현
 public function a_404_is_thrown_if_a_book_is_not_checked_out_first()
{
  $book = factory(Book::class)->create();
  $user = factory(User::class)->create();

  $this->actingAs($user)
    ->post('/checkin/' . $book->id)
    ->assertStatus(404);

  $this->assertCount(0, Reservation::all());
}

500 에러를 발생시킴. withoutExceptionHandling 를 통해 에러 디테일 확인. 에러가 발생하는 CheckinBookController@store 수정

public function store(Book $book)
{
  try {
    $book->checkin(auth()->user());
  } catch (\Exception $e) {
    return response([], 404);
  }
}

Test Driven Laravel 09 - Book Checkout 테스트 코드 구현 (Feature test)

|

Coder’s tape의 Test Driven Laravel 강의 를 듣고 정리한 포스팅 입니다.

1. Checkout 테스트 코드 작성

Unit Test에서 구현한 book checkout, checkin을 Feature Test에서 구현

php artisan make:test BookCheckoutTest

Unit Test의 로직을 기본적으로 따라감

// Unit 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의 경우 라우트의 엔드포인트를 통해 인증된 유저가 들어올 것이라고 가정을 하고 진행하므로 접근방법이 조금 다름. book_id를 함께 엔드포인트로 넘김. 만약 제대로 동작할 경우, 나머지 코드는 Unit Test의 코드를 동일하게 사용

  • actingAs 헬퍼 메소드는 특정사용자를 현재 사용자로 인증하는 단순한 방법을 제공함.
class BookCheckoutTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function a_book_can_be_checked_out_by_a_signed_in_user()
    {
        $book = factory(Book::class)->create();
        $user = factory(User::class)->create();

        $this->actingAs($user)->post('/checkout/' . $book->id);

        $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);
    }
}

에러가 발생. 상세하게 어떠한 에러인지를 알기 위해 withoutExceptionHandling 사용

Failed asserting that actual size 0 matches expected size 1.

라우트 설정

// Exception\NotFoundHttpException : POST http://localhost/checkout/1
Route::post('/checkout/{book}', 'CheckoutBookController@store');

CheckoutBookController 컨트롤러 생성 후 store 메소드 구현

// BindingResolutionException : Target class [App\Http\Controllers\CheckoutBookController] does not exist.
class CheckoutBookController extends Controller
{
    public function store()
    {
    }
}

Unit Test를 통해서 바로 다음 어떠한 로직/코드를 짜야하는지 쉽게 알 수 있음. Route Model Binding을 통해 Book 객체를 저장시키며, auth()->user() 를 통해 인증된 유저를 인자로 받아 Book@checkout 를 실행시킴.

class CheckoutBookController extends Controller
{
    public function store(Book $book)
    {
        $book->checkout(auth()->user());
    }
}

나머지 코드는 Unit Test 때와 그대로 동일하게 작성

public function a_book_can_be_checked_out_by_a_signed_in_user()
{
  $book = factory(Book::class)->create();
  $user = factory(User::class)->create();

  $this->actingAs($user)->post('/checkout/' . $book->id);

  $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);
}

2. Checkout 테스트 케이스 확장 - 인증된 유저만 체크아웃 가능

인증되지 않은 유저가 체크아웃을 진행할 경우, 로그인 페이지로 리다이렉트하는 테스트 케이스 작성. Reservation은 이루어지지 않았으므로 객체의 수는 0개여야 함.

/** @test */
public function only_signed_in_users_can_checkout_a_book()
{
  $book = factory(Book::class)->create();

  $this->post('/checkout/' . $book->id)
    ->assertRedirect('/login');

  $this->assertCount(0, Reservation::all());
}

테스트를 돌려보면 500 에러가 발생함. withoutExceptionHandling 를 통해 출력을 해보면 user_id가 존재하지 않음을 알수 있음. (Book@checkout에서 reservation 객체를 생성할 때 user_id가 필요하나, 누락되어있음)

// Response status code [500] is not a redirect status code.

// Book.php
public function checkout($user)
{
  $this->reservations()->create([
    'user_id' => $user->id,
    'checked_out_at' => now(),
  ]);
}

생성자 메소드를 통해 라라벨의 미들웨어중 하나인 Authenticate 를 동작시킴. Authenticate 파일을 살펴보면 인증에 실패했을 경우, 로그인 페이지로 리다이렉트 시키는 로직이 구현되어있음.

// Http\Middleware\Authenticate.php
class Authenticate extends Middleware
{
    protected function redirectTo($request)
    {
        if (! $request->expectsJson()) {
            return route('login');
        }
    }
}
class CheckoutBookController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth');
    }
    public function store(Book $book)
    {
        $book->checkout(auth()->user());
    }
}

테스트를 동작시키면 500에러가 발생함. 현재 Laravel authentication을 이용하고 있지만, 그에 따른 라우트 설정을 해주지 않았음(로그인, 로그아웃 등의 라우트가 존재하지 않음).

//  if  laravel version below 6.0
php artisan make:auth 
// else
composer require laravel/ui --dev 
php artisan ui vue --auth

이후 테스트를 돌려보면 성공적으로 작동하는 것을 알 수 있음.

3. Checkout 테스트 케이스 확장 - 책이 존재할 때만 체크아웃 가능

존재하지 않는 book_id를 하드코딩하여 입력한 후, assertStatus 가 404(not found)가 뜨도록 코드 작성

/** @test */
public function only_real_books_can_be_checked_out()
{
  $this->actingAs(factory(User::class)->create())
    ->post('/checkout/123')
    ->assertStatus(404);

  $this->assertCount(0, Reservation::all());
}

Test Driven Laravel 08 - Book Checkin 테스트 코드 구현 / 테스트 케이스 확장 (unit test)

|

Coder’s tape의 Test Driven Laravel 강의 를 듣고 정리한 포스팅 입니다.

1. Checkin 테스트 코드 구현

테스트 기본 코드 작성. Book@checkin 를 통해 checkin을 동작시키며, checked_in_at 시간을 적용함.

public function a_book_can_be_returned()
{
  $book = factory(Book::class)->create();
  $user = factory(User::class)->create();
  $book->checkout($user);

  $book->checkin($user);

  $this->assertCount(1, Reservation::all());
  $this->assertEquals($user->id, Reservation::first()->user_id);
  $this->assertEquals($book->id, Reservation::first()->book_id);
  $this->assertNotNull(Reservation::first()->checked_in_at);
  $this->assertEquals(now(), Reservation::first()->checked_in_at);
}

Book@checkin 구현. 체크인을 진행하려면 checked_out_at은 null이 아니어야하며, checked_in_at은 null값인 객체가 있다는 것이 선행되어야 함.

BadMethodCallException : Call to undefined method App\Book::checkin()
public function checkin($user)
{
  $reservation = $this->reservations()->where('user_id', $user->id)
    ->whereNotNull('checked_out_at')
    ->whereNull('checked_in_at')
    ->first();

  $reservation->update([
    'checked_in_at' => now()
  ]);
}

체크인 컬럼이 존재하지 않아 에러가 발생. Reservation 마이그레이션에 checked_in_at 컬럼 추가. book@checkout 로직이 동작할 시에는 체크아웃시간이 null값이어야 하므로 nullable() 적용

Error : Call to a member function update() on null
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->timestamp('checked_in_at')->nullable();
    $table->timestamps();
  });
}

2. 테스트 케이스 확장 - 체크아웃 2회

체크인을 하자마자 다시 체크아웃을 했을 경우의 상태는 다음과 같음

  • Reservation 객체는 2개 (체크아웃 2번)
  • Reservation의 id가 2인 객체와 비교
  • Checked_in_at의 값은 null
  • Reservation 객체의 checked_out_at에는 현재 시간이 저장
/** @test */
public function a_user_can_check_out_a_book_twice()
{
  $book = factory(Book::class)->create();
  $user = factory(User::class)->create();
  $book->checkout($user);
  $book->checkin($user);
  $book->checkout($user);

  $this->assertCount(2, Reservation::all());
  $this->assertEquals($user->id, Reservation::find(2)->user_id);
  $this->assertEquals($book->id, Reservation::find(2)->book_id);
  $this->assertNull(Reservation::find(2)->checked_in_at);
  $this->assertEquals(now(), Reservation::find(2)->checked_out_at);
}

마찬가지로, 이후 다시 체크인을 했을 경우 비교하는 코드도 추가 가능

/** @test */
public function a_user_can_check_out_a_book_twice()
{
  $book = factory(Book::class)->create();
  $user = factory(User::class)->create();
  $book->checkout($user);
  $book->checkin($user);
  $book->checkout($user);

  $this->assertCount(2, Reservation::all());
  $this->assertEquals($user->id, Reservation::find(2)->user_id);
  $this->assertEquals($book->id, Reservation::find(2)->book_id);
  $this->assertNull(Reservation::find(2)->checked_in_at);
  $this->assertEquals(now(), Reservation::find(2)->checked_out_at);

  $book->checkin($user);
  $this->assertCount(2, Reservation::all());
  $this->assertEquals($user->id, Reservation::find(2)->user_id);
  $this->assertEquals($book->id, Reservation::find(2)->book_id);
  $this->assertNotNull(Reservation::find(2)->checked_in_at);
  $this->assertEquals(now(), Reservation::find(2)->checked_in_at);
}

3. 테스트 케이스 확장 - 체크아웃 X

체크아웃을 하지 않고 체크인을 한 경우에 대한 테스트 기본 코드를 작성

use Exception;

/** @test */
public function if_not_checked_out_exception_is_thrown()
{
  $this->expectException(Exception::class);
  $book = factory(Book::class)->create();
  $user = factory(User::class)->create();

  $book->checkin($user);
}

테스트를 돌리면 에러가 발생. Book@checkout 을 통해 Reservation 객체가 생성되는데 바로 이를 거치지 않고, Book@checkin 을 호출하면 객체가 존재하지 않기 때문임. 따라서 객체가 존재하지 않을 경우 exception을 던지는 코드 작성

Failed asserting that exception of type "Error" matches expected exception "Exception". Message was: "Call to a member function update() on null" at
use Exception;

public function checkin($user)
{
  $reservation = $this->reservations()->where('user_id', $user->id)
    ->whereNotNull('checked_out_at')
    ->whereNull('checked_in_at')
    ->first();

  if (is_null($reservation)) {
    throw new Exception();
  }

  $reservation->update([
    'checked_in_at' => now()
  ]);
}