Test for storing character fails with table empty
I am trying to test my store route and when the test to store a character runs it fails with 'table empty'.
Here is the test itself:
it('Stores a character', function () {
// Arrange
$user = User::factory()->create();
$data = [
'name' => 'Test',
];
$this->actingAs($user)->
post(route('characters.store'), $data);
$this->assertDatabaseHas(Character::class, [
'name' => 'Test',
]);
});
Here is the controller:
/**
* Store a newly created resource in storage.
*/
public function store(StoreCharacterRequest $request): Response|RedirectResponse
{
// get the current player's characters count
$count = $this->countCharacter(auth()->user()->id);
// if the play is already at max characters
if ($count >= config("characters.max_allowed")) {
// display an error instead
return inertia('Characters/MaxCharacters');
}
// retrieve only the name and active status from the request
$validated = $request->safe()->only(['name', 'active']);
// if the player already has a character
if($count) {
// if the active checkbox was ticked
if($validated['active']) {
// get the player's active character if any
$character = Character::where('user_id', auth()->user()->id)->where('active', 1)->first();
// set the current one as not active
$character->update(['active' => 0]);
// and save the changes to the database
$character->save();
}
// otherwise this is the player's 1st character
} else {
// so mark the character as active
$validated['active'] = 1;
}
// get the first belt
$belt = Belt::where('min_xp', '<=', 1)->first();
// and create the players character
Character::create([
'name' => $validated['name'],
'user_id' => auth()->user()->id,
'belt_id' => $belt->id,
'active' => $validated['active'],
]);
// then redirect to the characters index
return redirect('characters');
}
Here is the character model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Character extends Model
{
use HasFactory;
use SoftDeletes;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'user_id',
'active',
'name',
'belt_id',
];
public function user() : BelongsTo
{
return $this->belongsTo(User::class);
}
public function belt(): BelongsTo
{
return $this->belongsTo(Belt::class);
}
}
It's migration:
<?php
use App\Models\Belt;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('characters', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(User::class)->constrained();
$table->boolean("active")->default(false);
$table->string("name", 15);
$table->integer("xp")->default(0);
$table->foreignIdFor(Belt::class)->constrained();
$table->integer("wins")->default(0);
$table->integer("loses")->default(0);
$table->integer("draws")->default(0);
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('characters');
}
};
and the character factory:
<?php
namespace Database\Factories;
use App\Models\Belt;
use App\Models\Character;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Log;
/**
* @extends Factory<Character>
*/
class CharacterFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$minXp = Belt::min('min_xp') ?? 0;
$maxXp = Belt::max('max_xp') ?? 50;
$xp = fake()->numberBetween($minXp , $maxXp);
$belt = Belt::where('min_xp', '<=', $xp)->orderBy('min_xp', 'desc')->first();
return [
'user_id' => User::factory(),
'name' => fake()->randomAscii(0, 25),
'xp' => $xp,
'belt_id' => $belt->id,
'wins' => fake()->numberBetween(0, 99),
'loses' => fake()->numberBetween(0, 99),
'draws' => fake()->numberBetween(0, 99)
];
}
}
it's belt bependency model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Belt extends Model
{
use HasFactory;
}
The belt migration:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('belts', function (Blueprint $table) {
$table->id();
$table->string("name", 32)->unique();
$table->string("image");
$table->integer("min_xp");
$table->integer("max_xp");
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('belts');
}
};
and the belt factory:
<?php
namespace Database\Factories;
use App\Models\Belt;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Belt>
*/
class BeltFactory extends Factory
{
// static variable to keep track if this is the 1st belt instance seeded
protected static bool $firstInstance = true;
// Static variable to keep track of the last max XP
protected static int $lastMaxXp = 0;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
// random element array
$randomElements = ['Amazing', 'Devine', 'Exquisite', 'Great', 'Marvelous', 'Radiant', 'Shining', 'Vibrant'];
// generate a random element for the belt
$randomElement = fake()->randomElements($randomElements);
// generate a random color for the belt
$colorName = fake()->colorName();
// generate the name using the random element and color name
$name = $randomElement[0] . " " . $colorName;
// generate url safe image name using the random element and color name
$image_url = $randomElement[0] . "_" . $colorName;
// if this is the first belt instance
if(self::$firstInstance) {
// Set the minimum XP to be 0
$minXp = 0;
// and set firstInstance to false for the next instance
self::$firstInstance = false;
} else {
// Otherwise set the minimum XP to be one more than the last max XP
$minXp = self::$lastMaxXp + 1;
}
// Generate a max XP that is greater than the min XP
// For example, you can use a random number between min XP + 20 and min XP + 50
$maxXp = $minXp + fake()->numberBetween(20, 50);
// Update the last max XP for the next belt
self::$lastMaxXp = $maxXp;
return [
'name' => $name,
'image' => storage_path('app/public/belts/' . $image_url . '.png'),
'min_xp' => $minXp,
'max_xp' => $maxXp,
];
}
}
Line 99:
// get the first belt
$belt = Belt::where('min_xp', '<=', 1)->first();
// and create the players character
Character::create([
'name' => $validated['name'],
'user_id' => auth()->user()->id,
'belt_id' => $belt->id, // <----- Line 99
'active' => $validated['active'],
]);
Altering the test to:
it('Stores a character', function () {
$this->withoutExceptionHandling();
// Arrange
$user = User::factory()->create();
$belt = Belt::factory()->create();
$data = [
'name' => 'Test',
];
$response = $this->actingAs($user)->post(route('characters.store'), $data);
dd($response);
// Check if redirection happens as expected
$response->assertRedirect('characters');
$this->assertDatabaseHas(Character::class, [
'name' => 'Test',
]);
Produces the error:
C:\laragon\bin\php\php-8.2.17-Win32-vs16-x64\php.exe C:\laragon\www\Ookma-Kyi-Core\vendor\pestphp\pest\bin\pest --teamcity --configuration C:\laragon\www\Ookma-Kyi-Core\phpunit.xml
ErrorException: Attempt to read property "id" on null
at vendor\laravel\framework\src\Illuminate\Foundation\Bootstrap\HandleExceptions.php:256
at app\Http\Controllers\CharacterController.php:99
at vendor\laravel\framework\src\Illuminate\Routing\Controller.php:54
at vendor\laravel\framework\src\Illuminate\Routing\ControllerDispatcher.php:43
at vendor\laravel\framework\src\Illuminate\Routing\Route.php:260
at vendor\laravel\framework\src\Illuminate\Routing\Route.php:206
at vendor\laravel\framework\src\Illuminate\Routing\Router.php:806
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:144
at vendor\laravel\framework\src\Illuminate\Auth\Middleware\EnsureEmailIsVerified.php:41
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets.php:19
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\inertiajs\inertia-laravel\src\Middleware.php:87
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\jetstream\src\Http\Middleware\ShareInertiaData.php:69
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Auth\Middleware\Authorize.php:57
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Routing\Middleware\SubstituteBindings.php:50
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Session\Middleware\AuthenticateSession.php:67
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Auth\Middleware\Authenticate.php:64
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken.php:81
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\View\Middleware\ShareErrorsFromSession.php:49
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Session\Middleware\StartSession.php:121
at vendor\laravel\framework\src\Illuminate\Session\Middleware\StartSession.php:64
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse.php:37
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Cookie\Middleware\EncryptCookies.php:75
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:119
at vendor\laravel\framework\src\Illuminate\Routing\Router.php:805
at vendor\laravel\framework\src\Illuminate\Routing\Router.php:784
at vendor\laravel\framework\src\Illuminate\Routing\Router.php:748
at vendor\laravel\framework\src\Illuminate\Routing\Router.php:737
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Kernel.php:200
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:144
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Middleware\TransformsRequest.php:21
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull.php:31
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Middleware\TransformsRequest.php:21
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Middleware\TrimStrings.php:50
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Http\Middleware\ValidatePostSize.php:27
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance.php:103
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Http\Middleware\HandleCors.php:49
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Http\Middleware\TrustProxies.php:57
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:119
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Kernel.php:175
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Kernel.php:144
at vendor\laravel\framework\src\Illuminate\Foundation\Testing\Concerns\MakesHttpRequests.php:585
at vendor\laravel\framework\src\Illuminate\Foundation\Testing\Concerns\MakesHttpRequests.php:375
at tests\Feature\Controllers\CharacterController\StoreTest.php:36
I have no clue what I'm doing wrong as I'm following something similar to the build a forum series from Laracasts with the revelant code. Any ideas?
Hey!
The error you're encountering (Attempt to read property "id" on null
) indicates that the variable $belt
is null when trying to access the id
property. The issue stems from this code block in the store
method:
// get the first belt
$belt = Belt::where('min_xp', '<=', 1)->first();
// and create the player's character
Character::create([
'name' => $validated['name'],
'user_id' => auth()->user()->id,
'belt_id' => $belt->id,
'active' => $validated['active'],
]);
The root cause is that the Belt
table does not have any entries that match the condition where('min_xp', '<=', 1)
, leading to the query returning null
.
You should ensure that the Belt
table contains at least one belt with a min_xp
value less than or equal to 1. This way, the query will return a valid Belt
instance, and the subsequent code will work as expected:
it('Stores a character', function () {
$this->withoutExceptionHandling();
// Arrange
$user = User::factory()->create();
$belt = Belt::factory()->create([
'name' => 'White Belt',
'image' => 'white_belt.png',
'min_xp' => 0,
'max_xp' => 10,
]);
$data = [
'name' => 'Test',
];
// Act
$response = $this->actingAs($user)->post(route('characters.store'), $data);
// Assert
$response->assertRedirect('characters');
$this->assertDatabaseHas(Character::class, [
'name' => 'Test',
'belt_id' => $belt->id,
]);
});
Make sure the BeltFactory
creates valid belts:
namespace Database\Factories;
use App\Models\Belt;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Belt>
*/
class BeltFactory extends Factory
{
protected $model = Belt::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
static $lastMaxXp = 0;
$minXp = $lastMaxXp + 1;
$maxXp = $minXp + $this->faker->numberBetween(10, 20);
$lastMaxXp = $maxXp;
return [
'name' => $this->faker->unique()->colorName() . ' Belt',
'image' => 'belt_image.png',
'min_xp' => $minXp,
'max_xp' => $maxXp,
];
}
}
Let me know how this goes! 🚀
Wow, you're good! Can you please help me understand something:
<?php
namespace Database\Factories;
use App\Models\Belt;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Belt>
*/
class BeltFactory extends Factory
{
// static variable to keep track if this is the 1st belt instance seeded
protected static bool $firstInstance = true;
// Static variable to keep track of the last max XP
protected static int $lastMaxXp = 0;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
// random element array
$randomElements = ['Amazing', 'Devine', 'Exquisite', 'Great', 'Marvelous', 'Radiant', 'Shining', 'Vibrant'];
// generate a random element for the belt
$randomElement = fake()->randomElements($randomElements);
// generate a random color for the belt
$colorName = fake()->colorName();
// generate the name using the random element and color name
$name = $randomElement[0] . " " . $colorName;
// generate url safe image name using the random element and color name
$image_url = $randomElement[0] . "_" . $colorName;
// if this is the first belt instance
if(self::$firstInstance) {
// Set the minimum XP to be 0
$minXp = 0;
// and set firstInstance to false for the next instance
self::$firstInstance = false;
} else {
// Otherwise set the minimum XP to be one more than the last max XP
$minXp = self::$lastMaxXp + 1;
}
// Generate a max XP that is greater than the min XP
// For example, you can use a random number between min XP + 20 and min XP + 50
$maxXp = $minXp + fake()->numberBetween(20, 50);
// Update the last max XP for the next belt
self::$lastMaxXp = $maxXp;
return [
'name' => $name,
'image' => storage_path('app/public/belts/' . $image_url . '.png'),
'min_xp' => $minXp,
'max_xp' => $maxXp,
];
}
}
On Line 42 I have:
// if this is the first belt instance
if(self::$firstInstance) {
// Set the minimum XP to be 0
$minXp = 0;
// and set firstInstance to false for the next instance
self::$firstInstance = false;
} else {
// Otherwise set the minimum XP to be one more than the last max XP
$minXp = self::$lastMaxXp + 1;
}
Since this is the first and only instance of BeltFactory
being instantiated in that particular test:
it('Stores a character', function () {
// Arrange
$user = User::factory()->create();
/** HERE */
$belt = Belt::factory()->create([
'min_xp' => 0,
]);
$data = [
'name' => 'Test',
];
$response = $this->actingAs($user)->post(route('characters.store'), $data);
// Check if redirection happens as expected
$response->assertRedirect('characters');
$this->assertDatabaseHas(Character::class, [
'name' => 'Test',
]);
});
min_xp should automatically be set to 0 for that test yet it isn't. Could you in your infinite wisdom explain why?
Hey!
I believe that in the case you're describing, the BeltFactory
does not automatically assign min_xp
to 0
because the static variable firstInstance
is not considered when using the create method directly with custom attributes.