In a previous post my coworker, Scott Newcomer, introduced a bag of tricks for getting the most out of Ember’s latest test capabilities. He ended his post by giving the example of a hypothetical component that would show text for a specific period of time. Testing this component could slow down your test suite by forcing your tests to wait for the run loop to clear and even lead to timed out tests. The solution proposed was an environment-specific timeout to be set to a low value in your test environment to enable your tests to continue quickly. As was pointed out, this is great for things like loading indicators or routine route animations. What if, however, you want to test the timing of the component interaction itself?
A simple component example
Let’s say you’ve been asked to write a component that shows confirmation text to the user for a configured period of time and then disappears. You write a component that utilizes the Ember run loop’s later
functionality to set the component’s isVisible
property to false with a function similar to:
import { cancel, later } from '@ember/runloop';
…
timeout: 2000,
_scheduleHide() {
let timer = later(this, () => {
if (this.isDestroyed || this.isDestroying) {
return;
}
set(this, 'isVisible', false);
set(this, '_currentTimer', null);
}, get(this, 'timeout'));
set(this, '_currentTimer', timer);
}
…
Making sure to clear out any pending timers when the component is destroyed.
_clearTimer() {
let timer = get(this, '_currentTimer');
if (!timer) {
return;
}
cancel(timer);
set(this, '_currentTimer', null);
},
willDestroy() {
this._clearTimer();
}
Finally, we need a way to trigger the label to show so we set up a showMessage
computed property with a custom getter/setter that can be set on the component to begin the message display.
showMessage: computed('isVisible', {
get() {
return get(this, 'isVisible');
},
set(key, value) {
if (value) {
if (get(this, 'isVisible')) {
return value;
}
set(this, 'isVisible', true);
this._scheduleHide();
}
return value;
}
})
When it comes time to test the component you reach into your new bag of tricks and convert the initial value of the timeout on the component to an environment variable set to a low value in your test environment… and all is well.
A not so simple component example
After a time you are told that users should be able to interrupt the disappearance of the text if they are engaging with it. The new requirement is that if a user is moused over the component, the message should remain visible until they mouse back out of the component. Once the user mouses out, the countdown should be restarted to make the message disappear.
You check the Ember API docs and remember that you can easily add event handlers for the mouseEnter
and mouseLeave
events on your component and set to work to update your code:
mouseEnter() {
if (get(this, 'isVisible')) {
this._clearTimer();
}
},
mouseLeave() {
if (get(this, 'isVisible')) {
this._scheduleHide();
}
}
This takes advantage of the code we already had to stop any automatic disappearance of the label while the user is mousing over, but starts the countdown to hiding once the user mouses out.
Testing in a time-sensitive world
In plain language you want to test that if you show the message and mouse over it before the timeout expires it continues to show even after the time has expired. Then you’d want to test that after mousing out of the component the message is removed. This is clearly more complicated than just a simple low value timeout.
As with many topics in programming, you are not alone. These problems have been solved before.
Sinon.js/Lolex to the rescue
Sinon.js is a great addition to any testing suite that allows intricate stubs, mocks, spies, and event timers. One feature of sinon.js allows you to bend time by simulating a system clock that you can “tick” forward. It also takes over setTimeout
, requestAnimationFrame
and other time-sensitive functions to allow them to be executed when you explicitly call for them in your tests. For the purposes of our scenario, we don’t need all of the functionality of Sinon, but only the timers. Fortunately, Sinon has separated the timers out into a standalone package (used internally by SinonJS) called Lolex.
Installing Lolex
You may be tempted to look immediately for an Ember shim addon of Lolex (which does exist) but I would argue that there is no need to add this when it is only a simple wrapping of the existing distributed output in an AMD definition providing no additional functionality. Ember can do that for you so easily and, as a side benefit, leaves you in charge of when to upgrade the package.
Lolex is available as an npm package so installing it to your application is simple:
npm install lolex --save-dev
Once installed, you can take advantage of some of the improvements in Ember’s build process to import it directly from the node_modules
folder and run it through a simple AMD transform to make it accessible in your test code.
In your ember-cli-build.js
file:
var app = new EmberApp(defaults, {
// Add options here
});
// Use `app.import` to add additional libraries to the generated
// output files.
app.import('node_modules/lolex/lolex.js', {
using: [
{ transformation: 'amd', as: 'lolex' }
],
type: 'test'
});
return app.toTree();
This tells Ember to include the pre-compiled lolex/lolex.js
file from your node_modules
folder and make it available to you with the import name of “lolex”. Specifying it as a test import makes it available only to your test support files rather than adding to the size of your production build.
Setting up Lolex in your Tests
The first step, like any other package, is to import it into your test file. You can then “install” the clock handler before your tests. Be sure to uninstall after your tests in order to allow the regular system methods to be restored.
import lolex from 'lolex';
import { setupRenderingTest } from 'ember-qunit';
module('Integration | Component | disappearing label', function(hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function() {
this.clock = lolex.install();
});
hooks.afterEach(function() {
this.clock.uninstall();
});
WARNING: Here be dragons…for some
Lolex works by taking over all clock/date functions typically handled by JavaScript in the browser such as the Date
object, setTimeout
related functions, and requestAnimationFrame
related functions. Ember also utilizes this same functionality for the run loop and for scheduling callbacks using the later
function. You will find that if you set up lolex in a test that interacts with code relying on later
that your tests will timeout if you do not explicitly manage the clock for that event. Essentially what is happening is that lolex is in control of the clock, so when you schedule something to happen later, later never comes unless you explicitly tick the lolex clock forward.
Fortunately, lolex provides fine grain controls over how it installs into your tests. For example, lolex provides an initialization parameter of shouldAdvanceTime
which allows the lolex managed clock to automatically advance as “real” time advances. As a result, your later
function will still execute after the specified timeout even without manually ticking the lolex clock forward.
Another initialization parameter you can pass to lolex is the toFake
array. This allows you to specify exactly what methods Lolex should hijack (with an empty array meaning that all should be hijacked). Check the lolex repository for details on these initialization options.
Back to the happy path
Before working on our more complicated test, let’s update a simple rendering test to validate that the message can be shown for a period of time and then be automatically removed. This time, rather than just shortening the timeout value in our component, we’ll bend time. For readability, let’s assume a helper function has been created for labelIsVisible()
that will return true if the label is visible and false if it is hidden.
test('it shows the notification for the configured period of time', async function(assert) {
await render(hbs`{{disappearing-label showMessage=true}}`);
assert.ok(labelIsVisible(), 'Message is shown when element is added');
this.clock.tick(3000);
assert.notOk(labelIsVisible(), 'The text is automatically hidden after timeout value.');
});
Looks pretty simple, right? But if you try to run this code right now you’ll see that it times out in the initial render. What gives?
In our simplistic component, passing the showMessage
variable to true will start the clock on hiding the message. Ember’s asynchronous test helpers all return a promise that resolves when the application is in the settled
state. This means that the run loop has finished, there are no pending or unresolved AJAX requests, there are no pending test waiters, and that there are no scheduled timers left in the run loop. Our code, however, adds a timer to the run loop and will not remove it without ticking the clock forward.
Your first thought may be just to remove the await
from the render
function since we don’t really want to wait for the settled state. If you do this you will find that the component hasn’t had a chance to finish the initial render and therefore you won’t be able to check for the visibility of the message. This leaves us stuck in a vicious cycle. We can’t check for the message until the message render has resolved, we can’t tell that the render has completed without clearing out the timers, we can’t clear the timers without ticking the clock.
This time it’s Ember to the rescue
Fortunately, Ember’s test helpers provide a way to handle these specific types of situations. The waitUntil
helper allows you to specify a callback that will execute on a timed interval and only resolve when you return true
. We can wait for this function to check for all of the pending states EXCEPT the run loop timers in order to ensure that we have the state we need in order to verify the label prior to moving the clock forward.
Now our test looks like this (with an updated test-helper import):
import { find, render, waitUntil, getSettledState } from '@ember/test-helpers';
…
test('it shows the notification for the configured period of time', async function(assert) {
render(hbs`{{disappearing-label showMessage=true}}`);
await waitUntil(() => {
// Check for the settled state minus hasPendingTimers
let { hasRunLoop, hasPendingRequests, hasPendingWaiters } = getSettledState();
if (hasRunLoop || hasPendingRequests || hasPendingWaiters) {
return false;
}
return true;
});
assert.ok(labelIsVisible(), 'Message is shown when element is added');
this.clock.tick(3000);
assert.notOk(labelIsVisible(), 'The text is automatically hidden after timeout value.');
});
Pretty neat. Now let’s tackle the big test.
For purposes of readability and maintenance again, we move our wait function into a helper:
function finishRender() {
return waitUntil(() => {
let { hasRunLoop, hasPendingRequests, hasPendingWaiters } = getSettledState();
if (hasRunLoop || hasPendingRequests || hasPendingWaiters) {
return false;
}
return true;
});
}
And finally, our complicated test isn’t so scary anymore.
test('it resets the timeout for hiding message upon hover', async function(assert) {
render(hbs`{{disappearing-label showMessage=true}}`);
await finishRender();
assert.ok(labelIsVisible(), 'Message is shown when element is added');
this.clock.tick(1000);
await triggerEvent(find('[data-test-disappearing-label]'), 'mouseover');
this.clock.tick(2500);
assert.ok(labelIsVisible(), 'Message is still visible after the timeout threshold when the user is hovered');
triggerEvent(find('[data-test-disappearing-label]'), 'mouseout');
await finishRender();
this.clock.tick(1000);
assert.ok(labelIsVisible(), 'Message is still visible after leaving the element during the timeout period.');
this.clock.tick(2500);
assert.notOk(labelIsVisible(), 'Message is automatically hidden after the timeout value from when the user left the element.');
});
We have to defer waiting to our custom finishRender
function twice:
- setting the initial scheduled timer to hide the message before user interaction
- upon mousing out of the component causing the hide to be rescheduled
As an aside, you may notice that even though our component tests for mouseEnter
and mouseLeave
our test triggers the mouseover
and mouseout
events. See Testing your Ember Application in 2018 to learn why.
Some Final Housekeeping
The only constant is change and so it is only a matter of time before the timeout value you are working with changes. Your tests should be testing the functionality of your component around the concept of a timeout rather than a specific timeout value.
You may have noticed that our component has a public timeout
property that it uses to set up the timers. In your tests, you can override this value to ensure that your test doesn’t have to change every time the timeout in your application changes. Depending on the complexity of your application, you may wish to store this value in a centralized configuration.
You can update your test to specify an overall timeout value for the test:
hooks.beforeEach(function() {
this.clock = lolex.install();// { shouldAdvanceTime: true }); (can't override "Date")
this.timeout = 2000;
});
Then utilize this timeout value when rendering the component within a test:
render(hbs`{{disappearing-label timeout=timeout showMessage=true}}`);
Finally, the full example component plus tests are available in a GitHub repository for reference.