Back to Course |
Testing in Laravel 11: Advanced Level

Testing Exception Classes

Let's talk about what happens if some Exceptions appear, and you want to test if a particular Exception is successfully thrown in your tests.


Exception Example 1

Imagine a scenario where, in a Service, you throw an Exception. It may be your custom Exception or a Laravel Exception like ModelNotFound.

In this example, we throw our custom Exception if the currency rate doesn't exist.

namespace App\Services;
 
use Illuminate\Support\Arr;
use App\Exceptions\CurrencyRateNotFoundException;
 
class CurrencyService
{
const RATES = [
'usd' => [
'eur' => 0.98
],
];
 
public function convert(float $amount, string $currencyFrom, string $currencyTo): float
{
if (! Arr::exists(self::RATES, $currencyFrom)) {
throw new CurrencyRateNotFoundException('Currency rate not found');
}
 
$rate = self::RATES[$currencyFrom][$currencyTo] ?? 0;
 
return round($amount * $rate, 2);
}
}

Another example: in the Model, you have an attribute to show a converted price, but in case of an Exception, you log, alert, or do something else.

use App\Services\CurrencyService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
use App\Exceptions\CurrencyRateNotFoundException;
use Illuminate\Database\Eloquent\Factories\HasFactory;
 
class Product extends Model
{
protected $fillable = [
'name',
'price',
];
 
protected function eurPrice(): Attribute
{
return Attribute::make(
get: function () {
try {
return (new CurrencyService())->convert($this->price, 'usd', 'eur');
} catch (CurrencyRateNotFoundException $e) {
// Log something, alert someone
}
},
);
}
}

The Test

This test is going to be a Unit test, not a Feature. We're not testing the application feature. We're testing the correct behavior of a specific unit (a Service class).

At first, we just test for the result, expecting 0.

tests/Unit/ProductsTest.php:

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

After running the test, we can see an Exception error precisely as expected.

Now, we need to test that this test throws an Exception. Naming tests is very important, so we also need to rename the test itself or create another one: instead of returns zero, it should be changed to throws exception.

For testing Exceptions using Pest, you only need to use the throws() method and pass the Exception. Optionally, you can pass the Exception message or only the message if the Exception type is irrelevant.

tests/Unit/ProductsTest.php:

use App\Services\CurrencyService;
use App\Exceptions\CurrencyRateNotFoundException;
 
test('convert gbp to usd throws exception', function () {
$convertedCurrency = (new CurrencyService())->convert(100, 'gbp', 'usd');
})->throws(CurrencyRateNotFoundException::class, 'Currency rate not found');

Alternatively, the expect() can accept a closure where an Exception is thrown and then use toThrow() expectation.

tests/Unit/ProductsTest.php:

use App\Services\CurrencyService;
use App\Exceptions\CurrencyRateNotFoundException;
 
test('convert gbp to usd throws exception', function () {
expect(fn() => (new CurrencyService())->convert(100, 'gbp', 'usd'))
->toThrow(CurrencyRateNotFoundException::class, 'Currency rate not found');
});

PHPUnit Example

With PHPUnit, you can add the expectException() method with the Exception parameter at the very beginning of the test. This way, the test method will know that the Exception must be thrown.

use PHPUnit\Framework\TestCase;
use App\Services\CurrencyService;
use App\Exceptions\CurrencyRateNotFoundException;
 
class UnitProductsTest extends TestCase
{
public function test_convert_gbp_to_usd_throws_exception()
{
$this->expectException(CurrencyRateNotFoundException::class);
 
$this->assertEquals(0, (new CurrencyService())->convert(100, 'gbp', 'usd'));
}
}

Example Scenario 2

Imagine you have a Service to create a product, but if the price is too high, it throws an Exception.

use App\Models\Product;
use Brick\Math\Exception\NumberFormatException;
 
class ProductService
{
public function create(string $name, int $price): Product
{
if ($price > 1_000_000) {
throw new NumberFormatException('Price too big');
}
 
return Product::create([
'name' => $name,
'price' => $price,
]);
}
}

The Test

In the test, you would call the service and pass a price too high so that an Exception would be thrown. Then, you would use the throws() method and pass the Exception and, optionally, the message.

use Brick\Math\Exception\NumberFormatException;
 
test('product service create validation', function () {
(new ProductService())->create('Test product', 1234567);
})->throws(NumberFormatException::class, 'Price too big');