Сейчас я расскажу про применение техники TDD для разработки моделей, используя Yii-framework.
Изначально предполагается, что была прочитана тема «Тестирование» из официального мануала (http://yiiframework.ru/doc/guide/ru/test.overview).
Итак, окружение настроено и сейчас нашей задачей будет — создать модели категории и продуктов(Category, Product) и покрыть их тестами.
Допустим, таблица категорий у нас имеет следующие поля:
Таблица продуктов:
Используя Gii, создаём модели по этим таблицам. Это будут модели Category и Product.
Так как модели у нас работают с базой, классы тестов будем наследовать от CDbTestCase.
Создаём класс теста для модели Category. Внутри создаём свойство «category» для объекта тестируемого класса и прописываем присваивание в методе setUp().
Во всех моделях у нас есть валидация полей, с неё и начнём тестирование.
Опишем какие же правила валидации должны будут существовать для нашей модели:
Итак, «title является обязательным полем». Руководствуясь TDD, сначала пишем тест.
Запускаемся, тест красный. Пишем валидацию.
Запускаемся, тест зелёный. Рефакторинга не требуется, поэтому переходим к следующему тесту.
Длина title максимально 150 символов
Запускаемся, тест красный. Нужно озеленить тест, добавив валидацию.
Запускаемся, тест зелёный, всё ок.
Теперь перейдём к валидации связей модели. В модели «Category» у нас подразумевается существование связи «parent».
Для реализации теста связей нам понадобятся фикстуры.
Создадим файл фикстуры в нужной папке.
Напомню, что файл фикстуры должен называться так же, как и таблица в которой будут хранится данные фикстуры.
Подключаем фикстуру к тесту.
Пишем тест на проверку связи.
Запускаемся, тест красный. Нужно описать связь в модели.
Запускаемся, тест зелёный.
С появлением фикстур появилась проблема. Метод setUp() класса CDbTestCase вызывает ресуркоемкий метод загрузки фикстур, даже, когда фикстуры тестового методу не нужны. Это проблему можно решить вот так.
Мы создали потомка CDbTestCase и модифицировали его. Теперь фикстуры будут вызываться лишь один раз, но если мы меняем в одном из тестов данные фикстур, то перезагружаем их вызвав метод loadFixtures() вручную.
Теперь приведу исходные коды конечного варианта классов «Category» и «CategoryTest». Написание тестов для модели «Product» остаётся как домашнее задание.
Изначально предполагается, что была прочитана тема «Тестирование» из официального мануала (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,
));
}