Watch out for this when testing Artisan commands

A weird reason for a failing test

Joel Clermont
Joel Clermont
2024-02-09

When you're testing an Artisan command, a simple change to how you write your test can cause your test to fail, even though the command you're testing is working perfectly.

Here's a simple example of a working test, with some logging statements to demonstrate what's happening:

// app/Console/Commands/SomeCommand.php
protected function handle()
{
    \Log::info('command is running');
}

// tests/Feature/SomeCommandTest.php
public function testSomeTest(): void
{
    \Log::info('about to start command');
    $this->artisan(SomeCommand::class)
        ->assertExitCode(0);
    \Log::info('command finished');
    
    // other command assertions here
    
    \Log:info('test is complete');
}

This example strips away everything except the very basics. We have three logging statements inside our test, and one inside the command.

When you run this test, the test passes, and you'll see the following in your log:

[timestamp] testing.INFO: about to start command
[timestamp] testing.INFO: command is running
[timestamp] testing.INFO: command finished
[timestamp] testing.INFO: test is complete

But if we make one simple change to our test, a change that seems like it should have no effect, the test will fail:

// tests/Feature/SomeCommandTest.php
public function testSomeTest(): void
{
    // only showing the change to capture as a variable
    $result = $this->artisan(SomeCommand::class);
    $result->assertExitCode(0);
}

And you'll see the following in your log:

[timestamp] testing.INFO: about to start command
[timestamp] testing.INFO: command finished
[timestamp] testing.INFO: test is complete
[timestamp] testing.INFO: command is running // 😱️ this is the problem!

Technically, when your assertions fail, the test method will exit, and test is complete won't actually be logged. But the sequencing here is correct, and this is the key to why things are failing.

The problem is that when we assign $this->artisan to a variable, the command is not actually run until the test method finishes. Because of this, any assertions we make about what the command should have done are going to fail. The command hasn't run yet!

Why does this happen? $this->artisan doesn't actually run the command, it returns an instance of PendingCommand. Inside the PendingCommand, nothing is executed until the object is destroyed and the __destruct() method is run.

This makes sense, because if it immediately ran the command, you'd have no way to set up other assertions about input or output. But by assigning it to a variable, we keep it in scope until our test finishes.

It can be a little confusing because we don't have the same sort of issues in a typical feature test where we are making requests. There, we can capture the response into a variable and make multiple assertions against it.

For command tests, the choice is to either just use the fluent syntax and not capture it as a variable, or if we really want to capture it as a variable, we need to manually call execute() on our PendingCommand instance.

Hope this helps,

Joel

P.S. Ever get stuck on something weird like this? A fresh pair of eyes always helps.

Toss a coin in the jar if you found this helpful.
Want a tip like this in your inbox every weekday? Sign up below 👇🏼

Level up your Laravel skills!

Each 2-minute email has real-world advice you can use.