framework
framework copied to clipboard
[9.x] Add support for running queued jobs asynchronously in tests
This PR adds support for asynchronously queued jobs in tests.
Imagine a scenario where you have a feature test for an endpoint that deletes a users. Inside the associated controller, a job is fired off called DeleteUserPosts.
That job should receive a User model which you pass in from your controller, but without thinking, you delete the model later in the request.
// Controller
class UserController extends Controller
{
//
public function destroy(User $user)
{
//
dispatch(new DeleteUserPosts($user));
$user->delete(); // This is going to be a problem when the above job is queued...
}
}
// Job
class DeleteUserPosts implements ShouldQueue
{
use SerializesModels;
public function __construct(public User $user)
{
}
public function handle()
{
$this->user->posts->delete();
}
}
Now, when you run the test, the job runs synchronously and you get the warm fuzzies seeing a nice green tick appear in your terminal. However out in the wild, the job is queued, a ModelNotFoundException is thrown as it no longer exists when the job handler attempts to refetch it.
public function testUserAndPostsCanBeDeleted()
{
$user = User::factory()
->has(Post::factory()->count(3))
->create();
$this->delete('/users/'.$user->id);
$this->assertNull($user->posts);
}
This is just one example, but a few times, I have run into scenarios that have bitten me as I've not been able to execute that job in isolation as part of my test.
Of course, some of these issues are taken away if the job is unit tested (though this doesn't guard against the deleted model example), but sometimes I just want to test my feature end to end and know it's behaving as it will do in production.
Introducing the new test helper - fakeAsync.
fakeAsync accepts and executes a callback while catching all the queued jobs dispatched during its execution. When the execution completes, each job is processed within a fresh application state.
public function testUserAndPostsCanBeDeleted()
{
$user = User::factory()
->has(Post::factory()->count(3))
->create();
Queue::fakeAsync(function () use ($user) {
$this->delete('/users/'.$user->id);
});
$this->assertNull($user->fresh()->posts);
}
Running this test would result in a ModelNotFoundException being thrown as the job attempts to unserialize the $user model.
You may pass an array of jobs as the second argument of fakeAsync to have control over which to process asynchronously.
Queue::fakeAsync(function () use ($user) {
$this->delete('/users/'.$user->id);
}, [DeleteUserPosts::class]);
Any other jobs will be processed synchronously.
Update
I tested this PR on the Laravel.io codebase and managed to catch a bug using Queue::fakeAsync 🎉
public function delete(Request $request, Thread $thread)
{
$this->authorize(ThreadPolicy::DELETE, $thread);
$this->dispatchSync(new DeleteThread($thread));
$request->whenFilled('reason', function () use ($thread) {
$thread->author()?->notify(
new ThreadDeletedNotification($thread, request('reason')),
);
});
$this->success('forum.threads.deleted');
return redirect()->route('forum');
}
Here, you can see the thread is deleted before firing off a queued notification which requires that thread. What makes this more interesting is we didn't know this was an issue a ModelNotFoundException is not reported by default.
It's worth pointing out fakeAsync does not work in conjunction when mocking other facades such as Notification::fake() or Bus::fake() - it will only work if jobs are actually pushed on to the queue.
You may pass an array of jobs as the second argument of fakeAsync to have control over which to process asynchronously. Any other jobs will be processed synchronously.
I'm wondering if it would be better to process the provided jobs synchrously, but fake any others instead. I would love feedback here if anyone has any.
The way I handle these situations is by using the database queue driver and invoking queue:work --once inside the test code. That way I can:
- Test the route that dispatches the job and make the appropriate assertions
- Assert the job has successfully serialized and stored
- Work the job
- Assert the expected outcome of the worked job
Feels to me that if you were to build small helpers to make this process easier would be much less cumbersome, but that's just my opinion.
public function test_something_todo_with_order_of_execution_with_jobs()
{
config()->set('queue.default', 'database');
$this->post('/my/endpoint', ['my' => 'parameters'])
->assertSuccessful()
->assertJsonFragmentAt('data.0.something', 'my-expected-value');
// Perform any assertion needed related to the Http Request
self::assertDatabaseHas();
$serializedJob = DB::table('jobs')->first();
// Perform assertion about the serialized job if necessary,
// but ideally best to not write code dependent of the internal structure of Laravel jobs.
$this->artisan('queue:work --once');
// Perform assertion about the job that has been executed
}
I kinda like @deleugpn's solution as it uses what we already have instead of needing to introduce much new code.