May 5th ʼ25 22 hours ago
7 min 1228 words
When testing Laravel applications with complex business logic, writing effective and maintainable tests can be challenging. In this article, I'll share two practical techniques I've developed for testing jobs that query models with multiple scopes—a common pattern in Laravel applications.
The Challenge: Testing Complex Query Chains
Consider this job that processes orders with multiple query conditions:
class MonthlyIPSPriceCalculationJob implements ShouldQueue
{
public function handle(): void
{
Order::query()
->notDemo()
->completed()
->appCompleted()
->farmerUnpaid()
->whereIncomeProtectedSales()
->paymentDateBetween(now()->startOfMonth(), now()->endOfMonth())
->cursor()
->each(function (Order $order): void {
IPSPriceCalculationJob::dispatch($order->id);
});
}
}
This job filters orders through multiple query scopes (notDemo()
, completed()
, etc.) before dispatching a calculation job for each matching order. Testing this effectively requires validating each scope's behavior.
The Traditional Approach: Verbose and Error-Prone
Traditionally, testing a job like this would require extensive manual setup for each test case:
#[Test]
public function it_does_dispatch_ips_price_calculation_job_for_suitable_orders(): void
{
// First create the basic order
$order = Order::factory()->create();
// 1. Set the retailer as non-demo
$order->retailer->update([
'is_demo' => false,
]);
// 2. Mark the order as completed
$order->update([
'status' => OrderStatus::COMPLETED,
'completed_at' => now(),
]);
// 3. Mark the application as completed
$order->application->update([
'status' => ApplicationStatus::COMPLETED,
]);
// 4. Add unpaid payment to farmer
Payment::factory()->create([
'order_id' => $order->id,
'status' => PaymentStatus::UNPAID,
]);
// 5. Add income protected sale
$order->update([
'is_income_protected_sale' => true,
]);
IncomeProtectedSale::factory()->create([
'order_id' => $order->id,
]);
// 6. Set farmer payment date
$newPaymentDate = now()
->startOfMonth()
->addDays(fake()->numberBetween(
0,
now()>daysInMonth
));
$order->update([
'farmer_payment_date' => $newPaymentDate,
]);
// Apply all changes
$order->refresh();
Bus::fake([IPSPriceCalculationJob::class]);
// Act
MonthlyIPSPriceCalculationJob::dispatchSync();
// Assert
Bus::assertDispatched(IPSPriceCalculationJob::class);
}
And testing each negative case (e.g., demo orders) would require duplicating most of this code with just one condition changed. This approach has several problems:
Verbose and repetitive setup code
Easy to miss or misconfigure a condition
Hard to understand which condition is being tested
Difficult to maintain as the model evolves
Pattern 1: Mirror Your Query Scopes in Factory States
The first pattern to solve this problem is creating factory states that directly mirror your model's query scopes:
For each query scope in your model, create a corresponding factory state
Name these states to reflect the scope they represent
Design factory states to accept parameters for testing both positive and negative scenarios
For example, if you have these scopes in your Order model:
public function scopeNotDemo($query)
{
return $query->whereHas('retailer', function ($query) {
$query->where('is_demo', false);
});
}
public function scopeCompleted($query)
{
return $query->where('status', OrderStatus::COMPLETED)
->whereNotNull('completed_at');
}
You would create matching factory states:
public function demo(bool $isDemo = true): static
{
return $this->afterCreating(function (Order $order) use ($isDemo): void {
$order->retailer->update([
'is_demo' => $isDemo,
]);
});
}
public function completed(bool $isCompleted = true): static
{
$status = $isCompleted
? OrderStatus::COMPLETED
: OrderStatus::getRandomCaseExcept(OrderStatus::COMPLETED);
$completedAt = $isCompleted ? now() : null;
return $this->state(fn (): array => [
'status' => $status,
'completed_at' => $completedAt,
]);
}
Notice how these factory states accept boolean parameters that let you easily create both positive and negative test cases. Using these mirrored factory states, we can rewrite our test much more concisely:
#[Test]
public function it_does_dispatch_ips_price_calculation_job_for_suitable_orders(): void
{
// 1. Arrange
Order::factory()
->demo(false)
->completed(true)
->appCompleted(true)
->farmerWithAnUnpaidPayment(true)
->withIncomeProtectedSale(true)
->forFarmerPaymentDate(now()->startOfMonth()->addDays(5))
->create();
Bus::fake([IPSPriceCalculationJob::class]);
// 2. Act
MonthlyIPSPriceCalculationJob::dispatchSync();
// 3. Assert
Bus::assertDispatched(IPSPriceCalculationJob::class);
}
Pattern 2: Build Happy Path Tests First, Then Test Variations
With your mirrored factory states in place, you can build a systematic testing strategy:
Start with a "happy path" test where all conditions are met
Then create variation tests by modifying just one condition at a time
It's crucial to include all conditions in each test rather than testing each condition in isolation. For example, if you only write:
#[Test]
public function it_does_not_dispatch_ips_price_calculation_job_for_demo_orders(): void
{
// 1. Arrange - Only change the demo state to true
Order::factory()
->demo(true);
->create();
Bus::fake([IPSPriceCalculationJob::class]);
// 2. Act
MonthlyIPSPriceCalculationJob::dispatchSync();
// 3. Assert
Bus::assertNotDispatched(IPSPriceCalculationJob::class);
}
You might get false positives from your tests. The test could pass because of other unspecified, default conditions rather than the specific condition you're trying to test. By starting with a complete happy path and then only changing one condition at a time, you ensure that your tests accurately verify each individual criterion while maintaining the context of all other requirements.
Here's a variation test where only the demo
condition is changed:
#[Test]
public function it_does_not_dispatch_ips_price_calculation_job_for_demo_orders(): void
{
// 1. Arrange - Only change the demo state to true
Order::factory()
->demo(true) // This is the only change from the happy path
->completed(true)
->appCompleted(true)
->farmerWithAnUnpaidPayment(fake()->boolean())
->withIncomeProtectedSale(true)
->forFarmerPaymentDate(now()
->startOfMonth()
->addDays(fake()->numberBetween(
0,
now()->daysInMonth)))
->create();
Bus::fake([IPSPriceCalculationJob::class]);
// 2. Act
MonthlyIPSPriceCalculationJob::dispatchSync();
// 3. Assert
Bus::assertNotDispatched(IPSPriceCalculationJob::class);
}
You can repeat this pattern for each condition:
#[Test]
public function it_does_not_dispatch_ips_price_calculation_job_for_not_completed_orders(): void
{
// 1. Arrange
Order::factory()
->demo(false)
->completed(false) // This is the only change from the happy path
->appCompleted(true)
->farmerWithAnUnpaidPayment(fake()->boolean())
->withIncomeProtectedSale(true)
->forFarmerPaymentDate(now()
->startOfMonth()
->addDays(fake()->numberBetween(
0,
now()->daysInMonth)))
->create();
Bus::fake([IPSPriceCalculationJob::class]);
// 2. Act
MonthlyIPSPriceCalculationJob::dispatchSync();
// 3. Assert
Bus::assertNotDispatched(IPSPriceCalculationJob::class);
}
When To Apply These Patterns
These patterns are particularly valuable when:
You're working with models that have multiple query scopes used together
Your business logic depends on complex combinations of these scopes
You need to test each condition independently
You want to maintain readable and maintainable tests as your application evolves
By creating factory states that mirror your query scopes, you can write clearer, more maintainable tests that precisely target your business logic conditions.
Implementation Strategy / TL;DR;
To implement this pattern in your Laravel projects:
Identify all query scopes used in your model classes
Create corresponding factory states with similar names
Make factory states accept parameters for testing both positive and negative conditions
Write a happy path test that satisfies all conditions
Create variation tests that change one condition at a time
This approach makes your tests not only more expressive and easier to understand, but also more maintainable, faster to write, and ensures complete coverage of your business logic conditions.