While upgrading the new screeenly.com to Laravel 8, I hit a bit of a wall. At first, it wasn't clear to me how I can get my previous setup of database factories to work with the new and improved Factory Classes.
See, in screeenly I'm using laravel/cashier-paddle. Cashier comes with 3 models: Customer
, Subscription
and Receipt
. In my Laravel 7 testsuite, I've created factories for all of them with different states. subscribed
, onTrial
, trialExpired
.
To make writing test easier, I've put those states on the User
Factory. Here's how the code look liked for the subscribed
state.
$factory->afterCreating(User::class, function ($user, $faker) {
$user->customer()->create();
});
$factory->afterCreatingState(User::class, 'subscribed', function ($user) {
$customer = $user->customer()->update([
'trial_ends_at' => null,
]);
factory(Subscription::class)->states(['status_active'])->create([
'billable_id' => $user->id,
'paddle_plan' => app(TestPlan::class)->paddleId()
]);
factory(Receipt::class)->create([
'billable_id' => $user->id,
]);
});
When a User
is created, a new "empty" Customer
model is attached to it. When the subscribed
state has been defined on the call to the User
-factory, the trial_ends_at
value is reset to null
, a new Subscription
is created and attached to the user. We also create a new Receipt
.
In my tests I could create this whole structure by using the following line.
$user = factory(User::class)->states(['subscribed'])->create();
I don't have to remember to create a Customer
or a Subscription
.
Super convenient. 👌
Make it work for Laravel 8#
I've used Laravel Shift to automate the upgrade to Laravel 8. It migrated my existing factories to the new class versions, but couldn't convert my exessive use of afterCreatingState
to the new format.
Getting the above code to work in Laravel 8 took me a couple of nights, as it wasn't clear to me from the docs, that I could put anything in the new states-methods. (Now, it's so obious to me)
In those methods I can combine $this->state()
with $this->afterCreating()
. 🤯
Here's how my User
-, Customer
- and Subscription
-Factories now look like to create the subscribed
-state:
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class UserFactory extends Factory
{
protected $model = User::class;
public function definition()
{
return [
'name' => $this->faker->name,
'email' => $this->faker->unique()->safeEmail,
'email_verified_at' => now(),
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
'remember_token' => Str::random(10),
'finished_onboarding_at' => null,
];
}
public function subscribed()
{
return $this
->afterCreating(function (User $user) {
CustomerFactory::new()->subscribed()->create([
'billable_id' => $user->id,
]);
});
}
}
The subscribed
-method doesn't update the User
itself with $this->state
, but calls a $this->afterCreating
callback where we create a new subscribed Customer
.
namespace Database\Factories;
use App\Domain\PaddleSubscription\Plans\TestPlan;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Laravel\Paddle\Customer;
class CustomerFactory extends Factory
{
protected $model = Customer::class;
public function definition()
{
return [
'billable_id' => User::factory(),
'billable_type' => User::class,
'trial_ends_at' => now()
];
}
public function subscribed()
{
return $this
->state(fn () => ['trial_ends_at' => null])
->afterCreating(function (Customer $customer) {
SubscriptionFactory::new()->statusActive()->create([
'billable_id' => $customer->id,
'paddle_plan' => app(TestPlan::class)->paddleId()
]);
});
}
}
The CustomerFactory
is similar to the UserFactory
. But instead of just using a afterCreating
-callback again, we combine it with $this->state
and reset the trial_ends_at
timestamp.
namespace Database\Factories;
use App\Domain\PaddleSubscription\Plans\TestPlan;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Laravel\Paddle\Subscription;
class SubscriptionFactory extends Factory
{
protected $model = Subscription::class;
public function definition()
{
return [
'billable_id' => User::factory(),
'billable_type' => User::class,
'name' => 'default',
'paddle_id' => $this->faker->unique()->randomNumber,
'paddle_status' => Subscription::STATUS_ACTIVE,
'paddle_plan' => app(TestPlan::class)->paddleId(),
'quantity' => 1,
'trial_ends_at' => null,
'paused_from' => null,
'ends_at' => null,
];
}
public function statusActive()
{
return $this->state(function () {
return [
'paddle_status' => Subscription::STATUS_ACTIVE
];
});
}
}
To finish things of, the Subscription
-factory creates a new active subscription.
The tests itself have been updated too. To create 5 new subscribed User
I can use this one-liner.
$users = User::factory()->times(5)->subscribed()->create();
Much easier to write and read than.
$users = User::factory()
->times(5)
->has(
CustomerFactory::new()
->has(SubscriptionFactory::new()->statusActive())
)
->create();
Conclusion#
Maybe you think I write my tests in a wrong way. It's not "professional" enough. Or I hide too much information in the factory and that I should create the Customer
and the Subscription
in every – single – test.
In the example of User
, Customer
and Subscription
I think it's acceptable to hide the relationship in those state
-methods. It's central to how the app works and the majority of the tests rely on that constellation. I don't want to repeat myself in every tests. Maintainable tests are as important as maintainable application code.
I hope this short posts helped you and makes your tests a bit more readable. When I find the time, I will contribute an example to the official docs.
Top comments (0)