Test for CLI Command
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) { | |
->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.