Testing Laravel Password Resets
Testing is an important yet often overlooked aspect of building successful Laravel applications. This article will provide an introduction to testing applications written using the Laravel Framework.
For our purposes we’ll be writing feature tests that make HTTP requests to our application and then make assertions about the responses and the state of the application’s database after the request is complete. We will make minimal changes to the authentication scaffolding provided by Laravel and focus on testing the Password Reset feature.
Getting Started
Assuming you are familiar with setting up a new Laravel project, use your terminal and the Laravel installer to create a new project.
If you aren’t familiar with setting up a development environment for a new Laravel application I encourage you to check out the documentation on installation and the Vagrant box Homestead.
Create a new Laravel application in the directory password-reset-testing
.
$ laravel new password-reset-testing
Once composer has finished installing everything, change your working directory to that of the new project.
$ cd password-reset-testing/
Next use Artisan to generate the authentication scaffolding for our application.
$ php artisan make:auth
Again using Artisan, run the database migrations to create the users
and password_resets
tables.
$ php artisan migrate
Naming Each Route
As a best practice, each of our application’s routes should have a name. By using route names and the route helper function instead of hard-coding routes, the URI of a route can be easily changed in the future.
Open up routes/web.php
and change the contents to match below.
<?php
// Welcome Route
Route::get('/', function () {
return view('welcome');
})->name('welcome');
// Authentication Routes
Route::get('login', 'Auth\LoginController@showLoginForm')
->name('login');
Route::post('login', 'Auth\LoginController@login')
->name('login.submit');
Route::post('logout', 'Auth\LoginController@logout')
->name('logout');
// Registration Routes
Route::get('register',
'Auth\RegisterController@showRegistrationForm')
->name('register');
Route::post('register',
'Auth\RegisterController@register')
->name('register.submit');
// Password Reset Routes
Route::get('password/reset',
'Auth\ForgotPasswordController@showLinkRequestForm')
->name('password.request');
Route::post('password/email',
'Auth\ForgotPasswordController@sendResetLinkEmail')
->name('password.email');
Route::get('password/reset/{token}',
'Auth\ResetPasswordController@showResetForm')
->name('password.reset');
Route::post('password/reset',
'Auth\ResetPasswordController@reset')
->name('password.reset.submit');
// Home Route
Route::get('/home', 'HomeController@index')
->name('home');
Note that we didn’t change any of the routes provided by the original Auth::routes()
statement, we simply rewrote them to include names for every route.
Editing the Base Test Case
Before we write our tests, let’s quickly edit the base test case. Open up the file at tests/TestCase.php
and edit the contents to match below.
<?php
namespace Tests;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Notification;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
use DatabaseTransactions;
/**
* Set up the test case.
*/
protected function setUp()
{
parent::setUp();
Notification::fake();
}
}
First we import the Illuminate\Foundation\Testing\DatabaseTransactions
trait and the Notification
facade.
The statement use DatabaseTransactions
at the top of the class tells Laravel to create a database transaction before each test and roll back the transaction after each test. This will keep our tests from affecting the state of our database; the database will be in the same starting state for each test.
We override the setUp
method which is called before running each test. In this method we first call the parent setUp
method then call fake
on the Notification
facade. This will fake all notifications sent out during any of our tests. Within each test we can then use another method on the Notification
facade to assert a notification would have been sent to the correct destination.
Creating the Test Class
Use artisan to generate a new feature test called PasswordResetTest
.
$ php artisan make:test PasswordResetTest
Open the new file at tests/Feature/PasswordResetTest.php
and edit the contents to match below.
<?php
namespace Tests\Feature;
use App\User;
use Hash;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\WithFaker;
use Notification;
use Password;
use Tests\TestCase;
class PasswordResetTest extends TestCase
{
use WithFaker;
const ROUTE_PASSWORD_EMAIL = 'password.email';
const ROUTE_PASSWORD_REQUEST = 'password.request';
const ROUTE_PASSWORD_RESET = 'password.reset';
const ROUTE_PASSWORD_RESET_SUBMIT = 'password.reset.submit';
const USER_ORIGINAL_PASSWORD = 'secret';
}
Here we’ve added import statements for the model App\User
, the facades Hash
, Notification
, and Password
, and the notification Illuminate\Auth\Notifications\ResetPassword
. We’ve also added an import statement for the trait Illuminate\Foundation\Testing\WithFaker
which conveniently instantiates a Faker factory for us for use within our tests. We simply specify our class is using the WithFaker
trait and each test case will have an instance of a Faker factory at $this->faker
.
Within our class we replaced the example test case with a statement specifying we’re using the WithFaker
trait, constants for each route name we’ll be using, and a constant for the password test users will have.
Writing Test Cases
We will write tests for the following cases:
- Showing the password reset request page
- Submitting the password reset request page with an invalid email address
- Submitting the password reset request page with an email address not in use
- Submitting the password reset request page with a valid email address in use
- Showing the reset password page
- Submitting the reset password page with an invalid email address
- Submitting the reset password page with an email address not in use
- Submitting the reset password page with a valid email address in use and a password that does not match the password confirmation
- Submitting the reset password page with a valid email address in use and a password that isn’t long enough
- Submitting the reset password page with a valid email address in use and a valid password matching the password confirmation
After each new test, feel free to run PHPUnit using your terminal.
$ ./vendor/bin/phpunit
Testing Showing the Password Reset Request Page
Now it’s time to write our first test! Edit the PasswordResetTest
class by adding the method below. By convention each test case method starts with test
which is then recognized by PHPUnit.
/**
* Testing showing the password reset request page.
*/
public function testShowPasswordResetRequestPage()
{
$this
->get(route(self::ROUTE_PASSWORD_REQUEST))
->assertSuccessful()
->assertSee('Reset Password')
->assertSee('E-Mail Address')
->assertSee('Send Password Reset Link');
}
In this test case we use the method get
to make a GET
request to the specified URI. We generate the URI using the route
helper method and the name of our route, which is stored in a constant. The assertSuccessful
method asserts the response has a 200
level status code. Next we use the assertSee
method to check for the presence of the text Reset Password
, E-Mail Address
, and Send Password Reset Link
.
Testing Submitting the Password Reset Request Page
Our next few tests will be testing submitting the password reset request page with various inputs.
Add the next test shown below which tests submitting a password reset request with an invalid email address.
/**
* Testing submitting the password reset request with an invalid
* email address.
*/
public function testSubmitPasswordResetRequestInvalidEmail()
{
$this
->followingRedirects()
->from(route(self::ROUTE_PASSWORD_REQUEST))
->post(route(self::ROUTE_PASSWORD_EMAIL), [
'email' => str_random(),
])
->assertSuccessful()
->assertSee(__('validation.email', [
'attribute' => 'email',
]));
}
When a request fails validation, Laravel will return a redirect to the location the request came from with validation error messages flashed to the session. To make assertions on the response the user will see, therefore, we need to follow redirects with the followingRedirects
method. We also specify a location we’re making the request from using the from
method.
Next we use the post
method to issue a POST
request to the password.email
route (again using the route helper and a previously defined constant) with data specifying the email
key as a random string (using the str_random
helper method).
We assert the response is successful and check for the presence of a validation message. The __
helper method is used to format the validation message using localization files. validation.email
specifies the file resources/lang/{locale}/validation.php
and the email
array key, where {locale}
is the application’s configured locale. The :attribute
parameter in the string The :attribute must be a valid email address.
will be replaced by the string email
as specified by the associative array passed as the second argument to the __
method.
Next we’ll be testing submitting the password reset request page with a valid email address that is not in use by any user of the application.
Add the test shown below.
/**
* Testing submitting the password reset request with an email
* address not in the database.
*/
public function testSubmitPasswordResetRequestEmailNotFound()
{
$this
->followingRedirects()
->from(route(self::ROUTE_PASSWORD_REQUEST))
->post(route(self::ROUTE_PASSWORD_EMAIL), [
'email' => $this->faker->unique()->safeEmail,
])
->assertSuccessful()
->assertSee(e(__('passwords.user')));
}
Again we follow redirects and set the location where our request should originate from, but this time we use Faker to generate an email address that is not in use by anyone in the world (as the domains are example.com
, example.net
, and example.org
). We use the unique
method to ensure the email address returned has not been previously returned by Faker.
We assert the response is successful and check for the presence of the validation error message specified by the user
key in the associative array in the file resources/lang/{locale}/passwords.php
. This time the validation message contains a reserved HTML character, '
, so we must use the e
helper method to replace the character with it’s corresponding character entity.
Finally it’s time to test successfully submitting the password reset request page with a valid email address present in our application’s database.
Add the test shown below.
/**
* Testing submitting a password reset request.
*/
public function testSubmitPasswordResetRequest()
{
$user = factory(User::class)->create();
$this
->followingRedirects()
->from(route(self::ROUTE_PASSWORD_REQUEST))
->post(route(self::ROUTE_PASSWORD_EMAIL), [
'email' => $user->email,
])
->assertSuccessful()
->assertSee(__('passwords.sent'));
Notification::assertSentTo($user, ResetPassword::class);
}
In this test we use the factory
helper method to create a new user in our database. Then we follow redirects for the response to our POST
request to the password.email
route. Our request specifies the created user’s email address in the email
key of the payload. We assert the response is successful and check for the presence of the string We have e-mailed your password reset link!
, specified with the argument passwords.sent
passed to the __
helper method.
Using the Notification
facade’s method assertSentTo
we assert the ResetPassword
notification was sent to the $user
. We can pass the model stored in the variable $user
directly into the assertSentTo
method because our User
model, by default, uses the Illuminate\Notifications\Notifiable
trait. When routing emails for any model using the Notifiable
trait, the email
property on the model will be used by default.
Testing Showing the Password Reset Page
Next, to test showing the password reset page, add the test shown below.
/**
* Testing showing the reset password page.
*/
public function testShowPasswordResetPage()
{
$user = factory(User::class)->create();
$token = Password::broker()->createToken($user);
$this
->get(route(self::ROUTE_PASSWORD_RESET, [
'token' => $token,
]))
->assertSuccessful()
->assertSee('Reset Password')
->assertSee('E-Mail Address')
->assertSee('Password')
->assertSee('Confirm Password');
}
We again create a user using the factory
helper method. Next we create a valid password reset token using the Password
facade.
The value of $token
is used to replace the token
parameter in the password.reset
route. We send a GET
request to this route, assert the response is successful, and check for the presence of the text for page elements.
Testing Submitting the Password Rest Page
Next we’ll test submitting the password reset page, starting with using an invalid email address.
Continue our testing by adding the test shown below.
/**
* Testing submitting the password reset page with an invalid
* email address.
*/
public function testSubmitPasswordResetInvalidEmail()
{
$user = factory(User::class)->create([
'password' => bcrypt(self::USER_ORIGINAL_PASSWORD),
]);
$token = Password::broker()->createToken($user);
$password = str_random();
$this
->followingRedirects()
->from(route(self::ROUTE_PASSWORD_RESET, [
'token' => $token,
]))
->post(route(self::ROUTE_PASSWORD_RESET_SUBMIT), [
'token' => $token,
'email' => str_random(),
'password' => $password,
'password_confirmation' => $password,
])
->assertSuccessful()
->assertSee(__('validation.email', [
'attribute' => 'email',
]));
$user->refresh();
$this->assertFalse(Hash::check($password, $user->password));
$this->assertTrue(Hash::check(self::USER_ORIGINAL_PASSWORD,
$user->password));
}
In this test we’re again using the factory
helper method to create at test user but this time we explicitly set the user’s password. To do this we use the bcrypt
helper method to hash the value of our constant.
We create another password reset token and generate a random string to use as the new password for our request’s payload. Again following redirects we POST
to the password.reset.submit
route with a request originating from the password.reset
route. A random string is used for the email address in the request payload.
After asserting the response was successful and checking for the validation.email
validation message we refresh the user model and use the check
method on the Hash
facade to assert the user’s password has not changed.
Next we’ll test submitting the password reset page with an email address not in use by our application’s database.
Add the test shown below.
/**
* Testing submitting the password reset page with an email
* address not in the database.
*/
public function testSubmitPasswordResetEmailNotFound()
{
$user = factory(User::class)->create([
'password' => bcrypt(self::USER_ORIGINAL_PASSWORD),
]);
$token = Password::broker()->createToken($user);
$password = str_random();
$this
->followingRedirects()
->from(route(self::ROUTE_PASSWORD_RESET, [
'token' => $token,
]))
->post(route(self::ROUTE_PASSWORD_RESET_SUBMIT), [
'token' => $token,
'email' => $this->faker->unique()->safeEmail,
'password' => $password,
'password_confirmation' => $password,
])
->assertSuccessful()
->assertSee(e(__('passwords.user')));
$user->refresh();
$this->assertFalse(Hash::check($password, $user->password));
$this->assertTrue(Hash::check(self::USER_ORIGINAL_PASSWORD,
$user->password));
}
Nothing new on this test. We create a user, password reset token, and new password. Then the follow redirects, POST
to the password.reset.submit
route from the password.reset
route using the token, a random and unique safe email, and the new password. We assert the response is successful, check for the presence of the passwords.user
translated string (after swapping any html character entities in the string), refresh the user, and assert the user’s password hasn’t changed.
The next test will be testing submitting the password reset page with a password that doesn’t match the password confirmation.
Add the test shown below.
/**
* Testing submitting the password reset page with a password
* that doesn't match the password confirmation.
*/
public function testSubmitPasswordResetPasswordMismatch()
{
$user = factory(User::class)->create([
'password' => bcrypt(self::USER_ORIGINAL_PASSWORD),
]);
$token = Password::broker()->createToken($user);
$password = str_random();
$password_confirmation = str_random();
$this
->followingRedirects()
->from(route(self::ROUTE_PASSWORD_RESET, [
'token' => $token,
]))
->post(route(self::ROUTE_PASSWORD_RESET_SUBMIT), [
'token' => $token,
'email' => $user->email,
'password' => $password,
'password_confirmation' => $password_confirmation,
])
->assertSuccessful()
->assertSee(__('validation.confirmed', [
'attribute' => 'password',
]));
$user->refresh();
$this->assertFalse(Hash::check($password, $user->password));
$this->assertTrue(Hash::check(self::USER_ORIGINAL_PASSWORD,
$user->password));
}
Again nothing new on this test except we’re checking for a different validation message.
Our last invalid submission case to test for submitting the password reset page is using a new password that’s too short.
Add the test shown below.
/**
* Testing submitting the password reset page with a password
* that is not long enough.
*/
public function testSubmitPasswordResetPasswordTooShort()
{
$user = factory(User::class)->create([
'password' => bcrypt(self::USER_ORIGINAL_PASSWORD),
]);
$token = Password::broker()->createToken($user);
$password = str_random(5);
$this
->followingRedirects()
->from(route(self::ROUTE_PASSWORD_RESET, [
'token' => $token,
]))
->post(route(self::ROUTE_PASSWORD_RESET_SUBMIT), [
'token' => $token,
'email' => $user->email,
'password' => $password,
'password_confirmation' => $password,
])
->assertSuccessful()
->assertSee(__('validation.min.string', [
'attribute' => 'password',
'min' => 6,
]));
$user->refresh();
$this->assertFalse(Hash::check($password, $user->password));
$this->assertTrue(Hash::check(self::USER_ORIGINAL_PASSWORD,
$user->password));
}
This time we pass an argument 5
to the str_random
helper function to specify the length of the random returned string (as opposed to the default length of 16
). Another difference in this test is we’re checking for the presence of a validation message, validation.min.string
, with two parameters, attribute
and min
.
Notice how we can use dot notation to specify a translation string in a nested array. To learn more about these validation messages and translation strings, check out the file at resources/lang/{locale}/validation.php
.
Finally, it’s time to test the happy path: submitting the password reset page with a valid email address belonging to a user with a valid password reset token and a password matching the confirmation password (that isn’t too short).
Add the final test shown below.
/**
* Testing submitting the password reset page.
*/
public function testSubmitPasswordReset()
{
$user = factory(User::class)->create([
'password' => bcrypt(self::USER_ORIGINAL_PASSWORD),
]);
$token = Password::broker()->createToken($user);
$password = str_random();
$this
->followingRedirects()
->from(route(self::ROUTE_PASSWORD_RESET, [
'token' => $token,
]))
->post(route(self::ROUTE_PASSWORD_RESET_SUBMIT), [
'token' => $token,
'email' => $user->email,
'password' => $password,
'password_confirmation' => $password,
])
->assertSuccessful()
->assertSee(__('passwords.reset'));
$user->refresh();
$this->assertFalse(Hash::check(self::USER_ORIGINAL_PASSWORD,
$user->password));
$this->assertTrue(Hash::check($password, $user->password));
}
In this test we use the Hash
facade to assert the user’s password has changed to the given password, thus successfully completing the password reset.
Conclusion
This concludes our testing for Laravel’s password resets. In ten short tests we were able to do things like create test users and valid password reset tokens, make HTTP requests to our application, assert the response contains desired content, and check if the user’s password has changed as a result of the request.
Laravel has provided ample testing capabilities and I strongly recommend reading the documentation for a deeper look at the possibilities.
You can view the source code for this project on GitHub.