Rebuilding my invoicing application in Laravel, Part 2 - Account Settings

To begin, I'll start with the "Account Settings" section. I won't be working on any of the frontend in this, or next next few, posts. Instead, I'll focus on getting the backend functionality working with tests.

Goal

To begin, I'll start with the "Account Settings" section. I won't be working on any of the frontend in this, or next next few, posts. Instead, I'll focus on getting the backend functionality working with tests. This post will show a look into some of the TDD practices I use to build out features. It will include almost every detail of the process. However, in future posts, I'll try to reduce this down since segments of the process are formulatic and can just be generalized.

Tests and Development

Before setting up the page and form, I will think of all the tests that should be performed for this feature. I won't always figure out every test from the beginning but I will do the best I can. This really helps me work through the build process. Tests make it super easy to check your code as you work. Before I started writing tests, I would really depend on constantly refreshing the browser to flush out issues. These test also work as a sort of "check list". It gives me an idea of how much work is needed to build out a feature. I'll get started by making my first test.

php artisan make:test Settings/AccountSettingsTest

tests/Feature/Settings/AccountSettingsTest.php

class AccountSettingsTest extends TestCase
{
    /**
     * @test
     */
    public function guests_can_not_view_account_settings_page()
    {

    }

    /**
     * @test
     */
    public function guests_can_not_update_account_settings()
    {

    }

    /**
     * @test
     */
    public function account_settings_page_loads_with_data()
    {

    }

    /**
     * @test
     */
    public function user_can_update_account_settings()
    {

    }

    /**
     * @test
     */
    public function updating_account_settings_requires_a_name()
    {

    }

    /**
     * @test
     */
    public function updating_account_settings_requires_an_email()
    {

    }

    /**
     * @test
     */
    public function updating_account_settings_requires_a_valid_email()
    {

    }

    /**
     * @test
     */
    public function updating_account_settings_requires_rate_to_be_an_integer()
    {

    }

    /**
     * @test
     */
    public function updating_account_settings_requires_a_timezone()
    {

    }

    /**
     * @test
     */
    public function updating_account_settings_requires_timezone_to_be_listed_in_list()
    {

    }
}

I think this is a good start. Notice that I have a few different types of tests.

  • Authentication
  • Functionality
  • Form Validation

Sometimes I separate these into their own tests if they grown too large. But in this case, I'll keep them in one test class. Let's start building!

Authentication Tests

/**
 * @test
 */
public function guests_can_not_view_account_settings_page()
{
    $this->call('GET', 'settings/account')->assertRedirect('login');
}
phpunit --filter guests_can_not_view_account_settings_page
PHPUnit 6.3.1 by Sebastian Bergmann and contributors.

F                                                                   1 / 1 (100%)

Time: 342 ms, Memory: 12.00MB

There was 1 failure:

1) Tests\Feature\Settings\AccountSettingsTest::guests_can_not_view_account_settings_page
Expected status code 401 but received 404.
Failed asserting that false is true.

/invoicing/vendor/laravel/framework/src/Illuminate/Foundation/Testing/TestResponse.php:77
/invoicing/tests/Feature/Settings/AccountSettingsTest.php:16

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

Obvously the test will fail since we haven't setup a route. This is good though. I like letting the tests guide me through the build process. This works especially great in Laravel since the errors can be very explicit. In this case, the 404 just tells me the route doesn't exist. Let's add the route and rerun the test. I'll truncate the errors moving forward.

routes/web.php

Route::get('settings/account', 'Settings\AccountSettingsController@get');
phpunit --filter guests_can_not_view_account_settings_page

1) Tests\Feature\Settings\AccountSettingsTest::guests_can_not_view_account_settings_page
Expected status code 401 but received 500.

Ok, now we get a 500. But why? Well for these errors I use a console log reader (MacOS Console) to inspect the application log located in storage/logs/laravel.log.

Class Invoicing\Http\Controllers\Settings\AccountSettingsController does not exist.

Ok, let's add this controller.

php artisan make:controller Settings/AccountSettingsController

The console reveals another error after running the test.

Method [get] does not exist.

I'll add the method and rerun the test.

app/Http/Controllers/Settings/AccountSettingsController.php

public function get()
{

}
phpunit --filter guests_can_not_view_account_settings_page

Expected status code 401 but received 200.

Ok, so we are hitting the controller method as a guest user and getting a 200 instead of the 401. How should we handle it? Laravel ships with a piece of middleware that can be applied to prevent guests from accessing routes that require authentication. There are many places to applied this middleware. In this case, I will add it in the endpoint controller's construct method. This will also apply to the authentication tests for the other verb tests as well.

app/Http/Controllers/Settings/AccountSettingsController.php

class AccountSettingsController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth');
    }

    public function get()
    {

    }
}
phpunit --filter guests_can_not_view_account_settings_page

OK (1 test, 2 assertions)

Boom. The first tests has passed. Let's move on to the next one. I'll shorten up the steps in this one.

tests/Feature/Settings/AcountSettingsTest.php

/**
 * @test
 */
public function guests_can_not_update_account_settings()
{
    $this->call('PUT', 'settings/account')->assertRedirect('login');
}

We should only need the route and controller method. The auth middleware is applied accross the entire controller so checking authentication should be good to go.

routes/web.php

Route::put('settings/account', 'Settings\AccountSettingsController@update');

app/Http/Controllers/Settings/AccountSettingsController.php

public function update()
{

}
OK (1 test, 2 assertions)

Done.

Functionality Tests

Let's get the core functionality tested and working. First, we need the page to load with the user data.

tests/Feature/Settings/AcountSettingsTest.php

/**
 * @test
 */
public function account_settings_page_loads_with_data()
{
    $user = factory(User::class)->create();

    $this->actingAs($user)
            ->call('GET', 'settings/account')
            ->assertViewHas('user');
}

If you notice, I'm using a helper called "factory". Model factories are a great way to create fake data for testing. In this case, we are telling the test to create a new user and store them in the database. Laravel provices a default User factory located in database/factories/UserFactory.php. Then we hit the GET endpoint "acting as" a newly created user who has been authentiated. Finally, we check that the view did receive the required user information. Let's run the test.

phpunit --filter account_settings_page_loads_with_data

1) Tests\Feature\Settings\AccountSettingsTest::account_settings_page_loads_with_data
Illuminate\Database\QueryException: SQLSTATE[HY000]: General error: 1 no such table: users

ERRORS!
Tests: 1, Assertions: 0, Errors: 1.

Again, I've truncated these errors for brevity. It looks like we need to add the trait which refreshes the database migrations.

tests/Feature/Settings/AcountSettingsTest.php

class AccountSettingsTest extends TestCase
{
    use RefreshDatabase;
phpunit --filter account_settings_page_loads_with_data

The response is not a view.

app/Http/Controllers/Settings/AccountSettingsController.php

public function get()
{
    return view('settings.account');
}
mkdir resources/views/settings && touch resources/views/settings/account.blade.php
phpunit --filter account_settings_page_loads_with_data

null does not match expected type "array".

The view needs the user data.

app/Http/Controllers/Settings/AccountSettingsController.php

public function get()
{
    return view('settings.account')
        ->with('user', Auth::user());
}

Passed! I'll make sure users can update their account information.

tests/Feature/Settings/AcountSettingsTest.php

/**
 * @test
 */
public function user_can_update_account_settings()
{
    $user = factory(User::class)->create();

    $data = [
        'name' => 'test-' . $user->name,
        'email' => 'test-' . $user->email,
        'rate' => $user->rate + 1,
        'timezone' => $user->timezone == 'America/Chicago' ? 'America/Anchorage' : 'America/Chicago',
        'password' => 'new_password'
    ];

    $this->actingAs($user)
        ->call('PUT', 'settings/account', $data);

    $this->assertDatabaseHas('users', [
        'name' => $data['name'],
        'email' => $data['email'],
        'rate' => $data['rate'],
        'timezone' => $data['timezone']
    ]);

    $this->assertTrue(Hash::check($data['password'], $user->password));
}
phpunit --filter user_can_update_account_settings

Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'test-Ms. Bella Kuhlman'
+'Ms. Bella Kuhlman'

Let's inject the request and update the user information.

app/Http/Controllers/Settings/AccountSettingsController.php

public function update(Request $request)
{
    Auth::user()->update($request->all());
}
phpunit --filter user_can_update_account_settings

1) Tests\Feature\Settings\AccountSettingsTest::user_can_update_account_settings
Failed asserting that null matches expected 1.

tests/Feature/Settings/AccountSettingsTest.php:62

Line 62 is trying to assert that the rate was updated. I'll add "rate" and "timezone" to the $fillable array in the User model and rerun the test.

app/User.php

/**
 * The attributes that are mass assignable.
 *
 * @var array
 */
protected $fillable = [
    'name', 'email', 'password', 'rate', 'timezone'
];
phpunit --filter user_can_update_account_settings

Failed asserting that two strings are equal.

Well that's not helpful. Let's take a look at the log.

General error: 1 no such column: rate

No rate column. Well that would do it. I'll add that and the timezone to the database migration.

database/migrations/{DATE}_create_users_table.php

/**
 * Run the migrations.
 *
 * @return void
 */
public function up()
{
    Schema::create('users', function (Blueprint $table) {
        $table->increments('id');
        $table->string('name');
        $table->string('email')->unique();
        $table->string('password');
        $table->unsignedInteger('rate');
        $table->string('timezone');
        $table->rememberToken();
        $table->timestamps();
    });
}
phpunit --filter user_can_update_account_settings

Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.rate

Whoa, now what. Well since we just added new columns to the migration, there is a good chance we will need to add it to the model factory. So let's update it.

database/factories/UserFactory.php

$factory->define(Invoicing\User::class, function (Faker $faker) {
    static $password;

    return [
        'name' => $faker->name,
        'email' => $faker->unique()->safeEmail,
        'password' => $password ?: $password = bcrypt('secret'),
        'remember_token' => str_random(10),
        'rate' => $faker->randomNumber(2),
        'timezone' => $faker->timezone
    ];
});

Notice that Laravel uses Faker to help generate fake data. Ok, let's see what the test will do.

phpunit --filter user_can_update_account_settings

Failed asserting that false is true.

It seems that the password is not saving correctly. However, the password is saving to the database but we are not hashing it. Let's fix it using Laravel's bcrypt helper.

app/Http/Controllers/Settings/AccountSettingsController.php

public function update(UpdateAccountSettingsRequest $request)
{
    if($request->has('password')) $request->offsetSet('password', bcrypt($request->password));

    Auth::user()->update($request->all());
}

That did it!

Validation Tests

Let's start with a simple validation test.

tests/Feature/Settings/AcountSettingsTest.php

/**
 * @test
 */
public function updating_account_settings_requires_a_name()
{
    $this->actingAs(factory(User::class)->create())
        ->call('PUT', 'settings/account')
        ->assertSessionHasErrors('name');
}
phpunit --filter updating_account_settings_requires_an_email

Expected status code 422 but received 200.

We get a 200 since there is no validation for this request. To manage validation we'll use Laravel's custom "Form Validation" class. Let's create one for this request and inject it on the update method.

php artisan make:request Settings/UpdateAccountSettingsRequest

app/Http/Controllers/Settings/AccountSettingsController.php

public function update(UpdateAccountSettingsRequest $request)
{
    Auth::user()->update($request->all());
}

Then we need to setup the validation rules in the request class. Note that the authorize method will just return true since authentication is checked with middleware on the controller.

app/Http/Requests/Settings/UpdateAccountSettingsRequest.php

class UpdateAccountSettingsRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'name' => 'required'
        ];
    }
}

Excellent, it passes. I won't walk through the other validation checks since they are basically the same. However, let's look at the final validation setup and tests.

app/Http/Requests/Settings/UpdateAccountSettingsRequest.php

/**
 * Get the validation rules that apply to the request.
 *
 * @return array
 */
public function rules()
{
    return [
        'name' => 'required',
        'email' => 'required|email',
        'rate' => 'required|integer',
        'timezone' => 'required|timezone'
    ];
}

tests/Feature/Settings/AccountSettingsTest.php

/**
 * @test
 */
public function updating_account_settings_requires_an_email()
{
    $this->actingAs(factory(User::class)->create())
        ->call('PUT', 'settings/account')
        ->assertSessionHasErrors('email');
}

/**
 * @test
 */
public function updating_account_settings_requires_a_valid_email()
{
    $this->actingAs(factory(User::class)->create())
        ->call('PUT', 'settings/account', ['email' => 'Not an email'])
        ->assertSessionHasErrors('email');
}

/**
 * @test
 */
public function updating_account_settings_requires_rate_to_be_an_integer()
{
    $this->actingAs(factory(User::class)->create())
        ->call('PUT', 'settings/account', ['rate' => 'Not an integer'])
        ->assertSessionHasErrors('rate');
}

/**
 * @test
 */
public function updating_account_settings_requires_a_timezone()
{
    $this->actingAs(factory(User::class)->create())
        ->call('PUT', 'settings/account')
        ->assertSessionHasErrors('timezone');
}

/**
 * @test
 */
public function updating_account_settings_requires_timezone_to_be_listed_in_list()
{
    $this->actingAs(factory(User::class)->create())
        ->call('PUT', 'settings/account', ['timezone' => 'Not a timezone'])
        ->assertSessionHasErrors('timezone');
}

I will run all the tests for this class to make sure we get "all green".

phpunit --filter AccountSettingsTest
PHPUnit 6.3.1 by Sebastian Bergmann and contributors.

..........                                                        10 / 10 (100%)

Time: 1.3 seconds, Memory: 20.00MB

OK (10 tests, 21 assertions)

Done!

Refactoring

Before we move on to the next post, I think this is a good place to look around for any refactoring opportunities. I'll just do a few updates.

I know I'll call Auth::user() in many of my controllers. So I'll make a little helper in the parent controller class.

app/Http/Controllers/Controller.php

/**
 * @return \Illuminate\Contracts\Auth\Authenticatable|null
 */
protected function getAuthenticatedUser()
{
    return Auth::user();
}

app/Http/Controllers/Settings/AccountSettingsController.php

public function get()
{
    return view('settings.account')
        ->with('user', $this->getAuthenticatedUser());
}

public function update(UpdateAccountSettingsRequest $request)
{
    if($request->has('password')) $request->offsetSet('password', bcrypt($request->password));

    $this->getAuthenticatedUser()->update($request->all());
}

Of course we want to check the tests which all passed.

Next, I really want to clean up the tests. It seems that I will create a user and use actingAs method quite a bit. So let's add some simple helpers to the parent TestCase class.

tests/TestCase.php

/**
 * Create a new user
 *
 * @return User
 */
protected function createUser()
{
    return factory(User::class)->create();
}

/**
 * Make a call on behalf of a newly created and authenticated user
 *
 * @return $this
 */
protected function actingAsNewUser()
{
    return $this->actingAs($this->createUser());
}

This will allow me to update $user = factory(User::class)->create(); to $user = $this->createUser();. In addition, I can also update $this->actingAs(factory(User::class)->create()) to $this->actingAsNewUser().

Next, I'll look at the validation tests. I know I'll be writing at least another 40 or so validation tests. So let's reduce the code we will need to write.

It seems like I could just write something like this for validation.

/**
 * @test
 */
public function updating_account_settings_requires_a_name()
{
    $this->putValidationTest('settings/account', 'name');
}

I can see that this might require a few different but related helpers. So let's just start by making a trait just for validation helpers.

testing/ValidationHelperTrait.php

trait ValidationHelperTrait
{
    public function postValidationTest($endpoint, $attribute, $data = [])
    {
        $this->validationTest('POST', $endpoint, $attribute, $data);
    }

    public function putValidationTest($endpoint, $attribute, $data = [])
    {
        $this->validationTest('PUT', $endpoint, $attribute, $data);
    }

    public function validationTest($method, $endpoint, $attribute, $data)
    {
        $this->actingAs(factory(User::class)->create())
            ->call($method, $endpoint, $data)
            ->assertSessionHasErrors($attribute);
    }
}

tests/Feature/Settings/AccountSettingsTest.php

class AccountSettingsTest extends TestCase
{
    use RefreshDatabase, ValidationHelperTrait;

After updating all the other test methods with the new helpers and running the tests I get all green. There is more that could be done but I'm ok with this so far. For most of these features, I won't be building any of the front end until most of the functionality is tested and built. So for the next post, I'm moving right on to the next feature without writing any HTML.

2019 Phil Mareu - Coder, Traveler & Disc Thrower