Login to bookmark this video
17.

Test for CLI Command

|

Share this awesome video!

|

The captain is tired of people running after the rocket because they show up late! That's why we created a command to send reminder emails! Problem solved! Now let's write a test to ensure it keeps working. "New feature, new test", that's my motto!

Jump over to your terminal and run:

symfony console make:test

Type? KernelTestCase. Name? SendBookingRemindersCommandTest.

SendBookingRemindersCommandTest

In our IDE, the new class was added to tests/. Open it up and move the class to a new namespace: App\Tests\Functional\Command, to keep things organized.

Perfect. First, clear out the guts and add some behavior traits: use ResetDatabase, Factories, InteractsWithMailer:

// ... lines 1 - 9
class SendBookingRemindersCommandTest extends KernelTestCase
{
use ResetDatabase, Factories, InteractsWithMailer;
// ... lines 13 - 22
}

Stub out two tests: public function testNoRemindersSent() with $this->markTestIncomplete() and public function testRemindersSent(). Also mark it incomplete:

// ... lines 1 - 9
class SendBookingRemindersCommandTest extends KernelTestCase
{
// ... lines 12 - 13
public function testNoRemindersSent()
{
$this->markTestIncomplete();
}
public function testRemindersSent()
{
$this->markTestIncomplete();
}
}

Back in the terminal run the tests with:

bin/phpunit

Testing TODO List

Check it out, our original two tests are passing, the two dots, and these I's are the new incomplete tests. I love this pattern: write test stubs for a new feature, then make a game of removing the incompletes one-by-one until they're all gone. Then, the feature is done!

Symfony has some out-of-the-box tooling for testing commands, but I like to use a package that wraps these up into a nicer experience. Install it with:

zenstruck/console-test

composer require --dev zenstruck/console-test

To enable this package's helpers, add a new behavior trait to our test: InteractsWithConsole:

// ... lines 1 - 10
class SendBookingRemindersCommandTest extends KernelTestCase
{
use ResetDatabase, Factories, InteractsWithMailer, InteractsWithConsole;
// ... lines 14 - 26
}

We're ready to knock down those I's!

testNoRemindersSent()

The first test is easy: we want to ensure that, when there's no bookings to remind, the command doesn't send any emails. Write $this->executeConsoleCommand() and just the command name: app:send-booking-reminders. Ensure the command ran successfully with ->assertSuccessful() and ->assertOutputContains('Sent 0 booking reminders'):

// ... lines 1 - 10
class SendBookingRemindersCommandTest extends KernelTestCase
{
// ... lines 13 - 14
public function testNoRemindersSent()
{
$this->executeConsoleCommand('app:send-booking-reminders')
->assertSuccessful()
->assertOutputContains('Sent 0 booking reminders')
;
}
// ... lines 22 - 26
}

testRemindersSent()

Arrange

On to the next test! This one is more involved: we need to create a booking that is eligible for a reminder. Create the booking fixture with $booking = BookingFactory::createOne(). Pass an array with 'trip' => TripFactory::new(), and inside that, another array with 'name' => 'Visit Mars', 'slug' => 'iss' (to avoid the image issue). The booking also needs a customer: 'customer' => CustomerFactory::new(). All we care about is the customer's email: 'email' => 'steve@minecraft.com'. Finally, the booking date: 'date' => new \DateTimeImmutable('+4 days'):

// ... lines 1 - 14
class SendBookingRemindersCommandTest extends KernelTestCase
{
// ... lines 17 - 26
public function testRemindersSent()
{
$booking = BookingFactory::createOne([
'trip' => TripFactory::new([
'name' => 'Visit Mars',
'slug' => 'iss',
]),
'customer' => CustomerFactory::new(['email' => 'steve@minecraft.com']),
'date' => new \DateTimeImmutable('+4 days'),
]);
// ... lines 37 - 56
}
}

Phew! We have a booking in the database that needs a reminder sent. This test's setup, or arrange step, is done.

Pre-Assertion

Add a pre-assertion to ensure this booking hasn't had a reminder sent: $this->assertNull($booking->getReminderSentAt()):

// ... lines 1 - 14
class SendBookingRemindersCommandTest extends KernelTestCase
{
// ... lines 17 - 26
public function testRemindersSent()
{
// ... lines 29 - 37
$this->assertNull($booking->getReminderSentAt());
// ... lines 39 - 56
}
}

Act

Now for the act step: $this->executeConsoleCommand('app:send-booking-reminders') ->assertSuccessful()->assertOutputContains('Sent 1 booking reminders'):

// ... lines 1 - 14
class SendBookingRemindersCommandTest extends KernelTestCase
{
// ... lines 17 - 26
public function testRemindersSent()
{
// ... lines 29 - 39
$this->executeConsoleCommand('app:send-booking-reminders')
->assertSuccessful()
->assertOutputContains('Sent 1 booking reminders')
;
// ... lines 44 - 56
}
}

Assert

Onto the assert phase to ensure the email was sent. In BookingTest, copy the email assertion and paste it here. Make a few adjustments: the email is steve@minecraft.com, subject is Booking Reminder for Visit Mars and this email doesn't have an attachment, so remove that assertion entirely:

// ... lines 1 - 14
class SendBookingRemindersCommandTest extends KernelTestCase
{
// ... lines 17 - 26
public function testRemindersSent()
{
// ... lines 29 - 44
$this->mailer()
->assertSentEmailCount(1)
->assertEmailSentTo('steve@minecraft.com', function(TestEmail $email) {
$email
->assertSubject('Booking Reminder for Visit Mars')
->assertContains('Visit Mars')
->assertContains('/booking/'.BookingFactory::first()->getUid())
;
})
;
// ... lines 55 - 56
}
}

Finally, write an assertion that the command updated the booking in the database. $this->assertNotNull($booking->getReminderSentAt()):

// ... lines 1 - 14
class SendBookingRemindersCommandTest extends KernelTestCase
{
// ... lines 17 - 26
public function testRemindersSent()
{
// ... lines 29 - 55
$this->assertNotNull($booking->getReminderSentAt());
}
}

Moment of truth! Run the tests:

bin/phpunit

All green!

Outside-In Testing

I find these type of outside-in tests really fun and easy to write because you don't have to worry too much about testing the inner logic and they mimic how a user interacts with your app. It's no accident that the assertions are focused on what the user should see and some high level post-interaction checks, like checking something in the database.

Now that we have tests for both of our email sending paths, let's take a victory lap & refactor with confidence to remove duplication.