DEV Community

Eduardo Guzmán
Eduardo Guzmán

Posted on

Tips for testing queued jobs in Laravel

When working with Laravel applications, it’s common to encounter scenarios where a command needs to perform an expensive task. To avoid blocking the main process, you might decide to offload the task to a job that can be processed by a queue.

Let’s walk through an example. Imagine the command app:import-users needs to read a large CSV file and create a user for each entry. Here’s what the command might look like:

/* ImportUsersCommand.php */

namespace App\Console\Commands;

/*...*/

class ImportUsersCommand extends Command
{
    protected $signature = 'app:import-users';

    public function handle()
    {
        dispatch(new ImportUsersJob());

        $this->line('Users imported successfully.');
        $this->line('There are: ' . User::count(). ' Users.');
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the command dispatches a job to handle the reading of the file and the creation of users. Here’s how the ImportUsersJob.php might look:

/* ImportUsersJob.php */

namespace App\Jobs;

/*...*/

class ImportUsersJob implements ShouldQueue
{
    public function handle(FileReader $reader): void
    {   
        foreach($reader->read('users.csv') as $data) {
            User::create([
                'name' => $data['name'], 
                'email' => $data['email'],
            ]);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

When testing this feature, a typical test for the command might look like this:

/* ImportUsersCommandTest.php */

namespace Tests\Feature;

/*...*/

class ImportUsersCommandTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_processes_the_file(): void
    {
        Storage::fake('local')->put('users.csv', "...");

        $this->artisan('app:import-users')
            ->expectsOutput('Users imported successfully.')
            ->expectsOutput('There are: 10 Users.')
            ->assertSuccessful();

        $this->assertDatabaseCount('users', 10);
    }
}
Enter fullscreen mode Exit fullscreen mode

At first glance, this test seems to work perfectly. Running the test suite shows a successful result:

screenshot of successful test suite

Real World Execution

However, when you run the app:import-users command in a real environment, you might get an unexpected result:

Image description

As you can see, the command output indicates that there are 0 users in the database. So, Why does this happen?

The reason is that the job is dispatched to a queue, so it doesn’t run synchronously with the command execution. The users will be created only when the queue processes the job later.

Why does the test pass?

The test suite uses the sync queue driver by default, meaning jobs are processed synchronously during the test. As a result, the job runs immediately, giving the idea that everything works as expected.

While this behavior is acceptable in the test environment, it’s important to recognize that real-world results depend on the QUEUE_CONNECTION configuration in your production environment. And given your project requirements, you might know that the job will be processed in an async queue.

Once you’re aware of this distinction, you may want to improve your tests to avoid “false positives”.

Testing your job is dispatched

First, it’s important to verify that the command actually dispatches the job, regardless of whether the job is processed synchronously or asynchronously. Here’s how to test that:

/* ImportUsersCommandTest.php */

namespace Tests\Feature;

/*...*/

class ImportUsersCommandTest extends TestCase
{    
    public function test_it_dispatches_the_job(): void
    {
        Queue:fake();

        $this->artisan('app:import-users')
            ->expectsOutput('Process has been queued.')
            ->assertSuccessful();

        Queue::assertPushed(ImportUsersJob::class);
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing your job is processed

Once you’ve confirmed that the job is dispatched, you can test the actual work performed by the job in a separate test. Here’s how you might structure the test for the job:

/* ImportUsersJobTest.php */

namespace Tests\Feature;

/*...*/

class ImportUsersJobTest extends TestCase
{
    use refreshDatabase;

    public function test_it_processes_the_file()
    {
        Storage::fake('local')->put('users.csv', "...");

        app()->call([new ImportUsersJob(), 'handle']);

        $this->assertDatabaseCount('users', 10);
    }
}

Enter fullscreen mode Exit fullscreen mode

This ensures that the job performs the necessary work, regardless of whether it’s processed by a queue or synchronously.

Handling edge cases

As in real life, edge cases might happen and you should be ready for these.

Laravel’s queue system, according to your workers configuration, will retry jobs when an exception occurs, and if retries are exceeded, the job will be marked as failed.

So, what happens if the file doesn’t exist? You need to handle such edge cases by validating inputs and throwing exceptions when necessary.

Here’s how you might handle this in your job:

/* ImportUsersJobTest.php */

namespace App\Jobs;

/*...*/

class ImportUsersJob implements ShouldQueue
{
    use Queueable;

    public function handle(FileReader $reader): void
    {   
        if(!Storage::disk('local')->exists('users.csv')){
            throw new Exception('The users.csv file doesn\'t exist.')
        }

        foreach($reader->read('users.csv') as $data) {
            User::create([
                'name' => $data['name'], 
                'email' => $data['email'],
            ]);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Here’s how you’d test this scenario:

/* ImportUsersJobTest.php */

namespace Tests\Feature;

/*...*/

class ImportUsersJobTest extends TestCase
{
    use refreshDatabase;

    /*...*/

    public function test_it_fails_when_file_doesnt_exist(): void
    {
        Storage::fake('local');

        $this->expectException(Exception::class);
        $this->expectExceptionMessage('The users.csv file doesn\'t exist.');

        dispatch(new ImportUsersJob());
    }
}

Enter fullscreen mode Exit fullscreen mode

Final thoughts

This approach ensures that your tests more accurately reflect how jobs will be processed in the real world.
The same strategy can be applied when a controller dispatches a job to a queue or where a event listener is queued.
As always, adjust these practices to fit your project and team.

I’d love to hear your thoughts!

Top comments (0)