Back to Repositories

Testing Video.js Control Components Implementation in video.js

This test suite validates the functionality of various video.js player controls including volume, mute, play/pause, playback rate, picture-in-picture, and fullscreen controls. It verifies proper control behavior, state management, and UI updates across different player interactions and states.

Test Coverage Overview

The test suite provides comprehensive coverage of video.js control components:
  • Volume and mute toggle controls functionality
  • Play/pause button state management
  • Playback rate menu visibility and updates
  • Picture-in-Picture toggle behavior
  • Fullscreen control state handling
  • SeekBar and progress tracking

Implementation Analysis

Tests use QUnit framework with sinon for mocking/stubbing. The implementation follows a component-based testing approach, isolating individual controls and verifying their behavior through simulated player events and state changes.

Key patterns include setup/teardown with beforeEach/afterEach hooks, event trigger testing, and state verification.

Technical Details

Testing tools and setup:
  • QUnit test framework
  • Sinon.js for mocks and stubs
  • TestHelpers utility for player instantiation
  • Fake timers for time-based testing
  • Event simulation for player and DOM events

Best Practices Demonstrated

The test suite demonstrates several testing best practices:
  • Proper test isolation and cleanup
  • Comprehensive edge case coverage
  • Clear test descriptions
  • Consistent assertion patterns
  • Proper async handling
  • Thorough component lifecycle testing

videojs/videoJs

test/unit/controls.test.js

            
/* eslint-env qunit */
import EventTarget from '../../src/js/event-target.js';
import VolumeControl from '../../src/js/control-bar/volume-control/volume-control.js';
import MuteToggle from '../../src/js/control-bar/mute-toggle.js';
import VolumeBar from '../../src/js/control-bar/volume-control/volume-bar.js';
import PlayToggle from '../../src/js/control-bar/play-toggle.js';
import PlaybackRateMenuButton from '../../src/js/control-bar/playback-rate-menu/playback-rate-menu-button.js';
import Slider from '../../src/js/slider/slider.js';
import PictureInPictureToggle from '../../src/js/control-bar/picture-in-picture-toggle.js';
import FullscreenToggle from '../../src/js/control-bar/fullscreen-toggle.js';
import ControlBar from '../../src/js/control-bar/control-bar.js';
import SeekBar from '../../src/js/control-bar/progress-control/seek-bar.js';
import RemainingTimeDisplay from '../../src/js/control-bar/time-controls/remaining-time-display.js';
import TestHelpers from './test-helpers.js';
import document from 'global/document';
import window from 'global/window';
import sinon from 'sinon';

QUnit.module('Controls', {
  beforeEach(assert) {
    this.clock = sinon.useFakeTimers();
  },
  afterEach(assert) {
    this.clock.restore();
  }
});

QUnit.test('should hide volume and mute toggle control if it\'s not supported', function(assert) {
  assert.expect(2);

  const player = TestHelpers.makePlayer();

  player.tech_.featuresVolumeControl = false;
  player.tech_.featuresMuteControl = false;

  const volumeControl = new VolumeControl(player);
  const muteToggle = new MuteToggle(player);

  assert.ok(volumeControl.hasClass('vjs-hidden'), 'volumeControl is not hidden');
  assert.ok(muteToggle.hasClass('vjs-hidden'), 'muteToggle is not hidden');

  player.dispose();
  volumeControl.dispose();
  muteToggle.dispose();
});

QUnit.test('should show replay icon when video playback ended', function(assert) {
  assert.expect(1);

  const player = TestHelpers.makePlayer();

  const playToggle = new PlayToggle(player);

  player.trigger('ended');

  assert.ok(playToggle.hasClass('vjs-ended'), 'playToogle is in the ended state');

  player.dispose();
  playToggle.dispose();
});

QUnit.test('should show replay icon when video playback ended and replay option is set to true', function(assert) {
  assert.expect(1);

  const player = TestHelpers.makePlayer();

  const playToggle = new PlayToggle(player, {replay: true});

  player.trigger('ended');

  assert.ok(playToggle.hasClass('vjs-ended'), 'playToogle is in the ended state');

  player.dispose();
  playToggle.dispose();
});

QUnit.test('should not show the replay icon when video playback ended', function(assert) {
  assert.expect(1);

  const player = TestHelpers.makePlayer();

  const playToggle = new PlayToggle(player, {replay: false});

  player.trigger('ended');

  assert.equal(playToggle.hasClass('vjs-ended'), false, 'playToogle is not in the ended state');

  player.dispose();
  playToggle.dispose();
});

QUnit.test('should test and toggle volume control on `loadstart`', function(assert) {
  const player = TestHelpers.makePlayer();

  player.tech_.featuresVolumeControl = true;
  player.tech_.featuresMuteControl = true;

  const volumeControl = new VolumeControl(player);
  const muteToggle = new MuteToggle(player);

  assert.equal(volumeControl.hasClass('vjs-hidden'), false, 'volumeControl is hidden initially');
  assert.equal(muteToggle.hasClass('vjs-hidden'), false, 'muteToggle is hidden initially');

  player.tech_.featuresVolumeControl = false;
  player.tech_.featuresMuteControl = false;
  player.trigger('loadstart');

  assert.equal(volumeControl.hasClass('vjs-hidden'), true, 'volumeControl does not hide itself');
  assert.equal(muteToggle.hasClass('vjs-hidden'), true, 'muteToggle does not hide itself');

  player.tech_.featuresVolumeControl = true;
  player.tech_.featuresMuteControl = true;
  player.trigger('loadstart');

  assert.equal(volumeControl.hasClass('vjs-hidden'), false, 'volumeControl does not show itself');
  assert.equal(muteToggle.hasClass('vjs-hidden'), false, 'muteToggle does not show itself');

  player.dispose();
  volumeControl.dispose();
  muteToggle.dispose();
});

QUnit.test('calculateDistance should use changedTouches, if available', function(assert) {
  const player = TestHelpers.makePlayer();

  player.tech_.featuresVolumeControl = true;

  const slider = new Slider(player);

  document.body.appendChild(slider.el_);
  slider.el_.style.position = 'absolute';
  slider.el_.style.width = '200px';
  slider.el_.style.left = '0px';

  const event = {
    pageX: 10,
    changedTouches: [{
      pageX: 100
    }]
  };

  assert.equal(slider.calculateDistance(event), 0.5, 'we should have touched exactly in the center, so, the ratio should be half');

  player.dispose();
  slider.dispose();
});

QUnit.test("SeekBar doesn't set scrubbing on mouse down, only on mouse move", function(assert) {
  const player = TestHelpers.makePlayer();
  const scrubbingSpy = sinon.spy(player, 'scrubbing');
  const seekBar = new SeekBar(player);
  const doc = new EventTarget();

  player.duration(0);

  // mousemove is listened to on the document.
  // Specifically, we check the ownerDocument of the seekBar's bar.
  // Therefore, we want to mock it out to be able to trigger mousemove
  seekBar.bar.dispose();
  seekBar.bar.el_ = new EventTarget();
  seekBar.bar.el_.ownerDocument = doc;

  seekBar.trigger('mousedown');
  assert.ok(scrubbingSpy.calledWith(), 'called scrubbing as a getter');
  assert.notOk(scrubbingSpy.calledWith(true), 'did not set scrubbing true');

  player.scrubbing(false);

  scrubbingSpy.resetHistory();

  doc.trigger('mousemove');
  assert.ok(scrubbingSpy.calledWith(), 'called scrubbing as a getter');
  assert.ok(scrubbingSpy.calledWith(true), 'did set scrubbing true');

  seekBar.dispose();
  player.dispose();
});

QUnit.test('SeekBar should be filled on 100% when the video/audio ends', function(assert) {
  const player = TestHelpers.makePlayer();
  const seekBar = player.controlBar.progressControl.seekBar;
  const oldRAF = window.requestAnimationFrame;
  const oldCAF = window.cancelAnimationFrame;

  window.requestAnimationFrame = (fn) => window.setTimeout(fn, 1);
  window.cancelAnimationFrame = (id) => window.clearTimeout(id);

  player.triggerReady();
  player.duration(1.5);

  this.clock.tick(30);
  player.trigger('timeupdate');
  this.clock.tick(1);

  assert.equal(seekBar.duration_, 1.5, 'SeekBar duration should equal player duration');
  assert.equal(seekBar.currentTime_, 0, 'SeekBar current time should be zero on start');
  assert.equal(seekBar.getPercent(), 0, 'SeekBar percent should be zero on start');

  this.clock.tick(30);
  player.currentTime(0.75);
  player.trigger('timeupdate');
  this.clock.tick(1);

  assert.equal(seekBar.currentTime_, 0.75, 'SeekBar currentTime should equal player currentTime');
  assert.equal(seekBar.getPercent(), 0.5, 'SeekBar percent equal to 50%');

  this.clock.tick(30);
  player.currentTime(1.495);
  player.trigger('timeupdate');
  this.clock.tick(1);
  player.currentTime(1.5);
  // The following 'timeupdate' should be wiped out by the throttle function!
  player.trigger('timeupdate');
  // The following 'ended' shouldn't be wiped out by the throttle function!
  player.trigger('ended');
  this.clock.tick(1);

  assert.equal(seekBar.currentTime_, 1.5, 'SeekBar currentTime should equal player currentTime');
  assert.equal(seekBar.getPercent(), 1, 'SeekBar percent equal to 100%');
  player.dispose();

  window.requestAnimationFrame = oldRAF;
  window.cancelAnimationFrame = oldCAF;
});

QUnit.test('Seek bar percent should represent scrub location if we are scrubbing on mobile and have a pending seek time', function(assert) {
  const player = TestHelpers.makePlayer();
  const seekBar = player.controlBar.progressControl.seekBar;

  player.duration(100);
  seekBar.pendingSeekTime_ = 20;

  assert.equal(seekBar.getPercent(), 0.2, 'seek bar percent set correctly to pending seek time');

  seekBar.pendingSeekTime_ = 50;

  assert.equal(seekBar.getPercent(), 0.5, 'seek bar percent set correctly to next pending seek time');
});

QUnit.test('playback rate button is hidden by default', function(assert) {
  assert.expect(1);

  const player = TestHelpers.makePlayer();
  const playbackRate = new PlaybackRateMenuButton(player);

  assert.ok(playbackRate.el().className.indexOf('vjs-hidden') >= 0, 'playbackRate is hidden');

  player.dispose();
  playbackRate.dispose();
});

QUnit.test('playback rate button is not hidden if playback rates are set', function(assert) {
  assert.expect(1);

  const player = TestHelpers.makePlayer({
    playbackRates: [1, 2, 3]
  });
  const playbackRate = new PlaybackRateMenuButton(player);

  assert.ok(playbackRate.el().className.indexOf('vjs-hidden') === -1, 'playbackRate is not hidden');

  player.dispose();
  playbackRate.dispose();
});

QUnit.test('should show or hide playback rate menu button on playback rates change', function(assert) {
  const rates = [1, 2, 3];
  const norates = [];
  let playbackRatesReturnValue = rates;
  const player = TestHelpers.makePlayer();

  player.playbackRates = () => playbackRatesReturnValue;

  const playbackRate = new PlaybackRateMenuButton(player);

  assert.ok(playbackRate.el().className.indexOf('vjs-hidden') === -1, 'playbackRate is not hidden');

  playbackRatesReturnValue = norates;

  player.trigger('playbackrateschange');

  assert.ok(playbackRate.el().className.indexOf('vjs-hidden') >= 0, 'playbackRate is hidden');

  player.dispose();
  playbackRate.dispose();
});

QUnit.test('Picture-in-Picture control text should be correct when enterpictureinpicture and leavepictureinpicture are triggered', function(assert) {
  const player = TestHelpers.makePlayer();
  const pictureInPictureToggle = new PictureInPictureToggle(player);

  player.isInPictureInPicture(true);
  player.trigger('enterpictureinpicture');
  assert.equal(pictureInPictureToggle.controlText(), 'Exit Picture-in-Picture', 'Control Text is correct while switching to Picture-in-Picture mode');

  player.isInPictureInPicture(false);
  player.trigger('leavepictureinpicture');
  assert.equal(pictureInPictureToggle.controlText(), 'Picture-in-Picture', 'Control Text is correct while switching back to normal mode');

  player.dispose();
  pictureInPictureToggle.dispose();
});

QUnit.test('Picture-in-Picture control enabled property value should be correct when enterpictureinpicture and leavepictureinpicture are triggered', function(assert) {
  const player = TestHelpers.makePlayer();
  const pictureInPictureToggle = new PictureInPictureToggle(player);

  assert.equal(pictureInPictureToggle.enabled_, false, 'pictureInPictureToggle button should be disabled after creation');

  if ('pictureInPictureEnabled' in document && player.disablePictureInPicture() === false) {
    player.isInPictureInPicture(true);
    player.trigger('enterpictureinpicture');
    assert.equal(pictureInPictureToggle.enabled_, true, 'pictureInPictureToggle button should be enabled after triggering an enterpictureinpicture event');

    player.isInPictureInPicture(false);
    player.trigger('leavepictureinpicture');
    assert.equal(pictureInPictureToggle.enabled_, true, 'pictureInPictureToggle button should be enabled after triggering an leavepictureinpicture event');
  } else {
    player.isInPictureInPicture(true);
    player.trigger('enterpictureinpicture');
    assert.equal(pictureInPictureToggle.enabled_, false, 'pictureInPictureToggle button should be disabled after triggering an enterpictureinpicture event');

    player.isInPictureInPicture(false);
    player.trigger('leavepictureinpicture');
    assert.equal(pictureInPictureToggle.enabled_, false, 'pictureInPictureToggle button should be disabled after triggering an leavepictureinpicture event');
  }

  player.dispose();
  pictureInPictureToggle.dispose();
});

QUnit.test('Picture-in-Picture control enabled property value should be correct when loadedmetadata is triggered', function(assert) {
  const player = TestHelpers.makePlayer();
  const pictureInPictureToggle = new PictureInPictureToggle(player);

  assert.equal(pictureInPictureToggle.enabled_, false, 'pictureInPictureToggle button should be disabled after creation');

  if ('pictureInPictureEnabled' in document && player.disablePictureInPicture() === false) {
    player.trigger('loadedmetadata');
    assert.equal(pictureInPictureToggle.enabled_, true, 'pictureInPictureToggle button should be enabled after triggering an loadedmetadata event');
  } else {
    player.trigger('loadedmetadata');
    assert.equal(pictureInPictureToggle.enabled_, false, 'pictureInPictureToggle button should be disabled after triggering an loadedmetadata event');
  }

  player.dispose();
  pictureInPictureToggle.dispose();
});

QUnit.test('Picture-in-Picture control is hidden when the source is audio', function(assert) {
  const player = TestHelpers.makePlayer({});
  const pictureInPictureToggle = new PictureInPictureToggle(player);

  player.src({src: 'example.mp4', type: 'video/mp4'});
  player.trigger('loadedmetadata');

  if (document.exitPictureInPicture) {
    assert.notOk(pictureInPictureToggle.hasClass('vjs-hidden'), 'pictureInPictureToggle button is not hidden initially');
  } else {
    assert.ok(pictureInPictureToggle.hasClass('vjs-hidden'), 'pictureInPictureToggle button is hidden if PiP is not supported');
  }

  player.src({src: 'example1.mp3', type: 'audio/mp3'});
  player.trigger('loadedmetadata');
  assert.ok(pictureInPictureToggle.hasClass('vjs-hidden'), 'pictureInPictureToggle button is hidden whenh the source is audio');

  player.dispose();
  pictureInPictureToggle.dispose();
});

QUnit.test('Picture-in-Picture control is displayed if docPiP is enabled', function(assert) {
  const player = TestHelpers.makePlayer({
    disablePictureInPicture: true,
    enableDocumentPictureInPicture: true
  });
  const pictureInPictureToggle = new PictureInPictureToggle(player);
  const testPiPObj = {};

  if (!window.documentPictureInPicture) {
    window.documentPictureInPicture = testPiPObj;
  }

  player.src({src: 'example.mp4', type: 'video/mp4'});
  player.trigger('loadedmetadata');

  if (document.exitPictureInPicture) {
    assert.notOk(pictureInPictureToggle.hasClass('vjs-hidden'), 'pictureInPictureToggle button is not hidden');
  } else {
    assert.ok(pictureInPictureToggle.hasClass('vjs-hidden'), 'pictureInPictureToggle button is hidden if PiP is not supported');
  }

  player.dispose();
  pictureInPictureToggle.dispose();
  if (window.documentPictureInPicture === testPiPObj) {
    delete window.documentPictureInPicture;
  }
});

QUnit.test('Picture-in-Picture control should only be displayed if the browser supports it', function(assert) {
  const player = TestHelpers.makePlayer();
  const pictureInPictureToggle = new PictureInPictureToggle(player);

  player.trigger('loadedmetadata');

  if (document.exitPictureInPicture) {
    // Browser that does support PiP
    assert.false(pictureInPictureToggle.hasClass('vjs-hidden'), 'pictureInPictureToggle button is not hidden');
  } else {
    // Browser that does not support PiP
    assert.true(pictureInPictureToggle.hasClass('vjs-hidden'), 'pictureInPictureToggle button is hidden');
  }

  player.dispose();
  pictureInPictureToggle.dispose();
});

QUnit.test('Fullscreen control text should be correct when fullscreenchange is triggered', function(assert) {
  const player = TestHelpers.makePlayer({controlBar: false});
  const fullscreentoggle = new FullscreenToggle(player);

  // make the fullscreenchange handler doesn't trigger
  player.off(player.fsApi_.fullscreenchange, player.boundDocumentFullscreenChange_);

  player.isFullscreen(true);
  player.trigger('fullscreenchange');
  assert.equal(fullscreentoggle.controlText(), 'Exit Fullscreen', 'Control Text is correct while switching to fullscreen mode');

  player.isFullscreen(false);
  player.trigger('fullscreenchange');
  assert.equal(fullscreentoggle.controlText(), 'Fullscreen', 'Control Text is correct while switching back to normal mode');

  player.dispose();
  fullscreentoggle.dispose();
});

QUnit.test('Clicking MuteToggle when volume is above 0 should toggle muted property and not change volume', function(assert) {
  const player = TestHelpers.makePlayer({ techOrder: ['html5'] });
  const muteToggle = new MuteToggle(player);

  assert.equal(player.volume(), 1, 'volume is above 0');
  assert.equal(player.muted(), false, 'player is not muted');

  muteToggle.handleClick();

  assert.equal(player.volume(), 1, 'volume is same');
  assert.equal(player.muted(), true, 'player is muted');

  player.dispose();
  muteToggle.dispose();
});

QUnit.test('Clicking MuteToggle when volume is 0 and muted is false should set volume to lastVolume and keep muted false', function(assert) {
  const player = TestHelpers.makePlayer({ techOrder: ['html5'] });
  const muteToggle = new MuteToggle(player);

  player.volume(0);
  assert.equal(player.lastVolume_(), 1, 'lastVolume is set');
  assert.equal(player.muted(), false, 'player is muted');

  muteToggle.handleClick();

  assert.equal(player.volume(), 1, 'volume is set to lastVolume');
  assert.equal(player.muted(), false, 'muted remains false');

  player.dispose();
  muteToggle.dispose();
});

QUnit.test('Clicking MuteToggle when volume is 0 and muted is true should set volume to lastVolume and sets muted to false', function(assert) {
  const player = TestHelpers.makePlayer({ techOrder: ['html5'] });
  const muteToggle = new MuteToggle(player);

  player.volume(0);
  player.muted(true);
  player.lastVolume_(0.5);

  muteToggle.handleClick();

  assert.equal(player.volume(), 0.5, 'volume is set to lastVolume');
  assert.equal(player.muted(), false, 'muted is set to false');

  player.dispose();
  muteToggle.dispose();
});

QUnit.test('Clicking MuteToggle when volume is 0, lastVolume is less than 0.1, and muted is true sets volume to 0.1 and muted to false', function(assert) {
  const player = TestHelpers.makePlayer({ techOrder: ['html5'] });
  const muteToggle = new MuteToggle(player);

  player.volume(0);
  player.muted(true);
  player.lastVolume_(0.05);

  muteToggle.handleClick();

  // `Number.prototype.toFixed()` is used here to circumvent rounding issues
  assert.equal(player.volume().toFixed(1), (0.1).toFixed(1), 'since lastVolume is less than 0.1, volume is set to 0.1');
  assert.equal(player.muted(), false, 'muted is set to false');

  player.dispose();
  muteToggle.dispose();
});

QUnit.test('ARIA value of VolumeBar should start at 100', function(assert) {
  const player = TestHelpers.makePlayer({ techOrder: ['html5'] });
  const volumeBar = new VolumeBar(player);

  this.clock.tick(1);

  assert.equal(volumeBar.el_.getAttribute('aria-valuenow'), 100, 'ARIA value of VolumeBar is 100');

  player.dispose();
  volumeBar.dispose();
});

QUnit.test('Muting with MuteToggle should set ARIA value of VolumeBar to 0', function(assert) {
  const player = TestHelpers.makePlayer({ techOrder: ['html5'] });
  const volumeBar = new VolumeBar(player);
  const muteToggle = new MuteToggle(player);

  this.clock.tick(1);

  assert.equal(player.volume(), 1, 'Volume is 1');
  assert.equal(player.muted(), false, 'Muted is false');
  assert.equal(volumeBar.el_.getAttribute('aria-valuenow'), 100, 'ARIA value of VolumeBar is 100');

  muteToggle.handleClick();

  // Because `volumechange` is triggered asynchronously, it doesn't end up
  // getting fired on `player` in the test environment, so we run it
  // manually.
  player.trigger('volumechange');

  assert.equal(player.volume(), 1, 'Volume remains 1');
  assert.equal(player.muted(), true, 'Muted is true');
  assert.equal(volumeBar.el_.getAttribute('aria-valuenow'), 0, 'ARIA value of VolumeBar is 0');

  player.dispose();
  muteToggle.dispose();
  volumeBar.dispose();
});

QUnit.test('controlbar children to false individually, does not cause an assertion', function(assert) {
  const defaultChildren = ControlBar.prototype.options_.children;

  defaultChildren.forEach((childName) => {
    const options = {controlBar: {}};

    options.controlBar[childName] = false;

    const player = TestHelpers.makePlayer(options);

    this.clock.tick(1000);
    player.triggerReady();
    player.dispose();
    assert.ok(true, `${childName}: false. did not cause an assertion`);
  });
});

QUnit.test('all controlbar children to false, does not cause an assertion', function(assert) {
  const defaultChildren = ControlBar.prototype.options_.children;
  const options = {controlBar: {}};

  defaultChildren.forEach((childName) => {
    options.controlBar[childName] = false;
  });

  const player = TestHelpers.makePlayer(options);

  this.clock.tick(1000);
  player.triggerReady();
  player.dispose();
  assert.ok(true, 'did not cause an assertion');
});

QUnit.test('Remaing time negative sign can be optional', function(assert) {
  const player = TestHelpers.makePlayer({ techOrder: ['html5'] });

  const rtd1 = new RemainingTimeDisplay(player);
  const rtd2 = new RemainingTimeDisplay(player, {displayNegative: false});

  this.clock.tick(1);

  assert.ok(rtd1.el().textContent.indexOf('-') > 0, 'Value is negative by default');
  assert.equal(rtd2.el().textContent.indexOf('-'), -1, 'Value is positive with option');

  rtd1.dispose();
  rtd2.dispose();
  player.dispose();
});