Articles Snippets Projects

Mirror Your Query Scopes in Factory States

A Useful Laravel Testing Pattern

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:

  1. Verbose and repetitive setup code

  2. Easy to miss or misconfigure a condition

  3. Hard to understand which condition is being tested

  4. 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:

  1. For each query scope in your model, create a corresponding factory state

  2. Name these states to reflect the scope they represent

  3. 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:

  1. Start with a "happy path" test where all conditions are met

  2. 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:

  1. You're working with models that have multiple query scopes used together

  2. Your business logic depends on complex combinations of these scopes

  3. You need to test each condition independently

  4. 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:

  1. Identify all query scopes used in your model classes

  2. Create corresponding factory states with similar names

  3. Make factory states accept parameters for testing both positive and negative conditions

  4. Write a happy path test that satisfies all conditions

  5. 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.