laravel / framework

The Laravel Framework.
https://laravel.com
MIT License
32.51k stars 11.02k forks source link

Eloquent model events are not triggered when testing #1181

Closed PascalZajac closed 11 years ago

PascalZajac commented 11 years ago

The various Eloquent model events ending with ing are not being triggered during tests.

I discovered this a while ago and assumed it was intended functionality (forcing you to test your event handlers separately, much as route filters are not run in test mode).

However today I discovered that the post-action event handler created is triggered when run in test mode. Is there a reason for the discrepancy?

ghost commented 10 years ago

This is super crazy! Here is my hacky workaround:

    trait ModelEventOverride {
      public $events = [ 'saving'   => 'beforeSave',   'saved'   => 'afterSaved',
                         'creating' => 'beforeCreate', 'created' => 'afterCreated',
                         'updating' => 'beforeUpdate', 'updated' => 'afterUpdated',
                         'deleting' => 'beforeDelete', 'deleted' => 'afterDeleted',
                         'validating' => 'beforeValidate','validated' => 'afterValidated'
                       ];

      protected function fireModelEvent($event, $halt = true)
      {
          $_event = $event;

            if ( isset(static::$dispatcher) ) {

                $event = "eloquent.{$event}: ".get_class($this);

                if ( !empty(static::$dispatcher->getListeners($event)) )
                {
                    $method = $halt ? 'until' : 'fire';
                    return static::$dispatcher->$method($event, $this);
                }

            }

            $event = $_event;

            if ( ! isset($this->events[$event])) return true;

            $method = $this->events[$event];

            if(method_exists($this, $method))
            {
                return call_user_func(array($this, $method),$this);
            }

            return true;
       }
    }

lol

igorpan commented 10 years ago

Just bumped into this one myself in Codeception's acceptance tests. Spent whole morning debugging just to discover that events aren't fired while in test......

ghost commented 10 years ago

@igorpan yep, I can understand how you feel. been there...

I don't know why, if it can't be fixed, adding a note on the doc is okay too. This could save us some time.

mk-relax commented 10 years ago

I've been using Laravel for three months now and have been very impressed with it so far, but running into this issue after writing only two small testcases for a small Model was an unpleasant (and unexpected) surprise. It took me a couple of hours to find out that it was not my own class, but the test framework itself causing some of my tests to fail. And to pass when ran individually... I came up with the following "solution" which I didn't see in this thread, so I thought I'd share it with you:

As an introduction: I'm using the models boot() method to implement record-level authorization: users may only update a model (in this case a Boat) if they own it. For example:

class Boat extends Eloquent {

    protected static function boot()
    {
        parent::boot();

        static::updating(
            function ($boat) {
                if (Authority::cannot('update', 'Boat', $boat)) {
                    return false;
                }
            }
        );
    }

Some test failed failed because the boot() method on my Model was only called once. The model events like 'update' were only registered with the event dispatcher of the first test, but not with the (new) event dispatchers of following tests because the Model class was already "$booted".

Since you can't "unload" a class in PHP, I decided to add an unboot() method to my model. By calling it from TestCase::tearDown() I tell the model it its no longer "$booted". It will then boot() again during the next test (whenever it instantiates a model). In short:

First, I created the following trait to implement two methods, unbootIfBooted and unboot(). Maybe a bit of overhead, but basically the counterparts of bootIfNotBooted and boot() from the Model. Note that unbootIfBooted() was made public static to be able to call it from a TestCase.

trait UnbootTrait {

    public static function unbootIfBooted()
    {
        $class = get_called_class();

        if (isset(static::$booted[$class])) {
            static::$booted[$class] = null;

            // fireModelEvent('unbooting', false);

            static::unboot();

            // fireModelEvent('unbooted', false);
        }
    }

    protected static function unboot()
    {
    }

}

Then you can simply add this trait to the models you want to test, without the need to change its inheritance:

class Boat extends Eloquent {
    use UnbootTrait;

And finally, tell the TestCase->teardown() method to "unboot" the models it used (usually only the model that's being tested):

class BoatTest extends TestCase {

    protected function tearDown()
    {
       parent::tearDown();
       Boat::unbootIfBooted();
    }

    public function testCanUpdateMyOwnBoat() {}

    public function testCannotUpdateSomeoneElsesBoat() {}

It's just a quick fix, but it works. Comments and suggestions are welcome. I hope it'll be a contribution to a definitive solution to this problem.

sorbing commented 10 years ago

Interesting solution, thanks) Although, I prefer not to think about the need unbooting models when writing Unit tests. Therefore, I prefer the solution based on EloquentEventsMechanic class. Still, I'm looking forward to that this "feature" will fixed and everything will work out of the box)