Back to Course |
Testing in Laravel 11 For Beginners

Feature vs Unit Tests: The Difference

In this lesson, let's talk about Unit Tests versus Feature Tests.

A unit is some internal piece of code in your application: usually a function or a class. So, the purpose of Unit tests is to test precisely those units, without loading the full page routes or API endpoints.

Unit tests usually don't use the full feature but instead, test that some internal method returns correct data.

Let me show you a practical example.


New Feature: Service Class

For this example, we want to show additional prices in Euro in the table.

products table with eur price

To show the price in Euro, there is a converter. Imagine a CurrencyService class in the App\Services directory. In the service, there is a function to convert the price.

app/Services/CurrencyService.php:

namespace App\Services;
 
class CurrencyService
{
const RATES = [
'usd' => [
'eur' => 0.98
],
];
 
public function convert(float $amount, string $currencyFrom, string $currencyTo): float
{
$rate = self::RATES[$currencyFrom][$currencyTo] ?? 0;
 
return round($amount * $rate, 2);
}
}

For simplicity, we just fake the conversion rate as a constant. In the real world, of course, there will be a more complex logic, taking the currency rate from the DB or external sources.

In the Product Model, we add an eloquent accessor.

app/Models/Product.php:

use App\Services\CurrencyService;
use Illuminate\Database\Eloquent\Casts\Attribute;
 
class Product extends Model
{
protected $fillable = [
'name',
'price',
];
 
protected function priceEur(): Attribute
{
return Attribute::make(
get: fn() => (new CurrencyService())->convert($this->price, 'usd', 'eur'),
);
}
}

And we show this field in the View.

resources/views/products/index.blade.php:

// ...
<thead>
<tr>
<th class="px-6 py-3 bg-gray-50 text-left">
<span class="text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">Name</span>
</th>
<th class="px-6 py-3 bg-gray-50 text-left">
<span class="text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">Price (USD)</span>
</th>
<th class="px-6 py-3 bg-gray-50 text-left">
<span class="text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">Price (EUR)</span>
</th>
</tr>
</thead>
 
<tbody class="bg-white divide-y divide-gray-200 divide-solid">
@forelse($products as $product)
<tr class="bg-white">
<td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-900">
{{ $product->name }}
</td>
<td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-900">
{{ number_format($product->price, 2) }}
</td>
<td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-900">
{{ $product->price_eur }}
</td>
</tr>
// ...

Unit Test for New Service Class

If we wanted to write a Feature test, it would assert if the page loads successfully with those values and the products.

But in addition to that, it would also be cool to test if the service converter works well with different currencies and values.

Let's create a new Unit test.

php artisan make:test CurrencyTest --unit

Notice: You can delete the example Unit test provided by the default Laravel skeleton.

In this test, we must call our CurrencyService class. This service will return some value. So, in our test, we can use Pest expectations syntax to check if that conversion result is float and equal to the expected value.

tests/Unit/CurrencyTest.php:

use App\Services\CurrencyService;
 
test('convert usd to eur successful', function () {
$convertedCurrency = (new CurrencyService())->convert(100, 'usd', 'eur');
 
expect($convertedCurrency)
->toBeFloat()
->toEqual(98.0);
});

Now, let's run the tests.

first unit test

Great, we have convert usd to eur successful under unit tests passed.

As you may have noticed, our Unit test doesn't call the route of the page. Also, it doesn't work with the database. It just calls the Service class and checks if it works correctly.

Let's add another Unit test for a different scenario. The expected result is 0 this time because the gbp currency rate isn't set in the service.

tests/Unit/CurrencyTest.php:

use App\Services\CurrencyService;
 
test('convert usd to eur successful', function () {
$convertedCurrency = (new CurrencyService())->convert(100, 'usd', 'eur');
 
expect($convertedCurrency)
->toBeFloat()
->toEqual(98.0);
});
 
test('convert usd to gbp returns zero', function () {
$convertedCurrency = (new CurrencyService())->convert(100, 'usd', 'gbp');
 
expect($convertedCurrency)
->toBeFloat()
->toEqual(0.0);
});

second unit test

So, we covered two scenarios. From here, it is your decision for what additional cases to test.


PHPUnit example

The PHPUnit syntax here is a bit different from that of Pest. Instead of expect(), PHPUnit uses the same assertion methods like $this->assertXXXXX().

tests/Unit/CurrencyTest.php:

use PHPUnit\Framework\TestCase;
use App\Services\CurrencyService;
 
class CurrencyTest extends TestCase
{
public function test_convert_usd_to_eur_successful()
{
$this->assertEquals(98, (new CurrencyService())->convert(100, 'usd', 'eur'));
}
}
public function test_convert_usd_to_gbp_returns_zero()
{
$this->assertEquals(0, (new CurrencyService())->convert(100, 'usd', 'gbp'));
}
}

So, Feature or Unit Tests?

So, when talking about which tests to write, typical Laravel project logic is this:

  • First, write "smoke" Feature tests to check if the main project routes/endpoints work correctly
  • Next, write Feature tests for other features that feel "risky": with a bigger probability of failure or more significant consequences if they do fail
  • Then, optionally write additional Unit tests for specific features that are separated as classes or functions

From my practice, you will see many Laravel projects with no Unit tests, only the Feature ones.

One of the reasons is that Unit tests require restructuring the application to have those separate units be testable in the first place. But, for larger applications, it's totally worth the effort.