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.
For this example, we want to show additional prices in Euro in the table.
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>// ...
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.
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);});
So, we covered two scenarios. From here, it is your decision for what additional cases to test.
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, when talking about which tests to write, typical Laravel project logic is this:
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.