Laravel Testing

In one of our Tutorial, we saw how we can use Laravel to Add and Edit our Resource. In this Tutorial, we are going to write Test Cases for the same.

We will see how we can write Feature Test to test our application. Laravel comes in built with PHPUnit and phpunit.xml file is also included in your application.

First of all we need to setup our Environment for Testing. We can do this by editing phpunit.xml File which is located at the root of our project.

We need to setup the URL of our application using APP_URL. We are going to set it to http://127.0.0.1:8000/

Next we are going to use SQLITE as our DB to run all the tests. We can do this by setting DB_CONNECTION. Our File looks like below:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
         backupStaticAttributes="false"
         bootstrap="vendor/autoload.php"
         colors="true"
         convertErrorsToExceptions="true"
         convertNoticesToExceptions="true"
         convertWarningsToExceptions="true"
         processIsolation="false"
         stopOnFailure="false">
    <testsuites>
        <testsuite name="Unit">
            <directory suffix="Test.php">./tests/Unit</directory>
        </testsuite>

        <testsuite name="Feature">
            <directory suffix="Test.php">./tests/Feature</directory>
        </testsuite>
    </testsuites>
    <filter>
        <whitelist processUncoveredFilesFromWhitelist="true">
            <directory suffix=".php">./app</directory>
        </whitelist>
    </filter>
    <extensions>
        <extension class="Tests\Bootstrap"/>
    </extensions>
    <php>
        <server name="APP_URL" value="http://127.0.0.1:8000/"/>
        <server name="APP_ENV" value="testing"/>
        <server name="BCRYPT_ROUNDS" value="4"/>
        <server name="CACHE_DRIVER" value="array"/>
        <server name="MAIL_DRIVER" value="array"/>
        <server name="QUEUE_CONNECTION" value="sync"/>
        <server name="SESSION_DRIVER" value="array"/>
        <server name="DB_CONNECTION" value="sqlite"/>
        <server name="DB_DATABASE" value=":memory:"/>
        <server name="APP_CONFIG_CACHE" value="bootstrap/cache/config.phpunit.php"/>
        <server name="APP_SERVICES_CACHE" value="bootstrap/cache/services.phpunit.php"/>
        <server name="APP_PACKAGES_CACHE" value="bootstrap/cache/packages.phpunit.php"/>
        <server name="APP_ROUTES_CACHE" value="bootstrap/cache/routes.phpunit.php"/>
        <server name="APP_EVENTS_CACHE" value="bootstrap/cache/events.phpunit.php"/>
    </php>
</phpunit>

We can create New Test Case using php artisan. We are going to call our Test as StoryTest and we can use following command to create it.

php artisan make:test StoryTest

This will create a File at /tests/features/StoryTest.php We are going to write all our test cases as various methods inside this Class.

We need to make sure that our Database is reset after each Test Case. For this we are going to use RefreshDatabase Trait.

Now let us begin by testing if we can add a Story to the Database. For this we need to hit the store() method of our controller. We will begin by passing it empty data so that we can test if our Validation is working correctly. So let us begin by creating a method subject_is_required

    /** @test */
    public function subject_is_required()
    {
        $response = $this->post( route('stories.store'), [
            'subject' => '',
            'body' => 'Just some dummy Body',
            'type' => 'short',
            'active' => 1,
        ]);
        $response->assertStatus(200);
    }

Let us see what we are doing here. We are generating a Post Request which will hit the store() method of the controller based on the Route. It is always the best practice to pass the route as named parameter instead of the actual URL. The 2nd Parameter is the array which will be the Post Data passed to this Request. You can see that we are passing the Subject as empty while all the other fields are passed in the same format as would be passed while submitting the Form.

You can run this Test Case using the following command

./vendor/bin/phpunit --filter StoryTest

Here we are passing the --filter Param which will make sure that only Test Cases which are defined in our Class will run at this stage. While you ran the above command you will get the warning that Test Case Failed and that the Status was 302 instead of 200. This is not very informative so we can tell Laravel to not throw the exception by using following command as the first line of our method

        $this->withoutExceptionHandling();

Now when you will run the Test Case, you will see that you got an Unauthenticated Exception and were redirected to Login Page. Well, the message is much more informative and makes sense because you need to be login to access this page. So how do we login?

In order to Login we will need a User. However, our Database is empty. There are no records in our Database. So we need to insert a User into our Database. Well we need to make use of the Factories to insert data into our Database for Testing Purposes. You can read more about the Factories in the Documentation. Laravel automatically creates a UserFactory for us so we do not need to create one at this stage. We can create a User as below

$user = factory(User::class)->create();

Next we need to Login Using this User and make the Post Request on behalf of this User. This can be done by using actingAs method. Also when we hit this Post Request, we expect to receive an Error Message in Subject Field. Also we expect that the story is not added to the DB so it has zero records. We can do this by

        // $this->withoutExceptionHandling();
        $user = factory(User::class)->create();
        $response = $this->actingAs($user)->post( route('stories.store'), [
            'subject' => '',
            'body' => 'Just some dummy Body',
            'type' => 'short',
            'active' => 1,
        ]);

        $response->assertSessionHasErrors('subject');
        $this->assertCount(0, Story::all());

If you now run the Test Case you will see that our Test Case has passed and all our assertions are true. We can further refine our Test Case. We know that our Error Message will be Please enter subject. So we can specifically Test for this Error Message.

        $user = factory(User::class)->create();
        $response = $this->actingAs($user)->post( route('stories.store'), [
            'subject' => '',
            'body' => 'Just some dummy Body',
            'type' => 'short',
            'active' => 1,
        ]);

        // $response->assertSessionHasErrors('subject');

        $response->assertSessionHasErrors([
            'subject' => 'Please enter subject'
        ]);
        $this->assertCount(0, Story::all());

If you run the Test Case again, you will see that we are still passing. Cool, lets see what other test cases we can define related to Subject Field. We could test for following cases:

  1. We can try entering a Subject less than 10 characters.
  2. We can try entering a Subject with more than 200 Characters
  3. We can also see if a User enters “Dummy Subject” and see its throwing the Validation Error.

We also have a Test Case for Unique Subject but we would come to that later. You should be able to create the above 3 Test Cases and check for corresponding Error Messages yourself. In case you are not confident you can peek ahead. Our StoryTest File looks like below after writing the above Test Cases.

    /** @test */
    public function subject_is_required()
    {
        // $this->withoutExceptionHandling();
        $user = factory(User::class)->create();
        $response = $this->actingAs($user)->post( route('stories.store'), [
            'subject' => '',
            'body' => 'Just some dummy Body',
            'type' => 'short',
            'active' => 1,
        ]);

        $response->assertSessionHasErrors([
            'subject' => 'Please enter subject'
        ]);
        $this->assertCount(0, Story::all());
    }

    /** @test */
    public function subject_min_length()
    {
        $user = factory(User::class)->create();
        $response = $this->actingAs($user)->post( route('stories.store'), [
            'subject' => 'Small',
            'body' => 'Just some dummy Body',
            'type' => 'short',
            'active' => 1,
        ]);

        $response->assertSessionHasErrors([
            'subject' => 'The subject must be at least 10 characters.'
        ]);
        $this->assertCount(0, Story::all());
    }

    /** @test */
    public function subject_max_length()
    {
        $user = factory(User::class)->create();
        $response = $this->actingAs($user)->post( route('stories.store'), [
            'subject' => Str::random(250),
            'body' => 'Just some dummy Body',
            'type' => 'short',
            'active' => 1,
        ]);

        $response->assertSessionHasErrors([
            'subject' => 'The subject may not be greater than 200 characters.'
        ]);
        $this->assertCount(0, Story::all());
    }

    /** @test */
    public function subject_dummy_subject()
    {
        $user = factory(User::class)->create();
        $response = $this->actingAs($user)->post( route('stories.store'), [
            'subject' => 'Dummy Subject',
            'body' => 'Just some dummy Body',
            'type' => 'short',
            'active' => 1,
        ]);

        $response->assertSessionHasErrors([
            'subject' => 'subject is invalid.'
        ]);
        $this->assertCount(0, Story::all());
    }

We can remove the duplicate code of creating a User and then logging from it. We can move this to setUp() method which is called before each test case is run. Don’t forget to call the parent::setUp() as well withing this method. Also we can refactor the code related to Dummy Data being passed to each Request.

    public function setup():void {
        parent::setUp();
        $user = factory(User::class)->create();
        $this->actingAs($user);
    }

    /** @test */
    public function subject_is_required()
    {
        $response = $this->post(
            route('stories.store'),
            array_merge($this->data(), array('subject' => ''))
        );        

        $response->assertSessionHasErrors([
            'subject' => 'Please enter subject'
        ]);
        $this->assertCount(0, Story::all());
    }

    /** @test */
    public function subject_min_length()
    {
        $response = $this->post(
            route('stories.store'),
            array_merge($this->data(), array('subject' => 'Small'))
        );

        $response->assertSessionHasErrors([
            'subject' => 'The subject must be at least 10 characters.'
        ]);
        $this->assertCount(0, Story::all());
    }

    /** @test */
    public function subject_max_length()
    {
        $response = $this->post(
            route('stories.store'),
            array_merge($this->data(), array('subject' => Str::random(250)))
        );

        $response->assertSessionHasErrors([
            'subject' => 'The subject may not be greater than 200 characters.'
        ]);
        $this->assertCount(0, Story::all());
    }

    /** @test */
    public function subject_dummy_subject()
    {
        $response = $this->post(
            route('stories.store'),
            array_merge($this->data(), array('subject' => 'Dummy Subject'))
        );

        $response->assertSessionHasErrors([
            'subject' => 'subject is invalid.'
        ]);
        $this->assertCount(0, Story::all());
    }

    private function data() {

        return [
            'subject' => Str::random(15),
            'body' => Str::random(70),
            'type' => 'short',
            'active' => 1,
        ];
    }

You will see that our Test Cases are still passing at this stage. You can similarly write Test Cases for Body, Type and Active Fields covering all the Validation Logic.

Now lets write a Test Case to check if Story is added correctly to the Database if correct Data is given. The correct Data is returned by our data() method. We will need to asset that the count of records in Story Model is 1 after the insertion. Also we will check that we have been redirected to the index page along with correct Message. We can define our Test Case like below

    /** @test */
    public function story_can_be_added()
    {
        $response = $this->post(
            route('stories.store'),
            $this->data()
        );

        $this->assertCount(1, Story::all()); //Checking that records is inserted to DB
        $response->assertRedirect(route('stories.index')); //Checking Redirect to index page
        $response->assertSessionHas(['status' => 'Story created']); //Checking the Status Message
    }

Go ahead and run it. You can also use the --filter flag to run the individual method instead of all the methods like below

./vendor/bin/phpunit --filter story_can_be_added

You will see that our TestCase has passed. We can further improve this Test Case by making sure that Story added in the DB has same user_id as the Authenticated User. We need to make slight change in our setUp() method so that instead of $user we will be using $this->user so that we can access the user_id in our Test Method

    public function setup():void {
        parent::setUp();
        $this->user = factory(User::class)->create();
        $this->actingAs($this->user);
    }

And inside the Test Method we can `assertEquals` like below to make sure that both values are equal.

$this->assertEquals( $this->user->id, Story::first()->user_id);

Alright, next we can write Test Case to make sure that we are able to update our Story correctly. Lets call our method as story_can_be_updated() To update a Story, we need to create a Story First. So our method will look like below

    /** @test */
    public function story_can_be_updated()
    {
        $response = $this->post(
            route('stories.store'),
            $this->data()
        );

        //Next we update our story and check the response
    }

So this code looks exactly like the one that we used while adding the book. However we are not checking for the assertions as we have already done that in the previous Test Case. So checking them again does not make any sense. So we are going to fetch the inserted $story from our Database. Then we are going to call the store() method of our controller with data with which we need to update. Similar to what we did for Add Story we will check if we have been redirected to index method along with appropriate message. We will then assert if the data has been updated correctly and there is still 1 Record in the DB. Our Test Method looks like below

    public function story_can_be_updated()
    {
        $this->post(
            route('stories.store'),
            $this->data()
        );

        $story = Story::first();

        $updateData = $this->data();

        $response = $this->patch( 
            route('stories.update', [$story]),
            $updateData
        );

        $story = Story::first(); //Get the story again.

        $this->assertCount(1, Story::all()); //Checking that there is still only 1 record in DB.
        $response->assertRedirect(route('stories.index')); //Checking Redirect to index page
        $response->assertSessionHas(['status' => 'Story updated']); //Checking the Status Message

        //Check all the Fiels are same as updateData
        $this->assertEquals( $updateData['subject'], $story->subject);
        $this->assertEquals( $updateData['body'], $story->body);
        $this->assertEquals( $updateData['type'], $story->type);
        $this->assertEquals( $updateData['active'], $story->active);

        $this->assertEquals( $this->user->id, $story->user_id); //Making sure user_id is same as Authenticated User

    }

Last thing we need to make sure is that User is only able to edit his own Story. Although we are using Policies and this Test Case is not required, but we will still go ahead and create it as it presents a unique challenge that we need to logout and then login with another user. So first of all we will create the Story. Then we will logout. Then we will create a 2nd User and login with this 2nd User. Then we will try to update the only Story in DB. Since this Story belonged to the 1st User, we will assert if we got a 403 Error. Our Method looks like

    /** @test */
    public function story_of_other_user_can_not_be_updated()
    {
        $this->post(
            route('stories.store'),
            $this->data()
        );

        Auth::logout();
        $user = factory(User::class)->create();

        
        $this->actingAs($user);

        $story = Story::first();
        $response = $this->patch( 
            route('stories.update', [$story]),
            $this->data()
        );

        $response->assertStatus(403);

If you try and run above Test you will see that this has passed. Now just run all the Tests again to make sure that nothing is broken

./vendor/bin/phpunit --filter StoryTest

If all your Test Cases are passing then you are good. Any change you make to the Code you can run these Test Cases to make sure that everything is still working fine. Of course, depending upon the further changes to your application you might need to add new test cases or tweak the existing ones.

Hope you have a good understanding of using Testing using Laravel after reading through the Tutorial. You can check the code at Github