2013-09-13 5 views
8

Я совершенно новый для Zend и модульного тестирования в целом. Я придумал небольшое приложение, которое использует Zend Framework 2 и Doctrine. У него есть только одна модель и контроллер, и я хочу выполнить некоторые модульные тесты на них.Zend Framework и Doctrine 2 - достаточны ли мои юнит-тесты?

Вот то, что я до сих пор: класс

базовой доктрины «сущности», содержащий методы, которые я хочу использовать во всех моих сущностей:

<?php 
/** 
* Base entity class containing some functionality that will be used by all 
* entities 
*/ 

namespace Perceptive\Database; 

use Zend\Validator\ValidatorChain; 

class Entity{ 

    //An array of validators for various fields in this entity 
    protected $validators; 

    /** 
    * Returns the properties of this object as an array for ease of use. Will 
    * return only properties with the ORM\Column annotation as this way we know 
    * for sure that it is a column with data associated, and won't pick up any 
    * other properties. 
    * @return array 
    */ 
    public function toArray(){ 
     //Create an annotation reader so we can read annotations 
     $reader = new \Doctrine\Common\Annotations\AnnotationReader(); 

     //Create a reflection class and retrieve the properties 
     $reflClass = new \ReflectionClass($this); 
     $properties = $reflClass->getProperties(); 

     //Create an array in which to store the data 
     $array = array(); 

     //Loop through each property. Get the annotations for each property 
     //and add to the array to return, ONLY if it contains an ORM\Column 
     //annotation. 
     foreach($properties as $property){ 
     $annotations = $reader->getPropertyAnnotations($property); 
     foreach($annotations as $annotation){ 
      if($annotation instanceof \Doctrine\ORM\Mapping\Column){ 
      $array[$property->name] = $this->{$property->name}; 
      } 
     } 
     } 

     //Finally, return the data array to the user 
     return $array; 
    } 

    /** 
    * Updates all of the values in this entity from an array. If any property 
    * does not exist a ReflectionException will be thrown. 
    * @param array $data 
    * @return \Perceptive\Database\Entity 
    */ 
    public function fromArray($data){ 
     //Create an annotation reader so we can read annotations 
     $reader = new \Doctrine\Common\Annotations\AnnotationReader(); 

     //Create a reflection class and retrieve the properties 
     $reflClass = new \ReflectionClass($this); 

     //Loop through each element in the supplied array 
     foreach($data as $key=>$value){ 
      //Attempt to get at the property - if the property doesn't exist an 
      //exception will be thrown here. 
      $property = $reflClass->getProperty($key); 

      //Access the property's annotations 
      $annotations = $reader->getPropertyAnnotations($property); 

      //Loop through all annotations to see if this is actually a valid column 
      //to update. 
      $isColumn = false; 
      foreach($annotations as $annotation){ 
      if($annotation instanceof \Doctrine\ORM\Mapping\Column){ 
       $isColumn = true; 
      } 
      } 

      //If it is a column then update it using it's setter function. Otherwise, 
      //throw an exception. 
      if($isColumn===true){ 
      $func = 'set'.ucfirst($property->getName()); 
      $this->$func($data[$property->getName()]); 
      }else{ 
      throw new \Exception('You cannot update the value of a non-column using fromArray.'); 
      } 
     } 

     //return this object to facilitate a 'fluent' interface. 
     return $this; 
    } 

    /** 
    * Validates a field against an array of validators. Returns true if the value is 
    * valid or an error string if not. 
    * @param string $fieldName The name of the field to validate. This is only used when constructing the error string 
    * @param mixed $value 
    * @param array $validators 
    * @return boolean|string 
    */ 
    protected function setField($fieldName, $value){ 
     //Create a validator chain 
     $validatorChain = new ValidatorChain(); 
     $validators = $this->getValidators(); 

     //Try to retrieve the validators for this field 
     if(array_key_exists($fieldName, $this->validators)){ 
     $validators = $this->validators[$fieldName]; 
     }else{ 
     $validators = array(); 
     } 

     //Add all validators to the chain 
     foreach($validators as $validator){ 
     $validatorChain->attach($validator); 
     } 

     //Check if the value is valid according to the validators. Return true if so, 
     //or an error string if not. 
     if($validatorChain->isValid($value)){ 
     $this->{$fieldName} = $value; 
     return $this; 
     }else{ 
     $err = 'The '.$fieldName.' field was not valid: '.implode(',',$validatorChain->getMessages()); 
     throw new \Exception($err); 
     } 
    } 
} 

Мой «конфигурации» лица, которое представляет собой однорядные таблица, содержащая некоторые параметры конфигурации:

<?php 
/** 
* @todo: add a base entity class which handles validation via annotations 
* and includes toArray function. Also needs to get/set using __get and __set 
* magic methods. Potentially add a fromArray method? 
*/ 
namespace Application\Entity; 

use Doctrine\ORM\Mapping as ORM; 
use Zend\Validator; 
use Zend\I18n\Validator as I18nValidator; 
use Perceptive\Database\Entity; 


/** 
* @ORM\Entity 
* @ORM\HasLifecycleCallbacks 
*/ 
class Config extends Entity{ 
    /** 
    * @ORM\Id 
    * @ORM\Column(type="integer") 
    */ 
    protected $minLengthUserId; 

    /** 
    * @ORM\Id 
    * @ORM\Column(type="integer") 
    */ 
    protected $minLengthUserName; 

    /** 
    * @ORM\Id 
    * @ORM\Column(type="integer") 
    */ 
    protected $minLengthUserPassword; 

    /** 
    * @ORM\Id 
    * @ORM\Column(type="integer") 
    */ 
    protected $daysPasswordReuse; 

    /** 
    * @ORM\Id 
    * @ORM\Column(type="boolean") 
    */ 
    protected $passwordLettersAndNumbers; 

    /** 
    * @ORM\Id 
    * @ORM\Column(type="boolean") 
    */ 
    protected $passwordUpperLower; 

    /** 
    * @ORM\Id 
    * @ORM\Column(type="integer") 
    */ 
    protected $maxFailedLogins; 

    /** 
    * @ORM\Id 
    * @ORM\Column(type="integer") 
    */ 
    protected $passwordValidity; 

    /** 
    * @ORM\Id 
    * @ORM\Column(type="integer") 
    */ 
    protected $passwordExpiryDays; 

    /** 
    * @ORM\Id 
    * @ORM\Column(type="integer") 
    */ 
    protected $timeout; 

    // getters/setters 
    /** 
    * Get the minimum length of the user ID 
    * @return int 
    */ 
    public function getMinLengthUserId(){ 
     return $this->minLengthUserId; 
    } 

    /** 
    * Set the minmum length of the user ID 
    * @param int $minLengthUserId 
    * @return \Application\Entity\Config This object 
    */ 
    public function setMinLengthUserId($minLengthUserId){ 
     //Use the setField function, which checks whether the field is valid, 
     //to set the value. 
     return $this->setField('minLengthUserId', $minLengthUserId); 
    } 

    /** 
    * Get the minimum length of the user name 
    * @return int 
    */ 
    public function getminLengthUserName(){ 
     return $this->minLengthUserName; 
    } 

    /** 
    * Set the minimum length of the user name 
    * @param int $minLengthUserName 
    * @return \Application\Entity\Config 
    */ 
    public function setMinLengthUserName($minLengthUserName){ 
     //Use the setField function, which checks whether the field is valid, 
     //to set the value. 
     return $this->setField('minLengthUserName', $minLengthUserName); 
    } 

    /** 
    * Get the minimum length of the user password 
    * @return int 
    */ 
    public function getMinLengthUserPassword(){ 
     return $this->minLengthUserPassword; 
    } 

    /** 
    * Set the minimum length of the user password 
    * @param int $minLengthUserPassword 
    * @return \Application\Entity\Config 
    */ 
    public function setMinLengthUserPassword($minLengthUserPassword){ 
     //Use the setField function, which checks whether the field is valid, 
     //to set the value. 
     return $this->setField('minLengthUserPassword', $minLengthUserPassword); 
    } 

    /** 
    * Get the number of days before passwords can be reused 
    * @return int 
    */ 
    public function getDaysPasswordReuse(){ 
     return $this->daysPasswordReuse; 
    } 

    /** 
    * Set the number of days before passwords can be reused 
    * @param int $daysPasswordReuse 
    * @return \Application\Entity\Config 
    */ 
    public function setDaysPasswordReuse($daysPasswordReuse){ 
     //Use the setField function, which checks whether the field is valid, 
     //to set the value. 
     return $this->setField('daysPasswordReuse', $daysPasswordReuse); 
    } 

    /** 
    * Get whether the passwords must contain letters and numbers 
    * @return boolean 
    */ 
    public function getPasswordLettersAndNumbers(){ 
     return $this->passwordLettersAndNumbers; 
    } 

    /** 
    * Set whether passwords must contain letters and numbers 
    * @param int $passwordLettersAndNumbers 
    * @return \Application\Entity\Config 
    */ 
    public function setPasswordLettersAndNumbers($passwordLettersAndNumbers){ 
     //Use the setField function, which checks whether the field is valid, 
     //to set the value. 
     return $this->setField('passwordLettersAndNumbers', $passwordLettersAndNumbers); 
    } 

    /** 
    * Get whether password must contain upper and lower case characters 
    * @return type 
    */ 
    public function getPasswordUpperLower(){ 
     return $this->passwordUpperLower; 
    } 

    /** 
    * Set whether password must contain upper and lower case characters 
    * @param type $passwordUpperLower 
    * @return \Application\Entity\Config 
    */ 
    public function setPasswordUpperLower($passwordUpperLower){ 
     //Use the setField function, which checks whether the field is valid, 
     //to set the value. 
     return $this->setField('passwordUpperLower', $passwordUpperLower); 
    } 

    /** 
    * Get the number of failed logins before user is locked out 
    * @return int 
    */ 
    public function getMaxFailedLogins(){ 
     return $this->maxFailedLogins; 
    } 

    /** 
    * Set the number of failed logins before user is locked out 
    * @param int $maxFailedLogins 
    * @return \Application\Entity\Config 
    */ 
    public function setMaxFailedLogins($maxFailedLogins){ 
     //Use the setField function, which checks whether the field is valid, 
     //to set the value. 
     return $this->setField('maxFailedLogins', $maxFailedLogins); 
    } 

    /** 
    * Get the password validity period in days 
    * @return int 
    */ 
    public function getPasswordValidity(){ 
     return $this->passwordValidity; 
    } 

    /** 
    * Set the password validity in days 
    * @param int $passwordValidity 
    * @return \Application\Entity\Config 
    */ 
    public function setPasswordValidity($passwordValidity){ 
     //Use the setField function, which checks whether the field is valid, 
     //to set the value. 
     return $this->setField('passwordValidity', $passwordValidity); 
    } 

    /** 
    * Get the number of days prior to expiry that the user starts getting 
    * warning messages 
    * @return int 
    */ 
    public function getPasswordExpiryDays(){ 
     return $this->passwordExpiryDays; 
    } 

    /** 
    * Get the number of days prior to expiry that the user starts getting 
    * warning messages 
    * @param int $passwordExpiryDays 
    * @return \Application\Entity\Config 
    */ 
    public function setPasswordExpiryDays($passwordExpiryDays){ 
     //Use the setField function, which checks whether the field is valid, 
     //to set the value. 
     return $this->setField('passwordExpiryDays', $passwordExpiryDays); 
    } 

    /** 
    * Get the timeout period of the application 
    * @return int 
    */ 
    public function getTimeout(){ 
     return $this->timeout; 
    } 

    /** 
    * Get the timeout period of the application 
    * @param int $timeout 
    * @return \Application\Entity\Config 
    */ 
    public function setTimeout($timeout){ 
     //Use the setField function, which checks whether the field is valid, 
     //to set the value. 
     return $this->setField('timeout', $timeout); 
    } 

    /** 
    * Returns a list of validators for each column. These validators are checked 
    * in the class' setField method, which is inherited from the Perceptive\Database\Entity class 
    * @return array 
    */ 
    public function getValidators(){ 
     //If the validators array hasn't been initialised, initialise it 
     if(!isset($this->validators)){ 
     $validators = array(
      'minLengthUserId' => array(
       new I18nValidator\Int(), 
       new Validator\GreaterThan(1), 
      ), 
      'minLengthUserName' => array(
       new I18nValidator\Int(), 
       new Validator\GreaterThan(2), 
      ), 
      'minLengthUserPassword' => array(
       new I18nValidator\Int(), 
       new Validator\GreaterThan(3), 
      ), 
      'daysPasswordReuse' => array(
       new I18nValidator\Int(), 
       new Validator\GreaterThan(-1), 
      ), 
      'passwordLettersAndNumbers' => array(
       new I18nValidator\Int(), 
       new Validator\GreaterThan(-1), 
       new Validator\LessThan(2), 
      ), 
      'passwordUpperLower' => array(
       new I18nValidator\Int(), 
       new Validator\GreaterThan(-1), 
       new Validator\LessThan(2), 
      ), 
      'maxFailedLogins' => array(
       new I18nValidator\Int(), 
       new Validator\GreaterThan(0), 
      ), 
      'passwordValidity' => array(
       new I18nValidator\Int(), 
       new Validator\GreaterThan(1), 
      ), 
      'passwordExpiryDays' => array(
       new I18nValidator\Int(), 
       new Validator\GreaterThan(1), 
      ), 
      'timeout' => array(
       new I18nValidator\Int(), 
       new Validator\GreaterThan(0), 
      ) 
     ); 
     $this->validators = $validators; 
     } 

     //Return the list of validators 
     return $this->validators; 
    } 

    /** 
    * @todo: add a lifecyle event which validates before persisting the entity. 
    * This way there is no chance of invalid values being saved to the database. 
    * This should probably be implemented in the parent class so all entities know 
    * to validate. 
    */ 
} 

и мой контроллер, который может читать и писать к объекту:

<?php 
/** 
* A restful controller that retrieves and updates configuration information 
*/ 
namespace Application\Controller; 

use Zend\Mvc\Controller\AbstractRestfulController; 
use Zend\View\Model\JsonModel; 

class ConfigController extends AbstractRestfulController 
{ 
    /** 
    * The doctrine EntityManager for use with database operations 
    * @var \Doctrine\ORM\EntityManager 
    */ 
    protected $em; 

    /** 
    * Constructor function manages dependencies 
    * @param \Doctrine\ORM\EntityManager $em 
    */ 
    public function __construct(\Doctrine\ORM\EntityManager $em){ 
     $this->em = $em; 
    } 

    /** 
    * Retrieves the configuration from the database 
    */ 
    public function getList(){ 
     //locate the doctrine entity manager 
     $em = $this->em; 

     //there should only ever be one row in the configuration table, so I use findAll 
     $config = $em->getRepository("\Application\Entity\Config")->findAll(); 

     //return a JsonModel to the user. I use my toArray function to convert the doctrine 
     //entity into an array - the JsonModel can't handle a doctrine entity itself. 
     return new JsonModel(array(
     'data' => $config[0]->toArray(), 
    )); 
    } 

    /** 
    * Updates the configuration 
    */ 
    public function replaceList($data){ 
     //locate the doctrine entity manager 
     $em = $this->em; 

     //there should only ever be one row in the configuration table, so I use findAll 
     $config = $em->getRepository("\Application\Entity\Config")->findAll(); 

     //use the entity's fromArray function to update the data 
     $config[0]->fromArray($data); 

     //save the entity to the database 
     $em->persist($config[0]); 
     $em->flush(); 

     //return a JsonModel to the user. I use my toArray function to convert the doctrine 
     //entity into an array - the JsonModel can't handle a doctrine entity itself. 
     return new JsonModel(array(
     'data' => $config[0]->toArray(), 
    )); 
    } 
} 

Из-за пределов характера на I не смог вставить в моих модульных тестов, но вот ссылки на мои модульных тестов до сих пор:

Для лица: https://github.com/hputus/config-app/blob/master/module/Application/test/ApplicationTest/Entity/ConfigTest.php

Для контроллера: https://github.com/hputus/config-app/blob/master/module/Application/test/ApplicationTest/Controller/ConfigControllerTest.php

Некоторые вопросы:

  • A m Я делаю что-то явно неправильно здесь?
  • В тестах для объекта я повторяю те же тесты для разных полей - есть ли способ минимизировать это? Например, как стандартная батарея тестов для запуска на целых столбцах, например?
  • В контроллере я пытаюсь «макетировать» администратор сущности доктрины, чтобы изменения не были действительно сохранены в базе данных - правильно ли я это делаю?
  • Есть ли что-нибудь еще в контроллере, который я должен проверить?

Заранее благодарен!

ответ

12

Хотя ваш код кажется достаточно прочным, он представляет собой пару проектных недочетов.

Прежде всего, Doctrine советует рассматривать объекты как простые, немые объекты значения и утверждает, что данные, которые они хранят, всегда считаются действительными.

Это означает, что любая бизнес-логика, такая как гидратация, фильтрация и валидация, должна перемещаться за пределы объектов на отдельный слой.

Говоря о гидратации, а не реализации по самостоятельно fromArray и toArray методов, вы можете использовать поставляемый DoctrineModule\Stdlib\Hydrator\DoctrineObject увлажняющее, который также может смешаться безотказно с Zend\InputFilter, для обработки фильтрации и проверки. Это заставит сущность тестировать гораздо менее многословную и, возможно, не столь необходимую, поскольку вы будете тестировать фильтр отдельно.

Еще одно важное предложение, исходящее от разработчиков Doctrine, - не вводить непосредственно в контроллеры ObjectManager. Это делается для целей инкапсуляции: желательно скрыть детали реализации вашего уровня персистентности до Controller и, опять же, выставить только промежуточный уровень.

В вашем случае, все это можно было бы сделать, имея ConfigService класс, разработанный контракт, который будет только предоставить методы, которые действительно необходимы (т.е. findAll(), persist() и другие удобные прокси), и спрячет зависимостей, которые не строго требуемый контроллером, например, EntityManager, входные фильтры и тому подобное. Это также будет способствовать облегчению насмешек.

Таким образом, если в один прекрасный день вы захотите внести некоторые изменения в свой уровень персистентности, вам просто нужно будет изменить, как ваша служба сущностей реализует свой контракт: подумайте о добавлении адаптера пользовательского кеша или используя ODM Doctrine, а не ORM или вообще не использовать Doctrine.

Помимо этого, ваш подход к тестированию устройств выглядит отлично.

TL; DR

  • Вы не должны встраивать бизнес-логику внутри доктрины сущностей.
  • Вы должны использовать гидраторы с входными фильтрами вместе.
  • Вам не следует вводить внутренние контроллеры EntityManager.
  • Промежуточный слой поможет реализовать эти варианты, сохраняя при этом развязку модели и контроллера.
+0

Спасибо за подробный ответ. Таким образом, каждый объект будет просто «немым» списком полей, и каждый из них будет иметь свой собственный «сервисный» класс, который содержит гидраторы (вместо toArray/fromArray), валидацию, геттеры и сеттеры и ссылку на EntityManager? Контроллер должен будет только взаимодействовать с классом «service»? Что касается модульного тестирования, это означало бы, что тестов не будет на самом объекте, а только на классе «службы»? – user1578653

+0

Да, в основном в сущности, у вас были бы только свойства со своими методами доступа и, возможно, метаданные аннотации, поэтому не очень много для проверки. –

+0

Что касается классов обслуживания, сколько из них вы хотите, зависит от того, насколько сложна иерархия объектов.Для простых архитектур вам, вероятно, не понадобится услуга для каждого объекта. То есть для модели, составленной с помощью User and Role entites, где вам никогда не понадобится роль без пользователя и наоборот, вам может понадобиться только UserService, который обрабатывает оба объекта, но в более сложном механизме auth вам может потребоваться больше гибкости, поэтому было бы лучше обрабатывать их отдельными услугами. В конце концов, это дизайнерское решение. ;) –

0
  1. Ваши тесты очень похожи на наши, поэтому нет ничего очевидного, что вы делаете неправильно. :)

  2. Я согласен, что это «немного пахнет», но у меня нет ответа для вас на этом. Наш стандарт - сделать все наши модели «немыми», и мы их не тестируем. Это не то, что я рекомендую, а потому, что я не встречал ваш сценарий, прежде чем я не хочу просто догадываться.

  3. Вы, кажется, тестирование довольно исчерпывающе, хотя я бы действительно рекомендую проверить насмешливые рамки: Phake (http://phake.digitalsandwich.com/docs/html/) Это действительно помогает отделить ваши утверждения от ваших насмешек, а также обеспечивает гораздо более перевариваемый синтаксис чем встроенные phpunit mocks.

  4. удачи!

+0

Если ваши модели «тупые», где вы пишете свою бизнес-логику? Могу ли я спросить. –

+0

В этом примере модели представляют собой объекты с переменными-членами и геттеры/сеттеры. Наш поток выглядит следующим образом: контроллеры вызывают оболочки api (которые часто возвращают модели, представляющие данные из БД), и передают эти модели другим классам, содержащим бизнес-логику, они принимают ответ от классов бизнес-логики и готовят их к Посмотреть. – STLMikey

Смежные вопросы