You add a discount feature to a checkout flow, try it in the browser, and everything works. Two days later a customer reports that orders without a discount now total incorrectly. An old feature broke quietly because nobody checked it again. Automated tests exist to catch exactly that.
Laravel ships with everything you need for testing, so there is no setup excuse. This guide covers writing your first tests, testing the database safely, faking mail and external APIs, and the errors that most often make beginners give up.
Why Tests Are Worth Writing
- They prevent regressions. One command rechecks dozens of scenarios in seconds.
- They are documentation that stays true. When behavior changes, the test fails.
- They make refactoring safe. Cleanup stops being scary.
The cost is real: tests take time up front. Start with what is most expensive to break, such as login, checkout, and anything involving money.
Feature Tests vs Unit Tests
| Aspect | Feature test | Unit test |
|---|---|---|
| Folder | tests/Feature | tests/Unit |
| Scope | HTTP request through response and database | One class or method in isolation |
| Framework access | Full: routing, database, session | None; plain PHP |
| Speed | Slower | Very fast |
| Best for | User flows: saving a product, login, checkout | Pure logic: discount math, invoice number formats |
Step 1: Use a Separate Test Database
Never run tests against a database with real data. In phpunit.xml, uncomment these two lines:
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
In-memory SQLite is rebuilt on every run and never touches your main database. If your app relies on MySQL-specific features such as fulltext search, point tests at a dedicated MySQL database instead.
Step 2: Generate Test Files
php artisan make:test ProductTest # feature test
php artisan make:test DiscountTest --unit # unit test
The Laravel 11 installer lets you choose Pest or PHPUnit. Both run on the same engine; Pest just offers a shorter syntax. The examples use PHPUnit, with a Pest version for comparison.
Step 3: Your First Feature Tests
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProductTest extends TestCase
{
use RefreshDatabase;
public function test_guests_are_redirected_to_login(): void
{
$this->get('/products')->assertRedirect('/login');
}
public function test_user_can_create_a_product(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->post('/products', ['name' => 'Latte', 'price' => 4.5, 'stock' => 10])
->assertRedirect('/products');
$this->assertDatabaseHas('products', ['name' => 'Latte']);
}
public function test_negative_price_is_rejected(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->post('/products', ['name' => 'Tea', 'price' => -1, 'stock' => 1])
->assertSessionHasErrors('price');
$this->assertDatabaseCount('products', 0);
}
}
RefreshDatabase migrates and resets data for every test, so test order never matters. actingAs() logs in without going through the form. The third test covers the failure path, which beginners often skip even though it is where bad input lives.
The same create test in Pest:
use App\Models\User;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
test('user can create a product', function () {
$this->actingAs(User::factory()->create())
->post('/products', ['name' => 'Latte', 'price' => 4.5, 'stock' => 10])
->assertRedirect('/products');
expect(\App\Models\Product::count())->toBe(1);
});
Arrange, Act, Assert
Each test above has three parts. Arrange the state, such as creating a user with a factory. Act once, such as sending a POST. Assert the result. Separate them with blank lines. When a test starts performing several different actions, split it so a failure points at one clear cause.
Step 4: Unit Tests for Pure Logic
Suppose discount math lives in App\Support\Discount with a static apply(int $cents, int $percent): int method. Its tests need no database:
namespace Tests\Unit;
use App\Support\Discount;
use PHPUnit\Framework\TestCase;
class DiscountTest extends TestCase
{
public function test_ten_percent_discount(): void
{
$this->assertSame(9000, Discount::apply(10000, 10));
}
public function test_discount_never_goes_below_zero(): void
{
$this->assertSame(0, Discount::apply(5000, 150));
}
}
Unit tests extend PHPUnit\Framework\TestCase, not Tests\TestCase, so Laravel does not boot and they run in milliseconds. Storing money as integer cents also avoids floating-point surprises.
Step 5: Fake Mail, Queues, and External APIs
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
Mail::fake();
Http::fake([
'api.shipping-example.test/*' => Http::response(['cost' => 7.5], 200),
]);
// ... run the checkout request ...
Mail::assertSent(\App\Mail\InvoiceMail::class);
Http::assertSentCount(1);
The domain and mail class are placeholders. The same pattern works with Queue::fake(), Notification::fake(), and Storage::fake(), so tests never send real email or charge real cards.
Running Tests
php artisan test # everything
php artisan test --filter=ProductTest # one file or test name
php artisan test --parallel # requires brianium/paratest
Deciding What to Test First
On an existing project with no tests, covering everything at once is unrealistic. Use this priority order:
- Money. Totals, discounts, taxes, and balances. Mistakes here cost you directly.
- Access. Admin pages are closed to regular users, and one customer never sees another's data.
- Core flows. Sign-up, login, placing an order, generating an invoice.
- Past bugs. Every fixed bug gets a test so it cannot return.
- Everything else, added gradually whenever you touch that feature.
Do not chase 100% coverage. Ten tests guarding critical flows are worth more than hundreds checking static pages.
Run Tests on Every Push
Tests help most when nobody has to remember to run them. On GitHub, add .github/workflows/tests.yml:
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- run: composer install --no-interaction --prefer-dist
- run: cp .env.example .env && php artisan key:generate
- run: php artisan test
Every push and pull request is now tested, and a red cross appears before broken code reaches your server.
Common Errors and Fixes
| Error | Cause | Fix |
|---|---|---|
| no such table: products | Missing RefreshDatabase | Add the trait to the test class |
| Call to undefined method factory() | Model lacks HasFactory | Add the trait to the model |
| Vite manifest not found | Frontend assets not built | Call $this->withoutVite() or run npm run build |
| Real data disappeared | Tests used the main database | Configure the test connection in phpunit.xml |
| Passes alone, fails in the suite | Tests depend on each other's data | Create data inside each test |
Testing Habits Checklist
- Every bug gets a failing test before the fix.
- Every feature has at least one success test and one failure test.
- Tests never touch production data or paid services.
php artisan testruns before every commit and deploy, ideally in CI.- Test names describe behavior, not method names.
New to building features? Start with a Laravel 11 CRUD from scratch and add tests for each action. Tests are also your safety net when customizing source code without breaking it and before deploying a Laravel app.