Summer Sale! All accounts are 50% off this week.

NoLAstNamE's avatar

Testing cache-based atomic lock

I'm new to testing and yeah I'm thinking of testing this cache-based atomic lock I implemented.

Here's the code of my cache-based atomic lock:

$lockKey = 'your-unique-key-for-purchase';

if (! Cache::has($lockKey)) {
    Cache::put($lockKey, true, now()->addSeconds(60));
} else {
    return to_route('purchases.create')
        ->with('error', 'Another purchase operation is already in progress. Please try again later.');
}

// store purchase here

return to_route('purchases.create')
    ->with('success', 'Purchase successful.');

I tried testing it but I can't make it pass. I think the concurrent request is not happening. Or I am doing it the wrong way?

Here's my test:

/** @test */
public function concurrent_purchases(): void
{
     $user = User::factory()->create();
     $item = Item::factory()->create();
     
     $this->actingAs($user);

     Cache::shouldReceive('put')->andReturn(true);

     // Act: First user makes the purchase request
     $response1 = $this->actingAs($user)->post(route('purchases.store'), [
         'item_id' => $item->id,
     ]);

     $response1->assertRedirect(route('purchases.create'));
     $response1->assertSessionHas('success', 'Purchase successful.');
     
     // Act: Second user makes the purchase request immediately after the first request
     $response2 = $this->actingAs($user)->post(route('purchases.package'), [
         'item_id' => $item->id,
     ]);

     $response2->assertRedirect(route('purchases.create'));
     $response2->assertSessionHas('error', 'Another purchase operation is already in progress. Please try again later.');
}

Do you think this can't make a concurrent request because the first action processes the first request and wait until it's finished and then process the second action?

0 likes
2 replies
NoLAstNamE's avatar
NoLAstNamE
OP
Best Answer
Level 8

Okay, based on my research that is the default behavior of testing, which processes each test method sequentially.

And I need to use Laravel Dusk, which is specifically designed for testing interactions with a web application, including simulating concurrent requests.

With Dusk, you can run browser tests that execute multiple requests simultaneously.

Here's my solution using the Dusk test:

/** @test */
public function concurrent_purchases(): void
{
    // Arrange: Create a user and an item
    $user = User::factory()->create();
    $item = Item::factory()->create();

    // Act: Use Dusk to execute concurrent requests
    $this->browse(function (Browser $browser1, Browser $browser2) use ($user, $item) {
        tap($browser1->loginAs($user)
            ->visit(route('purchases.create')), function (Browser $browser) use ($item) {
                $browser->type('item_id', $item->id)

                $browser->script("document.getElementById('purchase').click()");
        })
        ->assertRouteIs('purchases.index')
        ->waitForText('Purchase successful.', 5);

        tap($browser2->loginAs($user)
            ->visit(route('purchases.create')), function (Browser $browser) use ($item) {
                $browser->type('item_id', $item->id)

                $browser->script("document.getElementById('purchase').click()");
        })
        ->assertRouteIs('purchases.create')
        ->waitForText('Another purchase operation is already in progress. Please try again later.', 5);
    });
}

Notice I use $browser->script("document.getElementById('purchase').click()"); here because I'm getting an error that dusk can't click the button because of the debugbar.

But if you prefer press() you can do ->press('Press') to press the button.

LaryAI's avatar
Level 58

Yes, you're correct. The way you've written your test, it's not actually simulating concurrent requests. The second request will only be made after the first one has completed.

To simulate concurrent requests, you would need to use something like parallel testing or multi-threading, which isn't natively supported in PHPUnit or Laravel.

However, you can still test your cache-based atomic lock logic by mocking the Cache facade's has method to simulate the lock being present or not. Here's how you can do it:

/** @test */
public function concurrent_purchases(): void
{
    $user = User::factory()->create();
    $item = Item::factory()->create();
    
    $this->actingAs($user);

    // Mock the Cache facade's has method to return false, simulating that the lock is not present
    Cache::shouldReceive('has')->andReturn(false);
    Cache::shouldReceive('put')->andReturn(true);

    // Act: First user makes the purchase request
    $response1 = $this->actingAs($user)->post(route('purchases.store'), [
        'item_id' => $item->id,
    ]);

    $response1->assertRedirect(route('purchases.create'));
    $response1->assertSessionHas('success', 'Purchase successful.');

    // Mock the Cache facade's has method to return true, simulating that the lock is present
    Cache::shouldReceive('has')->andReturn(true);

    // Act: Second user makes the purchase request immediately after the first request
    $response2 = $this->actingAs($user)->post(route('purchases.store'), [
        'item_id' => $item->id,
    ]);

    $response2->assertRedirect(route('purchases.create'));
    $response2->assertSessionHas('error', 'Another purchase operation is already in progress. Please try again later.');
}

This way, you're testing the logic of your atomic lock, not the concurrency itself.

Please or to participate in this conversation.