Overview

브랜디 관리자 프로젝트는 Codeigniter + PHP로 구성되어 있습니다.

현재의 브랜디 관리자 서비스는 초기의 서비스 모델이기 때문에 계속 더 나은 방향으로 발전하고 있는 브랜디 랩스의 개발 방향성과 잘 맞지 않습니다. 따라서 브랜디 관리자는 새로운 프레임워크와 AWS Lamda로 구성될 준비를 하고 있습니다.

하지만 브랜디 관리자는 상당히 복잡하고 다양한 기능을 가지고 있는 몸집이 큰 서비스입니다. 그렇기 때문에 하루아침에 신규 관리자로 이관하는 것은 불가능에 가까울 정도로 어렵습니다. 따라서 기존 서비스의 안정성을 확보하기 위하여 PHP Codeigniter와 잘 맞는 TDD 도구들을 알아보았습니다.

현재 관리자 프로젝트에서 사용 가능한 TDD 도구들은 다음과 같습니다.

  1. PHPUnit
  2. SimpleTest
  3. TOAST

위의 도구들 중 PHPUnit과 SimpleTest는 native PHP 단위 테스트 도구이다 보니, Codeigniter 환경에서 CI 컨트롤러 객체를 이용하여 실 환경과 비슷하게 테스트하는 것이 상당히 까다롭습니다. 기본적인 실행 방법부터 터미널을 사용한 명령어로 실행해야 한다는 것이 큰 걸림돌이 되기도 합니다.

브랜디 관리자 서비스는 사용량에 따른 가변형 EC2 환경에서 구동되고 있는데, 상용 EC2 접근은 금지되어 있기 때문에 웹을 통한 TDD 도구가 필요했습니다.

그에 반해 TOAST는 CI_Controller를 기반으로 제작된 TDD 도구입니다.

TOAST는 Codeigniter의 내장 단위 테스트 라이브러리인 unit_test 라이브러리를 이용하여 제작되어서 CI에 완전히 통합되어 있기 때문에 웹을 이용한 테스트가 기본적으로 지원 되고, 상당히 가벼우면서 CI의 기능들을 저해하지 않았습니다.

TOAST assert 함수 목록

  • _assert_true($assertion) : if($assertion)일 때 성공
  • _assert_false($assertion) : if($assertion)가 아닐때 성공
  • _assert_false_strict($assertion) : $assertion === TRUE일 때 성공
  • _assert_false_strict($assertion) : $assertion === TRUE가 아닐 때 성공
  • _assert_equals($base, $check) : $base == $check일 때 성공
  • _assert_not_equals($base, $check) : $base == $check가 아닐 때 성공
  • _assert_equals_strict($base, $check) : $base === $check일 때 성공
  • _assert_not_equals_strict($base, $check) : $base === $check가 아닐 때 성공
  • _assert_empty($assertion) : if(empty(assertion))일 때 성공
  • _assert_not_empty($assertion) : if(empty(assertion))가 아닐 때 성공

위처럼 boolean, equal형은 엄격한(strict) 비교까지 8종의 assert 함수와 비어 있음 여부를 검사하는 assert 함수 2종을 제공합니다.

TOAST도 결국 Codeigniter Controller의 구현체이기 때문에 일반적인 codeigniter Controller 접근 방법을 통한 단위 테스트 및 개별 함수 테스트를 지원하고, toast_all을 통해 전체 테스트를 지원합니다.

위처럼 TOAST는 Codeigniter의 TDD 도구로 안성맞춤으로 보입니다만, Codeigniter 1.x 버전에서 개발이 중지되어 운영중인 버전에서 사용하기 위해서는 약간의 수정이 필요했습니다.

1. 테스트를 위한 환경 세팅

  1. xampp 설치

    https://www.apachefriends.org/index.html

  2. ci 설치

    https://codeigniter.com/download

  3. vhost(가상 호스트) 설정 - 별도의 url을 사용하지 않으면 생략하셔도 됩니다

     // 위치 : xampp/apache/conf/extra/httpd-vhosts.con
     <VirtualHost *:80>
         ServerName tdd.localhost
         DocumentRoot "c:/workplace/CI_Toast_TDD"
         <Directory "c:/workplace/CI_Toast_TDD">
             Options Indexes FollowSymLinks Includes execCGI
             AllowOverride all
             Require all granted
         </Directory>
     </VirtualHost>
    
  4. httpd 설정

     // 위치 : xampp/apache/conf/httpd.conf
     1. AllowOverride All
       <Directory> 태그의 AllowOverride 값을 All로 세팅 해줍니다.
    
     2. LoadModule rewrite_module modules/mod_rewrite.so
       위의 모듈이 주석처리 되어 있다면 해제 해줍니다.
    
  5. .htaccess 설정 index.php 날리기

     // 주소 끝에 index.php를 입력하지 않기 위하여
     //.htaccess 파일을 프로젝트 루트에 작성해 줍니다.
     <IfModule mod_rewrite.c>
       RewriteEngine On
       RewriteBase /
       RewriteCond $1 !^(index\.php|images|captcha|data|include|uploads|robots\.txt)
       RewriteCond %{REQUEST_FILENAME} !-f
       RewriteCond %{REQUEST_FILENAME} !-d
       RewriteRule ^(.*)$ /index.php/$1 [L]
     </IfModule>
    
  6. Toast 다운로드

    http://jensroland.com/projects/toast/

  7. CI 내부에 테스트 파일 배치

    • 다음 위치에 Toast 파일을 추가합니다. application/controllers/test
    • 기본적인 파일 구성은 Toast.php, Toast_all.php, example_tests.php입니다.

1. 테스트 도메인 접속

http://tdd.localhost/test/example_tests

toast
⇒ 에러 발생 Ci 1.x 버전용 Toast이기 때문에 정상적으로 작동하기 위해서는 약간의 수정이 필요합니다.



2. Codeigniter1에서 달라진 부분에 대하여 소스 수정

  1. example_tests.php 수정
  • TOAST에 기본적으로 포함된 테스트 파일 생성자 부분을 수정해 줍니다.

[AS-IS]

function Example_tests()
  {
    parent::Toast(__FILE__);
    // Load any models, libraries etc. you need here
  }

[TO-BE]

function __construct()
  {
    parent::__construct(__FILE__); // <- 여기가 수정됨
    // Load any models, libraries etc. you need here
  }

2. Toast.php 수정

  • 단위 테스트 메인 클래스인 Toast.php를 수정해 줍니다.
  • 상속받은 클래스를 Controller → CI_Controller로 변경해 줍니다.
  • 생성자 부분을 수정해 줍니다.

[AS-IS]

abstract class Toast extends Controller
{
  // The folder INSIDE /controllers/ where the test classes are located
  // TODO: autoset
  var $test_dir = '/test/';

  var $modelname;
  var $modelname_short;
  var $message;
  var $messages;
  var $asserts;

  function Toast($name)
  {
    parent::Controller();
    $this->load->library('unit_test');
    $this->modelname = $name;
    $this->modelname_short = basename($name, '.php');
    $this->messages = array();
  }

... 생략

[TO-BE]

abstract class Toast extends CI_Controller // <- 여기가 수정됨
{
  // The folder INSIDE /controllers/ where the test classes are located
  // TODO: autoset
  var $test_dir = '/test/';

  var $modelname;
  var $modelname_short;
  var $message;
  var $messages;
  var $asserts;

  function __construct($name) // <- 여기가 수정됨
  {
    parent::__construct();
    $this->load->library('unit_test');
    $this->modelname = $name;
    $this->modelname_short = basename($name, '.php');
    $this->messages = array();
  }

... 생략

3. Toast_all.php 수정

  • 전제 테스트를 위한 Toast_all.php 클래스를 수정해 줍니다.
  • 상속받은 클래스를 Controller → CI_Controller로 변경해 줍니다.
  • 생성자 부분을 수정해 줍니다.

[AS-IS]

class Toast_all extends Controller
{
  // The folder INSIDE /controllers/ where the test classes are located
  // TODO: autoset
  var $test_dir = '/test/';

  // Files to skip (ie. non-test classes) inside the test dir
  var $skip = array(
    'Toast.php',
    'Toast_all.php'
  );

  // CURL multithreaded mode (only set to true if you are sure your tests
  // don't conflict when run in parallel)
  var $multithreaded = false;

  function Toast_all()
  {
    parent::Controller();
  }

... 생략

[TO-BE]

class Toast_all extends CI_Controller // <- 여기가 수정됨
{
  // The folder INSIDE /controllers/ where the test classes are located
  // TODO: autoset
  var $test_dir = '/test/';

  // Files to skip (ie. non-test classes) inside the test dir
  var $skip = array(
    'Toast.php',
    'Toast_all.php'
  );

  // CURL multithreaded mode (only set to true if you are sure your tests
  // don't conflict when run in parallel)
  var $multithreaded = false;

  function __construct() // <- 여기가 수정됨
  {
    parent::__construct();
  }

... 생략

4. Codeigniter 버전에 맞게 수정한 TOAST 테스트

4-1. 클래스 지정 단위 테스트 : http://tdd.localhost/test/example_tests

toast

4-2. 함수 단위의 단일 테스트 : http://tdd.localhost/test/example_tests/that_fails

toast

4-3. 테스트 폴더 내의 모든 테스트 클래스 전체 테스트: http://tdd.localhost/test/toast_all

toast

주의사항

  • 로컬 테스트 시 virtual host를 사용한다면 hosts가장 호스트 경로를 등록해 주어야 toast_all에서 결과를 확인할 수 있습니다. (DNS가 등록되지 않아 Could not resolve host 에러가 발생함)
  • toast_all의 base url 은 config.php 의 $config[‘base_url’] 주소를 사용합니다.

3. Codeigniter Custom Loader 클래스를 구현하여 Database Mock 사용하기

아쉽게도 TOAST에서는 자체 mock 클래스를 지원하지 않습니다.

단위테스트 에서 자주 사용되는 mock 데이터를 이용하기 위하여 CI Loader 클래스를 상속받은 MY_Loader.php를 구현하여 $this→load→model() 함수를 사용할 때 mock 디렉터리 하위의 mock 클래스를 불러올 수 있도록 구현해 보겠습니다.

(MY_Loader.php 에서 ‘MY_’ prefix는 config.php $config[‘subclass_prefix’] = ‘MY_’; 에서 수정 하실 수 있습니다.)

  1. system/core/Loader.php를 복사하여 application/core/ 하위에 붙여 넣습니다.
  2. 소스에서 필요한 부분만 남기고 모두 제거하고, 소스를 알맞게 수정해 줍니다.
    • 필요한 함수인 model을 제외하고 모두 삭제합니다.
    • 상속 구문 extend CI_Loader를 추가합니다.
    • 생성자를 추가합니다.
    • 남겨둔 model 함수의 마지막 인자로 $is_mock 인자를 받습니다.
<?php

class MY_Loader extends CI_Loader // 원본 CI_Loader 상속
{
    // 생성자에서 부모(CI_Loader) 생성자 실행
    function __construct()
    {
        parent::__construct();
    }

    /**
     * CI_Loader의 model 함수를 그대로 복사한 후
     * 목업 데이터를 이용하기 위한 구분 변수만 추가
     */
    public function model($model, $name = '', $db_conn = FALSE, $is_mock = FALSE)
    {
        /* ... 소스 생략 */

        foreach ($this->_ci_model_paths as $mod_path)
        {
            // 로드할 파일 경로 지정 부분을 분기 처리해 줍니다.
            $filename = $mod_path.($is_mock ? 'mock/' : 'models/').$path.$model.'.php';

            if ( ! file_exists($filename))
            {
                continue;
            }

            if ($db_conn !== FALSE AND ! class_exists('CI_DB'))
            {
                if ($db_conn === TRUE)
                {
                    $db_conn = '';
                }

                $CI->load->database($db_conn, FALSE, TRUE);
            }

            if ( ! class_exists('CI_Model'))
            {
                load_class('Model', 'core');
            }

            require_once($filename);

            $model = ucfirst($model);

            $CI->$name = new $model();

            $this->_ci_models[] = $name;
            return;
        }

        // couldn't find the model
        show_error('Unable to locate the model you have specified: '.$model);
    }
}

3. 커스텀 Loader를 사용하여 실제 DB 데이터와 mock 데이터 로드

  • 상품 목록 조회 모델 객체 제작
//위치 : application/model/product_m.php

<?php
class Product_m extends CI_Model
{
    public function __construct()
    {
        parent::__construct();
    }

    public function select_product_list()
    {
        $sql = '
            SELECT
                no
                , name
                , price
                , inventory
            FROM
                table_product
        ';

        return $this->db->query($sql, $sql)->result();
    }
}
  • Mock 객체 제작
//위치 : application/mock/product_m.php

<?php
class Product_m // Mock 클레스이기 때문에 CI_Model 상속이 필요 없다.
{
    public function select_product_list()
    {
        $list = array();

        for ($i = 0; $i < 3; $i++) {

            $no = ($i + 1);
            $product = new \stdClass();

            $product->no = $no;
            $product->name = 'Mock 테스트 상품 ' . $no;
            $product->price = ($no * 1000);
            $product->inventory = 99;
            $list[] = $product;
        }

        return $list;
    }
}
  • DB 데이터 조회 예시
// application/controllers의 Product.php

// 일반적인 상품 목록 조회
public function product_list() {
        $this->load->model('product_m'); // 일반적인 모델 호출
        $product_list = $this->product_m->select_product_list();

        $this->output->set_content_type('text/json');
        $this->output->set_output(json_encode(array('product_list'=>$product_list)));
  }
toast
일반적인 DB 조회 결과 화면



  • MOCK 데이터를 이용한 데이터 로드 소스

$this→load→model()의 마지막 인자인 $is_mock을 true로 호출 합니다.

// application/controllers의 Product.php

// 일반적인 상품 목록 조회
public function product_list() {
        $this->load->model('product_m', '', false, true); // 마지막 인자인 $is_mock을 true로 설정
        $product_list = $this->product_m->select_product_list();

        $this->output->set_content_type('text/json');
        $this->output->set_output(json_encode(array('product_list'=>$product_list)));
  }
toast
DB를 조회하지 않고 Mock 데이터를 출력한 결과 화면



4. mock 데이터와 TOAST를 이용한 테스트 진행

  • TOAST 테스트 클래스 소스 작성
<?php
require_once(APPPATH . '/controllers/test/Toast.php');

class Product_tests extends Toast
{
   /* ... 소스 생략 */
    function test_product_list()
    {
        // Mock class를 이용해 데이터 로드
        $this->load->model('product_m', '', false, true);
        $product_list = $this->product_m->select_product_list();

        $this->message = '테스트용 Mock 정보: ';

        foreach ($product_list as $i=>$product) {
            $this->message .= json_encode($product, JSON_UNESCAPED_UNICODE).PHP_EOL;

            // _assert_equals() 함수를 통해 상품명 검증
            $this->_assert_equals($product_list[$i]->name, 'Mock 테스트 상품 '.($i+1));
        }
    }
}


  • 결과 화면 - 단위 테스트
toast

의도대로 mock 데이터를 활용한 상품명의 검증이 성공하여 결과 화면이 표출되었습니다.

위 테스트가 전체 테스트에서도 작동하는지 진행해봅니다.

  • 결과 화면 - 전체 테스트
toast

위처럼 전체 테스트 url로 접속해보니 정상적으로 테스트가 진행되었습니다.

Conclusion

‘어차피 개편할 거니까..’ 하며, 일거리에 치여 핑계만 만들고 차일 피일 미루다가 밀린 숙제를 하는 느낌이 되어버렸네요. ‘PHP + Codeigniter TDD’라는 키워드가 생각보다 자료가 없어서 뜬구름 잡듯이 구상만 하다가, 괜찮은 도구를 찾아내어 방향성을 잡을 수 있었습니다.

그렇게 TOAST를 선택하여 한참 테스트를 진행하고 있었는데 Mock 기능이 없어서 상당히 난감했었습니다. 그래도 다행히 Codeigniter가 확장성이 좋아서 Loder Class의 간단한 수정을 통해 mock 데이터를 이용할 수 있었습니다.

Codeigniter를 사용 중인데 마땅한 TDD 도구가 없어 고민이신 분들은 TOAST를 한번 사용해보세요!

약간 수정이 필요하지만 심플해서 사용하기 좋습니다.


강원우 | 커머스 개발팀
kangww@brandi.co.kr
브랜디, 오직 예쁜 옷만