Zend Framework и Doctrine 2 – достаточны ли мои тесты?

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

Вот что я до сих пор:

Base doctrine 'entity' class, содержащий методы, которые я хочу использовать во всех моих сущностях:

<?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); } } } 

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

 <?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(), )); } } 

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

Для объекта: 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

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

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

Заранее спасибо!

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

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

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

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

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

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

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

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

TL; DR

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

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

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

  4. удачи!