Skip to main content
    Back to all articles

    Mastering Unit Testing in Laravel: PHPUnit and Beyond

    Testing
    12 min read
    By Bahaj abderrazak
    Featured image for "Mastering Unit Testing in Laravel: PHPUnit and Beyond"

    Comprehensive testing is not just a best practice; it's an essential part of building robust, maintainable, and scalable Laravel applications. Laravel provides an excellent testing environment out-of-the-box with PHPUnit. This guide will take you through mastering unit and feature testing, mocking external dependencies, and strategies for testing various parts of your Laravel application.

    ---

    Why Test Your Laravel Applications?

    • Early Bug Detection: Catch issues before they reach production.
    • Refactoring Confidence: Make changes without fear of breaking existing functionality.
    • Documentation: Tests serve as living documentation of your code's expected behavior.
    • Code Quality: Encourages better design and cleaner code.
    • Collaboration: Ensures consistency when working in teams.

    ---

    Laravel's Testing Environment

    Laravel configures phpunit.xml and provides a tests directory with Feature and Unit subdirectories.

    • Unit Tests: Focus on a small, isolated part of the application (e.g., a single method or class) without hitting the database or external services.
    • Feature Tests: Test a larger portion of the application, often simulating HTTP requests, interacting with the database, and testing routes and controllers.

    Running Tests

          # Run all tests
          php artisan test
    
          # Run unit tests only
          php artisan test --testsuite=Unit
    
          # Run feature tests only
          php artisan test --testsuite=Feature
    
          # Run a specific test file
          php artisan test tests/Feature/UserTest.php
    
          # Run a specific test method
          php artisan test --filter create_user_successfully

    ---

    Unit Testing with PHPUnit

    Unit tests should be fast and isolated. Use Laravel's TestCase and PHPUnit's assertions.

    Example: A simple utility class

          // app/Support/Calculator.php
          <?php
    
          namespace AppSupport;
    
          class Calculator
          {
              public function add(int $a, int $b): int
              {
                  return $a + $b;
              }
    
              public function subtract(int $a, int $b): int
              {
                  return $a - $b;
              }
          }
    
          // tests/Unit/CalculatorTest.php
          <?php
    
          namespace TestsUnit;
    
          use PHPUnitFrameworkTestCase;
          use AppSupportCalculator;
    
          class CalculatorTest extends TestCase
          {
              /** @test */
              public function it_can_add_two_numbers(): void
              {
                  $calculator = new Calculator();
                  $this->assertEquals(5, $calculator->add(2, 3));
                  $this->assertEquals(0, $calculator->add(-1, 1));
              }
    
              /** @test */
              public function it_can_subtract_two_numbers(): void
              {
                  $calculator = new Calculator();
                  $this->assertEquals(1, $calculator->subtract(3, 2));
                  $this->assertEquals(-2, $calculator->subtract(0, 2));
              }
          }

    ---

    Feature Testing with Laravel's HTTP Test Helpers

    Laravel provides powerful helpers for simulating HTTP requests, asserting JSON structures, and interacting with the database.

    Example: Testing a User Creation Endpoint

          // tests/Feature/UserRegistrationTest.php
          <?php
    
          namespace TestsFeature;
    
          use IlluminateFoundationTestingRefreshDatabase;
          use IlluminateFoundationTestingWithFaker;
          use TestsTestCase;
          use AppModelsUser;
    
          class UserRegistrationTest extends TestCase
          {
              use RefreshDatabase; // Resets the database for each test
    
              /** @test */
              public function a_new_user_can_register(): void
              {
                  $userData = [
                      'name' => 'John Doe',
                      'email' => 'john.doe@example.com',
                      'password' => 'password123',
                      'password_confirmation' => 'password123',
                  ];
    
                  $response = $this->postJson('/api/register', $userData);
    
                  $response->assertStatus(201) // Assert HTTP status code
                           ->assertJson([
                               'message' => 'User registered successfully',
                               'user' => [
                                   'name' => 'John Doe',
                                   'email' => 'john.doe@example.com',
                               ]
                           ]);
    
                  // Assert user exists in the database
                  $this->assertDatabaseHas('users', [
                      'email' => 'john.doe@example.com',
                      'name' => 'John Doe',
                  ]);
    
                  // Assert password is hashed (not directly asserted here, but important)
                  $this->assertTrue(
                      app('hash')->check('password123', User::where('email', 'john.doe@example.com')->first()->password)
                  );
              }
    
              /** @test */
              public function registration_requires_valid_email(): void
              {
                  $userData = [
                      'name' => 'Jane Doe',
                      'email' => 'invalid-email', // Invalid email
                      'password' => 'password123',
                      'password_confirmation' => 'password123',
                  ];
    
                  $response = $this->postJson('/api/register', $userData);
    
                  $response->assertStatus(422) // Unprocessable Entity
                           ->assertJsonValidationErrors(['email']); // Check for specific validation error
              }
          }

    ---

    Mocking and Fakes

    Mocking is crucial for isolating components when testing, preventing your tests from interacting with external services, actual databases (in unit tests), or slow dependencies. Laravel's facade system makes mocking straightforward.

    Mocking a Facade (e.g., Mail facade)

          <?php
    
          namespace TestsFeature;
    
          use IlluminateSupportFacadesMail;
          use TestsTestCase;
          use AppMailWelcomeEmail;
    
          class UserAccountTest extends TestCase
          {
              /** @test */
              public function a_welcome_email_is_sent_on_registration(): void
              {
                  Mail::fake(); // Prevent actual emails from being sent
    
                  $userData = [
                      'name' => 'Test User',
                      'email' => 'test@example.com',
                      'password' => 'password',
                      'password_confirmation' => 'password',
                  ];
    
                  $this->postJson('/api/register', $userData);
    
                  Mail::assertSent(WelcomeEmail::class, function ($mail) use ($userData) {
                      return $mail->hasTo($userData['email']) &&
                             $mail->user->name === $userData['name'];
                  });
    
                  Mail::assertSent(WelcomeEmail::class, 1); // Assert only one welcome email was sent
                  Mail::assertNotSent(AnotherEmail::class); // Assert another email was NOT sent
              }
          }

    Mocking External HTTP Requests (e.g., Http facade)

          <?php
    
          namespace TestsFeature;
    
          use IlluminateSupportFacadesHttp;
          use TestsTestCase;
    
          class ExternalServiceTest extends TestCase
          {
              /** @test */
              public function it_fetches_data_from_external_api(): void
              {
                  Http::fake([
                      'jsonplaceholder.typicode.com/*' => Http::response(['title' => 'Test Post'], 200),
                  ]);
    
                  // Assume you have a service that makes this API call
                  $response = $this->get('/api/posts/external/1');
    
                  $response->assertStatus(200)
                           ->assertJson(['title' => 'Test Post']);
    
                  Http::assertSent(function ($request) {
                      return $request->url() == 'https://jsonplaceholder.typicode.com/posts/1' &&
                             $request->method() == 'GET';
                  });
              }
          }

    ---

    Database Testing with RefreshDatabase and Factories

    The RefreshDatabase trait automatically migrates and then rolls back your database for each test, ensuring a clean slate. Laravel Factories are powerful for creating test data.

          <?php
    
          namespace TestsFeature;
    
          use IlluminateFoundationTestingRefreshDatabase;
          use TestsTestCase;
          use AppModelsUser;
          use AppModelsPost;
    
          class PostManagementTest extends TestCase
          {
              use RefreshDatabase;
    
              /** @test */
              public function a_user_can_create_a_post(): void
              {
                  $user = User::factory()->create(); // Create a user using a factory
    
                  $response = $this->actingAs($user) // Log in the user
                                   ->postJson('/api/posts', [
                                       'title' => 'My First Post',
                                       'content' => 'This is the content of my first post.'
                                   ]);
    
                  $response->assertStatus(201)
                           ->assertJson([
                               'message' => 'Post created successfully',
                               'post' => [
                                   'title' => 'My First Post',
                                   'user_id' => $user->id,
                               ]
                           ]);
    
                  $this->assertDatabaseHas('posts', [
                      'title' => 'My First Post',
                      'user_id' => $user->id,
                  ]);
              }
    
              /** @test */
              public function guests_cannot_create_posts(): void
              {
                  $response = $this->postJson('/api/posts', [
                      'title' => 'Guest Post',
                      'content' => 'This should fail.'
                  ]);
    
                  $response->assertStatus(401); // Unauthorized
                  $this->assertDatabaseMissing('posts', ['title' => 'Guest Post']);
              }
          }

    ---

    Testing Queues and Events

    Laravel provides Queue::fake() and Event::fake() to prevent queues and events from being dispatched during tests.

          // Example of testing a job dispatched to a queue
          use IlluminateSupportFacadesQueue;
          use AppJobsProcessPodcast;
    
          // ... inside a test method
          Queue::fake();
          // ... perform action that dispatches job
          Queue::assertPushed(ProcessPodcast::class);
          Queue::assertPushed(ProcessPodcast::class, function ($job) use ($podcast) {
              return $job->podcast->id === $podcast->id;
          });

    ---

    Beyond Basic Testing: Best Practices

    • Name Your Tests Clearly: Use descriptive names like it_can_add_two_numbers() or a_user_can_create_a_post().
    • One Assertion Per Test (Ideally): While not always strictly followed in feature tests, aim for focused tests.
    • Test Edge Cases: What happens with invalid input, empty data, or error conditions?
    • Use setUp() and tearDown(): For setting up test environments or cleaning up resources (though RefreshDatabase handles much of the latter).
    • Don't Over-Mock: Mock only what's necessary to isolate your tested unit.
    • Continuous Integration: Integrate your tests into a CI pipeline (e.g., GitHub Actions, GitLab CI) to run them automatically on every push.

    ---

    Conclusion

    Mastering testing in Laravel with PHPUnit is a significant step towards building high-quality, maintainable applications. By leveraging Laravel's powerful testing tools, including RefreshDatabase, factories, and mocking facades, you can write comprehensive tests that give you confidence in your codebase and accelerate your development process. Embrace testing as an integral part of your Laravel development workflow.

    Tags

    Laravel
    PHPUnit
    Testing
    Unit Tests
    Feature Tests
    Mocking