Unit and Integration Testing with PHPUnit and Pest in Laravel
Content Index
- What are unit tests and why use them in Laravel?
- Advantages of automated testing
- Types of tests in Laravel
- Importance of Automated Tests
- Why test?
- What to test?
- Testing with Pest/PHPUnit
- ⚙️ Setting Up the Testing Environment
- Database in Testing Mode
- Factories and Seeders
- Creating a Test
- Understanding Tests
- HTTP Requests
- Unit Tests with PHPUnit in Laravel
- Most Used Assertion Methods
- assertOk() — Verifies HTTP 200 responses.
- assertStatus(404) — Verifies specific status codes.
- assertSee('Text') — Confirms that a view contains certain text.
- assertViewHas('posts') — Checks that a view receives a parameter.
- ⚡ Migrating from PHPUnit to Pest Step by Step
- Key Differences
- API Testing with Token Authentication and Pest
- Running and Debugging Tests
- PHPUnit's setUp() Method for Common Code
- Enable DB_CONNECTION and DB_DATABASE in the phpunit.xml in Laravel
- Best Practices
- ⚖️ PHPUnit vs Pest: Quick Comparison
- Conclusion
- ❓ Frequently Asked Questions
Unit tests in Laravel are one of the most powerful tools to ensure that your application works as expected. They not only validate your code's behavior but also help you keep it clean, secure, and scalable. In this guide, I will show you how to implement tests with PHPUnit and Pest, the two most popular options in the Laravel ecosystem, and how to migrate between them easily.
Now that errors like Eager loading and lazy loading in Laravel have been fixed, we move on to the next step, which is to have EVERYTHING tested through testing.
What are unit tests and why use them in Laravel?
Unit tests are small code snippets that verify that each component (model, controller, helper, etc.) works correctly in isolation. In Laravel, tests are natively integrated with powerful tools and an expressive syntax.
Advantages of automated testing
- Detects errors before they reach production.
- Allows for fearless refactoring.
- Improves code quality and team confidence.
- Facilitates applying TDD (Test Driven Development), a methodology that starts with writing the tests first and then the functional code.
Types of tests in Laravel
Laravel supports different types of tests:
- Unit: verify individual functions or methods.
- Integration: evaluate how different components interact.
- End-to-End: test the complete user flow, for example with Laravel Dusk.
TDD in action
In my experience, applying TDD in Laravel changes the way you program. When I started developing modules like the dashboard, I implemented the tests first, and that allowed me to establish clear rules about what each route or controller should do; with AI, you can ask it to first generate the tests based on a module, and then the modules from there, gaining consistency and setting limits on the development you want to carry out
Importance of Automated Tests
Tests are a crucial part of any application we are going to create. Regardless of the technology, it is always advisable to perform automatic tests to validate the system when new changes are implemented. This way, we save a lot of time, since it is not necessary to perform all tests manually, but simply to execute a command.
Tests consist of verifying components individually. In the case of the application we have built, they would be each of the API methods, along with any associated dependencies. When these automated tests are executed and the application passes them all, it means no errors were found; if it doesn't pass them, it means we must make changes both to the application and to the implemented tests.
Tests are 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; 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.
Tests consist of testing the 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 of these methods; this way, when these automated tests are executed, 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 must be made at the application level or to the implemented tests.
Why test?
Tests help ensure that the application will function as expected. As the project grows in modules and complexity, it is possible to implement new tests and adapt existing ones.
It is important to mention that tests are not perfect. Even if the application passes all tests, it does not mean that it is bug-free, but it is a very good initial indicator of software quality. Furthermore, testable code is often a sign of good architecture.
Tests should be part of the project's development cycle to guarantee stable operation, running them constantly.
What to test?
Tests should focus on small units of code in isolation.
For example, in a Laravel app (or web in general):
- Controllers
- View responses
- Status codes
- Nominal conditions (GET, POST, etc.)
- Forms
- Individual helper functions
Laravel officially supports two testing tools: Pest and PHPUnit.
Testing with Pest/PHPUnit
PHPUnit is one of the testing frameworks for PHP and comes pre-installed by default in Laravel. It is the oldest within the ecosystem, so we will cover it first. To follow this section, you must have selected PHPUnit as your testing environment when creating your project.
When creating a Laravel project, a tests folder is automatically generated, which evidences the importance of testing. Although they are not part of functional development, they are part of the software life cycle, and creating them is evidence of good practices.
In Laravel 11, although several folders disappeared to simplify the structure, tests/ is still present, reaffirming its relevance.
Inside this folder we find:
- tests/Unit
- tests/Feature
Unit tests verify specific modules, usually isolated code: facades, models, helpers, etc. These go in tests/Unit.
Integration or feature tests test larger components: controllers, database queries, helpers, facades, JSON responses, views, etc. These go in tests/Feature.
Laravel already includes basic examples, such as ExampleTest.php, which contains a "hello world".
By default, Laravel already comes with some tests and files ready to use; one of the example tests is ExampleTest.php, which includes the hello world for our application.
Regardless of whether you are using Pest or PHPUnit, the logic is the same, what changes is the syntax, and to run our tests we have the command:
$ vendor/bin/phpunitFor PHPUnit, or:
$ vendor/bin/pestFor Pest, or easier:
$ php artisan testFor any of the above.
Additionally, you can create a .env.testing file in the root of your project to manage configurations in the testing environment. This file will be used instead of the .env file when running Pest and PHPUnit tests or running Artisan commands with the --env=testing option.
⚙️ Setting Up the Testing Environment
Key Files and Folders
Laravel already includes a basic structure for testing inside the /tests folder. There you will find two main types:
- Feature: tests that cover the complete application.
- Unit: tests that validate specific functions.
Additionally, the phpunit.xml file defines the main PHPUnit configuration, while Pest uses tests/Pest.php to register global functions and configurations like traits or base classes.
Database in Testing Mode
Laravel makes it easy to handle temporary databases during tests. The `RefreshDatabase` trait ensures that each test starts with a clean database.
uses(TestCase::class, RefreshDatabase::class)->in('Feature');Factories and Seeders
You can quickly create data with factories:
Category::factory(10)->create();In my case, I use this to populate categories, users, or posts before running CRUD tests.
Creating a Test
To create a unit test:
$ php artisan make:test ClassTest --unitTo create a feature test:
$ php artisan make:test ClassTestUnderstanding Tests
To start with something simple, let's create a class with mathematical operations:
app\Utils\MathOperations.php
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; }
}Then we generate a unit test:
$ php artisan make:test MathOperationsTest --unitAnd in tests/Unit/MathOperationsTest.php we add the methods to test each operation.
This will generate a file in:
tests/Unit/MathOperationsTest.php
In which, we create functions that allow us to test the previous methods for performing 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 simplify the exercise, we copy the content of MathOperations inside the unit file.
In this example, we have four test methods, one for each method defined in the auxiliary class MathOperations that allows testing the addition, subtraction, multiplication, and division operations respectively, and with this we can appreciate the heart of the tests which is through assert methods or assertion type methods.
You can see the immense complete list at:
https://laravel.com/docs/master/http-tests#response-assertions
Although don't worry about having to learn them all, we usually only use a few of them.
Finally, to run the unit tests, we use the command:
$ vendor/bin/phpunitAnd 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 run:
$ vendor/bin/phpunitWe will see an output like the following:
/MathOperationsTest.php:47
FAILURES!
Tests: 29, Assertions: 65, Failures: 1.Which clearly indicates that an error occurred.
Unit tests are not infallible, since everything depends on the tests we execute. Keeping the same error we caused before, if the test were as follows:
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, we clearly have a problem in the definition of the auxiliary class, therefore, tests are not infallible, they are only a means to verify that we did not find errors in the application but it does not mean that the application is error-free. With this, we can have a basic and necessary understanding of how unit tests work. With this example, we can now move on to really testing the modules that make up the application.
Tests rely on assert methods, such as:
- assertStatus: Verifies the status code in the response.
- assertOk: Verifies if the obtained response is type 200.
- assertJson: Verifies if the response is JSON type.
- assertRedirect: Verifies if the response is a redirection.
- assertSee: Verifies based on a provided string, if it is part of the response.
- assertDontSee: Verifies if the provided string is not part of the response.
- assertViewIs: Verifies if the view was returned by the route.
- assertValid: Verifies if there are no validation errors in the submitted form.
Assert methods are nothing more than advanced conditionals.
HTTP Requests
Our application is made up of controllers that are consumed through HTTP requests. This type of testing is performed using methods such as:
- get
- post
- put
- patch
- delete
And require inheriting from:
use Tests\TestCase;Unit Tests with PHPUnit in Laravel
PHPUnit is the most veteran testing framework in the PHP ecosystem. Laravel integrates it perfectly and offers a clear syntax for defining test classes.
Basic Structure
class PostTest extends TestCase
{
use DatabaseMigrations;
protected function setUp(): void
{
parent::setUp();
User::factory(1)->create();
$user = User::first();
$this->actingAs($user);
}
public function test_index()
{
$response = $this->get(route('post.index'))
->assertOk()
->assertViewIs('dashboard.post.index')
->assertSee('Dashboard');
}
}Most Used Assertion Methods
assertOk() — Verifies HTTP 200 responses.
assertStatus(404) — Verifies specific status codes.
These are the first assertion methods we'll see and the most essential ones we must use when evaluating our tests. First, we have assertStatus() and assertOk(), the latter being equivalent to using assertStatus(200).
The 200 code corresponds to the HTTP status returned in a normal request, like the one we have here. If we load this page, we get 200 by default, since it is the code used to indicate that everything is "Ok."
assertSee('Text') — Confirms that a view contains certain text.
Another essential assertion method is assertSee, which tells us what the view is "seeing." We simply pass it a text, and that text must be present in the obtained response.
In this case, this would be a test for the detail view, and therefore we must ensure that the main elements appear correctly. This would be the beauty we have here: the post title, the category, and the content.
So, first, we get the post (which we generated with its dependencies). Then, we look for the post by its ID and verify that the view contains the title, the content, and, of course, the associated category.
That's what assertSee is for: to ensure that specific text elements are visible in the view returned by the application.
assertViewHas('posts') — Checks that a view receives a parameter.
Another essential assertion method that we must use when working with controllers that return a view is the assertViewHas() method. With this method, we can indicate the name of the parameter we expect to receive and also specify what that parameter should contain.
In this case, the parameter called posts (plural) must contain a pagination with only two levels. This is exactly what we can see here, as this is what the controller is returning. For this scenario, the assertViewHas() assertion method works perfectly.
assertDatabaseHas('posts', $data) — Validates records in the database.
⚡ Migrating from PHPUnit to Pest Step by Step
PestPHP is a modern, minimalist, and 100% compatible alternative to PHPUnit. Its main advantage is cleaner and more readable syntax.
Key Differences
- Pest doesn't use classes, but global functions (test() or it()).
- setUp() is replaced by beforeEach().
- Assertions are written the same, except for minor differences.
- assertStringContainsString() is replaced by assertMatchesRegularExpression()
- setUp() $\to$ beforeEach()
- Classes with `extends TestCase` $\to$ Functions test() or it()
- @test $\to$ test('...')
Practical Example
PHPUnit:
class CategoryTest extends TestCase { function test_all() { $this->get('/api/category/all') ->assertOk() ->assertJson([...]); } }Pest:
test('test all', function () { $this->get('/api/category/all') ->assertOk() ->assertJson([...]); });
In my case, when migrating the tests, I found that the logic was identical; only the way of writing it changed. The beforeEach() function was ideal for me to prepare users and roles:
beforeEach(function () {
User::factory(1)->create();
$this->user = User::first();
$this->actingAs($this->user);
});You can view the test code in PHPUnit and compare the changes yourself based on what was discussed earlier:
<?php
namespace Tests\Feature\dashboard;
use App\Models\Category;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class PostTest extends TestCase
{
use DatabaseMigrations;
public User $user;
protected function setUp(): void
{
parent::setUp();
User::factory(1)->create();
$this->user = User::first();
$role = Role::firstOrCreate(['name' => 'Admin']);
Permission::firstOrCreate(['name' => 'editor.post.index']);
Permission::firstOrCreate(['name' => 'editor.post.create']);
Permission::firstOrCreate(['name' => 'editor.post.update']);
Permission::firstOrCreate(['name' => 'editor.post.destroy']);
$role->syncPermissions([1, 2, 3, 4]);
$this->user->assignRole($role);
$this->actingAs($this->user);
}
function test_index()
{
User::factory(1)->create();
$user = User::first();
$this->actingAs($user);
Category::factory(3)->create();
User::factory(3)->create();
Post::factory(20)->create();
$response = $this->get(route('post.index'))
->assertOk()
->assertViewIs('dashboard.post.index')
->assertSee('Dashboard')
->assertSee('Create')
->assertSee('Show')
->assertSee('Delete')
->assertSee('Edit')
->assertSee('Id')
->assertSee('Title')
// ->assertViewHas('posts', Post::paginate(10))
;
$this->assertInstanceOf(LengthAwarePaginator::class, $response->viewData('posts'));
}
function test_create_get()
{
Category::factory(10)->create();
$response = $this->get(route('post.create'))
->assertOk()
->assertSee('Dashboard')
->assertSee('Title')
->assertSee('Slug')
->assertSee('Content')
->assertSee('Category')
->assertSee('Description')
->assertSee('Posted')
->assertSee('Send')
->assertViewHas('categories', Category::pluck('id', 'title'))
->assertViewHas('post', new Post());
$this->assertInstanceOf(Post::class, $response->viewData('post'));
$this->assertInstanceOf(Collection::class, $response->viewData('categories'));
}
function test_create_post()
{
Category::factory(1)->create();
$data = [
'title' => 'Title',
'slug' => 'title',
'content' => 'Content',
'description' => 'Content',
'category_id' => 1,
'posted' => 'yes',
'user_id' => $this->user->id
];
$this->post(route('post.store', $data))
->assertRedirect(route('post.index'));
$this->assertDatabaseHas('posts', $data);
}
function test_create_post_invalid()
{
Category::factory(1)->create();
$data = [
'title' => '',
'slug' => '',
'content' => '',
'description' => '',
// 'category_id' => 1,
'posted' => '',
];
$this->post(route('post.store', $data))
->assertRedirect('/')
->assertSessionHasErrors([
'title' => 'The title field is required.',
'slug' => 'The slug field is required.',
'content' => 'The content field is required.',
'description' => 'The description field is required.',
'posted' => 'The posted field is required.',
'category_id' => 'The category id field is required.',
]);
}
function test_edit_get()
{
User::factory(3)->create();
Category::factory(10)->create();
Post::factory(1)->create();
$post = Post::first();
$response = $this->get(route('post.edit', $post))
->assertOk()
->assertSee('Dashboard')
->assertSee('Title')
->assertSee('Slug')
->assertSee('Content')
->assertSee('Category')
->assertSee('Description')
->assertSee('Posted')
->assertSee('Send')
->assertSee($post->title)
->assertSee($post->content)
->assertSee($post->description)
->assertSee($post->slug)
->assertViewHas('categories', Category::pluck('id', 'title'))
->assertViewHas('post', $post);
$this->assertInstanceOf(Post::class, $response->viewData('post'));
$this->assertInstanceOf(Collection::class, $response->viewData('categories'));
}
function test_edit_put()
{
User::factory(3)->create();
Category::factory(10)->create();
Post::factory(1)->create();
$post = Post::first();
$data = [
'title' => 'Title',
'slug' => 'title',
'content' => 'Content',
'description' => 'Content',
'category_id' => 1,
'posted' => 'yes'
];
$this->put(route('post.update', $post), $data)
->assertRedirect(route('post.index'));
$this->assertDatabaseHas('posts', $data);
$this->assertDatabaseMissing('posts', $post->toArray());
}
function test_edit_put_invalid()
{
User::factory(3)->create();
Category::factory(10)->create();
Post::factory(1)->create();
$post = Post::first();
$this->get(route('post.edit', $post));
$data = [
'title' => 'a',
'slug' => '',
'content' => '',
'description' => '',
// 'category_id' => 1,
'posted' => '',
];
$this->put(route('post.update', $post), $data)
->assertRedirect(route('post.edit', $post))
->assertSessionHasErrors([
'title' => 'The title field must be at least 5 characters.',
'slug' => 'The slug field is required.',
'content' => 'The content field is required.',
'description' => 'The description field is required.',
'posted' => 'The posted field is required.',
'category_id' => 'The category id field is required.',
])
;
}
function test_edit_destroy()
{
User::factory(3)->create();
Category::factory(10)->create();
Post::factory(1)->create();
$post = Post::first();
$data = [
'id' => $post->id
];
$this->delete(route('post.destroy', $post))
->assertRedirect(route('post.index'));
$this->assertDatabaseMissing('posts', $data);
}
}And with Pest:
<?php
use App\Models\User;
use App\Models\Post;
use App\Models\Category;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
beforeEach(function () {
User::factory(1)->create();
$this->user = User::first();
$role = Role::firstOrCreate(['name' => 'Admin']);
Permission::firstOrCreate(['name' => 'editor.post.index']);
Permission::firstOrCreate(['name' => 'editor.post.create']);
Permission::firstOrCreate(['name' => 'editor.post.update']);
Permission::firstOrCreate(['name' => 'editor.post.destroy']);
$role->syncPermissions([1, 2, 3, 4]);
$this->user->assignRole($role);
$this->actingAs($this->user);
});
test('test index', function () {
Category::factory(3)->create();
User::factory(3)->create();
Post::factory(20)->create();
$response = $this->get(route('post.index'))
->assertOk()
->assertViewIs('dashboard.post.index')
->assertSee('Dashboard')
->assertSee('Create')
->assertSee('Show')
->assertSee('Delete')
->assertSee('Edit')
->assertSee('Id')
->assertSee('Title')
// ->assertViewHas('posts', Post::paginate(10))
;
$this->assertInstanceOf(LengthAwarePaginator::class, $response->viewData('posts'));
});
test('test create get', function () {
Category::factory(10)->create();
$response = $this->get(route('post.create'))
->assertOk()
->assertSee('Dashboard')
->assertSee('Title')
->assertSee('Slug')
->assertSee('Content')
->assertSee('Category')
->assertSee('Description')
->assertSee('Posted')
->assertSee('Send')
->assertViewHas('categories', Category::pluck('id', 'title'))
->assertViewHas('post', new Post());
$this->assertInstanceOf(Post::class, $response->viewData('post'));
$this->assertInstanceOf(Collection::class, $response->viewData('categories'));
});
test('test create post', function () {
Category::factory(1)->create();
$data = [
'title' => 'Title',
'slug' => 'title',
'content' => 'Content',
'description' => 'Content',
'category_id' => 1,
'posted' => 'yes',
'user_id' => $this->user->id
];
$this->post(route('post.store', $data))
->assertRedirect(route('post.index'));
$this->assertDatabaseHas('posts', $data);
});
test('test create post invalid', function () {
Category::factory(1)->create();
$data = [
'title' => '',
'slug' => '',
'content' => '',
'description' => '',
// 'category_id' => 1,
'posted' => '',
];
$this->post(route('post.store', $data))
->assertRedirect('/')
->assertSessionHasErrors([
'title' => 'The title field is required.',
'slug' => 'The slug field is required.',
'content' => 'The content field is required.',
'description' => 'The description field is required.',
'posted' => 'The posted field is required.',
'category_id' => 'The category id field is required.',
]);
});
test('test edit get', function () {
User::factory(3)->create();
Category::factory(10)->create();
Post::factory(1)->create();
$post = Post::first();
$response = $this->get(route('post.edit', $post))
->assertOk()
->assertSee('Dashboard')
->assertSee('Title')
->assertSee('Slug')
->assertSee('Content')
->assertSee('Category')
->assertSee('Description')
->assertSee('Posted')
->assertSee('Send')
->assertSee($post->title)
->assertSee($post->content)
->assertSee($post->description)
->assertSee($post->slug)
->assertViewHas('categories', Category::pluck('id', 'title'))
->assertViewHas('post', $post);
$this->assertInstanceOf(Post::class, $response->viewData('post'));
$this->assertInstanceOf(Collection::class, $response->viewData('categories'));
});
test('test edit put', function () {
User::factory(3)->create();
Category::factory(10)->create();
Post::factory(1)->create();
$post = Post::first();
$data = [
'title' => 'Title',
'slug' => 'title',
'content' => 'Content',
'description' => 'Content',
'category_id' => 1,
'posted' => 'yes'
];
$this->put(route('post.update', $post), $data)
->assertRedirect(route('post.index'));
$this->assertDatabaseHas('posts', $data);
$this->assertDatabaseMissing('posts', $post->toArray());
});
test('test edit put invalid', function () {
User::factory(3)->create();
Category::factory(10)->create();
Post::factory(1)->create();
$post = Post::first();
$this->get(route('post.edit', $post));
$data = [
'title' => 'a',
'slug' => '',
'content' => '',
'description' => '',
// 'category_id' => 1,
'posted' => '',
];
$this->put(route('post.update', $post), $data)
->assertRedirect(route('post.edit', $post))
->assertSessionHasErrors([
'title' => 'The title field must be at least 5 characters.',
'slug' => 'The slug field is required.',
'content' => 'The content field is required.',
'description' => 'The description field is required.',
'posted' => 'The posted field is required.',
'category_id' => 'The category id field is required.',
])
;
});
test('test destroy', function () {
User::factory(3)->create();
Category::factory(10)->create();
Post::factory(1)->create();
$post = Post::first();
$data = [
'id' => $post->id
];
$this->delete(route('post.destroy', $post))
->assertRedirect(route('post.index'));
$this->assertDatabaseMissing('posts', $data);
});Remaining tests:
https://github.com/libredesarrollo/book-course-laravel-base-11
API Testing with Token Authentication and Pest
And for API authentication, I implemented a global helper:
function generateTokenAuth()
{
User::factory()->create();
return User::first()->createToken('myapptoken')->plainTextToken;
}From this same method, we can use it anywhere without any problem.
For example, in our test, we can use it to test obtaining categories without pagination, just as we have it here: all categories are retrieved, and you can see that there is no change.
Similarly, we can use assertOk() to verify the HTTP code and assertJson() to check the response in JSON format.
For the rest, here we also use Factory to automatically generate test data.
test('test all', function () {
Category::factory(10);
$categories = Category::get()->toArray();
$this->get(
'/api/category/all',
[
'Authorization' => 'Bearer ' . generateTokenAuth()
]
)->assertOk()->assertJson($categories);
});Then, make the request, just as we do with PHPUnit:
$this->get(
'/api/category/all',
[
'Authorization' => 'Bearer ' . generateTokenAuth()
]In this case, we pass the token directly as a parameter. The only difference is that we no longer use the header to define it; instead, we pass it directly:
'Bearer ' . generateTokenAuth()Remember that if you have any questions, you can leave them in the comment block. I will not repeat the explanation because we have already done it several times. Again, the generation of test data and the assertion methods are exactly the same.
For creation or update tests, we also apply the same as before. The main difference is in this change:
Previously we used:
$this->assertStringContainsString("The title field is required.", $response->getContent());This method no longer exists, and instead we must use:
$this->assertMatchesRegularExpression("/The title field is required./", $response->getContent());Which indicates that we are evaluating the response based on a regular expression, working similarly to contains.
The rest of the code remains the same:
- Verifications are maintained with assertContent(), assertStatus(), assertOk(), as appropriate.
Tests for put methods or creation are very similar to each other.
In the end, I performed a small refactoring of the test file: I eliminated the intermediate response variable and placed all the codes directly in the test file. This keeps the test clearer and more organized, without affecting its operation.
Running and Debugging Tests
To run your tests in Laravel you can use any of the following commands:
$ php artisan testOr
vendor/bin/pestBoth show a clear summary of the tests executed. In my workflow, I always provoke an intentional error before confirming that everything works, to verify that the testing system responds correctly.
PHPUnit's setUp() Method for Common Code
In this example, we are using permissions with Spatie and we can have a common method to create roles and permissions, and then, in each test, this method runs BEFORE and populates the database with these records so that the tests can be implemented later.
tests/Feature/dashboard/PostTest.php
class PostTest extends TestCase
{
use DatabaseMigrations;
protected function setUp(): void
{
parent::setUp();
User::factory(1)->create();
$user = User::first();
// dd($user);
$role = Role::firstOrCreate(['name' => 'Admin']);
Permission::firstOrCreate(['name' => 'editor.post.index']);
Permission::firstOrCreate(['name' => 'editor.post.create']);
Permission::firstOrCreate(['name' => 'editor.post.edit']);
Permission::firstOrCreate(['name' => 'editor.post.delete']);
$role->syncPermissions([1,2,3,4]);
$user->assignRole($role);
$this->actingAs($user);//->withSession(['role' => 'Admin']);
}
}In the previous code, we use the setUp() method which runs before each test. In this method, we can place common code to execute in each of the tests, in this example, creating the user and establishing an Admin role and permissions.
In addition, once the user is configured, we set up authentication using actingAs(), to which we can set session data if necessary.
Enable DB_CONNECTION and DB_DATABASE in the phpunit.xml in Laravel
I wanted to explain to you the importance of why we have to configure a database at the time of development or for the testing environment. By default, I have this data:
Usually unit tests must be carried out in a test database, other than the development one and much less the production one. For now, we have been using the database that we use in development, so all the operations performed Because the tests persist in the same and with this, we do not have a controlled environment to do the tests, to establish a parallel database to do the tests we must make a configuration from the following file:
phpunit.xml
***
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<!-- <env name="DB_CONNECTION" value="sqlite"/> -->
<!-- <env name="DB_DATABASE" value=":memory:"/> -->
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
***Here you can customize the database to be used, in this example, SQLite (DB_DATABASE) and make it in-memory (DB_CONNECTION), which means that database operations will be performed in an in-memory database and not doing read/write operations on the database.
Note that it is using our development database here and what would happen if we run it again it will basically eliminate the ENTIRE development database. To avoid this, we must activate the database for testing and in memory so that the Operations are simulated in memory and not performed in the database and with this, they are faster:
***
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
***Best Practices
- Name tests according to the behavior they validate.
- Avoid dependencies between tests.
- Keep data clean with RefreshDatabase.
- Integrate testing into your CI/CD pipeline (e.g., GitHub Actions).
⚖️ PHPUnit vs Pest: Quick Comparison
Aspect $\quad$ PHPUnit $\quad$ Pest
Syntax $\quad$ Traditional (classes and methods) $\quad$ Modern, minimalist
Learning Curve $\quad$ Slightly more technical $\quad$ Very friendly
Compatibility $\quad$ Full with Laravel $\quad$ Full (uses PHPUnit internally)
Focus $\quad$ Formal and structured $\quad$ Light and expressive
Ideal for $\quad$ Large teams with legacy projects $\quad$ New or fast development projects
In my experience, there is no single "best" universal option. If you already have an advanced project, PHPUnit is more stable. But if you are starting from scratch or prefer a fluent syntax, Pest is a pleasure to use.
Conclusion
Laravel puts testing within reach of all developers. Both PHPUnit and Pest allow you to create robust tests, but Pest simplifies the syntax without losing power.
Applying unit tests constantly saves you time, reduces errors, and increases confidence in your code.
If you can, integrate a TDD flow and automate your tests with CI/CD pipelines. Your application—and your team—will thank you.
Always run tests after every change.
Use dd() to inspect responses.
First check the status code (assertStatus(200)), because if the route fails, the rest of the tests are meaningless.
❓ Frequently Asked Questions
- What are unit tests in Laravel?
- They are code snippets that verify the correct functioning of small parts of your application.
- What is the difference between PHPUnit and Pest?
- Pest uses cleaner syntax, but internally relies on PHPUnit, so both offer the same capabilities.
- How is the Pest.php file configured?
- It includes the necessary traits and defines global functions, for example uses(TestCase::class, RefreshDatabase::class)->in('Feature');.
- How to implement authentication in tests?
- You can use $this->actingAs($user) or create a token with generateTokenAuth() if testing APIs.
- Is it worth applying TDD?
- Yes, because it forces you to write clearer and more verifiable code from the start.
I agree to receive announcements of interest about this Blog.
We will talk about how tests work in Laravel, tips, considerations, importance and first steps in Laravel.