Unit and Integration Testing with PHPUnit and Pest in Laravel

Testing is a crucial part of any application we are going to create, regardless of the technology, it is always advisable to perform automatic tests to test the system when new changes are implemented; in this way we save a lot of time since there is no need to perform many of the tests manually but by executing a simple command.

Testing consists of testing components individually; in the case of the application we have built, they would be each of the API methods, as well as any other dependency on these methods; this way, when these automated tests are run, if the application passes all the tests, it means that no errors were found, but, if it does not pass the tests, it means that changes have to be made at the application level or implemented tests.

Why should we make tests?

Testing helps ensure that your application will function as expected and as the application grows in modules and complexity, new tests can be implemented and current tests adapted.

It is important to mention that the tests are not perfect, that is, even if the application passes all the tests, it does not mean that the application is error-free, but it is a good initial indicator of the quality of the software. Additionally, testable code is generally a sign of good software architecture.

 

Testing must be part of the application development cycle to guarantee its proper functioning by running them constantly.

What to test?

Testing should focus on testing small units of code in isolation or individually.

For example, in a Laravel application or a web application in general:

  • Controllers:
    • View Responses
    • State codes
    • Nominal conditions (GET, POST, etc.) for a view function
  • Forms
  • Individual help functions

In Laravel, we officially have two ways to use testing, using Pest and PHPUnit.

Tests are the basis of testing in Laravel, with which we can test the methods that make up our application in isolation.

Testing with Pest/PHPUnit

PHPUnit is one of the frameworks for testing in PHP that comes already installed by default in Laravel. As it is the one that has been part of Laravel for the longest time, it is the one that we are going to cover first. It is important to clarify that to follow this section, you must have selected PHPunit as a testing environment when creating the project in Laravel.

When creating a project in Laravel, it also automatically creates a tests folder, with this, you can realize how important tests are, that although they are not part of the functional development of an application, they are part of the development cycle life of this and creating them is evidence of good programming practices.

As we have mentioned previously, Laravel starting with version 11, one of the most important changes is its cleaning of folders, removing files and folders to merge them with others or generate others on demand as in the case of api.php, but, you can see that the tests folder is still present as soon as we create the project, giving complete evidence of the importance of creating these tests for all the applications we develop.

By default, the tests folder contains two folders:

  • tests/Feature
  • tests/Unit

Unit tests are those that test a specific module of the application, as its name says, it is a unit, something that we cannot divide, for example, a facade, a model, a helper are candidates for unit tests, since they have isolated code of the application and do not communicate with each other as in the case of controllers or components, these tests are stored in tests/Unit.

While the tests/Feature folder is the one used to perform integration tests, such as the controllers or those components that are not individual as in the previous case, otherwise, where many things occur such as database connections, use helpers, facades or similar, return jsons, views, etc. These tests are known as function tests where we test larger blocks of code that usually resolve a complete HTTP response.

By default, Laravel already comes with some tests and files ready to use, one of the example tests is ExampleTest.php and that brings the hello world to our application.

Regardless of whether you are using Pest or PHPUnit, the logic is the same, what changes is the syntax and to execute our tests we have the command:

$ vendor/bin/phpunit

For PhpUnit, or:

$ vendor/bin/pest

For Pest, or easier:

$ php artisan test

For any of the above.

Additionally, you can create a .env.testing file in the root of your project to manage configurations in the test environment. This file will be used in place of the .env file when running Pest and PHPUnit tests or running Artisan commands with the --env=testing option.

 

 

 

Test folder

 

 

To create a unit test, we have the following command:

$ php artisan make:test <ClassTest>

Where the test will be placed in the tests/Feature folder:

Or if you want to create a test inside the tests/Unit folder, you can use the --unit option when running the make:test command:

$ php artisan make:test <ClassTest> --unit

More information in:

https://laravel.com/docs/master/testing

Understanding the tests

So that the use of tests is understood in a less abstract way, we are going to create a small exercise of mathematical operations before starting to create tests to test modules of our application, such as in the case of controllers, that is, the scheme of request/response.

For these first tests, let's create a math operations file like the following:

app\Utils\MathOperations.php

class MathOperations
{
 public function add($a, $b) {
    return $a + $b;
 }

 public function subtract($a, $b) {
    return $a - $b;
 }

 public function multiply($a, $b) {
    return $a * $b;
 }

 public function divide($a, $b) {
    return $a / $b;

 }
}

Now let's generate our first file for the first unit test using:

$ php artisan make:test MathOperationsTest --unit

This will generate a file in:

tests/Unit/MathOperationsTest.php

In which, we create some functions that allow us to test the previous methods to perform mathematical operations:

tests/Unit/MathOperationsTest.php

<?php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

// use App\Utils\MathOperations;

class MathOperations
{

    public function add($a, $b)
    {
        return $a + $b;
    }


    public function subtract($a, $b)
    {
        return $a - $b;
    }


    public function multiply($a, $b)
    {
        return $a * $b;
    }

    public function divide($a, $b)
    {
        return $a / $b;
    }
}

class MathOperationsTest extends TestCase
{

    // public function test_example(): void
    // {
    //     $this->assertTrue(true);
    // }

 public function testAdd()
    {
        $mathOperations = new MathOperations();
        $result = $mathOperations->add(2, 3);
        $this->assertEquals(5, $result);
    }
    public function testSubtract()
    {
        $mathOperations = new MathOperations();
        $result = $mathOperations->subtract(5, 3);
        $this->assertEquals(2, $result);
    }
    public function testSubtraction()
    {
        $mathOperations = new MathOperations();
        $result = $mathOperations->multiply(5, 3);
        $this->assertEquals(15, $result);
    }
    public function testDivide()
    {
        $mathOperations = new MathOperations();
        $result = $mathOperations->divide(8, 2);
        $this->assertEquals(4, $result);
    }
}

To facilitate the exercise, we copy the contents of MathOperations into the unit file.

In this example, we have four test methods, one for each method defined in the MathOperations auxiliary class that allows testing the operations of addition, subtraction, multiplication and division respectively and with this we can appreciate the heart of the tests, which is through assert or assertion type methods:

  • assertStatus: Check the status code in the response.
  • assertOk: Checks if the response obtained is of type 200.
  • assertJson: Checks if the response is of type JSON.
  • assertRedirect: Checks if the response is a redirect.
  • assertSee: Verifies based on a supplier string, if it is part of the response.
  • assertDontSee: Checks if the supplied string is not part of the response.
  • assertViewIs: Checks if the view was returned by the route.
  • assertValid: Checks if there are no validation errors in the submitted form.

They are nothing more than conditionals with which we verify that the expected results are obtained. In this example, the assertEquals method is used, but there are several with a similar operation and we will see some of them throughout the chapter.

You can see the complete immense list at:

https://laravel.com/docs/master/http-tests#response-assertions

Don't worry about having to learn them all though, we usually use a few of them.

Finally, to run the unit tests, we use the command:

$ vendor/bin/phpunit

And we should see an output like the following:

Time: 00:00.850, Memory: 42.50 MB
OK (29 tests, 65 assertions)

If we cause an error in the auxiliary class, such as adding the same parameter twice, ignoring the other:

public function add($a, $b)
{
    return $a + $a;
}

And we execute:

$ vendor/bin/phpunit

We will see an output like the following:

/MathOperationsTest.php:47
FAILURES!
Tests: 29, Assertions: 65, Failures: 1.

We will see an output like the following:

public function testAdd()
{
    $mathOperations = new MathOperations();
    $result = $mathOperations->add(2, 2);
    $this->assertEquals(4, $result);
}

The tests would pass:

Time: 00:00.733, Memory: 42.50 MB
OK (29 tests, 65 assertions)

But, clearly we have a problem in the definition of the auxiliary class, therefore, the tests are not infallible, they are only a means to verify that we do not find errors in the application but it does not mean that the application is error-free, with we can have a basic and necessary understanding of how unit tests work, with this example, we can now move on to actually test the modules that make up the application.

HTTP requests

Our application is made up of controllers or similar that are consumed through HTTP requests of different types and this is exactly what we usually have to test; unit tests are made up of two main blocks, the aspiration methods and the http methods, which in PHPUnit, we have one method for each HTTP method, that is, get, post, put, patch, or delete methods; to be able to send HTTP requests; we must inherit from the class:

use Tests\TestCase;

Laravel Course - Unit Testing with PHPUnit, Testing a paginated list of the Rest Api 5

Now we are going to start closing all this it really wouldn't be necessary I'm going to leave this here so that it locates the folder here only and here we are going to create our test for the categories Well you already know what the make option is because we are going to create something that something is a test and the name is going to be category test Well you can also put category Api but right now as I am working on different projects then with category it is more than fine for me but if you have everything mixed there it might be better category Api or you put it inside the Api folder however you want then Well we execute it is not a unit test but an integration test and here notice Where it is located here we are in unit and here it would be the feature one Don't worry so much about this, notice that already here we have a warning:

public function test_example(): void
    {
        
        $this->get('/');
    }

This good you can also remove it if you want so it doesn't generate noise and over here the name that I'm going to give it would be es and remember that they have to start with test at the bottom so that it is precisely taken as a test. Well, I'm going to leave this there. Although it will generate a little noise here when I run the tests, but it doesn't matter. So we run the test and let's see what happens and right now I'll explain the code a little bit again. Don't worry. And look, something failed here. Well, five passed, you already know what that is, so we have it here and notice that over here it tells us that it doesn't know what the hell the method called get is, which doesn't exist. So this was what I was telling you before, by default we are inheriting from the php unit framework Test Case, which was what we had here. When we have a unit test here, but over here we are:

use Tests\TestCase;

Making a request in this case a route that does not exist But it does not matter in my case at least it returns the welcome; the one from php unit framework Test Case but as Laravel is good people when we create a feature it seems to ASSUME that we are going to do other types of tests in this case these integration tests to test the application itself and not an isolated module as it would be in this case that in this case it would not be necessary to use this type of schemes, that is, use get post ppad and delete type methods because they are isolated methods, that is, they are to test models or helpers or fate or whatever you want, then it automatically completes enough things for us that we are going to use them. Of course but well little by little then here we can see that this is no longer useful to us so we should remove it and the one we have to inherit is from Test Case, look here:

use Tests\TestCase;

It is important that you understand the imports, this is an internal import that would be here from the vendor folder, well the one we have here that is part of the module or the project itself, which would be the one we have here, these imports that begin with illuminate and the whole thing. But as you can see, they are local, which indicate that it is importing from the Test folder, which is precisely the one we have here and the one we have here which is the test Case, which is the one I showed you before, that here you can see that we have lar code. To put it in some way, we have an import there in which surely all those get post and delete type methods are included. I don't know if they are here, well they are not but surely they are in some of these that we have here, so this is exactly what we have to do. This makes all the sense in the world since by default php unit does not allow this type of integration since this is something from Laravel, that is to say.

For making requests, it makes perfect sense that the Laravel folks include these types of packages.

So, we put it this would be the one we are going to use now and notice that the error has already disappeared now in my case I think it gives a 404 because I think I tested it yesterday and it gave me a 404 whether or not Well perfect it doesn't give 404 to see the same I can come here it shouldn't because it is the Welcome one but I don't remember it has been a 404 then Well here it simply made the request and we are not doing anything else and that is what it is indicating to us here it tells us Well perfect everything is fine now we do not have the previous error but you are not making any assertion Since this is the sense of the tests if it is the request and we are not doing anything else Then what would be the assertion precisely the one we have here that the people at L were very kind and saves us work would be to use another assertion type method notice all the ones we have the names indicate quite a bit but if we start writing here it says a status of what they are all going to start with assertion of course It's good This basically verifies the status of the request. Remember again that here we can see it if we come here more developer tools or control f12 Sorry here in the network part when I reloaded you will see the code that in this case is a 404 this would be all, the question for this to look nice but here we have a 404 if here we put Because for me this route does not exist if for you it exists then put something that does not exist for example This then here it would pass for whatever the reason that you want to test here a 404 that does not make sense But again It is to present a little all this here all the tests should pass But obviously I do not want to test This then I put that I want a 200 that would be the response that we always get when the arabel returns a view to us and here you can see that it fails and the world ended and everything else here indicates that well it is typical he returned a 404 But he expected a 200 and obviously 404 is not equal to 200 even if they are even numbers So the problem is the same as always you have to see If it is a problem with the test, which in this case is or is it something else that the application failed in this case is obviously the test. So here we have to place what we want to test, which in this case is Api category and then with this we execute:

public function test_example(): void
    {
        $response = $this->get('/api/category/all');
        dd($response)
        $response->assertStatus(404);
    }

and Notice that the test passed successfully since this is exactly what returns a 200 in In these cases where you may have doubts that something is being done always feel free to momentarily change the code or well the test the assertion for something that you know is not going to work for example a 400 and you see what it tells you here it tells you that I returned a 200 and you are evaluating for a 404 So we change it and everyone is happy and here it works successfully again.

So that's basically it, I hope it was clear any comment you know you can do it right for me so here it is finally testing something but what the hell is there So the next tip I'm going to give you is to never hesitate to print this with our best friend which is dd so we print here to see what the hell we are getting in response:

dd($response)

Note that well here it gives us a lot of information is not exactly this that well remember here I am also using a sniped here to say an extension in Google Chrome to make it look pretty but in the end this is a JSON and at least here although it is not very clear we can see the data here we can see that it is returning what we have in our development database that well we correct this later since it is not the database that we are going to use but, at least we can see that s is doing something then with this we can feel more confident that it is testing the correct thing then well clarified this you can always print here as I tell you the answer so you can see this the next thing we have to do is test that I think it will do it at once and then we go little by little something a little more interesting that would be the Data that is to say we already know that this test has to return all our categories so in case we do not remember what we did we can come here then we review the method here we have sorry if this is the Api here we have Sorry it was the category controller over here we have Here it is This is what we do a basically there you can see that it is the same so the logical thing is to use the same thing that we are using here whether you like the get or the al So here we can get the categories we get all this and well we import the category I said category for the gods category the model here they are well I'm going to leave this here as I told you I'm going to organize it a little bit I'm going to leave mine down here better the ones from the framework up there and mine here and I'll leave this over there well I can remove this I'm going to give it to you here for reference when you see the source code and we use this then well we go down here again so here we get all the categories we can run at least to know that it is not giving errors, so, never doubt when you are learning this to run the tests every now and then.

Then we would pass this eye it is not going to work spoiler and we expect the worst as always so we run and it failed how to:

    public function test_all(): void
    {
        Category::factory(10)->create();
        $categories = Category::get();
        // dd($categories);

        $response = $this->withHeaders([
            'Authorization' => 'Bearer ' . $this->generateTokenAuth()
        ])
        ->get('/api/category/all');
        // dd($response);
        $response->assertStatus(200);
        $response->assertJson($categories);
    }

It indicated here:

Collection is not callable

It is clearly indicating to us what the problem is, it is indicating to us that a collection is not valid, well it is not so clear but at least it is indicating to us that the problem is the collection. So what can we do? We have to convert this to something that Laravel can handle, something a little curious because here we can tell response Json directly to work with the collection but in this case for that particular Method at least at the time in which I am recording this class is not integrated, it would be expected that it does the translation. But well, maybe they do not want to implement it that way, anyway for whatever reason, then as we saw before basically the collections are the Arrays with vitamins and well we can go backwards so here we have a method that is from Laravel. You can use this anywhere, be careful that it is not something here from the tests you could place it here and see that it will be the same result, of course it does not make sense here because you are not going to do additional operations for anything really, then any collection of data understands that collection with what Laravel returns to us here we can get the array and again if you have doubts with this we can print it. Here we print this:

   public function test_all(): void
    {
        Category::factory(10)->create();
        $categories = Category::get()->toArray();
        // dd($categories);

        $response = $this->withHeaders([
            'Authorization' => 'Bearer ' . $this->generateTokenAuth()
        ])
        ->get('/api/category/all');
        // dd($response);
        $response->assertStatus(200);
        $response->assertJson($categories);
    }

And notice that we have an array and before we had it was a blessed collection, so we execute it and here we have this whole mess that is a collection just as I indicated here, so let's try our luck now by passing it an array to see what happens if this blessed method wants to take it, we execute it again and notice that now it does take it and it works perfectly, so every time you want to work with a collection of data simply convert it to an array and test it here and that would be practically everything, with that we complete the first test.

Note that it works correctly, This is not what it is returning and therefore it is going to give us a nice exception, well you could say as you can see and I am going to indicate to you that look, this is what we have And this is what it is comparing you are crazy it does not work then correct Your test or correct the application itself Well the same as always there you have to see what the problem is and correct it obviously in this case obviously we know that this is not what we have to pass if it would be this and that would be practically everything so we save we execute again and everything works correctly These are two simple methods that we have here the next thing I tell you is that when you finish the section Or at least when you advance a little more I would highly recommend that you see documentation on the internet see some posts and well More or less you also see the experience of other people or the vision of other people I show you mine But you do not have to copy it as is I am doing it surely other people also perform other types of assertion methods to well guarantee that we are precisely verifying something since suppose that I do not know here we have some other problem I can't think of anything and that's why I think there's no need to do another assertion type method But this may be returning something strange so in the end that strange thing we're also comparing here we're not doing anything Something similar to what happened here with the a+ a method when we left it here but then I would always recommend that too another tip that I give you here When you do a little more in all this and understand how this works and have a slightly broader vision of this I would recommend that you consult some documentation some posts on the internet so that you have the vision of other people as well and you yourself with all this basically build your own way of doing the tests since the tests are something Quite personal more personal of the implementation of the codes that are already the arel guides us a little of how we have to do it and this is a little as I tell you more personal for the implementation as we will see little by little and as we try some more complex methods that in this case we do not have so much Because it is an example application of a course but in the real environment they would be a little more complex surely so nothing as I tell you also as Norma almost that I think that s is fine really no I do not see the reason why not this is always the first thing that we should test since if this does not exist it does not make sense to do any other test, that is to say suppose that for example here we knock down the route again in the test or at the project level and we execute the first thing that has to be encountered is this since it does not make sense to test something that is not going to return a 200 Unless I do not know for whatever reason you configured that the status code d of some request does not return a 200 which again I would not understand why it does not make sense But this is the first thing we have to test since it is the entrance to everything else if it returns a 200 perfect everything is fine then we continue testing based on what is expected and Well here it says that it returned a 500 for some reason and that well obviously it is not equal to 200 and with that the test stops and with that we can start to make the correction since if you place this here at the beginning and we left the test here more, well the route here the error would be a little bit stranger. Well here at least it returned that the route has not been found, at least it is a pretty nice error but I think it is more direct that you see here directly the code that is returning instead of asking yourself about this, suppose it is a route that we are here Armando, then it can be prone to errors of not knowing if there is a problem in the definition of the route or is it precisely here. So we remove this by placing here that the first thing that is evaluated is 200.

- Andrés Cruz

En español

This material is part of my complete course and book; You can purchase them from the books and/or courses section, Curso y Libro Laravel 11 con Tailwind Vue 3, introducción a Jetstream Livewire e Inerta desde cero - 2025.

Andrés Cruz

Develop with Laravel, Django, Flask, CodeIgniter, HTML5, CSS3, MySQL, JavaScript, Vue, Android, iOS, Flutter

Andrés Cruz In Udemy

I agree to receive announcements of interest about this Blog.

!Courses from!

10$

On Udemy

There are 3d 11:35!


Udemy

!Courses from!

4$

In Academy

View courses

!Books from!

1$

See the books
¡Become an affiliate on Gumroad!