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.
2 Comments
Is this a course about Symfony and its components, or is it about promoting your own libraries and wrappers?
Sorry for the snarkiness, but it bothers me a bit ;]
Hey @Kris,
Hah, both I guess?
I find the out-of-the-box testing helpers with Symfony, when they exist, to be pretty verbose. I've written several
*-testlibraries that help here. They also make it really easy for me to keep these video tutorials succinct and to the point.I get they may not be everyone's cup of tea, so thank you for the feedback!
--Kevin
"Houston: no signs of life"
Start the conversation!