Mocking is a pain in the ***, even if you use PHPUnit. I present an alternative way to do side-effects and mocking, by combining the pipeline pattern, the command pattern and generators.
Use-case: With a simple Yii 2 console controller, fetch a user from the database, revert its admin status, and print the result.
Code:
public function actionIndex($userId = 1)
{
$io = new IOFactory(); // TODO: Inject
return [
$io->queryOne('SELECT * FROM users WHERE id = ' . $userId),
new FilterEmpty($io->printline('Found no such user')),
/**
* @param array $user TODO: Filter to convert array -> object
* @return int
*/
function (array $user) use ($io) {
yield $io->printline('Yay, found user!');
$becomeAdmin = $user['is_admin'] ? 0 : 1;
yield $io->query(
sprintf(
'UPDATE users SET is_admin = %d WHERE id = %d',
$becomeAdmin,
$user['id']
)
);
return $becomeAdmin;
},
/**
* NB: If previous closure has both yields and return, two arguments will be sent to next closure.
* @param int $rowsAffected Result from last yield
* @param int $becomeAdmin Result from previous closure's return
* @return int
*/
function ($rowsAffected, $becomeAdmin) use ($io) {
if ($becomeAdmin === 1) {
yield $io->printline('User is now admin');
} else {
yield $io->printline('User is no longer admin');
}
return ExitCode::OK;
}
];
}
Instead of injecting a repository, model or database connection, we inject a side-effect factory class (shortened to $io). This can be replaced with a very simple mock to replace the results of the executed commands. E.g., to mock the first query, we simply replace $io with
$io = new Mock(
[
0 => ['id' => 1, 'is_admin' => 0],
]
);
where 0
is “side-effect number 1”, and the array the result from the query. The other side-effects can be omitted since the code doesn’t really depend on anything else (UPDATE
query and stdout).
So, pretty cool. Only a small change was needed to the Yii controller to make it return a pipeline.
Example output:
~/pipe/basic$ ./yii hello 1
Yay, found user!
User is now admin
~/pipe/basic$ ./yii hello 1
Yay, found user!
User is no longer admin
~/pipe/basic$ ./yii hello 2
Found no such user
~/pipe/basic$
Full code: https://github.com/olleharstedt/piiype
If you want to move code to a service class, you’d have to make it yield too, and then do yield from $service->doSomething()
in the closure to propagate the side-effect commands to the top.
Things that can be done in the future:
- Add support for rollback at failure
- Add caching
- Middleware interaction
Things I didn’t figure out:
- Multiple database connections
- A more complex use-case involving HTTP, curl, database and file IO