Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Within a QUnit test the click event will only fire if the reference is obtained before the test runs

I have the following code that I want to test with Qunit.

// my code under test
document.getElementById('saveButton').addEventListener('click',save);

function save() {
  console.log('save clicked');
}

My QUnit test gets a reference to the button and calls the click function:

(function () {
    "use strict";

    // HACK: with this line here click  works
    //var btn = document.getElementById('saveButton');

    // the test
    QUnit.test("click save", function (assert) {
         // with this line no click
        var btn = document.getElementById('saveButton');
        btn.click(); // will it click?
        assert.ok(btn !== null , 'button found');
    });
}());

Strangely enough if I call getElementById inside Qunit's test method the eventhandler that is attached to the button doesn't get invoked. However, if I move the call to getElementById outside of test function, the event does trigger the click eventhandler.

What is QUnit doing there that prevents that my test from working as expected and what is the proper/recommended way to address the issue I'm facing?

Here is an MCVE (also on JSFiddle) that demonstrates the non-working version. I've commented on what to change to get the click working (working means here: output text to the console)

// code under test
document.getElementById('saveButton').addEventListener('click',save);

function save() {
  console.log('save clicked');
}

// QUnit tests
(function () {
	"use strict";
  // with this line here click  works
  //var btn = document.getElementById('saveButton');
  QUnit.test("click save", function (assert) {
    // with this line no click, comment it to test the working variant
    var btn = document.getElementById('saveButton');
    btn.click(); // will it click?
    assert.ok(btn !== null , 'button found');
  });
}());
<script src="https://code.jquery.com/qunit/qunit-2.9.2.js"></script>
<div id="qunit"></div>
<div id="qunit-fixture">
  <button id="saveButton">test</button>
</div>

It is worth noting that I explicitly don't use jQuery here as the code I'm writing tests for isn't using jQuery either. I'm not yet at the stage I can or am willing to change the code under test.

like image 737
rene Avatar asked Mar 10 '19 19:03

rene


1 Answers

When you execute QUnit.test() it appears that everything in the DOM below <div id="qunit-fixture"> is cloned and replaced. This means that the event listener you added prior to that call is on a different <button> than now exists in the DOM. The btn.click(); works because it's firing the event on the original <button> to which you've saved a reference with the original var btn =.

If btn is defined within the QUnit.test(), then it references the new <button>, which doesn't have an event listener assigned to it. QUnit is expecting to be used mostly with jQuery, so it probably does a jQuery .clone(), which can be set to copy the jQuery based event listeners, but not the vanilla JavaScript listeners, because the underlying Node.cloneNode() doesn't have that capability.

You can confirm that the original btn is disconnected from the DOM by console.log(btn.parentNode.parentNode); within the QUnit.test(). That outputs null for the original btn, but is the <body> for the one that exists within the QUnit.test(). To demonstrate this, the btn determined prior to running the test is assigned to btnBeforeQUnit in the code below.

QUnit cloning the HTML content is a good way to handle making tests independent of each other, but should be documented. It's usually desirable for unit tests to be independent. After all, there could be tests which change the DOM structure, which should be restored between tests.

However, for some reason, QUnit does not restore the original DOM elements at the end of the QUnit.test(). The documentation indicates that it should re-clone the original prior to the next test, but it doesn't restore the original when the test is done.

In addition to btnBeforeQUnit and btn, the 2nd level parents for btnAfterQUnit and btnDelayedAfterQUnit are also output to the console, to more accurately demonstrate when the DOM substitution happens with the asynchronous execution of the callback provided to QUnit.test().

// code under test
document.getElementById('saveButton').addEventListener('click',save);

function save() {
  console.log('save clicked');
}

// QUnit tests
(function () {
	"use strict";
  // with this line here click  works
  var btnBeforeQUnit = document.getElementById('saveButton');
  QUnit.test("click save", function (assert) {
    // with this line no click, comment it to test the working variant
    var btn = document.getElementById('saveButton');
    var btnInsideQUnit = btn;
    btn.click(); // will it click?
    console.log('btnBeforeQUnit.parentNode.parentNode', btnBeforeQUnit.parentNode.parentNode);
    console.log('btnInsideQUnit.parentNode.parentNode', btnInsideQUnit.parentNode.parentNode)
    assert.ok(btn !== null , 'buttin found');
  });
  var btnAfterQUnit = document.getElementById('saveButton');
  console.log('btnAfterQUnit.parentNode.parentNode', btnAfterQUnit.parentNode.parentNode);
  setTimeout(function() {
    var btnDelayedAfterQUnit = document.getElementById('saveButton');
    console.log('btnDelayedAfterQUnit.parentNode.parentNode', btnAfterQUnit.parentNode.parentNode);
  }, 1000);
}());
<script src="https://code.jquery.com/qunit/qunit-2.9.2.js"></script>
<div id="qunit"></div>
<div id="qunit-fixture">
  <button id="saveButton">test</button>
</div>

Adjusting this behavior

You can get the behavior that you were expecting by setting QUnit.config.fixture to null:

QUnit.config.fixture = null;

The documentation says:

QUnit.config.fixture (string) | default: undefined

Defines the HTML content to use in the fixture container which is reset at the start of each test.

By default QUnit will use whatever the starting content of #qunit-fixture is as the fixture reset. If you do not want the fixture to be reset in between tests, set the value to null.

However, care should be exercised when setting this option to null. It will mean that the DOM not be independent for all of the tests which are run while that's set to null. In other words, changes which you make to the DOM in one test will affect changes in other tests.

IMO, the overall behavior of QUnit.test() in cloning DOM is not clearly documented. There definitely should be some mention of the behavior in the main documentation. In particular, the side effects wrt. existing event listeners should be explicitly mentioned, but I did not find anything in the QUinit documentation that explicitly described this process and it's effects.

The following is the same code, but with QUnit.config.fixture = null; added. As you can see, the "save clicked" is output to the console from the test, whereas it's not output in the original.

// code under test
document.getElementById('saveButton').addEventListener('click',save);

function save() {
  console.log('save clicked');
}

// QUnit tests
(function () {
	"use strict";
  QUnit.config.fixture = null;
  // with this line here click  works
  var btnBeforeQUnit = document.getElementById('saveButton');
  QUnit.test("click save", function (assert) {
    // with this line no click, comment it to test the working variant
    var btn = document.getElementById('saveButton');
    var btnInsideQUnit = btn;
    btn.click(); // will it click?
    console.log('btnBeforeQUnit.parentNode.parentNode', btnBeforeQUnit.parentNode.parentNode);
    console.log('btnInsideQUnit.parentNode.parentNode', btnInsideQUnit.parentNode.parentNode)
    assert.ok(btn !== null , 'buttin found');
  });
  var btnAfterQUnit = document.getElementById('saveButton');
  console.log('btnAfterQUnit.parentNode.parentNode', btnAfterQUnit.parentNode.parentNode);
  setTimeout(function() {
    var btnDelayedAfterQUnit = document.getElementById('saveButton');
    console.log('btnDelayedAfterQUnit.parentNode.parentNode', btnAfterQUnit.parentNode.parentNode);
  }, 1000);
}());
<script src="https://code.jquery.com/qunit/qunit-2.9.2.js"></script>
<div id="qunit"></div>
<div id="qunit-fixture">
  <button id="saveButton">test</button>
</div>

Alternately, use HTML attributes for your pre-defined event listeners

For the specific issue that you are having, of wanting an event listener set up prior to the test, you could use an HTML attribute to define the listener (e.g. onclick="save()" in your case.

// code under test
document.getElementById('saveButton').addEventListener('click',save);

function save() {
  console.log('save clicked');
}

// QUnit tests
(function () {
	"use strict";
  // with this line here click  works
  var btnBeforeQUnit = document.getElementById('saveButton');
  QUnit.test("click save", function (assert) {
    // with this line no click, comment it to test the working variant
    var btn = document.getElementById('saveButton');
    var btnInsideQUnit = btn;
    btn.click(); // will it click?
    console.log('btnBeforeQUnit.parentNode.parentNode', btnBeforeQUnit.parentNode.parentNode);
    console.log('btnInsideQUnit.parentNode.parentNode', btnInsideQUnit.parentNode.parentNode)
    assert.ok(btn !== null , 'buttin found');
  });
  var btnAfterQUnit = document.getElementById('saveButton');
  console.log('btnAfterQUnit.parentNode.parentNode', btnAfterQUnit.parentNode.parentNode);
  setTimeout(function() {
    var btnDelayedAfterQUnit = document.getElementById('saveButton');
    console.log('btnDelayedAfterQUnit.parentNode.parentNode', btnAfterQUnit.parentNode.parentNode);
  }, 1000);
}());
<script src="https://code.jquery.com/qunit/qunit-2.9.2.js"></script>
<div id="qunit"></div>
<div id="qunit-fixture">
  <button id="saveButton" onclick="save()">test</button>
</div>

Using HTML attributes was mentioned by Mat in chat.

like image 154
Makyen Avatar answered Oct 18 '22 03:10

Makyen