Header Ads

Twitter From Scratch Using Laravel: Our First Test


In our previous lesson we've learned how to setup our database. In this lesson we are going to setup our testing environment and make our first test. We're going to make a test for displaying user tweets.

When it comes testing it's not advisable to use our local database because it might be populated with tons of data related only for testing, instead we're going to use another database for our testing environment. We can do that by specifying our DB_CONNECTION and DB_DATABASE in our phpunit.xml file like so.
...
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
...
So, basically we're telling Laravel to use a sqlite database connection and use in-memory database instead of a file. In this way our tests will be a little bit faster.

Next, we make sure our new configuration settings are taken into account by clearing the configuration cache like so.
php artisan config:clear

Now, let's create a feature test for our user. We can use the make:test Artisan command for that like so.
php artisan make:test UserTest
This will create a UserTest.php in the tests/Feature directory.

Next, let's make a test that a user can view a tweet. So, what is the first step? First, is always remember to name your test method as humanly readable as possible because more or less the one who will read your test is probably human 😄. So, in this case maybe it would make sense if we name our method as a_user_can_view_a_tweet.
<?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Foundation\Testing\DatabaseMigrations;

class UserTest extends TestCase
{
    use DatabaseMigrations;

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

    }
}
Also, when dealing with database in our test we have to use DatabaseMigrations trait. What it does is it migrates everything to the database first, before running the actual test and rolled everything back once the test is done.

Next, let's run phpunit like so:
phpunit
We should get green because there is nothing yet in our a_user_can_view_a_tweet method.

Now, let's add some logic in the a_user_can_view_a_tweet method. If you come to think about it, we are testing that a user can view a tweet and we know for a fact that everything in twitter is public. So, in this case it doesn't really matter whether the user is logged in or not. All we need for now is a tweet. We can use model factory for that like so:
...
$tweet = factory('App\Tweet')->create();
...
What this does is it persist one tweet to the tweets table and one user to the users table as well. When we run phpunit again we should still get green.

Next, let's simulate a user viewing the tweet and assert that the body of the tweet is displayed in the browser:
...
$tweet = factory('App\Tweet')->create();

$response = $this->get($tweet->url);

$response->assertSee($tweet->body);
...
Now, when we run phpunit again, it should failed which is a good thing because it means our test is working the way we expect. Remember that we are not touching our code base yet, well, at least in terms of our model, views and controller.

Now, let's make our test back to green again. First, we know that the value of $tweet->url is null right? Let's fix that first, and you know what? this is a good candidate for unit testing. So, let's make a unit test for that like so:
php artisan make:test TweetTest --unit
Adding a --unit flag means create a test in the tests/Unit directory.

Basically it's the same class structure with feature test, but this time we're going to zoom in a little bit to unit level. Meaning we're not going to use any http request when it comes to unit testing unless your test is required to access an api or something.

Next, let's make an assertion that a $tweet->url must not be null like so:
<?php

namespace Tests\Unit;

use Tests\TestCase;
use Illuminate\Foundation\Testing\DatabaseMigrations;

class TweetTest extends TestCase
{
    use DatabaseMigrations;

    /** @test */
    public function a_tweet_url_must_not_be_null()
    {
        $tweet = factory('App\Tweet')->create();

        $this->assertNotNull($tweet->url);
    }
}

Sometimes it's handy to filter through your test like so:
phpunit --filter a_tweet_url_must_not_be_null
This means that phpunit will only execute the a_tweet_url_must_not_be_null method. But it is recommended to run all your tests from time to time because sometime a particular test can affect other tests in your test suite.

Next, let's make a_tweet_url_must_not_be_null test pass. We can achieve that by creating an eloquent accessor in our Tweet model:
...
/**
 *  Get the tweet url
 * 
 *  @return string
 */
 public function getUrlAttribute()
 {
     return route('user.tweet.show', ['username' => $this->user->username, 'id' => $this->id]);
 }
...
Notice that we are using the Laravel's route helper function and just making up what our tweet url might look like. Come to think about it, a tweet should belongs to a user right? So, maybe it would be nice that our tweet url should look like youdomain.com/username/tweet/id.

Next, when we run our test again it should failed like so:
phpunit --filter a_tweet_url_must_not_be_null
...
1) Tests\Unit\TweetTest::a_tweet_url_must_not_be_null
ErrorException: Trying to get property of non-object

/home/vagrant/Projects/twitter/app/Tweet.php:16
...
This is because we're trying to access the username of the owner ($this->owner->username), but we don't have an owner relationship yet in our Tweet model.

So, let's make one:
/**
 *  Get the owner of the tweet.
 * 
 *  @return Illuminate\Database\Eloquent\Relations\BelongsTo
 */
 public function owner()
 {
     return $this->belongsTo(User::class, 'user_id');
 }
We're using one to one relationship here because a tweet can only have one owner.

Let's run our test again:
phpunit --filter a_tweet_url_must_not_be_null
...
1) Tests\Unit\TweetTest::a_tweet_url_must_not_be_null
InvalidArgumentException: Route [user.tweet.show] not defined.
...
Now, we get a different error which is a good thing because it means that the first one is already fixed, well, hopefully. 😄

Next, looks like this is a route name related error. So, let's check what are available routes do we have for now, using route:list artisan command:
php artisan route:list
+--------+----------+----------+------+---------+--------------+
| Domain | Method   | URI      | Name | Action  | Middleware   |
+--------+----------+----------+------+---------+--------------+
|        | GET|HEAD | /        |      | Closure | web          |
|        | GET|HEAD | api/user |      | Closure | api,auth:api |
+--------+----------+----------+------+---------+--------------+
Notice that we don't have a route name called user.tweet.show.

Let's fix that by creating our first route. Let's add it in routes/web.php like so:
Route::group(['prefix' => '{username}', 'namespace' => 'User'], function () {
    Route::resource('tweet', 'TweetController');
});
Notice we're using Laravel's Route Groups here to put all user related stuff in one place. I also like to use Laravel's Resource Controllers because it makes our routes file a little bit cleaner because we don't have to specify every endpoint that we need, like for example we need to show all tweets, delete tweet, edit tweet, etc. I also like the idea that the fewer methods in your controller the better.

Let's run our test again:
phpunit --filter a_tweet_url_must_not_be_null
...
1) Tests\Unit\TweetTest::a_tweet_url_must_not_be_null
InvalidArgumentException: Route [user.tweet.show] not defined.
...
and we get the same error.

Let's check our route list again:
php artisan route:list
[ReflectionException]
Class App\Http\Controllers\User\TweetController does not exist
Notice this time we get Reflection Exception error, and says Class App\Http\Controllers\User\TweetController does not exist.

Let's fix that by creating one using make:controller artisan command with --resource flag:
php artisan make:controller User\TweetController --resource
This will create a TwitterController.php file under app/Http/Controllers/User directory.

Next, let's check our route list again:
php artisan route:list
+--------+-----------+----------------------------+----------------+---------------------------------------------------+--------------+
| Domain | Method    | URI                        | Name           | Action                                            | Middleware   |
+--------+-----------+----------------------------+----------------+---------------------------------------------------+--------------+
|        | GET|HEAD  | /                          |                | Closure                                           | web          |
|        | GET|HEAD  | api/user                   |                | Closure                                           | api,auth:api |
|        | GET|HEAD  | {username}/tweet              | tweet.index   | App\Http\Controllers\User\TweetController@index   | web          |
|        | POST      | {username}/tweet              | tweet.store   | App\Http\Controllers\User\TweetController@store   | web          |
|        | GET|HEAD  | {username}/tweet/create       | tweet.create  | App\Http\Controllers\User\TweetController@create  | web          |
|        | GET|HEAD  | {username}/tweet/{tweet}      | tweet.show    | App\Http\Controllers\User\TweetController@show    | web          |
|        | PUT|PATCH | {username}/tweet/{tweet}      | tweet.update  | App\Http\Controllers\User\TweetController@update  | web          |
|        | DELETE    | {username}/tweet/{tweet}      | tweet.destroy | App\Http\Controllers\User\TweetController@destroy | web          |
|        | GET|HEAD  | {username}/tweet/{tweet}/edit | tweet.edit    | App\Http\Controllers\User\TweetController@edit    | web          |
+--------+-----------+----------------------------+----------------+---------------------------------------------------+--------------+
Notice that our route names is not what we want in this case.

We have two options for that, one is renaming our resource routes name manually, or update our tweet url accessor (getUrlAttribute()) in the Tweet model. In this case let's do the latter:
public function getUrlAttribute()
{
    return route('tweet.show', ['username' => $this->user->username, 'id' => $this->id]);
}

Next, let's die dump the tweet url property to make sure we have a correct url:
/** @test */
public function a_tweet_url_must_not_be_null()
{
    $tweet = factory('App\Tweet')->create();

    dd($tweet->url);
                        
    $this->assertNotNull($tweet->url);
}
Notice when we run our test the url is http://localhost/tweet/1 which is not what we want.

Looks like we need another test for that. But first, if you have a custom domain in your local environment like I do, you have to change localhost to whatever custom domain you're using. In my case I have to change it to twitter.dev by updating APP_URL in my .env file:
APP_URL=http://twitter.dev

Now, let's create a new test in our tests/Unit/TweetTest.php that will make sure our tweet url is in correct format:
/** @test */
public function a_tweet_url_must_in_correct_format()
{
    $tweet = factory('App\Tweet')->create();

    $url = url($tweet->owner->username.'/tweet/'.$tweet->id);

    $this->assertEquals($url, $tweet->url);
}
Notice when we run our test again we get green, but when we die dump the tweet url (dd($tweet->url)) it doesn't have username in it.

Let's fix that by creating a unit test first for our user related stuff:
php artisan make:test UserTest --unit
/** @test */
public function a_user_must_have_a_username()
{
    $user = factory('App\User')->create();

    $this->assertNotNull($user->username);
}
Of course this is gonna failed.

Let's make it pass that by adding a username column in our users table:
...
$table->string('username');
...

Next, let's update our user model factory:
return [
    'username' => $faker->word,
    ...
]

Next, let's run our whole test suite:
phpunit
There was 1 failure:

1) Tests\Feature\UserTest::a_user_can_view_a_tweet
Failed asserting that '' contains "Est soluta aliquam rerum.".
...
Looks like we're now closer to our goal for this lesson that a user can view a tweet. Good job!

Our a_user_can_view_a_tweet test is failing because we don't have a view yet for our tweet. But, first let's add an authentication feature in our application using make:auth artisan command.
php artisan make:auth
This command will generate a nice authentication scaffolding for us.

Next, let's create a show.blade.php file under resources/views/user/tweet directory.
@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row">
        <div class="col-md-8 col-md-offset-2">
            <div class="panel panel-default">
                <div class="panel-body">
                    {{ $tweet->body }}
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

Next, let's update our show method in app/Http/Controllers/User/TweetController.php:
/**
 * Display the specified resource.
 *
 * @param  string $username
 * @param  \App\Tweet $tweet
 * @return \Illuminate\Http\Response
 */
public function show($username, Tweet $tweet)
{
    return view('user.tweet.show', compact('tweet'));
}
Notice we're using Laravel's Implicit Route Model Binding here. We also have to accept $username as first parameter because our route need to pass a username to the show() method ({username}/tweet/{tweet}).

Now, when we run our test again we should get green, and when we access it in the browser using: http://twitter.dev/maiores/tweet/1 for example, it should look like this. One more thing, make sure to run php artisan migrate:refresh --seed first before accessing it in the browser.

That's all for this lesson hope you learned something new from this lesson. If you have any questions please don't hesitate to ask in the comment below. Stay tuned for the next lesson. Thanks!

View the source code for this lesson on GitHub.

Previous: Initial Database SetupNext: Custom Authentication


No comments