Pest-Driven Development: Testing Routes For Authenticated Users in Laravel Jetstream
Mary
Posted on September 18, 2023
I'm blogging along with the laracasts series Pest Driven Laravel created by Christoph Rumpel as I work on a project I hope to open source. I'm just documenting how I'm working, the resources I'm using, the problems I run into and some cool things I'm reminded of as I develop.
I'm moving forward with a new understanding of my goal, and already I can tell that my goalpost has shifted. This past week involved a lot of failing. I'm excited about that because while I do enjoy all the resources I wrote about in my last blog post, I always learn the most by breaking things in new ways and paying a lot of attention while I fix it. It's easier to pay attention when I'm trying to fix something. This year's Laracon had an excellent talk by Joel Clermont about getting unstuck which had a particularly tricky debugging situation as its final example. No spoilers, but I knew exactly what the problem was because I've done it in Python.
I also moved to a new blog writing method now that I've put out all the fires I can find so everything's going to be in past tense this time.
I made a new test file just for task.show
. This is the named route we're going to be talking about today. It displays a navigation bar and the details of one task that's already been created.
<?php
use App\Models\User;
use App\Models\Task;
use function Pest\Laravel\{get};
it('gives back successful redirect response for unauthenticated users for the tasks.show page', function () {
$task = Task::factory()->create(['task_description' => 'Test Task 1', 'sheets_id' => 1, 'public' => false]);
get(route('tasks.show', ['user' => $user, 'task' => $task]))->assertStatus(302);
});
The test ran and it passed.
Failing With A Default Factory
The next step was to actually test the routes with a logged-in user. Building on the principles I laid out for myself, I decided to test the smallest thing I can: the show
method for a single task. So I wrote this based on the progress I've made in the course so far:
it('can be accessed by verified user', function () {
// Arrange
$user = User::factory()->create();
$task = Task::factory()->create();
// Act & Assert
$this->actingAs($user)
->get(route('tasks.show'))
->assertOk();
});
It failed. Normally that's step 1, but in this case I already had a task view written and I was expecting it to pass. This test has some new stuff going on:
-
$user = User::factory()->create();
: Here, we return a facade which uses a factory to create and store a new instance ofUser
in the testing database. These records are created during the test instantiation and destroyed when the test is complete, so they'll look different every time pest is run. Pest manages the creation and deletion of these records for you. It relies on php faker to seed random data so you don't have to write seeders. There's also a python faker for my fellow polygots who may or may not have spent a bunch of hours writing their own seeder only to be told Laravel already has a good one. Turns out Python has a good one too. Oops. -
$this->actingAs($user)
...: I still cannot tell you what$this
is with confidence (is it the testing context??), but I can tell you thatactingAs($user)
is a chained method which accepts our temporary$user
as a parameter and allows us to make requests to a route as that fake user. This comes from Laravel Passport, the dependency jetstream uses to spin up an OAuth2 server implementation for us. I've been working through a book about OAuth2 for almost 3 years. It is so refreshing to bump into OAuth2 while searching docs as opposed to bending to its will. -
$this->actingAs($user)
->get(route(tasks.show))
...: Now we've come to same old version of our previous get request. This time it's being called with more contextual information stored in$this
.
The error comes from eloquent, the built-in Laravel dbms. Our create()
method tried to commit a Task
to the database without required attributes. It's calling out the sheets_id
attribute because it's listed first, but there's going to be a few more problems we can fix by writing a [Factory]()
right now.
Task Factory
When I created my task
model, I specified I wanted a factory to be automatically created for me. It's already there in {{root}}\database\factories\TaskFactory
.
I also already have a working user
factory provided by Laravel. It's in /vendor/laravel/framework/src/Illuminate/Database/Eloquent/Factories/Factory.php
. More general code for factories is also already in your app at /vendor/laravel/framework/src/Illuminate/Database/Eloquent/Factories/Factory.php
.
In general, we can use faker
generators; you can find the generator class in your ./vendor/fakerphp/faker/src/Faker/generator.php
directory if you want to see a list real quick. In this case, a few attributes I'm going to keep consistent just so I'm seeing consistent data across a test if I need to see it with dump()
later.
Here's the factory I ended up with:
class TaskFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
// get a date sometime in the next two months
$currentDateTime = Carbon::now();
$dateTime = $currentDateTime->addDays(rand(2, 60))->toDateString();
$currentDateTime = $currentDateTime->toDateString();
$name = $this->faker->firstName();
$sheetsId = $name . $currentDateTime;
return [
'sheets_created_at' => "",
'sheets_id' => $sheetsId,
'name' => $this->faker->randomDigitNotNull(),
'author' => $this->faker->firstName(),
'start_date' => $dateTime,
'start_time' => $dateTime,
'public' => rand(0, 1) == 1,
'client_address' => $this->faker->streetAddress(),
'task_description' => $this->faker->text(),
'destination' => $this->faker->streetAddress(),
'volunteer' => "",
'status' => "unassigned",
'contact_information' => $this->faker->text(),
];
}
}
The other line to notice here is $currentDateTime = Carbon::now()
and $dateTime = $currentDateTime->addDays(rand(2,60))->toDateString();
. I wanted to create a start_date
and start_time
between now and 2 months for now, because that's about the amount of time I'm seeing most tasks scheduled. If that was all I wanted I could use $dateTime = Carbon::now()->addDays(rand(2,60))->toDateString();
, but I also want to see a sheets_created_at
with a really similar timestamp to the created_at
in my database timestamps.
I'm using carbon to get my epic $currentDateTime
timestamp (pun intended this time). I know of Carbon from Laravel Up And Running, so I used these handy digitalocean tutorials on Getting an Easy Datetime in Laravel and Manipulating The Date And Time to copy and paste. Then I googled again to assign it to my attributes as a string. I'm going to format this output later but right now I have my test set up and nothing is null
that shouldn't be nullable
.
So far I:
- wrote a failing test to hit the route as authenticated
- wrote a blade template
- added my route to
web.php
- told
TaskController.php
what to do when a request hits the route - modified my test to include
$actingAs
before theget
and includeUser
andTask
factories through facades - wrote a
Task
factory and useddump()
to check that my creates a complete, random$task
I can pass to myroute
That's everything the course teaches about showing detail, I think. It should pass now, right? Right?
Failing Without a User Group
No, it didn't pass. Now I'm getting an error from my navigation bar template in blade that wants the name and id of the organization associated with my user. I have a fake user, a fake task, and no fake organization. Not going to lie, it took me a while to realize what this error meant. There's not much we can do in jetstream without running into this issue because the navigation bar is on every page and users really should be associated with teams at all times.
This is a problem I signed up for. I'm coding along with the course instead of going ahead through it because I want to make all of these mistakes now. I'm VERY tempted to reach out and ask for help (or thank people for being awesome and making course content), but I can be pretty thick-headed and I know that if I don't understand why I'm doing these things and fix them as I break them I'll learn them for ever ever. If I don't, I'll lose time (potentially for years) forgetting things I don't rememeber learning. That's why I'm not a copy and paste from stack overflow person and never will be. Unless it's a super elegant one-liner like public
: rand(0, 1) == 1 which I'm sure I understand fully before I use it. Should I compromise? Yes. Would I compromise if I was being compensated for my time? Also yes. Moving on.
In order to display my template, I need to create a user organization, but it can't just be any user organization. They have to have a relationship or the test will still fail. My $user
is going to have a belongs to relationship to the personal_team
provided by jetstream. I will sometimes refer to this personal_team
it a team because that is what jetstream named it. I also sometimes call it an organization, because that is what it represents in my problem domain. There's this great solution on laracasts.com, which helped me find the Laravel Documentation addressing the problem.
Now my test looks like this:
it('returns 200 on GET request for authenticated user @ tasks.show', function () {
// Arrange
$user = User::factory()
->hasAttached(
Team::factory()
->state(function (array $attributes, User $user) {
return ['user_id' => $user->id, 'personal_team' => true];
}),
)
->create();
$task = Task::factory()->create();
// Act & Assert
$this->actingAs($user)
->get(route('tasks.show', ['task' => $task, 'user' => $user]))
->assertOk();
});
And it passes. There's NOTHING like a passing test case.
Conclusion
After getting tasks.show
to pass for authenticated users, I added new files for each view that already existed in my previous SuccessfulGetResponse2.php
except /home
. Then I used variations of the method described above to produce this output:
Posted on September 18, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
September 18, 2023