Юнит-тестирование моделей в 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 DbTestCase
	{
		/**
		 * @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 CActiveRecord
	{
		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,
			));
		}

	
Метки:
Поделиться публикацией
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 11
  • –3
    А в итоге кода получается в несколько раз больше, чем может содержать модель, а что уж говорить про затраченное время…
    • +2
      Большую часть времени можно сэкономить инкапсулируя схожие тесты в классы, допустим проверка наличия лабелов у полей — ModelTest, и от него наследовать все остальные тесты для моделей. Да и при тестировании это может сэкономить уйму времени.
      • +1
        Это ключевой вопрос парадигмы TDD — если вы делаете блог — то возможно можно обойтись и «натурным» тестированием —
        Если у вас десятки модулей — и много разработчиков, то после каждого коммита не натестируешься.
        А написание тестов дисциплинирует — причем всех. ;-)
        • +2
          Во-первых, такие тесты очень простые и пишутся довольно быстро. Вообще, все тесты должны быть просты.
          Во-вторых, время на дебаг с тех пор как я начал писать тесты заметно уменьшилось. А дебаг это обычно 50-80% времени работы на проектом.
          В-третьих, я уверен в своём коде. Эта уверенность, поверьте, многого стоит. Я больше не «боюсь» своих проектов.
        • 0
          Все хорошо, только эти тесты полноценно назвать unit нельзя, т.к. они зависят от БД.
          Как следствие:
          1) тесты могут быть провалены из-за отсутствия коннекта к БД или отсутствия таблицы;
          2) они долго выполняются, особенно если моделей много.

          С другой стороны я не знаю как в рамках архитектуры Yii можно сделать простые и легко изменяемые unit-тесты моделей (например, в запросах, которые в конечном счете получаются могут появляться скобки, условия не обязательно дописываются в конец и более того, нельзя заранее с 100% уверенности предсказать, какой запрос получится, а значит TDD не получится).

          Отмечу, что в официальной доке www.yiiframework.com/doc/guide/1.1/en/test.unit, эти тесты тоже называются unit.

          Просьба автору внести это замечание в статью, т.к. это может вводить в заблуждение читающего.
          • 0
            хорошая и подробная статья, спасибо. на ее основе освоил юнит тестирование в yii.
            хотя у меня чисто unit тестирования не получается, модель использует api для валидации некоторых параметров и я не вижу разумного пути отвязать api (правила для одного параметра, допустим, могут меняться каждый день).

            использую показатель code coverage %, но судя по структуре Yii, он оставляет желать лучшего. вся функция rules выглядит зеленой (phpstorm) даже после одного теста. соответственно вся модель зеленеет, что красиво для клиента, но неудобно для разработчика. как выкрутиться — пока еще не придумал.
            • 0
              Так ли необходимо тестировать атрибуты? Для этого ведь есть validate(), разве что проверить разработчиков.
              • 0
                Необходимо, так ты проверяешь что указал все нужные валидаторы.
              • 0
                По моему метод generateString немного пахнет параноей:
                • +1
                  А почему вы нигде не проверяете метод $category->save()?
                  Может быть такая ситуация, что вы или кто то напишет обработчик события на onBeforeSave(), с ошибкой при каких то условиях, но не напишет на него тест.
                  Вы запустите тесты — все гуд, все зеленые.
                  А самое главное — сохранять модель — не получится.
                  Ваши тесты подтверждают только то, что у модели есть правила, но не то проверяют может модель сохраниться или нет.

                  А вообще местами интересная. спасибо.
                  • 0
                    У меня немного глупый вопрос, но я никак не могу понять, зачем писать столько методов, если можно все эти правила указать в одном методе testValidate()?
                    Проще будет найти, где отвалился тест — сомнительно, ведь для проверки модно указать сообщение.

                    Я не встречал ни одной реальной модели, которая содержала бы только один сценарий валидации. Получается, если несколько сценариев — количество подобных методов станет просто огромным и их будет неудобно поддерживать.

                    К тому же, как я понимаю, суть unit-теста проверить корректность работы конкретного метода т.е. один метод — один тестовый метод на него. Или я не прав?

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