derekmd's avatar

withoutEvents() for only integration test setup

I have a scenario where Event::fire() is called in both created and deleted events of a model. During the API's PUT update endpoint integration test I want to know:

  • that event isn't fired when the update action is successful. (i.e., "InvalidArgumentException: No connector for []" wouldn't being thrown during the integration test scenario.)

However factory()->create() when setting up the integration test does fire the created model's event. That means I'd have to call $this->withoutEvents() which means the integration test won't assert that event was mistakenly fired for the update action itself (not the test setup.)

Likewise when testing the API's DELETE endpoint, $this->expectsEvents(PaymentMethodChanged::class) wouldn't just be capturing the delete action's event trigger - there'd also be an event for chucking in a database row for the integration test's setup.

So I'd like to temporarily suppress events in setup then re-enable for the test itself. A few things I tried:

  • Use $this->withoutEvents() in a PHPUnit @dataProvider method. However Laravel's service container isn't available in those methods so I can't call factory() to make the model instance.

  • Override the application's 'events' facade and restore it after a closure executes:

    /**
     * Temporarily disable event firing for a test setup closure function.
     *
     * @param callable $callback
     *
     * @return mixed Value returned by the callback.
     */
    protected function withoutEventsFor(callable $callback)
    {
        $events = $this->app['events'];
    
        $this->withoutEvents();
    
        $value = $callback();
    
        $this->app->instance('events', $events);
    
        return $value;
    }
    
    
    public function testUpdateIsPrimary()
    {
        $this->beUser();
    
        list($primary, $secondary) = $this->withoutEventsFor(function () {
            return [
                factory(PaymentMethod::class, 'paypal')->create([
                    'user_id' => $this->user->id,
                    'is_primary' => true,
                ]),
                factory(PaymentMethod::class, 'emt')->create([
                    'user_id' => $this->user->id,
                    'is_primary' => false,
                ]),
            ];
        });
    
        // then perform API test
    }
    

For the latter approach, I found Event::fire() referencing the app's 'events' singleton instance were still being suppressed as if the mock created by $this->withoutEvents() was still in place.

Anyone have an idea how I could go about ignoring event dispatches for factory()->create()?

0 likes
3 replies
derekmd's avatar

I took another crack at this and found calling Event::swap($instance) (instead of $this->app->instance('events', $instance)) will correctly restore Event::fire() connector behaviour after the $this->withoutEventsFor() closure.

class TestCase extends Illuminate\Foundation\Testing\TestCase
{
    /**
     * @var \App\Models\User
     */
    protected $user;

    /**
     * If the Event facade is referenced before this method (either in
     * $this->withoutEventsFor() or elsewhere), we must also refresh the
     * facade's resolved instance to reflect the app container mock.
     *
     * @return $this
     */
    protected function withoutEvents()
    {
        return tap(parent::withoutEvents(), function () {
            Event::swap($this->app['events']);
        });
    }

    /**
     * Temporarily disable event firing for a test setup closure function.
     *
     * @param callable $callback
     *
     * @return mixed Value returned by the callback.
     */
    protected function withoutEventsFor(callable $callback)
    {
        $events = $this->app['events'];

        $this->withoutEvents();

        return tap($callback(), function () use ($events) {
            Event::swap($events);
        });
    }
}

Above is a /tests project class my test suite always extends. You could also implement this as a trait although this method depends on TestCase@withoutEvents() and $this->app declared in Illuminate\Foundation\Testing\TestCase.

EventFellows's avatar

I found for my 5.2 setup that there was no way to make it work with Event::fire(new WhateverEvent);

But if Event::fire is replaced with just the event(new WhateverEvent); helper it works just as expected.

derekmd's avatar

withoutEvents() override

@EventFellows A few weeks ago I updated my reply above with an override to Illuminate\Foundation\Testing\Concerns\withoutEvents() that will also swap the facade instance in case $this->withoutEvents() is called before $this->withoutEventsFor().

event() just uses app('events') so it's dynamic to the mock or concrete object currently in the app container. However Event::fire() resolves the app('events') instance once then keeps that instance in the Illuminate\Support\Facades\Event singleton for the remainder of the current request unless Event::swap() is invoked.

With the override, you can be sure either event dispatching technique uses the expected Mockery object or concrete Illuminate\Contracts\Events\Dispatcher instance.

Example

Here's a real world integration test I can confirm passes assertions when Event::fire() is used inside Eloquent event listeners triggered within factory() calls in the below closure.

/* @test */
public function subscription_purchase_is_succcessful_with_valid_card
{
    $this->expectsJobs(App\Jobs\Email\OrderReceiptEmail::class);
    $this->expectsEvents(App\Events\PaymentMethodsChanged::class);

    list($order, $orderItem) = $this->withoutEventsFor(function () {
        return [
            factory(Order::class)->create([
                'sub_total' => '349.00',
                'tax' => '45.37',
                'total' => '394.37',
            ]),
            factory(OrderItem::class)->create([
                'order_id' => $order->id,
                'product_id' => ProductId::ANNUAL_SUBSCRIPTION,
                'amount' => '349.00',
                'tax' => '45.37',
                'transaction_type' => OrderItemTransactionType::Purchase,
            ]),
        ];
    });

    // etc.
}

Please or to participate in this conversation.