This is a follow-on from a previous question I had: How to decouple my data layer better and restrict the scope of my unit tests?
I've read around on Zend and DI/IoC and came up with the following changes to my code:
Module Bootstrap
class Api_Bootstrap extends Zend_Application_Module_Bootstrap
{
protected function _initAllowedMethods()
{
$front = Zend_Controller_Front::getInstance();
$front->setParam('api_allowedMethods', array('POST'));
}
protected function _initResourceLoader()
{
$resourceLoader = $this->getResourceLoader();
$resourceLoader->addResourceType('actionhelper', 'controllers/helpers', 'Controller_Action_Helper');
}
protected function _initActionHelpers()
{
Zend_Controller_Action_HelperBroker::addHelper(new Api_Controller_Action_Helper_Model());
}
}
Action Helper
class Api_Controller_Action_Helper_Model extends Zend_Controller_Action_Helper_Abstract
{
public function preDispatch()
{
if ($this->_actionController->getRequest()->getModuleName() != 'api') {
return;
}
$this->_actionController->addMapper('account', new Application_Model_Mapper_Account());
$this->_actionController->addMapper('product', new Application_Model_Mapper_Product());
$this->_actionController-&g开发者_如何学Pythont;addMapper('subscription', new Application_Model_Mapper_Subscription());
}
}
Controller
class Api_AuthController extends AMH_Controller
{
protected $_mappers = array();
public function addMapper($name, $mapper)
{
$this->_mappers[$name] = $mapper;
}
public function validateUserAction()
{
// stuff
$accounts = $this->_mappers['account']->find(array('username' => $username, 'password' => $password));
// stuff
}
}
So, now, the controller doesn't care what specific classes the mappers are - so long as there is a mapper...
But how do I now replace those classes with mocks for unit-testing without making the application/controller aware that it is being tested? All I can think of is putting something in the action helper to detect the current application enviroment and load the mocks directly:
class Api_Controller_Action_Helper_Model extends Zend_Controller_Action_Helper_Abstract
{
public function preDispatch()
{
if ($this->_actionController->getRequest()->getModuleName() != 'api') {
return;
}
if (APPLICATION_ENV != 'testing') {
$this->_actionController->addMapper('account', new Application_Model_Mapper_Account());
$this->_actionController->addMapper('product', new Application_Model_Mapper_Product());
$this->_actionController->addMapper('subscription', new Application_Model_Mapper_Subscription());
} else {
$this->_actionController->addMapper('account', new Application_Model_Mapper_AccountMock());
$this->_actionController->addMapper('product', new Application_Model_Mapper_ProductMock());
$this->_actionController->addMapper('subscription', new Application_Model_Mapper_SubscriptionMock());
}
}
}
This just seems wrong...
It is wrong, your system under test shouldn't have any knowledge of mock objects at all.
Thankfully, because you have DI in place, it doesn't have to. Just instantiate your object in the test, and use addMapper() to replace the default mappers with mocked versions.
Your test case should look something like:
public function testBlah()
{
$helper_model = new Api_Controller_Action_Helper_Model;
$helper_model->_actionController->addMapper('account', new Application_Model_Mapper_AccountMock());
$helper_model->_actionController->addMapper('product', new Application_Model_Mapper_ProductMock());
$helper_model->_actionController->addMapper('subscription', new Application_Model_Mapper_SubscriptionMock());
// test code...
}
You could also put this code in your setUp() method so that you don't have to repeat it for every test.
So, after a few misses, I settled on rewriting the action helper:
class Api_Controller_Action_Helper_Model extends Zend_Controller_Action_Helper_Abstract
{
public function preDispatch()
{
if ($this->_actionController->getRequest()->getModuleName() != 'api') {
return;
}
$registry = Zend_Registry::getInstance();
$mappers = array();
if ($registry->offsetExists('mappers')) {
$mappers = $registry->get('mappers');
}
$this->_actionController->addMapper('account', (isset($mappers['account']) ? $mappers['account'] : new Application_Model_Mapper_Account()));
$this->_actionController->addMapper('product', (isset($mappers['product']) ? $mappers['product'] : new Application_Model_Mapper_Product()));
$this->_actionController->addMapper('subscription', (isset($mappers['subscription']) ? $mappers['subscription'] : new Application_Model_Mapper_Subscription()));
}
}
This means that I can inject any class I like via the registry, but have a default/fallback to the actual mapper.
My test case is:
public function testPostValidateAccount($message)
{
$request = $this->getRequest();
$request->setMethod('POST');
$request->setRawBody(file_get_contents($message));
$account = $this->getMock('Application_Model_Account');
$accountMapper = $this->getMock('Application_Model_Mapper_Account');
$accountMapper->expects($this->any())
->method('find')
->with($this->equalTo(array('username' => 'sjones', 'password' => 'test')))
->will($this->returnValue($accountMapper));
$accountMapper->expects($this->any())
->method('count')
->will($this->returnValue(1));
$accountMapper->expects($this->any())
->method('offsetGet')
->with($this->equalTo(0))
->will($this->returnValue($account));
Zend_Registry::set('mappers', array(
'account' => $accountMapper,
));
$this->dispatch('/api/auth/validate-user');
$this->assertModule('api');
$this->assertController('auth');
$this->assertAction('validate-user');
$this->assertResponseCode(200);
$expectedResponse = file_get_contents(dirname(__FILE__) . '/_testPostValidateAccount/response.xml');
$this->assertEquals($expectedResponse, $this->getResponse()->outputBody());
}
And I make sure that I clear the default Zend_Registry instance in my tearDown()
Below is my solution to inject a mocked timestamp for a ControllerTest unit test, which is similar to the question originally posted above.
In the ControllerTest class, a $mockDateTime is instantiated and added as a parameter to the FrontController before calling dispatch().
public function testControllerAction() {
....
$mockDateTime = new DateTime('2011-01-01T12:34:56+10:30');
$this->getFrontController()->setParam('datetime', $mockDateTime);
$this->dispatch('/module/controller/action');
...
}
In the Controller class, dispatch() will pass any parameters into _setInvokeArgs(), which we extend here:
protected function _setInvokeArgs(array $args = array())
{
$this->_datetime = isset($args['datetime']) ? $args['datetime'] : new DateTime();
return parent::_setInvokeArgs($args);
}
The major advantage of this solution is that it allows dependency injection while it does not require the unit tests to clean up global state.
精彩评论