Skip to main content
Create your own
Lesson illustration

Unit Testing Fundamentals

Hello! Welcome to the next lesson in our module on Production-Ready Testing.

In our last session, we mastered feature testing. We learned how to test our application from the "outside-in," simulating user or API client interactions to verify that entire features, from the HTTP request to the database and back, work together as expected.

Today, we're flipping our perspective to look at the application from the "inside." This lesson focuses on unit tests. Our learning outcome is to write unit tests for individual classes and methods, isolating their logic. This means testing the smallest, most granular parts of your application—like a single method in a service class or a calculated attribute on a model—completely on their own, without worrying about the database, HTTP requests, or any other external components.


1. What is a Unit Test?

First, let's solidify the distinction between the feature tests you've already written and the unit tests we're about to explore.

Laravel Feature or Unit Tests: The Difference

This video from Laravel Daily offers a concise and clear explanation of the difference between feature and unit tests, and demonstrates a perfect first example of a unit test.

Watch from the beginning to 03:59. Pay close attention to the distinction between testing a visible 'feature' (like a whole page) and an 'invisible' unit of logic (like a function that formats a value).

As the video explained, the key characteristics of a unit test are:

  • Focus: It tests a single "unit" of your application—a method, a class, or a small, cohesive group of functions.
  • Isolation: It runs independently of other parts of the application. A true unit test does not interact with the database, make HTTP calls, or read from the filesystem.
  • Speed: Because they are isolated and don't have the overhead of booting the full application framework or touching external services, unit tests are extremely fast.

Creating a Unit Test

You can generate a unit test file using the --unit flag with the make:test Artisan command:

php artisan make:test Services/OrderCalculatorTest --unit

This creates a new test file in tests/Unit/Services/OrderCalculatorTest.php.

While Laravel's testing tools are built on PHPUnit, we've been using the more expressive and readable syntax of Pest. It's helpful to see how they compare for a basic test.

PHPUnit vs. Pest Basic Unit Test Comparison
This image contrasts the class-based structure of PHPUnit with Pest's more concise, function-based approach. We will continue using the Pest syntax for our examples.

2. Testing a Simple, Isolated Unit

Let's start by testing a class that is already naturally isolated—a "service class" that performs some calculations. This type of class is a perfect candidate for unit testing because it typically takes some input, performs logic, and returns an output, without relying on other parts of Laravel.

Imagine we have an OrderCalculator service in app/Services/OrderCalculator.php. Its job is to calculate totals and apply discounts.

Complete Laravel Testing Guide: Testing a Service Class

The following guide provides a fantastic, self-contained example of a service class and its corresponding unit tests written with Pest. This is a model example of how to approach unit testing.

In the article, find the section 'Unit Testing Fundamentals' and then the subsection 'Testing a Service Class'. Study the OrderCalculator.php class code, and then carefully review the OrderCalculatorTest.php code that follows it. Notice how separate it() blocks are used to test different scenarios for each method.

The OrderCalculatorTest demonstrates a crucial principle of unit testing: test for multiple scenarios. For the calculateTotal method, the tests cover:

  • The "happy path" with a default tax rate.
  • An alternative path with a custom tax rate.
  • An edge case with an empty array of items.

Likewise, the calculateDiscount method is tested with both a valid and an invalid coupon. This thoroughness is what gives you confidence that your unit of code will behave correctly under various conditions.


3. The Challenge of Isolation: Mocking

The OrderCalculator was easy to test because it had no dependencies. But what happens when the class you want to test relies on another class?

For example, a CreateOrderAction class might need to use a SendOrderInformationAction class to send an email after creating an order. If we test CreateOrderAction, we don't want to actually send an email. We only want to verify that CreateOrderAction attempted to use the SendOrderInformationAction.

This is where mocking comes in. A mock is a "fake" version of an object that we can control in our test. We use it to isolate the class we are testing (the "unit under test") from its dependencies.

Unit Testing vs. Unit Testing with Mocking Framework
This diagram perfectly illustrates the role of a mock. Instead of letting our code call a real dependency (like the database or an email service), the mocking framework inserts a 'Fake Object' that intercepts the call, allowing us to test our code in isolation.

The primary reasons we use mocks are:

  1. To Isolate the Unit: We can test Class A without running the code from its dependency, Class B.
  2. To Control the Environment: We can force a dependency to return a specific value or even throw an exception to see how our unit handles it.
  3. To Avoid Side Effects: We prevent our tests from sending real emails, making real API calls, or writing to a database.

4. How to Mock Dependencies in Laravel

Laravel provides a simple and powerful helper method, $this->mock(), which integrates with a library called Mockery.

Let's dive into the fundamentals of how to create and use mocks.

How to mock dependencies in Laravel

This guide by Ralph J. Smit is an excellent primer on mocking in Laravel. It clearly explains why we mock and then shows how to do it.

Please read the first three main sections: 'What is mocking?' to understand the core problem. 'How to mock a Laravel dependency' to see the basic syntax of $this->mock(). 'Defining Mockery expectations' to learn the methods like shouldReceive, with, once, and andReturn.\nDon't worry about converting the syntax to Pest; the principles are identical.

Practical Mocking Example

Let's put those concepts into a Pest test. Imagine we have this CreateOrderAction class, which uses dependency injection to receive the SendOrderInformationAction:

// app/Actions/Order/CreateOrderAction.php
class CreateOrderAction
{
    public function __construct(
        public SendOrderInformationAction $sendOrderInfoAction
    ) {}

    public function execute(array $orderData): Order
    {
        // Logic to create an order...
        $order = Order::create($orderData);

        // Call the dependency
        $this->sendOrderInfoAction->execute($order);

        return $order;
    }
}

Our goal is to test CreateOrderAction and confirm it calls the execute method on its dependency. We do not want to run the real SendOrderInformationAction.

Here is how you would write that unit test:

// tests/Unit/Actions/Order/CreateOrderActionTest.php

use App\Actions\Order\CreateOrderAction;
use App\Actions\Order\SendOrderInformationAction;
use App\Models\Order;
use Mockery\MockInterface;

it('sends order information after creating an order', function () {
    // Arrange
    $order = Order::factory()->make(['id' => 1]); // Create a model instance in memory
    
    // 1. Mock the dependency
    $this->mock(SendOrderInformationAction::class, function (MockInterface $mock) use ($order) {
        // 2. Set expectations on the mock
        $mock->shouldReceive('execute') // We expect the 'execute' method to be called...
             ->once()                   // ...exactly one time...
             ->with($order);            // ...with this specific $order object.
    });

    // Act
    // 3. Resolve the class under test from the container.
    // Laravel will automatically inject our mock.
    $action = app(CreateOrderAction::class);
    $action->execute($order->toArray());

    // Assert
    // Mockery handles the assertions automatically. If the expectations set on
    // the mock are not met by the end of the test, it will fail.
});

This test guarantees that our CreateOrderAction correctly interacts with its dependency, achieving perfect isolation. This is only possible because CreateOrderAction uses dependency injection. If it created its dependency manually (new SendOrderInformationAction()), we would not be able to replace it with a mock.


5. Unit Testing Eloquent Models

You can also write unit tests for your Eloquent models. While a feature test might check if a model can be saved to and retrieved from the database, a unit test focuses on the model's behavior in isolation.

Laravel Feature or Unit Tests: The Difference

Let's return to the Laravel Daily video to see a practical example of unit testing a model's calculated attribute without ever touching the database.

Watch from 03:59 to 07:59. Note how a new model is created in memory with new Video() and how multiple tests are written to cover different scenarios for the formatting logic.

Besides calculated attributes, you can also test other aspects of a model's configuration.

Complete Laravel Testing Guide: Testing Models

The 'Complete Laravel Testing Guide' also shows several other excellent examples of model-specific unit tests.

Find the 'Testing Models' subsection within 'Unit Testing Fundamentals'. Review the examples for testing fillable attributes, hidden attributes, attribute casting, and relationship methods.

These examples show you can verify a model's internal setup, like confirming:

  • getFillable() contains the correct fields.
  • toArray() does not include the password.
  • A date field is correctly cast to a Carbon instance.
  • A relationship method (posts()) returns the correct relationship class instance.

All of these tests run in memory without any database queries, making them true, fast unit tests.


Conclusion

You have now learned the fundamentals of unit testing in Laravel. By focusing on small, isolated components, you can write fast, precise tests that verify the core business logic of your application. This "inside-out" approach is the perfect complement to the "outside-in" feature tests we covered previously.

Key Takeaways:

  • Unit vs. Feature: Unit tests are for small, isolated pieces of code (a single method or class), while feature tests check an entire slice of functionality from end to end.
  • Isolation is Paramount: True unit tests do not interact with the database, file system, or network.
  • Test Multiple Scenarios: For each unit, write tests for the happy path, edge cases, and error conditions to ensure it's robust.
  • Mocking for Isolation: When a class has dependencies, use $this->mock() to create fake versions of those dependencies. This allows you to test the unit in isolation and assert that it interacts correctly with its collaborators.
  • Dependency Injection is Key: Code that relies on the service container and dependency injection is easy to test; code that uses the new keyword is hard to test.

Up Next:

In this lesson, we strictly followed the rule that unit tests don't touch the database. However, sometimes we need to test the logic that specifically involves database interactions. In our next lesson, we will learn how to test database interactions using transactions and factories to ensure a clean test state. This will bridge the gap between pure unit tests and full feature tests.

Can't find a good explanation? Sign up and we'll make it for you

Sign up