Юнит-тестирование моделей в Yii

в 11:28, , рубрики: tdd, yii, метки: , ,

Сейчас я расскажу про применение техники TDD для разработки моделей, используя Yii-framework.
Изначально предполагается, что была прочитана тема «Тестирование» из официального мануала (http://yiiframework.ru/doc/guide/ru/test.overview).

Итак, окружение настроено и сейчас нашей задачей будет — создать модели категории и продуктов(Category, Product) и покрыть их тестами.


Допустим, таблица категорий у нас имеет следующие поля:

  • parent_id
  • name
  • description
  • status

Таблица продуктов:

  • category_id
  • name
  • description
  • price
  • status

Используя Gii, создаём модели по этим таблицам. Это будут модели Category и Product.

Так как модели у нас работают с базой, классы тестов будем наследовать от CDbTestCase.
Создаём класс теста для модели Category. Внутри создаём свойство «category» для объекта тестируемого класса и прописываем присваивание в методе setUp().

	class CategoryTest extends CDbTestCase
	{
	  /**
	   * @var Category
	   */
	  protected $category;

	  protected function setUp()
	  {
	      parent::setUp();
	      $this->category = new Category();
	  }

	}
	

Во всех моделях у нас есть валидация полей, с неё и начнём тестирование.

Опишем какие же правила валидации должны будут существовать для нашей модели:

  • title является обязательным полем
  • длина title максимально 150 символов
  • parent_id если заполнен, должен обязательно существовать в таблице
  • длина description максимально 4000 символов
  • status является обязательным полем
  • status должен содержать значение из заданного списка статусов

Итак, «title является обязательным полем». Руководствуясь TDD, сначала пишем тест.

	public function testTitleIsRequired()
	{
	    $this->category->title = '';
	    $this->assertFalse($this->category->validate(array('title')));
	}
	

  • Название теста явно говорит нам о том, что этот метод тестирует.
  • Внутри мы присваеваем title пустую строку, запускаем валидацию и проверяем, что она не прошла.

Запускаемся, тест красный. Пишем валидацию.

	    public function rules()
	    {
	        return array(
	            array('title', 'required'),
	        );
	    }    
	

Запускаемся, тест зелёный. Рефакторинга не требуется, поэтому переходим к следующему тесту.

Длина title максимально 150 символов

	public function testTitleMaxLengthIs150()
	{
	    $this->category->title = generateString(151);
	    $this->assertFalse($this->category->validate(array('title')));

	    $this->category->title = generateString(150);
	    $this->assertTrue($this->category->validate(array('title')));
	}

	//метод generateString(), генерирует строку с заданной длиной
	function generateString($length)
	{
	    $random= "";
	    srand((double)microtime()*1000000);
	    $char_list = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
	    $char_list .= "abcdefghijklmnopqrstuvwxyz";
	    $char_list .= "1234567890";
	    // Add the special characters to $char_list if needed

	    for($i = 0; $i < $length; $i++)
	    {
	        $random .= substr($char_list,(rand()%(strlen($char_list))), 1);
	    }
	    return $random;
	}
	

Запускаемся, тест красный. Нужно озеленить тест, добавив валидацию.

	    public function rules()
	    {
	        return array(
	            array('title', 'required'),
	            array('title', 'length', 'max' => 150)
	        );
	    }    
	

Запускаемся, тест зелёный, всё ок.

Теперь перейдём к валидации связей модели. В модели «Category» у нас подразумевается существование связи «parent».
Для реализации теста связей нам понадобятся фикстуры.
Создадим файл фикстуры в нужной папке.
Напомню, что файл фикстуры должен называться так же, как и таблица в которой будут хранится данные фикстуры.

	    return array(
	        'sample' => array(
	            'id' => 1,
	        ),
	        'sample2' => array(
	            'id' => 2,
	            'parent_id' => 1
	        )
	    );
	

Подключаем фикстуру к тесту.

	class CategoryTest extends CDbTestCase
	{
	    public $fixtures = array(
	        'categories' => 'Category'
	    );

	  ...
	

Пишем тест на проверку связи.

	public function testBelongsToParent()
	{
	    $category = Category::model()->findByPk(2);

	    $this->assertInstanceOf('Category', $category->parent);
	}
	

Запускаемся, тест красный. Нужно описать связь в модели.

	public function relations()
	{
	    return array(
	        'parent' => array(self::BELONGS_TO, __CLASS__, 'parent_id'),
	    );
	}
	

Запускаемся, тест зелёный.
С появлением фикстур появилась проблема. Метод setUp() класса CDbTestCase вызывает ресуркоемкий метод загрузки фикстур, даже, когда фикстуры тестового методу не нужны. Это проблему можно решить вот так.

	class DbTestCase extends CDbTestCase
	{
	    private static $_loadFixturesFlag = false;

	    /**
	     * Load fixtures one time
	     */
	    protected function setUp()
	    {
	        if (!self::$_loadFixturesFlag && is_array($this->fixtures)) {
	            $this->loadFixtures();
	            self::$_loadFixturesFlag = true;
	        }
	    }

	    /**
	     * Load fixtures
	     */
	    public function loadFixtures($fixtures = null)
	    {
	        if ($fixtures === null) {
	            $fixtures = $this->fixtures;
	        }

	        $this->getFixtureManager()->load($fixtures);
	    }
	}
	

Мы создали потомка CDbTestCase и модифицировали его. Теперь фикстуры будут вызываться лишь один раз, но если мы меняем в одном из тестов данные фикстур, то перезагружаем их вызвав метод loadFixtures() вручную.

Теперь приведу исходные коды конечного варианта классов «Category» и «CategoryTest». Написание тестов для модели «Product» остаётся как домашнее задание.

	class CategoryTest extends CDbTestCase
	{
		/**
		 * @var Category
		 */
		protected $category;

		protected function setUp()
		{
			parent::setUp();
			$this->category = new Category();
		}

		public function testAllAttributesHaveLabels()
		{
			$attributes = array_keys($this->category->attributes);

			foreach ($attributes as $attribute) {
				$this->assertArrayHasKey($attribute, $this->category->attributeLabels());
			}
		}

		public function testBelongsToParent()
		{
			$category = Category::model()->findByPk(2);

			$this->assertInstanceOf('Category', $category->parent);
		}


		public function testTitleIsRequired()
		{
			$this->category->title = '';
			$this->assertFalse($this->category->validate(array('title')));
		}


		public function testTitleMaxLengthIs150()
		{
			$this->category->title = generateString(151);
			$this->assertFalse($this->category->validate(array('title')));

			$this->category->title = generateString(150);
			$this->assertTrue($this->category->validate(array('title')));
		}

		public function testParentIdIsExist()
		{
			$this->category->parent_id = 'not-exist-value';
			$this->assertFalse($this->category->validate(array('parent_id')));

			$this->category->parent_id = 1;
			$this->assertTrue($this->category->validate(array('parent_id')));
		}

		public function testDescriptionMaxLengthIs4000()
		{
			$this->category->description = generateString(4001);
			$this->assertFalse($this->category->validate(array('description')));

			$this->category->description generateString(4000);
			$this->assertTrue($this->category->validate(array('description')));
		}

		public function testStatusIsRequired()
		{
			$this->category->status = '';
			$this->assertFalse($this->category->validate(array('status')));
		}

		public function testStatusExistsInStatusList()
		{
			$this->category->status = 'not-in-list-value';
			$this->assertFalse($this->category->validate(array('status')));

			$this->category->status = array_rand($this->category->getStatusList());
			$this->assertTrue($this->category->validate(array('status')));
		}

		public function testSafeAttributesOnSearchScenario()
		{
			$category = new Category('search');

			$mustBeSafe = array('title', 'description');
			$safeAttrs = $category->safeAttributeNames;
			sort($mustBeSafe); sort($safeAttrs);

			$this->assertEquals($mustBeSafe, $safeAttrs);
		}
	}

	/**
	 * This is the model class for table "{{categories}}".
	 *
	 * The followings are the available columns in table '{{categories}}':
	 * @property integer $id
	 * @property integer $parent_id
	 * @property string $title
	 * @property string $description
	 * @property integer $status
	 */
	class Category extends ActiveRecord
	{
		const STATUS_PUBLISH = 1;
		const STATUS_DRAFT = 2;

		/**
		 * Get status list or status label, if key exist
		 * @static
		 * @param string $key
		 * @return array
		 */
		public static function getStatusList($key = null)
		{
			$arr = array(
				self::STATUS_PUBLISH => 'Publish',
				self::STATUS_DRAFT   => 'Draft',
			);

			return $key === null ? $arr : $arr[$key];
		}

		/**
		 * @return string the associated database table name
		 */
		public function tableName()
		{
			return '{{categories}}';
		}

		/**
		 * @return array validation rules for model attributes.
		 */
		public function rules()
		{
			return array(
				array('title, status', 'required'),
				array('title', 'length', 'max' => 150),
				array('parent_id', 'exist', 'className' => __CLASS__, 'attributeName' => 'id'),
				array('description', 'length', 'max' => 4000),
				array('status', 'in', 'range' => array_keys($this->getStatusList())),

				array('title, description', 'safe', 'on' => 'search')
			);
		}

		/**
		 * @return array relational rules.
		 */
		public function relations()
		{
			return array(
				'parent' => array(self::BELONGS_TO, __CLASS__, 'parent_id'),
			);
		}

		/**
		 * @return array customized attribute labels (name=>label)
		 */
		public function attributeLabels()
		{
			return array(
				'id'          => 'ID',
				'parent_id'   => 'Parent ID',
				'title'       => 'Title',
				'description' => 'Description',
				'status'      => 'Status',
			);
		}

		/**
		 * Retrieves a list of models based on the current search/filter conditions.
		 * @return CActiveDataProvider the data provider that can return the models based on the search/filter conditions.
		 */
		public function search()
		{
			$criteria=new CDbCriteria;

			$criteria->compare('title',$this->title,true);
			$criteria->compare('description',$this->description,true);

			return new CActiveDataProvider($this, array(
				'criteria'=>$criteria,
			));
		}

	

Автор: fantgeass

* - обязательные к заполнению поля