Back to Repositories

Testing Event Handling Mixin Implementation in video.js

This test suite validates the evented mixin functionality in video.js, which implements event handling capabilities for objects. The tests cover event listener management, error handling, and DOM data cleanup.

Test Coverage Overview

The test suite comprehensively covers the evented mixin’s core functionality including event binding, triggering, and removal. Key areas tested include:

  • Event listener attachment with on(), one(), and any() methods
  • Event trigger validation and error handling
  • Single and multiple event type handling
  • Cross-object event binding
  • DOM data management and cleanup

Implementation Analysis

The testing approach uses QUnit’s module and test structure with sinon.js for spies and mocks. The implementation validates both the public API and internal behavior of the evented mixin, ensuring proper event bus management and listener lifecycle handling.

Tests verify error conditions, event object properties, and listener context preservation.

Technical Details

Testing tools and setup:

  • QUnit as the primary test framework
  • Sinon.js for spy functionality
  • Custom validation helpers for listener calls
  • DOM element creation and management utilities
  • Error validation for invalid inputs

Best Practices Demonstrated

The test suite exemplifies strong testing practices including:

  • Thorough setup and teardown with beforeEach/afterEach hooks
  • Comprehensive error case coverage
  • Isolation of test subjects using unique targets
  • Explicit validation of event object properties
  • Clean separation of concerns between test cases

videojs/videoJs

test/unit/mixins/evented.test.js

            
/* eslint-env qunit */
import sinon from 'sinon';
import evented from '../../../src/js/mixins/evented';
import DomData from '../../../src/js/utils/dom-data';
import * as Dom from '../../../src/js/utils/dom';
import * as Obj from '../../../src/js/utils/obj';
import * as Events from '../../../src/js/utils/events.js';

// Common errors thrown by evented objects.
const errors = {
  type: (objName, fnName) => new Error(`Invalid event type for ${objName}#${fnName}; must be a non-empty string or array.`),
  listener: (objName, fnName) => new Error(`Invalid listener for ${objName}#${fnName}; must be a function.`),
  target: (objName, fnName) => new Error(`Invalid target for ${objName}#${fnName}; must be a DOM node or evented object.`),
  trigger: (objName) => new Error(`Invalid event type for ${objName}#trigger; must be a non-empty string or object with a type key that has a non-empty value.`)
};

const validateListenerCall = (call, thisValue, eventExpectation) => {
  const eventActual = call.args[0];

  QUnit.assert.strictEqual(call.thisValue, thisValue, 'the listener had the expected "this" value');
  QUnit.assert.strictEqual(typeof eventActual, 'object', 'the listener was passed an event object');

  // We don't use `deepEqual` here because we only want to test a subset of
  // properties (designated by the `eventExpectation`).
  Object.keys(eventExpectation).forEach(key => {
    QUnit.assert.strictEqual(eventActual[key], eventExpectation[key], `the event had the expected "${key}"`);
  });
};

QUnit.module('mixins: evented', {

  beforeEach() {
    this.targets = {};
  },

  afterEach() {
    Object.keys(this.targets).forEach((k) => this.targets[k].trigger('dispose'));
  }
});

QUnit.test('evented() mutates an object as expected', function(assert) {
  const target = this.targets.a = {};

  assert.strictEqual(typeof evented, 'function', 'the mixin is a function');
  assert.strictEqual(evented(target), target, 'returns the target object');

  assert.ok(Obj.isObject(target), 'the target is still an object');
  assert.ok(Dom.isEl(target.eventBusEl_), 'the target has an event bus element');
  assert.strictEqual(typeof target.off, 'function', 'the target has an off method');
  assert.strictEqual(typeof target.on, 'function', 'the target has an on method');
  assert.strictEqual(typeof target.one, 'function', 'the target has a one method');
  assert.strictEqual(typeof target.trigger, 'function', 'the target has a trigger method');
});

QUnit.test('evented() with custom element', function(assert) {
  const target = this.targets.a = evented({foo: Dom.createEl('span')}, {eventBusKey: 'foo'});

  assert.strictEqual(target.eventBusEl_, target.foo, 'the custom DOM element is re-used');

  assert.throws(
    () => evented({foo: {}}, {eventBusKey: 'foo'}),
    new Error('The eventBusKey "foo" does not refer to an element.'),
    'throws if the target does not have an element at the supplied key'
  );
});

QUnit.test('trigger() errors', function(assert) {
  class Test {}
  const targeta = evented({});
  const targetb = evented(new Test());
  const targetc = evented(new Test());

  targetc.name_ = 'foo';

  [targeta, targetb, targetc].forEach((target) => {
    const objName = target.name_ || target.constructor.name || typeof target;
    const triggerError = errors.trigger(objName);

    assert.throws(() => target.trigger(), triggerError, 'expected error');
    assert.throws(() => target.trigger('   '), triggerError, 'expected error');
    assert.throws(() => target.trigger({}), triggerError, 'expected error');
    assert.throws(() => target.trigger({type: ''}), triggerError, 'expected error');
    assert.throws(() => target.trigger({type: '    '}), triggerError, 'expected error');

    delete target.eventBusEl_;

    assert.throws(() => target.trigger({type: 'foo'}), errors.target(objName, 'trigger'), 'expected error');
  });
});

QUnit.test('on(), one(), and any() errors', function(assert) {
  class Test {}
  const targeta = this.targets.a = evented({});
  const targetb = this.targets.b = evented({});
  const targetc = this.targets.c = evented(new Test());
  const targetd = this.targets.d = evented(new Test());

  targetd.name_ = 'foo';

  ['on', 'one', 'any'].forEach(method => {
    [targeta, targetc, targetd].forEach((target) => {
      const objName = target.name_ || target.constructor.name || typeof target;

      assert.throws(() => target[method](), errors.type(objName, method), 'expected error');
      assert.throws(() => target[method]('   '), errors.type(objName, method), 'expected error');
      assert.throws(() => target[method]([]), errors.type(objName, method), 'expected error');
      assert.throws(() => target[method]('x'), errors.listener(objName, method), 'expected error');
      assert.throws(() => target[method]({}, 'x', () => {}), errors.target(objName, method), 'expected error');
      assert.throws(() => target[method](targetb, 'x', null), errors.listener(objName, method), 'expected error');
    });
  });
});

QUnit.test('off() errors', function(assert) {
  class Test {}
  const targeta = this.targets.a = evented({});
  const targetb = this.targets.b = evented({});
  const targetc = this.targets.c = evented({});
  const targetd = this.targets.d = evented({});
  const targete = this.targets.e = evented(new Test());
  const targetf = this.targets.f = evented(new Test());

  targetf.name_ = 'foo';

  // An invalid event actually causes an invalid target error because it
  // gets passed into code that assumes the first argument is the target.
  [targeta, targete, targetf].forEach(function(target) {
    const objName = target.name_ || target.constructor.name || typeof target;

    assert.throws(() => target.off([]), errors.target(objName, 'off'), 'expected error');
    assert.throws(() => target.off({}, 'x', () => {}), errors.target(objName, 'off'), 'expected error');
    assert.throws(() => target.off(targetb, '', () => {}), errors.type(objName, 'off'), 'expected error');
    assert.throws(() => target.off(targetc, [], () => {}), errors.type(objName, 'off'), 'expected error');
    assert.throws(() => target.off(targetd, 'x', null), errors.listener(objName, 'off'), 'expected error');
    assert.throws(() => target.off(targetd, 'x', null), errors.listener(objName, 'off'), 'expected error');
  });
});

QUnit.test('on() can add a listener to one event type on this object', function(assert) {
  const a = this.targets.a = evented({});
  const spy = sinon.spy();

  a.on('x', spy);
  a.trigger('x');

  assert.strictEqual(spy.callCount, 1, 'the listener was called the expected number of times');

  validateListenerCall(spy.getCall(0), a, {
    type: 'x',
    target: a.eventBusEl_
  });
});

QUnit.test('on() can add a listener to an array of event types on this object', function(assert) {
  const a = this.targets.a = evented({});
  const spy = sinon.spy();

  a.on(['x', 'y'], spy);
  a.trigger('x');
  a.trigger('y');

  assert.strictEqual(spy.callCount, 2, 'the listener was called the expected number of times');

  validateListenerCall(spy.getCall(0), a, {
    type: 'x',
    target: a.eventBusEl_
  });

  validateListenerCall(spy.getCall(1), a, {
    type: 'y',
    target: a.eventBusEl_
  });
});

QUnit.test('one() can add a listener to one event type on this object', function(assert) {
  const a = this.targets.a = evented({});
  const spy = sinon.spy();

  a.one('x', spy);
  a.trigger('x');
  a.trigger('x');

  assert.strictEqual(spy.callCount, 1, 'the listener was called the expected number of times');

  validateListenerCall(spy.getCall(0), a, {
    type: 'x',
    target: a.eventBusEl_
  });
});

QUnit.test('one() can add a listener to an array of event types on this object', function(assert) {
  const a = this.targets.a = evented({});
  const spy = sinon.spy();

  a.one(['x', 'y'], spy);
  a.trigger('x');
  a.trigger('y');
  a.trigger('x');
  a.trigger('y');

  assert.strictEqual(spy.callCount, 2, 'the listener was called the expected number of times');

  validateListenerCall(spy.getCall(0), a, {
    type: 'x',
    target: a.eventBusEl_
  });

  validateListenerCall(spy.getCall(1), a, {
    type: 'y',
    target: a.eventBusEl_
  });
});

QUnit.test('one() can add a listener to an array of event types on this object', function(assert) {
  const a = this.targets.a = evented({});
  const spy = sinon.spy();

  a.one(['x', 'y'], spy);
  a.trigger('x');
  a.trigger('y');
  a.trigger('x');
  a.trigger('y');

  assert.strictEqual(spy.callCount, 2, 'the listener was called the expected number of times');

  validateListenerCall(spy.getCall(0), a, {
    type: 'x',
    target: a.eventBusEl_
  });

  validateListenerCall(spy.getCall(1), a, {
    type: 'y',
    target: a.eventBusEl_
  });
});

QUnit.test('any() can add a listener to one event type on this object', function(assert) {
  const a = this.targets.a = evented({});
  const spy = sinon.spy();

  a.any('x', spy);
  a.trigger('x');
  a.trigger('x');

  assert.strictEqual(spy.callCount, 1, 'the listener was called the expected number of times');

  validateListenerCall(spy.getCall(0), a, {
    type: 'x',
    target: a.eventBusEl_
  });
});

QUnit.test('any() can add a listener to an array of event types on this object', function(assert) {
  const a = this.targets.a = evented({});
  const spy = sinon.spy();

  a.any(['x', 'y'], spy);
  a.trigger('x');
  a.trigger('y');
  a.trigger('x');
  a.trigger('y');

  assert.strictEqual(spy.callCount, 1, 'the listener was called the expected number of times');

  validateListenerCall(spy.getCall(0), a, {
    type: 'x',
    target: a.eventBusEl_
  });
});

QUnit.test('on() can add a listener to one event type on a different target object', function(assert) {
  const a = this.targets.a = evented({});
  const b = this.targets.b = evented({});
  const spy = sinon.spy();

  a.on(b, 'x', spy);
  b.trigger('x');

  // Make sure we aren't magically binding a listener to "a".
  a.trigger('x');

  assert.strictEqual(spy.callCount, 1, 'the listener was called the expected number of times');

  validateListenerCall(spy.getCall(0), a, {
    type: 'x',
    target: b.eventBusEl_
  });
});

QUnit.test('on() can add a listener to an array of event types on a different target object', function(assert) {
  const a = this.targets.a = evented({});
  const b = this.targets.b = evented({});
  const spy = sinon.spy();

  a.on(b, ['x', 'y'], spy);
  b.trigger('x');
  b.trigger('y');

  // Make sure we aren't magically binding a listener to "a".
  a.trigger('x');
  a.trigger('y');

  assert.strictEqual(spy.callCount, 2, 'the listener was called the expected number of times');

  validateListenerCall(spy.getCall(0), a, {
    type: 'x',
    target: b.eventBusEl_
  });

  validateListenerCall(spy.getCall(1), a, {
    type: 'y',
    target: b.eventBusEl_
  });
});

QUnit.test('one() can add a listener to one event type on a different target object', function(assert) {
  const a = this.targets.a = evented({});
  const b = this.targets.b = evented({});
  const spy = sinon.spy();

  a.one(b, 'x', spy);
  b.trigger('x');

  // Make sure we aren't magically binding a listener to "a".
  a.trigger('x');

  assert.strictEqual(spy.callCount, 1, 'the listener was called the expected number of times');

  validateListenerCall(spy.getCall(0), a, {
    type: 'x',
    target: b.eventBusEl_
  });
});

// TODO: This test is incorrect! this listener should be called twice,
//       but instead all listeners are removed on the first trigger!
//       see https://github.com/videojs/video.js/issues/5962
QUnit.test('one() can add a listener to an array of event types on a different target object', function(assert) {
  const a = this.targets.a = evented({});
  const b = this.targets.b = evented({});
  const spy = sinon.spy();

  a.one(b, ['x', 'y'], spy);
  b.trigger('x');
  b.trigger('y');
  b.trigger('x');
  b.trigger('y');

  // Make sure we aren't magically binding a listener to "a".
  a.trigger('x');
  a.trigger('y');

  assert.strictEqual(spy.callCount, 1, 'the listener was called the expected number of times');

  validateListenerCall(spy.getCall(0), a, {
    type: 'x',
    target: b.eventBusEl_
  });
});

QUnit.test('any() can add a listener to one event type on a different target object', function(assert) {
  const a = this.targets.a = evented({});
  const b = this.targets.b = evented({});
  const spy = sinon.spy();

  a.any(b, 'x', spy);
  b.trigger('x');

  // Make sure we aren't magically binding a listener to "a".
  a.trigger('x');

  assert.strictEqual(spy.callCount, 1, 'the listener was called the expected number of times');

  validateListenerCall(spy.getCall(0), a, {
    type: 'x',
    target: b.eventBusEl_
  });
});

QUnit.test('any() can add a listener to an array of event types on a different target object', function(assert) {
  const a = this.targets.a = evented({});
  const b = this.targets.b = evented({});
  const spy = sinon.spy();

  a.any(b, ['x', 'y'], spy);
  b.trigger('x');
  b.trigger('y');
  b.trigger('x');
  b.trigger('y');

  // Make sure we aren't magically binding a listener to "a".
  a.trigger('x');
  a.trigger('y');

  assert.strictEqual(spy.callCount, 1, 'the listener was called the expected number of times');

  validateListenerCall(spy.getCall(0), a, {
    type: 'x',
    target: b.eventBusEl_
  });
});

QUnit.test('off() with no arguments will remove all listeners from all events on this object', function(assert) {
  const a = this.targets.a = evented({});
  const spyX = sinon.spy();
  const spyY = sinon.spy();

  a.on('x', spyX);
  a.on('y', spyY);
  a.trigger('x');
  a.trigger('y');
  a.off();
  a.trigger('x');
  a.trigger('y');

  assert.strictEqual(spyX.callCount, 1, 'the listener was called the expected number of times');

  validateListenerCall(spyX.getCall(0), a, {
    type: 'x',
    target: a.eventBusEl_
  });

  assert.strictEqual(spyY.callCount, 1, 'the listener was called the expected number of times');

  validateListenerCall(spyY.getCall(0), a, {
    type: 'y',
    target: a.eventBusEl_
  });
});

QUnit.test('off() can remove all listeners from a single event on this object', function(assert) {
  const a = this.targets.a = evented({});
  const spyX = sinon.spy();
  const spyY = sinon.spy();

  a.on('x', spyX);
  a.on('y', spyY);
  a.trigger('x');
  a.trigger('y');
  a.off('x');
  a.trigger('x');
  a.trigger('y');

  assert.strictEqual(spyX.callCount, 1, 'the listener was called the expected number of times');

  validateListenerCall(spyX.getCall(0), a, {
    type: 'x',
    target: a.eventBusEl_
  });

  assert.strictEqual(spyY.callCount, 2, 'the listener was called the expected number of times');

  validateListenerCall(spyY.getCall(0), a, {
    type: 'y',
    target: a.eventBusEl_
  });

  validateListenerCall(spyY.getCall(1), a, {
    type: 'y',
    target: a.eventBusEl_
  });
});

QUnit.test('off() can remove a listener from a single event on this object', function(assert) {
  const a = this.targets.a = evented({});
  const spyX1 = sinon.spy();
  const spyX2 = sinon.spy();
  const spyY = sinon.spy();

  a.on('x', spyX1);
  a.on('x', spyX2);
  a.on('y', spyY);
  a.trigger('x');
  a.trigger('y');
  a.off('x', spyX1);
  a.trigger('x');
  a.trigger('y');

  assert.strictEqual(spyX1.callCount, 1, 'the listener was called the expected number of times');

  validateListenerCall(spyX1.getCall(0), a, {
    type: 'x',
    target: a.eventBusEl_
  });

  assert.strictEqual(spyX2.callCount, 2, 'the listener was called the expected number of times');

  validateListenerCall(spyX2.getCall(0), a, {
    type: 'x',
    target: a.eventBusEl_
  });

  validateListenerCall(spyX2.getCall(1), a, {
    type: 'x',
    target: a.eventBusEl_
  });

  assert.strictEqual(spyY.callCount, 2, 'the listener was called the expected number of times');

  validateListenerCall(spyY.getCall(0), a, {
    type: 'y',
    target: a.eventBusEl_
  });

  validateListenerCall(spyY.getCall(1), a, {
    type: 'y',
    target: a.eventBusEl_
  });
});

QUnit.test('off() can remove a listener from a single event on a different target object', function(assert) {
  const a = this.targets.a = evented({});
  const b = this.targets.b = evented({});
  const spy = sinon.spy();

  a.on(b, 'x', spy);
  b.trigger('x');
  a.off(b, 'x', spy);
  b.trigger('x');

  assert.strictEqual(spy.callCount, 1, 'the listener was called the expected number of times');

  validateListenerCall(spy.getCall(0), a, {
    type: 'x',
    target: b.eventBusEl_
  });
});

QUnit.test('off() can remove a listener from an array of events on a different target object', function(assert) {
  const a = this.targets.a = evented({});
  const b = this.targets.b = evented({});
  const spy = sinon.spy();

  a.on(b, ['x', 'y'], spy);
  b.trigger('x');
  b.trigger('y');
  a.off(b, ['x', 'y'], spy);
  b.trigger('x');
  b.trigger('y');

  assert.strictEqual(spy.callCount, 2, 'the listener was called the expected number of times');

  validateListenerCall(spy.getCall(0), a, {
    type: 'x',
    target: b.eventBusEl_
  });

  validateListenerCall(spy.getCall(1), a, {
    type: 'y',
    target: b.eventBusEl_
  });
});

QUnit.test('Removes DomData on dispose', function(assert) {
  const el_ = Dom.createEl('div');
  const eventBusEl_ = Dom.createEl('span', {className: 'vjs-event-bus'});
  const target = evented({el_, eventBusEl_}, {eventBusKey: 'eventBusEl_'});

  assert.equal(DomData.get(eventBusEl_).handlers.dispose.length, 1, 'event bus has dispose handler');
  assert.notOk(DomData.get(target), 'evented obj has no handlers');
  assert.notOk(DomData.get(el_), 'evented el_ has handlers');

  target.on('foo', () => {});

  assert.equal(DomData.get(eventBusEl_).handlers.foo.length, 1, 'foo handler added to bus');

  Events.on(eventBusEl_, 'bar', () => {});
  assert.equal(DomData.get(eventBusEl_).handlers.bar.length, 1, 'bar handler added to bus');

  Events.on(el_, 'foo', () => {});
  assert.equal(DomData.get(el_).handlers.foo.length, 1, 'foo handler added to el_');

  Events.on(target, 'foo', () => {});
  assert.equal(DomData.get(target).handlers.foo.length, 1, 'foo handler added to evented object');

  target.trigger('dispose');
  assert.notOk(DomData.get(eventBusEl_), 'eventBusEl_ DomData deleted');
  assert.notOk(DomData.get(target), 'evented object DomData deleted');
  assert.notOk(DomData.get(el_), 'el_ DomData deleted');
});