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 HashNotification, 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 PasswordE-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 filesvalidation.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.comexample.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.

Comments are closed.