DEV Community

Edmilson Rocha
Edmilson Rocha

Posted on • Edited on

Laravel 5.5, testing, and you

If you're a programmer, chances are you love writing code. It's great and makes you feel awesome (at least when it's working!). Every line of code translates into more progress into your current task. However, that same thought could dissuade you from a very important step in building quality software: writing tests.

As a Laravel fanboy (if you use PHP and never heard of it, check it out - seriously), I can't help but notice how awesome it is to write tests with it right out of the box. However, we need to do it with responsability: maintaining the high quality of your project's code means your test code should also be good.

With that in mind and with some new tricks that Laravel 5.5 ships with, I decided to compile a few tips on how to improve your test code quality to help you keep your project bug-free and awesome. So let's get to it!

1. Use setUp() and tearDown()

I can't stress this enough. We all know that duplicate code is (usually) bad code, and this definitely applies to test code as well. Let's take a simple example where we test a social network application:

<?php

class ExampleTest extends TestCase {
use DatabaseTransactions;

    /** @test */
    public function users_can_follow_each_other() {
        $user1 = factory(User::class)->create();
        $user2 = factory(User::class)->create();

        $this->actingAs($user1);

        $this->post('/follow', [
            'user_id' => $user2->id
        ]);

        $this->assertTrue($user2->isFollowedBy($user1));
    }

    /** @test */
    public function users_can_message_each_other() {
        $user1 = factory(User::class)->create();
        $user2 = factory(User::class)->create();

        $this->actingAs($user1);

        $this->post('/messages', [
            'user_id' => $user2->id
        ]);

        $this->assertDatabaseHas('messages', [
            'from' => $user1->id,
            'to' => $user2->id
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Note that we are creating fresh new users every single test. We could just set up two and reuse them. And here comes the setUp() method to save us:

<?php

class ExampleTest extends TestCase {
    use DatabaseTransactions;

    private $user1;
    private $user2;

    protected function setUp() {
        parent::setUp(); // this is important!

        $this->user1 = factory(User::class)->create();
        $this->user2 = factory(User::class)->create();
        $this->actingAs($this->user1);
    }

    /** @test */
    public function users_can_follow_each_other() {
        $this->post('/follow', [
            'user_id' => $this->user2->id
        ]);

        $this->assertTrue($this->user2->isFollowedBy($this->user1));
    }

    /** @test */
    public function users_can_message_each_other() {
        $this->post('/messages', [
            'user_id' => $this->user2->id
        ]);

        $this->assertDatabaseHas('messages', [
            'from' => $this->user1->id,
            'to' => $this->user2->id
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice how much better and to the point the tests are now. All we had to do is override the setUp() method and create our users there, then assigning them as class attributes. We even tossed the actingAs method there because it made more sense to that test class. Just don't forget to add the parent::setUp() to the beginning of the setUp() method.

(pro tip: if you're using PhpStorm, hit CTRL+O to quickly override methods).

Now, every time any of the tests in this class run, the setUp() method will be called first, giving us fresh new users to play with. Pretty great. Can you imagine if we had a dozen methods that use users or that require a more complex set up? This will save a ton of your time when refactoring or incrementing your test code.

The tearDown() method can also be used in the same way, but it runs right after your test ends. This is useful if you want to undo something, for example.

2. Use Carbon for time-sensitive scenarios

Carbon is awesome. Dealing with time can be a little annoying with PHP's native classes, but Carbon gives us a nice and intuitive syntax for all your date/time needs.

If you have time-sensitive scenarios (e.g you want a different behavior if a User is 10 days old), testing that might sound hard. A mediocre approach would be injecting the current time in the specific method you're trying to test, but that's annoying.

Fortunately, Carbon ships with a cool method to mock its now() method, essentially faking the current time for your tests!

<?php

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

        $result = $user->likePost($post);
        $this->assertFalse($result);

        $tenDaysInTheFuture = Carbon::now()->addDays(10);
        Carbon::setTestNow($tenDaysInTheFuture);

        $result = $user->likePost($post);
        $this->assertTrue($result);
    }
Enter fullscreen mode Exit fullscreen mode

The trick here is to use Carbon::setTestNow by passing the desired date to mock (which is also a Carbon object!).

A very important warning: this will also mock the current time for ALL future Carbon objects, which may cause problems when you're running all the tests in a single batch. To avoid that, usetip #1 to "cancel" the mock with the tearDown() method:

<?php

protected function tearDown() {
        parent::tearDown();

        Carbon::setTestNow(); // this will clear the mock!
    }
Enter fullscreen mode Exit fullscreen mode

3. Be smart with your assertions

Try to be as semantic as possible with your assertions. They come in different shapes for a reason and will give a better failure message when your test inevitably fails.

Of course you can just always use $this->assertTrue, but this will make your test less readable. Let's refactor an example:

<?php

$group->addUsers([$user1, $user2]);
        $group->save();

        $this->assertTrue($group->users->count() === 2);
Enter fullscreen mode Exit fullscreen mode

Sure it works, but why not have a more semantic approach with $this->assertCount or even $this->assertContains?

<?php

$group->addUsers([$user1, $user2]);
        $group->save();

        $this->assertCount(2, $group->users->toArray());
Enter fullscreen mode Exit fullscreen mode

That's better.

Remember you have dozens of assertions, so try picking the best one for your test scenario. Want to see if something was recorded or not in the database? $this->assertDatabaseHas() and $this->assertDatabaseMissing() are your friends.

Also, remember that when you're testing HTTP routes (using $this->post, for example), it's going to return a result object. That object also has its own assertions, like $response->assertRedirect and $response->assertSessionHasErrors.

One final tip: avoid complex logical assertions! Never assert that $this->assertTrue(!$condition). That's basically saying "Let's assert that it is true that $condition is false". Using $this->assertFalse($condition) reads much better. Your team will appreciate :)

4. Exceptions: handle them (or don't)

Up to 5.4, Laravel had a bit of an issue with exception handling in tests - they were ignored, which caused way too many false negative tests in my experience. Fortunately, Laravel 5.5 has a new trick to solve this problem.

Sometimes you want PHP to throw exceptions at your code, sometimes you don't. When you're trying to test the middleware of, say, a route that requires you to be logged in, but you're trying to force your way as a guest, you might want to use $this->expectException to check if an AuthenticationException is thrown.

<?php

/** @test */
    public function guest_cant_see_skills() {
        $this->expectException(AuthenticationException::class);

        $response = $this->get('/skills');
        $response->assertRedirect('/login');
    }
Enter fullscreen mode Exit fullscreen mode

In Laravel 5.5, this is going to return Failed asserting that exception of type "Illuminate\Auth\AuthenticationException" is thrown, thus, a failure. This happens because laravel automatically "handles" the exception behind the scenes and never throws it on our tests. To handle this ourselves, we need to use one of L5.5's new methods:

<?php

    /** @test */
    public function guest_cant_see_skills() {
        $this->expectException(AuthenticationException::class);
        $this->withoutExceptionHandling(); // <-- this!

        $response = $this->get('/skills');
        $response->assertRedirect('/login');
    }
Enter fullscreen mode Exit fullscreen mode

Now it's going to run beautifully. You can also re-enable exception handling at any time using $this->withExceptionHandling().

5. Hidden validation errors are a problem

Whenever you run HTTP tests on routes with validation, make sure to properly assert that the response came without errors.

<?php

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

        $response = $this->post('/skills', [
            'name' => 'PHP',
            'proficiency' => 20,
        ]);

        $response->assertSessionMissing('errors');

        $this->assertTrue($user->hasSkill('PHP'));
    }
Enter fullscreen mode Exit fullscreen mode

It might sound dumb to do that, especially because you KNOW you're passing the right request to your POST route, but believe me: as soon as the requirements change and your route validation rules change too, you might stare at your screen looking through your tests and trying to understand why they're not working (I know I did that in the past...) when in the end it was only because your post request wasn't valid anymore (say you add another required field to your forms).

I do wonder why laravel has a assertSessionHasErrors() method for HTTP requests, but not a assertSessionDoesntHaveErrors(). Got a better idea than using assertSessionMissing('errors')? Share with us in the comments :)

6. Mocks are your friends

If you're using Laravel's built-in solution for events, notifications and mail, testing them couldn't be easier. Laravel ships with helpers in its facades right out of the box so you don't have to mess with Mockery.

All you need to do is initiate with the fake() method (probably in your setUp()) and any mail, notification or event called will be "faked" and you can actually assert over the new mocks!

Here's a quick example using notifications:

<?php

/** @test */
    public function guest_cant_see_skills() {
        Notification::fake();

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

        $response = $this->post('/skills', [
            'name' => 'Laravel',
            'proficiency' => 85,
        ]);

        $response->assertSessionMissing('errors');
        $mentor = $user->mentor;

        Notification::assertSentTo([$mentor], StudentLearnedNewSkill::class);
    }
Enter fullscreen mode Exit fullscreen mode

Laravel has a sweet list of all the mocks available in its documentation, so check them out!

Well, that's it for now. I hope you learned something new :)

Got any more useful tips that you'd like to share or ideas on how to improve the topics I covered? Let me know in the comments - and follow me on Twitter ;)

Top comments (5)

Collapse
 
mpinto198 profile image
Maximiliano Pinto

this is awesome, Laravel and unit testing, thanks for sharing.

Collapse
 
devloger profile image
Devloger

Nice article :). Thx for this.

Collapse
 
chagamkamalakar profile image
kamalakar

thanks for sharing these super cool tips

Collapse
 
daniel0101 profile image
DanielCodec

Thanks for sharing, really nice!

Collapse
 
eichgi profile image
Hiram

I am learning tests in Laravel and this was so helpful!