Description
Background
The docs call out $io->createFile() as an "often important part of many console commands", but Cake's ConsoleIntegrationTestTrait doesn't provide a mechanism to stub/mock the ConsoleIo class it passes to the command runner.
Current code
|
public function exec(string $command, array $input = []): void |
|
{ |
|
$runner = $this->makeRunner(); |
|
|
|
$this->_out ??= new StubConsoleOutput(); |
|
$this->_err ??= new StubConsoleOutput(); |
|
if ($this->_in === null || $input) { |
|
$this->_in = new StubConsoleInput($input); |
|
} |
|
$this->_out->clear(); |
|
$this->_err->clear(); |
|
|
|
$args = $this->commandStringToArgs("cake {$command}"); |
|
$io = new ConsoleIo($this->_out, $this->_err, $this->_in); |
|
|
|
try { |
|
$this->_exitCode = $runner->run($args, $io); |
|
} catch (MissingConsoleInputException $e) { |
Issue
Because the above trait code works entirely with variables local to the method call ($args and $io), there's no opportunity to inject a mocked ConsoleIo class for the purpose of avoiding writing real files to disk during test runs.
Suggested Fixes
One option would be to just expose the ConsoleIo instance (somehow) so developers could override the $io instance passed to $runner->run($args, $io);. One possible implementation would be to use Cake's service container to build the ConsoleIo instance. That would allow developers to use the existing ->mockService() hook to inject a modified ConsoleIo instance into the exec() method itself. A downside to using the service registry for fetching modified/mocked class instances gets a little 'meta' though.
The more extreme (but probably much nicer experience) approach would be to refactor ::exec() to pre-stub the createFile method and always block disk writes by default (when using ConsoleIntegrationTestTrait::exec() at least.) This pattern is already in place for things like Caches and Io messages, so it doesn't seem like a huge stretch to me to bundle createFile in with those.
Example pseudo code (untested):
public function exec(string $command, array $input = []): void
{
$runner = $this->makeRunner();
$this->_out ??= new StubConsoleOutput();
$this->_err ??= new StubConsoleOutput();
if ($this->_in === null || $input) {
$this->_in = new StubConsoleInput($input);
}
$this->_out->clear();
$this->_err->clear();
$args = $this->commandStringToArgs("cake {$command}");
- $io = new ConsoleIo($this->_out, $this->_err, $this->_in);
+ $this->_io ??= $this->getMockBuilder(ConsoleIo::class)
+ ->setConstructorArgs([$this->_out, $this->_err, $this->_in])
+ ->onlyMethods(['createFile'])
+ ->getMock();
try {
- $this->_exitCode = $runner->run($args, $io);
+ $this->_exitCode = $runner->run($args, $this->_io);
} catch (MissingConsoleInputException $e) {
$messages = $this->_out->messages();
if ($messages !== []) {
$e->setQuestion($messages[count($messages) - 1]);
}
throw $e;
} catch (StopException $exception) {
$this->_exitCode = $exception->getCode();
}
}
There are loads of possible variations, of course. I think the key part is switching from $io = to $this->_io ??= since this gives developers the opportunity to pre-set the ConsoleIo instance before $runner->run($args, $this->_io) is called. (Yes I know ??= might not be available in all php versions that Cake still supports-- this is just the most convenient syntax with which to share the idea of, "if not already set, use this mock".)
Happy to help brainstorm or test other possible avenues-- I'm in these weeds myself already.
CakePHP Version
5.4
PHP Version
8.5
Description
Background
The docs call out
$io->createFile()as an "often important part of many console commands", but Cake'sConsoleIntegrationTestTraitdoesn't provide a mechanism to stub/mock theConsoleIoclass it passes to the command runner.Current code
cakephp/src/Console/TestSuite/ConsoleIntegrationTestTrait.php
Lines 84 to 101 in ceeda19
Issue
Because the above trait code works entirely with variables local to the method call (
$argsand$io), there's no opportunity to inject a mocked ConsoleIo class for the purpose of avoiding writing real files to disk during test runs.Suggested Fixes
One option would be to just expose the ConsoleIo instance (somehow) so developers could override the
$ioinstance passed to$runner->run($args, $io);. One possible implementation would be to use Cake's service container to build the ConsoleIo instance. That would allow developers to use the existing->mockService()hook to inject a modified ConsoleIo instance into the exec() method itself. A downside to using the service registry for fetching modified/mocked class instances gets a little 'meta' though.The more extreme (but probably much nicer experience) approach would be to refactor
::exec()to pre-stub thecreateFilemethod and always block disk writes by default (when usingConsoleIntegrationTestTrait::exec()at least.) This pattern is already in place for things like Caches and Io messages, so it doesn't seem like a huge stretch to me to bundle createFile in with those.Example pseudo code (untested):
public function exec(string $command, array $input = []): void { $runner = $this->makeRunner(); $this->_out ??= new StubConsoleOutput(); $this->_err ??= new StubConsoleOutput(); if ($this->_in === null || $input) { $this->_in = new StubConsoleInput($input); } $this->_out->clear(); $this->_err->clear(); $args = $this->commandStringToArgs("cake {$command}"); - $io = new ConsoleIo($this->_out, $this->_err, $this->_in); + $this->_io ??= $this->getMockBuilder(ConsoleIo::class) + ->setConstructorArgs([$this->_out, $this->_err, $this->_in]) + ->onlyMethods(['createFile']) + ->getMock(); try { - $this->_exitCode = $runner->run($args, $io); + $this->_exitCode = $runner->run($args, $this->_io); } catch (MissingConsoleInputException $e) { $messages = $this->_out->messages(); if ($messages !== []) { $e->setQuestion($messages[count($messages) - 1]); } throw $e; } catch (StopException $exception) { $this->_exitCode = $exception->getCode(); } }There are loads of possible variations, of course. I think the key part is switching from
$io =to$this->_io ??=since this gives developers the opportunity to pre-set the ConsoleIo instance before$runner->run($args, $this->_io)is called. (Yes I know??=might not be available in all php versions that Cake still supports-- this is just the most convenient syntax with which to share the idea of, "if not already set, use this mock".)Happy to help brainstorm or test other possible avenues-- I'm in these weeds myself already.
CakePHP Version
5.4
PHP Version
8.5