raf-stub
Accurate and predictable testing of requestAnimationFrame
and cancelAnimationFrame
.
What can raf-stub
enable you to do?
- Step through
requestionAnimationFrame
calls one frame at a time - Continue to call
requestionAnimationFrame
until there are no frames left. This lets you fast forward to the end of animations. - Clear out all animation frames without calling them
- Control animations that are orchestrated by third party libraries such as react-motion
- Control time values passed to your
requestAnimationFrame
callbacks
This is not designed to be a polyfill and is only intended for test code.
Basic usage
// assuming node running environment so 'global' is the global object ; const render = { return ;}; ;
requestAnimationFrame
Replace existing ; // override requestAnimationFrame and cancelAnimationFrame with a stub; const render = { return ;}; ;
Installation
## npm npm install raf-stub --save-dev ## yarn yarn add raf-stub --dev
stub
Created by createStub()
type signature
type Stub = | number void void void void|;
An isolated mock that contains it's own state. Each stub
is independent and have it's own state.
Note changing the time values (startTime
, frameDuration
and duration
) do not actually impact how long your test takes to execute, nor does it attach itself to the system clock. It is simply a way for you to have control over the first argument (currentTime
) to requestAnimationFrame
callbacks.
createStub()
type signature
: Stub
Basic usage
const stub = ;
Advanced usage
const frameDuration = 1000 / 60 * 2; // an extra slow frameconst startTime = performance 1000;const stub = ;
stub.add(callback)
It schedules the callback to be called in the next frame.
It returns an id
that can be used to cancel the frame in the future. Same api as requestAnimationFrame
.
Callbacks will not automatically get called after a period of time. You need to explicitly release it using stub.step()
or stub.flush()
type signature
: number
const stub = ;const callback = {}; stub;
stub.remove(id)
It takes the id of a stub.add()
call and cancels it without calling it. Same api as cancelAnimationFrame(id)
.
type signature
: void
const stub = ;const callback = console; const id = stub; stub; // callback is not called as it is no longer queuedstub; // *crickets*
.step()
Executes all callbacks in the current frame and optionally additional frames.
type signature
steps
=> the amount of animation frames you would like to release. Defaults to1
. This is useful when you have nested calls.duration (Number)
=> the amount of time the frame takes to execute. The defaultduration
value is provided by theframeDuration
argument tocreateStub(frameDuration)
. However, you can override it for a specific.step()
call using theduration
argument.
Simple example
const callback1 = console;const callback2 = console;const stub = ; stub;stub;stub; // console.log => 'first callback'// console.log => 'second callback'
Nested example
Some times calls to requestAnimationFrame
themselves call requestAnimationFrame
. step()
will let you step through them one at a time.
// this example will use the 'replaceRaf' syntax as it is a little clearerconst callback = { console; // second frame ;}; ; // release the first framerequestAnimationFrame; // console.log => 'first callback' // release the second framerequestAnimationFrame; // console.log => 'second callback'
Time manipulated example
const startTime = performance;const frameDuration = 10;const longFrameDuration = frameDuration * 2;const stub = ;const callback = console; stub;stub; // console.log => call time: 20
stub.flush()
Executes all requestAnimationFrame
callbacks, including nested calls. It will keep executing frames until there are no frames left. An easy way to to think of this function is "step()
until there are no more steps left.
type signature
duration
=> the duration for each frame in the flush - each frame gets the same value. If you want different frames to get different values then use.step()
. The defaultduration
value is provided by theframeDuration
argument tocreateStub(frameDuration)
. However, you can override it for a specific.flush()
call using theduration
argument.
Warning if your code just calls requestAnimationFrame
in an infinite loop then this will never end. Consider using .step()
for this use case
Simple example
// this example will use the 'replaceRaf' syntax as it is a little clearer const callback = { console; // second frame ;}; ;api; // console.log => 'first callback'// console.log => 'second callback'
Time manipulated example
const startTime = performance;const stub = ;const callback = console; stub;stub; // console.log => 'call time: 200'
.reset()
Clears all the frames without executing any callbacks, unlike flush()
which executes all the callbacks. Reverts the stub to it's initial state. This is similar to remove(id)
but it does not require an id
; reset
will also clear all callbacks in the frame whereas remove(id)
only removes a single one.
type signature
void
const callback = console; ;api; // callback has been removed so this will do nothingapi; // *crickets*
replaceRaf
replaceRaf()
This function is used to set overwrite requestAnimationFrame
and cancelAnimationFrame
on a root (eg window
). This is useful if you want to control requestAnimationFrame
for dependencies.
type signature
type ReplaceRafOptions = frameDuration?: number startTime?: number;
roots
=> an optional array of roots to be stubbed (eg [window
,global
]). If no root is provided then the function will automatically figure out whether to usewindow
orglobal
options
=> optional additional values to control the stub. These values are passed as theframeDuration
andstartTime
arguments tocreateStub()
options
startTime
=> seecreateStub()
frameDuration
=> seecreateStub()
Basic usage
; const root = {}; // could be window, global etc.; // can let multiple roots share the one stub// useful for when you testing environment uses `global`// but some libraries may use `window` ; // if called with no arguments it will use 'window' in the browser and 'global' in node; // you can override the frameDuration and startTime for the stub;
After calling replaceRaf
a root it's requestAnimationFrame
and cancelAnimationFrame
functions have been set and given new capabilities.
// assuming running in node so 'global' is the global rather than 'window'; ; const callback = ; // existing browser api mapped to stub.addconst id = ; // existing browser api mapped to stub.remove; // step - see stub.steprequestAnimationFrame; // flush - see stub.flushrequestAnimationFrame; // reset - see stub.resetrequestAnimationFrame;
See stub for api documentation on step()
, flush()
and reset()
.
Disclaimers!
- Each call to
replaceRaf
will add a new stub to theroot
. If you want to have the same stub on multipleroots
then pass them in at the same time (egreplaceRaf([window, global])
). - If you do a one time setup of
replaceRaf()
in a test setup file you will remember to clear the stub after each test.
requestAnimationFrame;
ES5 / ES6
ES6 syntax:
;
require
);
ES5 syntax (compatible with node.js var stub = default;var replaceRaf = replaceRaf;
Flow types
This library uses and publishes flow types. This ensures internal API consistency and also provides a great consumption story. If your project is using flow types then you can get type checking for all of your raf-stub
calls, as well as auto complete depending on your editor.
Semantic Versioning
This project used Semantic versioning 2.0.0 to ensure a consistent versioning strategy.
X.Y.Z
(major, minor, patch)
X
: breaking changesY
: new features (non breaking)Z
: patches
A safe raf-stub
package.json
dependency would therefore be anything that allows changes to the minor or patch version
currentTime
precision warning
Frame When you a frame is called by .step()
or .flush()
it is given the currentTime
as the first argument.
const stub = ;const callback = console; stub;stub; // console.log('the current time is 472759.63');
By default frameDuration
is 1000 / 60
and startTime is performance.now
. Both of these numbers are ugly decimals (eg 16.6666667
). When they are added together in .step()
or .flush()
this can cause known precision issues in JavaScript. You can find some further discussion about it's impact here.
Work arounds If you want to assert the current time inside of a callback - be sure to add the expected time values:
const frameDuration = 1000 / 60;const startTime = performance;const stub = ;const child = sinon;const parent = sinon; stub;stub;stub; // okaytobetrue; // not okay - will not mimic precision issues// doing this can lead to flakey teststobetrue; // okay;
Another simple option is to use integers for both frameDuration
and startTime
.
const frameDuration = 16;const startTime = 100;const stub = ;const child = sinon;const parent = sinon; stub;stub;stub; // okaytobetrue; // okaytobetrue; // okay;
Recipes
frameDuration
and startTime
The first argument to a requestAnimationFrame
callback is a DOMHighResTimeStamp
;
Under normal circumstances you would not want to modify the default values. Being able to manipulate the startTime
and endTime
will let you test code that does some logic based on the currentTime
argument.
Note changing the time values does not actually impact how long your test takes to execute, nor does it attach itself to the system clock. It is simply a way for you to have control over the first argument (currentTime
) to requestAnimationFrame
callbacks.
const idealFrameDuration = 1000 / 2;const slowFrameDuration = idealFrameDuration * 2;const startTime = performance;const stub = ; stub; stub; // console.log => 'a slow frame occured'
Controlling the frameDuration
and startTime
:
const callback = console; // this will set the frameDuration and startTime for all stub calls. They can be overwritted with specific function callsconst stub = ; stub;// this will use the values specific when creating the stubstub;// console.log => 'time taken: 100' stub;// this will overwrite the duration of the frame to '200' for this callstub;// console.log => 'time taken: 200' stub;stub;// console.log => 'time taken: 100' stub;stub;// console.log => 'time taken: 200'
Library dependency
Let's say you use a library that uses request animation frame
// library.jsconst ponyfill = ;const raf = requestAnimationFrame || ponyfill; { ;}
The trouble with this is that the library uses a local variable raf
. This reference is declared when the modules are importing. This means that raf
will always points to the reference it has when the module is imported for the first time.
The following will not work
// test.js;; ;
This won't work when:
requestAnimationFrame
does exist
raf === requestAnimationFrame;
This is because doing sinon.stub(global, 'requestAnimationFrame', stub.add)
will change the reference that requestAnimationFrame
points to. What that means is that the library when it calls raf
will call the original requestAnimationFrame
and not your stub
requestAnimationFrame
does not exist
raf === ponyfill;
If the ponyfill is being used then we cannot override the reference to raf
as it is not exposed. Stubbing requestAnimationFrame
will not help because the library uses a reference to the ponyfill.
How can we get this working?
replaceRaf
to the rescue!
before any of your tests code is executed, including module imports, then take the opportunity to set up your stub!
// test-setup.js// your test setup will be running before babel so I will write valid node code.var createStub = default; // option 1: setup a stub yourselfvar stub = ;requestAnimationFrame = stubadd;requestAnimationFrame = stubremove; // add additional helpers to requestAnimationFrame:Object; // option 2: use replaceRaf! (this does option1 for you);
Then everything will work as expected!
// test.js; ;
mocha
end-to-end setup
Full For when you need to make a stub which can be used by a library (or module that ponyfills requestAnimationFrame
at compile time)
package.json
mocha test.js
our test file--presets es2015
this will let us use es6 es6 modules in our test files--require test-setup.js
this is our file where we will setup our stub
test-setup.js
// using node require as the babel tranform is not applied to this file;
render.js
const ponyfill = ;const raf = requestAnimationFrame || ponyfill; { ;}
render.test.js
; ;
command line
npm test
raf-stub
Tests for To run the tests for this library simply execute:
npm install
npm test
Rationale for library
Let's say you wanted to test some code that uses requestAnimationFrame
. How would you do it? Here is an example that uses sinon
and setTimeout
. You do not need to use sinon
but it lets you write sequential code rather than needing to use nested timeouts.
;
We are all good right? Well sort of. For this basic example setTimeout
was sufficient. Let's have a look at a few more cases where setTimeout
is not ideal.
The following examples will assume the same setup as the above example unless otherwise specified
setTimeout
and requestAnimationFrame
Case 1: mixture of ;
Because both setTimeout
and requestAnimationFrame
use setTimeout
the way we step through an animation frame is the same way we step through a timeout. This can become hard to reason about in larger functions where there may be a large combination of setTimeout
and requestAnimationFrame
code. Having a shared mechanism is prone to misunderstandings. This is solved by stub.step()
and stub.flush()
Case 2: nested requestAnimationFrames
;
This was not too hard. But something to notice is that the nest code is identicial to the previous example. We are relying on comments to understand what is going on.
Let's go a bit deeper:
;
The problem we have here is that we do not know exactly how many times render
will call requestAnimationFrame
. To get around this we just tick the clock
forward some really large amount. This feels like a hack. Also, you might use a number that is big enough in some circumstances but not others. This can lead to flakey tests. This is solved by stub.flush()
setTimeout
leakage
Case 3: ; ;
What happened here? We did not clear out all of therequestAnimationFrame
's in the original test and they leaked into our second test. We need to reset
the setTimeout
queue before running test 2
.
;
We got around this issue by flushing the clock. We did this by calling clock.tick(some big number)
. This suffers from the problem that existed a previous example: you cannot be sure that you have actually emptied the queue. You might have noticed a strange console.log
in the afterEach
function. This is because when you emptied the setTimeout
queue with clock.tick
all of the callbacks executed. In some cases it might lead to unintended consequences. stub.reset()
allows us to empty a queue without needing to execute any of the callbacks.
requestAnimationFrame
callbacks
Case 4: controlling the first argument to Lets say you have setup that looks like this:
const idealFrameDuration = 1000 / 60; const startAnimation = { let previousTime = startTime; const loop = { if !currentTime throw 'could not get the current time'; const diff = currentTime - previousTime; if diff > idealFrameDuration console; else console; previousTime = currentTime; ; }; ;}; ;
How could we test the behaviour of the loop
function? Let's try with setTimeout
;
What happened here? By default setTimeout
does not pass any argument as the first parameter to callbacks. Getting this to work is hard because we would need to both use setTimeout
as a replacement for requestAnimationFrame
as well as changing it's behaviour to pass a controlled value as the first argument.
How can raf-stub
help us?
;const startTime = performance;const idealFrameDuration = 1000 / 60; ; ;