Skip to main content

First Test: Is Product Table Empty?

Lesson 02/26 9 min read
Autoplay

Lesson Overview

- Creating and testing a basic products list page
- Writing first Feature test for empty and non-empty tables
- Using View assertions to verify content
- Comparing Pest and PHPUnit test syntaxes

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

empty products table


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 new installer 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 --dev
php 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.

empty products 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 found when 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.

test fails with no table error

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_CONNECTION and DB_DATABASE from phpunit.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.

products table empty test

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.

tests sqlite 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=Laravel
APP_ENV=testing
APP_KEY=base64:Oo7mTQpSW00WmjWs1kJVjqFZw/oQVENUxyhuyLCQgfk=
APP_DEBUG=true
APP_URL=http://localhost
 
LOG_CHANNEL=stack
LOG_LEVEL=debug
 
DB_CONNECTION=sqlite
DB_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.

Comments & Discussion

D
dabms ✓ Link copied!

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

M
Modestas ✓ Link copied!

Open storage/logs/laravel.log file 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)

M
Mantas ✓ Link copied!

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:

public function test_homepage_contains_empty_table(): void {

$user = User::factory()->create(); // Create a new user
$response = $this->actingAs($user)->get('/products'); // Authenticate the user before sending the request
 
$response = $this->get('/products');
$response->assertStatus(200);
$response->assertSee(__('No products found'));
}
D
dabms ✓ Link copied!

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.

SP
Steve Purcell ✓ Link copied!

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.

DM
daniel MB ✓ Link copied!

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 ?

RB
Rodrigo Borges ✓ Link copied!

@dabms, the problem is because products/index.blade.php uses

This layout carries the logged in user information. The error occurs when trying to load the user name information.

The solution that the colleague provided for the test is to create the user and perform authentication (actingAs) and then call the products route.

KV
Kailash Vele ✓ Link copied!

Why is my Laravel test for the homepage, which should contain an empty table, failing with a status code of 302 instead of the expected 200? FAILED Tests\Feature\ProductsTest > homepage contains empty table Expected response status code [200] but received 302. Failed asserting that 302 is identical to 200.

at tests/Feature/ProductsTest.php:6 2▕ use function Pest\Laravel\get; 3▕ 4▕ test('homepage contains empty table', function () { 5▕ get('/products') ➜ 6▕ ->assertStatus(200) 7▕ ->assertSee(__('No products found')); 8▕ }); 9▕

M
Modestas ✓ Link copied!

This is probably because you don't have a user assigned to your test session. You need to have:

actingAs($user)

In your test. The 302 is a redirect, which is most likely a /login page in your case

KV
Kailash Vele ✓ Link copied!

yes i have added thanks. use function Pest\Laravel\get; use App\Models\User; use function Pest\Laravel{actingAs};

test('homepage contains empty table', function () { $user = User::factory()->create();

actingAs($user)
->get('/products')
->assertStatus(200)
->assertSee(__('No products found'));

});

IS
Ilya Savianok ✓ Link copied!

Does assertSee() also work the same way if I use api only?

A
Aleksandr ✓ Link copied!

If your test relies on user to be authenticated you can this on top of your test code:

beforeEach(function () {
actingAs(User::factory()->create());
});
I
info ✓ Link copied!

To make the tests pass, I had to create a user and use actingAs

We'd Love Your Feedback

Tell us what you like or what we can improve

Feel free to share anything you like or dislike about this page or the platform in general.