Now, let's start writing tests for a simple but real project that would list the products.

Initial Project
First, we will make a simple page listing the products. For the frontend, we will be using Laravel Breeze.
Note: Since Laravel 12, the
laravel newinstaller offers official starter kits (React, Vue, Livewire) instead of Breeze. However, Breeze is still available as a separate package and works well for this course, since it provides a simple Blade-based authentication scaffold without extra frontend complexity.
composer require laravel/breeze --devphp artisan breeze:install blade
So first, the Model and Migration.
database/migrations/xxx_create_products_table.php:
public function up(): void{ Schema::create('products', function (Blueprint $table) { $table->id(); $table->string('name'); $table->integer('price'); $table->timestamps(); });}
app/Models/Product.php:
class Product extends Model{ protected $fillable = [ 'name', 'price', ];}
Next, the Controller, View, and Route.
app/Http/Controllers/ProductController.php:
use App\Models\Product;use Illuminate\Contracts\View\View; class ProductController extends Controller{ public function index(): View { $products = Product::all(); return view('products.index', compact('products')); }}
resources/views/products/index.blade.php:
<!DOCTYPE html><html lang="{{ str_replace('_', '-', app()->getLocale()) }}"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Products</title> @vite(['resources/css/app.css'])</head><body class="bg-gray-100 p-8"> <div class="max-w-3xl mx-auto"> <h2 class="text-xl font-semibold text-gray-800 mb-4">{{ __('Products') }}</h2> <div class="bg-white rounded-lg shadow overflow-hidden"> <table class="min-w-full divide-y divide-gray-200"> <thead class="bg-gray-50"> <tr> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Price</th> </tr> </thead> <tbody class="divide-y divide-gray-200"> @forelse($products as $product) <tr class="hover:bg-gray-50"> <td class="px-6 py-4 text-sm text-gray-900">{{ $product->name }}</td> <td class="px-6 py-4 text-sm text-gray-900">${{ number_format($product->price, 2) }}</td> </tr> @empty <tr> <td colspan="2" class="px-6 py-4 text-sm text-gray-500">{{ __('No products found') }}</td> </tr> @endforelse </tbody> </table> </div> </div></body></html>
routes/web.php:
use App\Http\Controllers\ProductController; Route::get('/', function () { return view('home');})->name('home'); Route::resource('products', ProductController::class); // ...
Notice: we are using Resource for Route because later, we will add other methods like
create(),store(), etc.
After visiting /products, we should see an empty table.

Now, let's write tests for this page.
Writing Tests
For the first tests, we will check two scenarios:
- Whether the page contains the text
No products foundwhen there are no records - And the opposite: whether this text isn't rendered when the record in the DB exists.
Reminder: We will use Pest PHP testing framework in this course. At the end of the lesson, we will see how the same could be achieved using PHPUnit.
We can create a new test class using an artisan command. It will generate a feature test.
php artisan make:test ProductsTest
In this test, we need to get the /products URL and, from the response, assert if we see the No products found text. The test's name will be homepage contains empty table so that it will be clear what it tests.
tests/Feature/ProductsTest.php:
use function Pest\Laravel\get; test('homepage contains empty table', function () { get('/products') ->assertStatus(200) ->assertSee(__('No products found'));});
Instead of using typical $this->get() syntax we used the get() function from the pestphp/pest-plugin-laravel plugin.
Run the test — it fails.

This is expected. Even this simple read-only test fails because Product::all() inside the controller tries to query a products table that does not exist yet in the test database. Before writing any test that touches the database — even just reading — we need to configure a safe, isolated test database and run the migrations.
DB For Testing: RefreshDatabase and phpunit.xml
This is an important step. Tests should never run against your live database — they create and delete data on every run.
When creating a new Laravel project, the database configuration for tests is already set up safely by default in phpunit.xml. The two key variables, DB_CONNECTION and DB_DATABASE, point to SQLite in-memory:
phpunit.xml:
<?xml version="1.0" encoding="UTF-8"?><phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true"> <testsuites> <testsuite name="Unit"> <directory>tests/Unit</directory> </testsuite> <testsuite name="Feature"> <directory>tests/Feature</directory> </testsuite> </testsuites> <source> <include> <directory>app</directory> </include> </source> <php> <env name="APP_ENV" value="testing"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="BCRYPT_ROUNDS" value="4"/> <env name="BROADCAST_CONNECTION" value="null"/> <env name="CACHE_STORE" value="array"/> <env name="DB_CONNECTION" value="sqlite"/> <env name="DB_DATABASE" value=":memory:"/> <env name="DB_URL" value=""/> <env name="MAIL_MAILER" value="array"/> <env name="QUEUE_CONNECTION" value="sync"/> <env name="SESSION_DRIVER" value="array"/> <env name="PULSE_ENABLED" value="false"/> <env name="TELESCOPE_ENABLED" value="false"/> <env name="NIGHTWATCH_ENABLED" value="false"/> </php></phpunit>
This means tests run on an in-memory SQLite database that exists only for the duration of the test run — nothing is written to your real database.
IMPORTANT: If you change or remove
DB_CONNECTIONandDB_DATABASEfromphpunit.xml, any DB operations in your tests could run against your live database. Keep them in place.
Running tests using the SQLite connection and :memory: as the database is safe for most applications. If you use MySQL-specific functions that SQLite does not support, create a separate MySQL database (e.g. db_testing) and configure it in config/database.php instead.
Now we have the connection, but tests still do not have a schema. The RefreshDatabase trait handles this — it runs your migrations before each test and rolls them back after, giving every test a clean slate.
By default, Pest adds RefreshDatabase for all feature tests in tests/Pest.php, but it is commented out:
tests/Pest.php:
use Illuminate\Foundation\Testing\RefreshDatabase;use Tests\TestCase; pest()->extend(TestCase::class) // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) ->in('Feature');// ...
That single line means every feature test automatically gets a fresh, migrated database. Run the tests again — the first test is now green.

And if you check your live products table, no new records were added.
Now let's add the second test to assert that the No products found text isn't rendered when the table has records. Instead of assertSee(), the opposite assertDontSee() method is used.
tests/Feature/ProductsTest.php:
use App\Models\Product;use function Pest\Laravel\get; test('homepage contains empty table', function () { get('/products') ->assertStatus(200) ->assertSee(__('No products found'));}); test('homepage contains non empty table', function () { Product::create([ 'name' => 'Product 1', 'price' => 123, ]); get('/products') ->assertStatus(200) ->assertDontSee(__('No products found'));});
Run the tests again — both are green.

Alternative: .env.testing
Instead of configuring DB settings in phpunit.xml, you can use a .env.testing file. When tests run, Laravel reads this file instead of .env.
Comment out the DB lines in phpunit.xml and create a .env.testing file:
.env.testing:
APP_NAME=LaravelAPP_ENV=testingAPP_KEY=base64:Oo7mTQpSW00WmjWs1kJVjqFZw/oQVENUxyhuyLCQgfk=APP_DEBUG=trueAPP_URL=http://localhost LOG_CHANNEL=stackLOG_LEVEL=debug DB_CONNECTION=sqliteDB_DATABASE=:memory: // ...
When using SQLite in-memory, you don't need DB_HOST, DB_PORT, DB_USERNAME, or DB_PASSWORD — those only apply to MySQL/PostgreSQL connections.
Both approaches work. phpunit.xml keeps everything in one place; .env.testing is useful if you have more environment-specific settings to override.
Arrange, Act, Assert
Every test you write follows the same three-step structure. It is called AAA:
- Arrange — prepare the scenario: create data, set up state
- Act — simulate the action: call a URL, submit a form, trigger a method
- Assert — check the outcome: verify what the response contains, what changed in the database
Looking at the second test we just wrote, all three steps are visible:
test('homepage contains non empty table', function () { Product::create([ // ← Arrange: create a product 'name' => 'Product 1', 'price' => 123, ]); get('/products') // ← Act: visit the page ->assertStatus(200) // ← Assert: check the response ->assertDontSee(__('No products found')); // ← Second Assert: check the response});
A few things to keep in mind:
- Arrange can be multiple operations, or none at all — the first test in this lesson has no arrange step because we test the empty state.
- Act is usually a single call.
- Assert can have multiple assertions — but watch out: if assertions are testing completely different things, consider splitting them into separate tests.
So, we have written our first practical tests, set up a safe isolated test database with RefreshDatabase, and learned the Arrange-Act-Assert structure that applies to every test going forward.
As soon as I run the test, I get an error message: Attempt to read property "name" on null. I get a status code 500 and my user is deleted from the database when I run the test. I have tried it with sqlite and mysql and also with Laravel 10.x and 11. Unfortunately I can't find the solution to the problem. Does anyone have the same problem or a solution?
Greetings Mario
Open
storage/logs/laravel.logfile and you should see a full stack trace of the error. Once you see that, you should be able to pin-point the broken code piece by looking at the files list.While doing that, can you you also write the test that fails? And attach the laravel.log stacktrace for that error (tip: delete all the file contents, run test, it should only have 1 failure inside instead of many)
I have the same problem. Looks like before or after I run the tests my Users table in DB is reset - not sure when it happens. But I passed test with those changes:
Hello Mantas, with the code you provided, the test works for me too. How did you come up with your solution? @Modestas, there is so much in the log file that I don't even know what to look for.
The 500 error related to users is seen because we aren't first logging in, so the navigation blade doesn't have a $name to display. Which makes sense since the test is just simulating opening a browser to /products. If you do that before logging in you will get the same 500 error.
Commenting that out in layouts/app.blade.php worked for me.
why do you need to actingAs but in our route file the resource route is not in the route dont need to be authenticate to be reach .. is this because we use resource ?