DEV Community

loading...
Cover image for How to Make Your Laravel App More Testable

How to Make Your Laravel App More Testable

Ash Allen
I am Laravel web developer that specialises in building websites and systems for small businesses
Originally published at ashallendesign.co.uk ・8 min read

Introduction

Testing is an integral part to web and software development. It helps to give you the confidence that any code that you have written meets acceptance criterias and also reduces the chances of your code having bugs. In fact, TDD (test driven development), a popular development approach, actually focuses around tests being written before any code is added to the actual app code base.

Intended Audience

This article is aimed at developers who are fairly new to the Laravel world but have a basic understanding of tests. This article won't cover how to write basic tests (that's for another post in the future), but it will show you how you can approach your code in a slightly different way to improve your code quality and test quality.

Why Should I Write Tests?

Tests are often thought of as being an afterthought and a "nice to have" for any code that is written. This is seen especially in organisations where business goals and time constraints are putting pressure on the development team. And in all fairness, if you're only trying to get an MVP (minimum viable product) or a prototype built together quickly, maybe the tests can take a bit of a backseat. But, the reality is that writing tests before the code is released into production is always the best option!

When you write tests, you are doing multiple things:

  • Spotting bugs early - Be honest, how many times have you written code, ran it once or twice and then committed it. I'll hold my hand up, I've done it myself. You think to yourself "it looks right and it seems to run, I'm sure it'll be fine". Every single time I did this, I ended up with either my pull requests being rejected or bugs being released into production. So by writing tests, you can spot bugs before you commit your work and have a bit more confidence whenever you release to production.
  • Making future work and refactoring easier - Imagine that you need to refactor one of the core classes in your Laravel app. Or, that you maybe need to add some new code to that class to extend the functionality. Without tests, how are you going to know for certain that changing or adding any code isn't going to break the existing functionality? Without a lot of manual testing, there's not much way of quickly checking. So, by writing tests when you write the first version of the code, you can treat them as regression tests. That means that every time you update any code, you can run the tests to make sure everything is still working. You can also keep adding tests every time you add new code, so that you can be sure your additions are also working.
  • Changing the way you approach writing code - When I first learnt about testing and started writing my first tests (for a Laravel app using PHPUnit), I quickly realised that my code was pretty difficult to write tests for. It was hard to do things like mocking classes, preventing third-party API calls and making some assertions. To be able to write code is a way that can be tested, you have to look at the structure of your classes and methods from a slightly different angle than before.

OK, Let's Write a Test

To explain how we can make your code more testable, we'll use a simple example. Of course, there's different ways that you could write the code and this might be that simple that it doesn't matter. But, hopefully it should help explain the overall concept.

Let's take this example controller method:


use App\Services\NewsletterSubscriptionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class NewsletterSubscriptionController extends Controller
{
    /**
     * Store a new newsletter subscriber.
     *
     * @param  Request  $request
     * @return JsonResponse
     */
    public function store(Request $request): JsonResponse
    {
        $service = NewsletterSubscriptionService();
        $service->handle($request->email);

        return response()->json(['success' => true]);
    }
}

The above method, which we'll assume is invoked if you make a POST request to /newsletter/subscriptions, which accepts an email parameter which is then passed to a service. We can then assume that the service handles all of the different processes that need to be carried out to complete a user's subscription to the newsletter.

To test the above controller method, we code create the following test:


class NewsletterSubscriptionControllerTest extends TestCase
{
    /** @test */
    public function success_response_is_returned()
    {
        $this->postJson('/newsletter/subscriptions', [
            'email' => 'mail@ashallendesign.co.uk',
        ])->assertExactJson([
            'success' => true,
        ]);
    }
}

There's just one problem that you have might noticed in our test. It doesn't actually check to see if the service class' handle() method was called! So, if by accident we were to delete or comment out that line in the controller, we wouldn't actually know.

Now, Let's Write a Better Test

What's The Problem?

One of the problems that we have here is that without adding extra code to flag or log that the service class has been called, it's pretty difficult for us check that it's been written.

Sure, we could add more assertions in this controller test to test the service class's code is all being run. But that can lead to an overlap in your tests. For arguments sake, let's imagine that our Laravel app allows users to register and that whenever they register they are automatically signed up to the newsletter. Now, if we were to write tests for this controller as well that checked that all of the service class was run correctly, we'd have 2 near duplicates of test code. This would mean that if we were to update the way that the service class runs internally, we'd also need to update all of these tests as well.

In all fairness, sometimes you might actually want to do that. If you're writing a feature test and run assertions against the whole end-to-end process, this would be suitable. However, if you're trying to write unit tests and only want to check the controller, this approach won't quite work.

How Can We Fix The Problem?

In order to improve the test that we've got, we can make use of mocking, the service container and dependency injection. I won't go too much into too much depth about what the service container is (that's a whole new blog post in itself). But, I'd definitely recommend reading into it because it can be incredibly helpful and is a core part of Laravel. In fact, the Laravel docs actually cover the service container really well, so they're definitely worth a read.

In short (and very basic terms), the service container manages class dependencies and allows us to use classes that Laravel has already set up for us. I'll make a post in the future on how to use service providers to bind classes to the service container for dependency injection. For the time being though, this following example should hopefully give a brief idea.

To make our code example more testable, we can instantiate the NewsletterSubscriptionService by using dependency injection to resolve it from the service container, like this:


use App\Services\NewsletterSubscriptionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class NewsletterSubscriptionController extends Controller
{
    /**
     * Store a new newsletter subscriber.
     *
     * @param  Request  $request
     * @param  NewsletterSubscriptionService $service
     * @return JsonResponse
     */
    public function store(Request $request, NewsletterSubscriptionService $service): JsonResponse
    {
        $service->handle($request->email);

        return response()->json(['success' => true]);
    }
}

What we've done above is we've added the NewsletterSubscriptionService class as an argument to the store() method because Laravel allows dependency injection in controllers. What this basically does is it tells Laravel when it's calling this method is "Hey, I also want you to pass me a NewsletterSubscriptionService!". Laravel then replies and says "Okay, I'll grab one now for you from the service container".

In this case, our service class doesn't have any constructor arguments, so it's nice and simple. However, if we had to pass in constructor arguments, we'd potentially have to create a service provider that handles what data is passed into the class when we first instantiate it.

Because we're now resolving from the container, we can update our test like so:


class NewsletterSubscriptionControllerTest extends TestCase
{
    /** @test */
    public function success_response_is_returned()
    {
        // Create the mock of the service class.
        $mock = Mockery::mock(NewsletterSubscriptionService::class)->makePartial();
        
        // Set the mocked class' expectations.
        $mock->shouldReceive('handle')
            ->once()
            ->withArgs(['mail@ashallendesign'])
            ->andReturnNull();
        
        // Add this mock to the service container to take the service class' place.
        app()->instance(NewsletterSubscriptionService::class, $mock);
    
        $this->postJson('/newsletter/subscriptions', [
            'email' => 'mail@ashallendesign.co.uk',
        ])->assertExactJson([
            'success' => true,
        ]);
    }
}

Now, in the above test, we start off by using Mockery to create a mock of the service class. We then tell the service class that by the time the test finishes running, we expect that the handle() method will have been called once and have mail@ashallendesign.co.uk as the only parameter. After doing that, we then tell Laravel "Hey Laravel, if you need to resolve a NewsletterSubscriptionService at any point, here's one for you to return".

This means now that in our controller, the second parameter isn't actually the service class itself, but instead a mocked version of this class.

So, when we run the test now, we'll see that the handle() method is actually called. As a result of this, if we were to ever delete where that code is called or add any logic which might prevent it from being called, the test would fail because Mockery would detect that the method was not invoked.

Bonus Tip

There might be times when you're inside a class of code and it turns out that without some major refactoring you won't be able to inject your class (that you want to mock) by passing it as an extra method argument. In these cases, you can make use of the resolve() helper method that comes with Laravel.

The resolve() method simply returns a class from the service container. As a small example, let's look at how we could have updated our example controller method to still be testable with Mockery but without adding an extra argument:


use App\Services\NewsletterSubscriptionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class NewsletterSubscriptionController extends Controller
{
    /**
     * Store a new newsletter subscriber.
     *
     * @param  Request  $request
     * @return JsonResponse
     */
    public function store(Request $request): JsonResponse
    {
        $service = resolve(NewsletterSubscriptionService::class);
        $service->handle($request->email);

        return response()->json(['success' => true]);
    }
}

Conclusion

So, I'm hoping that this article has given a little bit of an insight into how you can make your Laravel app more testable by making use of the service container, mocking and dependency injection.

Remember that tests are your friends and can save you an unbelievably huge amount of time, stress and pressure if they're added when code is first written. And as an added bonus, higher quality tests and increased test coverage usually means less bugs, which means less support tickets and happier clients!

There are more things that can be done to make your code more testable and I'll hopefully get them written up into more blog posts like this. So, if you liked the way this post was written or if you'd prefer a different style, comment below and let me know.

Discussion (4)

Collapse
mmp4k profile image
Marcin

Unfortunately, that test is incorrect and it's very fragile.

The test has to pass if you change implementation. So, if I change class NewsletterSubscriptionService to another one, a method handle to something else then the test will fail, whenever an email is saved correctly.

Collapse
ashallendesign profile image
Ash Allen Author

Hi Marcin, thanks for the comment.

I do agree that this test can be seen as being fragile, but this example is just a simple one that we can use to explain the concept of dependency injection and mocking. In a real-life scenario, I'd have used an interface instead to decouple the code so that we could switch out the implementation further down the line. I didn't want to throw too much at the readers at once though and scare them away haha :)

Collapse
m7md3omer profile image
Mohammed Omer • Edited

Very informative post!
I recommend readers to dig in and read about ServiceContainers and ServiceProviders.
A good resource is of course the laravel docs and Jeffrey Ways's videos on laracasts laracasts.com/series/laravel-6-fro...

Collapse
ashallendesign profile image
Ash Allen Author

Thanks Mohammed, I'm glad that you find it informative!