Animation lends itself to test-driven development just as much as any other feature. In this chapter, we’ll animate the Logo turtle movement as the user inputs commands.
There are two types of animation in Spec Logo:
Much of this chapter is about test-driving the window.requestAnimationFrame function. This is the browser API that allows us to animate visual elements on the screen, such as the position of the turtle or the length of a line. The mechanics of this function are explained in the third section of this chapter, Animating with requestAnimationFrame.
The importance of manual testing
When writing animation code, it’s natural to want to visually check what we’re building. Automated tests aren’t enough. Manually testing is also important because animation is not something that most programmers do every day. When something is new, it’s often better to do lots of manual tests to verify behavior in addition to your automated tests.
In fact, while preparing for this chapter, I did a lot of manual testing. The walk-through presented here experiments with several different approaches. There were many, many times that I opened my browser to type forward 100 or right 90 to visually verify what was happening.
This chapter covers the following topics:
The code we’ll write is relatively complicated compared to the code in the rest of the book, so we need to do some upfront design first.
By the end of the chapter, you’ll have gained a deep understanding of how to test-drive one of the more complicated browser APIs.
The code files for this chapter can be found here:
As you read through this section, you may wish to open src/Drawing.js and read the existing code to understand what it’s doing.
The current Drawing component shows a static snapshot of how the drawing looks at this point. It renders a set of Scalable Vector Graphics (SVG) lines to represent the path the turtle has taken to this point, and a triangle to represent the turtle.
The component makes use of two child components:
We will add a new AnimatedLine component that represents the current line being animated. As lines complete their animation, they will move into the StaticLines collection.
We’ll need to do some work to convert this from a static view to an animated representation.
As it stands, the component takes a turtle prop and a drawCommands prop. The turtle prop is the current position of the turtle, given that all the draw commands have already been drawn.
In our new animated drawing, we will still treat drawCommands as a list of commands to execute. But rather than relying on a turtle prop to tell us where the turtle is, we’ll store the current position of the turtle as a component state. We will work our way through the drawCommands array, one instruction at a time, and update the turtle component state as it animates. Once all instructions are completed, the turtle component state will match what would have originally been set for the turtle prop.
The turtle always starts at the 0,0 coordinate with an angle of 0.
We will need to keep track of which commands have already been animated. We’ll create another component state variable, animatingCommandIndex, to denote the index of the array item that is currently being animated.
We start animating at the 0 index. Once that command has been animated, we increment the index by 1, moving along to the next command, and animate that. The process is repeated until we reach the end of the array.
This design means that the user can enter new drawCommands at the prompt even if animations are currently running. The component will take care to redraw with animations at the same point it left off at.
Finally, are two types of draw commands: drawLine and rotate. Here are a couple of examples of commands that will appear in the drawCommands array:
{ drawCommand: "drawLine", id: 123, x1: 100, y1: 100, x2: 200, y2: 100 } { drawCommand: "rotate", id: 234, previousAngle: 0, newAngle: 90 }
Each type of animation will need to be handled differently. So, for example, the AnimatedLine component will be hidden when the turtle is rotating.
That about covers it. We’ll follow this approach:
Let’s get started with the AnimatedLine component.
In this section, we’ll create a new AnimatedLine component.
This component contains no animation logic itself but, instead, draws a line from the start of the line being animated to the current turtle position. Therefore, it needs two props: commandToAnimate, which would be one of the drawLine command structures shown previously, and the turtle prop, containing the position.
Let’s begin:
import React from "react";
import ReactDOM from "react-dom";
import {
initializeReactContainer,
render,
element,
} from "./reactTestExtensions";
import { AnimatedLine } from "../src/AnimatedLine";
import { horizontalLine } from "./sampleInstructions";
const turtle = { x: 10, y: 10, angle: 10 };
describe("AnimatedLine", () => {
beforeEach(() => {
initializeReactContainer();
});
const renderSvg = (component) =>
render(<svg>{component}</svg>);
const line = () => element("line");
});
it("draws a line starting at the x1,y1 co-ordinate of the command being drawn", () => {
renderSvg(
<AnimatedLine
commandToAnimate={horizontalLine}
turtle={turtle}
/>
);
expect(line()).not.toBeNull();
expect(line().getAttribute("x1")).toEqual(
horizontalLine.x1.toString()
);
expect(line().getAttribute("y1")).toEqual(
horizontalLine.y1.toString()
);
});
import React from "react";
export const AnimatedLine = ({
commandToAnimate: { x1, y1 }
}) => (
<line x1={x1} y1={y1} />
);
it("draws a line ending at the current position of the turtle", () => {
renderSvg(
<AnimatedLine
commandToAnimate={horizontalLine}
turtle={{ x: 10, y: 20 }}
/>
);
expect(line().getAttribute("x2")).toEqual("10");
expect(line().getAttribute("y2")).toEqual("20");
});
export const AnimatedLine = ({
commandToAnimate: { x1, y1 },
turtle: { x, y }
}) => (
<line x1={x1} y1={y1} x2={x} y2={y} />
);
it("sets a stroke width of 2", () => {
renderSvg(
<AnimatedLine
commandToAnimate={horizontalLine}
turtle={turtle}
/>
);
expect(
line().getAttribute("stroke-width")
).toEqual("2");
});
it("sets a stroke color of black", () => {
renderSvg(
<AnimatedLine
commandToAnimate={horizontalLine}
turtle={turtle}
/>
);
expect(
line().getAttribute("stroke")
).toEqual("black");
});
export const AnimatedLine = ({
commandToAnimate: { x1, y1 },
turtle: { x, y }
}) => (
<line
x1={x1}
y1={y1}
x2={x}
y2={y}
strokeWidth="2"
stroke="black"
/>
);
That completes the AnimatedLine component.
Next, it’s time to add it into Drawing, by setting the commandToAnimate prop to the current line that’s animating and using requestAnimationFrame to vary the position of the turtle prop.
In this section, you will use the useEffect hook in combination with window.requestAnimationFrame to adjust the positioning of AnimatedLine and Turtle.
The window.requestAnimationFrame function is used to animate visual properties. For example, you can use it to increase the length of a line from 0 units to 200 units over a given time period, such as 2 seconds.
To make this work, you provide it with a callback that will be run at the next repaint interval. This callback is provided with the current animation time when it’s called:
const myCallback = time => { // animating code here }; window.requestAnimationFrame(myCallback);
If you know the start time of your animation, you can work out the elapsed animation time and use that to calculate the current value of your animated property.
The browser can invoke your callback at a very high refresh rate, such as 60 times per second. Because of these very small intervals of time, your changes appear as a smooth animation.
Note that the browser only invokes your callback once for every requested frame. That means it’s your responsibility to repeatedly call the requestAnimationFrame function until the animation time reaches your defined end time, as in the following example. The browser takes care of only invoking your callback when the screen is due to be repainted:
let startTime; let endTimeMs = 2000; const myCallback = time => { if (startTime === undefined) startTime = time; const elapsed = time - startTime; // ... modify visual state here ... if (elapsed < endTimeMs) { window.requestAnimationFrame(myCallback); } }; // kick off the first animation frame window.requestAnimationFrame(myCallback);
As we progress through this section, you’ll see how you can use this to modify the component state (such as the position of AnimatedLine), which then causes your component to rerender.
Let’s begin by getting rid of the existing turtle value from the Redux store—we’re no longer going to use this, and instead, rely on the calculated turtle position from the drawCommands array:
it("initially places the turtle at 0,0 with angle 0", () => {
renderWithStore(<Drawing />);
expect(Turtle).toBeRenderedWithProps({
x: 0,
y: 0,
angle: 0
});
});
const { drawCommands } = useSelector(
({ script }) => script
);
import React, { useState } from "react";
const [turtle, setTurtle] = useState({
x: 0,
y: 0,
angle: 0
});
beforeEach(() => {
...
jest
.spyOn(window, "requestAnimationFrame");
});
describe("movement animation", () => {
const horizontalLineDrawn = {
script: {
drawCommands: [horizontalLine],
turtle: { x: 0, y: 0, angle: 0 },
},
};
it("invokes requestAnimationFrame when the timeout fires", () => {
renderWithStore(<Drawing />, horizontalLineDrawn);
expect(window.requestAnimationFrame).toBeCalled();
});
});
import React, { useState, useEffect } from "react";
export const Drawing = () => {
...
useEffect(() => {
requestAnimationFrame();
}, []);
return ...
};
import { act } from "react-dom/test-utils";
import { AnimatedLine } from "../src/AnimatedLine";
jest.mock("../src/AnimatedLine", () => ({
AnimatedLine: jest.fn(
() => <div id="AnimatedLine" />
),
}));
const triggerRequestAnimationFrame = time => {
act(() => {
const mock = window.requestAnimationFrame.mock
const lastCallFirstArg =
mock.calls[mock.calls.length - 1][0]
lastCallFirstArg(time);
});
};
it("renders an AnimatedLine with turtle at the start position when the animation has run for 0s", () => {
renderWithStore(<Drawing />, horizontalLineDrawn);
triggerRequestAnimationFrame(0);
expect(AnimatedLine).toBeRenderedWithProps({
commandToAnimate: horizontalLine,
turtle: { x: 100, y: 100, angle: 0 }
});
});
Using the turtle position for animation
Remember that the AnimatedLine component draws a line from the start position of the drawLine instruction to the current turtle position. That turtle position is then animated, which has the effect of the AnimatedLine instance growing in length until it finds the end position of the drawLine instruction.
const commandToAnimate = drawCommands[0];
const isDrawingLine =
commandToAnimate &&
isDrawLineCommand(commandToAnimate);
useEffect(() => {
const handleDrawLineFrame = time => {
setTurtle(turtle => ({
...turtle,
x: commandToAnimate.x1,
y: commandToAnimate.y1,
}));
};
if (isDrawingLine) {
requestAnimationFrame(handleDrawLineFrame);
}
}, [commandToAnimate, isDrawingLine]);
Using the functional update setter
This code uses the functional update variant of setTurtle that takes a function rather than a value. This is used when the new state value depends on the old value. Using this form of setter means that the turtle doesn’t need to be in the dependency list of useEffect and won’t cause the useEffect hook to reset itself.
import { AnimatedLine } from "./AnimatedLine";
<AnimatedLine
commandToAnimate={commandToAnimate}
turtle={turtle}
/>
it("does not render AnimatedLine when not moving", () => {
renderWithStore(<Drawing />, {
script: { drawCommands: [] }
});
expect(AnimatedLine).not.toBeRendered();
});
{isDrawingLine ? (
<AnimatedLine
commandToAnimate={commandToAnimate}
turtle={turtle}
/> : null}
it("renders an AnimatedLine with turtle at a position based on a speed of 5px per ms", () => {
renderWithStore(<Drawing />, horizontalLineDrawn);
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(250);
expect(AnimatedLine).toBeRenderedWithProps({
commandToAnimate: horizontalLine,
turtle: { x: 150, y: 100, angle: 0 }
});
});
Using animation duration to calculate the distance moved
The handleDrawLineFrame function, when called by the browser, will be passed a time parameter. This is the current duration of the animation. The turtle travels at a constant velocity, so knowing the duration allows us to calculate where the turtle is.
const distance = ({ x1, y1, x2, y2 }) =>
Math.sqrt(
(x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)
);
const movementSpeed = 5;
useEffect(() => {
let duration;
const handleDrawLineFrame = time => {
setTurtle(...);
};
if (isDrawingLine) {
duration =
movementSpeed * distance(commandToAnimate);
requestAnimationFrame(handleDrawLineFrame);
}
}, [commandToAnimate, isDrawingLine]);
useEffect(() => {
let duration;
const handleDrawLineFrame = time => {
const { x1, x2, y1, y2 } = commandToAnimate;
setTurtle(turtle => ({
...turtle,
x: x1 + ((x2 - x1) * (time / duration)),
y: y1 + ((y2 - y1) * (time / duration)),
}));
};
if (isDrawingLine) {
...
}
}, [commandToAnimate, isDrawingLine]);
it("calculates move distance with a non-zero animation start time", () => {
const startTime = 12345;
renderWithStore(<Drawing />, horizontalLineDrawn);
triggerRequestAnimationFrame(startTime);
triggerRequestAnimationFrame(startTime + 250);
expect(AnimatedLine).toBeRenderedWithProps({
commandToAnimate: horizontalLine,
turtle: { x: 150, y: 100, angle: 0 }
});
});
useEffect(() => {
let start, duration;
const handleDrawLineFrame = time => {
if (start === undefined) start = time;
const elapsed = time - start;
const { x1, x2, y1, y2 } = commandToAnimate;
setTurtle(turtle => ({
...turtle,
x: x1 + ((x2 - x1) * (elapsed / duration)),
y: y1 + ((y2 - y1) * (elapsed / duration)),
}));
};
if (isDrawingLine) {
...
}
}, [commandToAnimate, isDrawingLine]);
it("invokes requestAnimationFrame repeatedly until the duration is reached", () => {
renderWithStore(<Drawing />, horizontalLineDrawn);
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(250);
triggerRequestAnimationFrame(500);
expect(
window.requestAnimationFrame.mock.calls
).toHaveLength(3);
});
const handleDrawLineFrame = (time) => {
if (start === undefined) start = time;
if (time < start + duration) {
const elapsed = time - start;
const { x1, x2, y1, y2 } = commandToAnimate;
setTurtle(...);
requestAnimationFrame(handleDrawLineFrame);
}
};
describe("after animation", () => {
it("animates the next command", () => {
renderWithStore(<Drawing />, {
script: {
drawCommands: [horizontalLine, verticalLine]
}
});
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(500);
expect(AnimatedLine).toBeRenderedWithProps(
expect.objectContaining({
commandToAnimate: verticalLine,
})
);
});
});
const [
animatingCommandIndex,
setAnimatingCommandIndex
] = useState(0);
const commandToAnimate =
drawCommands[animatingCommandIndex];
if (time < start + duration) {
...
} else {
setAnimatingCommandIndex(
animatingCommandIndex => animatingCommandIndex + 1
);
}
it("places line in StaticLines", () => {
renderWithStore(<Drawing />, {
script: {
drawCommands: [horizontalLine, verticalLine]
}
});
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(500);
expect(StaticLines).toBeRenderedWithProps({
lineCommands: [horizontalLine]
});
});
const lineCommands = drawCommands
.slice(0, animatingCommandIndex)
.filter(isDrawLineCommand);
If you run the app, you’ll now be able to see lines being animated as they are placed on the screen.
In the next section, we’ll ensure the animations behave nicely when multiple commands are entered by the user at the same time.
The useEffect hook we’ve written has commandToAnimate and isDrawingLine in its dependency list. That means that when either of these values updates, the useEffect hook is torn down and will be restarted. But there are other occasions when we want to cancel the animation. One time this happens is when the user resets their screen.
If a command is currently animating when the user clicks the Reset button, we don’t want the current animation frame to continue. We want to clean that up.
Let’s add a test for that now:
it("calls cancelAnimationFrame on reset", () => {
renderWithStore(<Drawing />, {
script: { drawCommands: [horizontalLine] }
});
renderWithStore(<Drawing />, {
script: { drawCommands: [] }
});
expect(window.cancelAnimationFrame).toBeCalledWith(
cancelToken
);
});
describe("Drawing", () => {
const cancelToken = "cancelToken";
beforeEach(() => {
...
jest
.spyOn(window, "requestAnimationFrame")
.mockReturnValue(cancelToken);
jest.spyOn(window, "cancelAnimationFrame");
});
});
useEffect(() => {
let start, duration, cancelToken;
const handleDrawLineFrame = time => {
if (start === undefined) start = time;
if (time < start + duration) {
...
cancelToken = requestAnimationFrame(
handleDrawLineFrame
);
} else {
...
}
};
if (isDrawingLine) {
duration =
movementSpeed * distance(commandToAnimate);
cancelToken = requestAnimationFrame(
handleDrawLineFrame
);
}
return () => {
cancelAnimationFrame(cancelToken);
}
});
it("does not call cancelAnimationFrame if no line animating", () => {
jest.spyOn(window, "cancelAnimationFrame");
renderWithStore(<Drawing />, {
script: { drawCommands: [] }
});
renderWithStore(<React.Fragment />);
expect(
window.cancelAnimationFrame
).not.toHaveBeenCalled();
});
Unmounting a component
This test shows how you can mimic an unmount of a component in React, which is simply by rendering <React.Fragment /> in place of the component under test. React will unmount your component when this occurs.
return () => {
if (cancelToken) {
cancelAnimationFrame(cancelToken);
}
};
That’s all we need to do for animating the drawLine commands. Next up is rotating the turtle.
Our lines and turtle are now animating nicely. However, we still need to handle the second type of draw command: rotations. The turtle will move at a constant speed when rotating to a new angle. A full rotation should take 1 second to complete, and we can use this to calculate the duration of the rotation. For example, a quarter rotation will take 0.25 seconds to complete.
In the last section, we started with a test to check that we were calling requestAnimationFrame. This time, that test isn’t essential because we’ve already proved the same design with drawing lines. We can jump right into the more complex tests, using the same triggerRequestAnimationFrame helper as before.
Let’s update Drawing to animate the turtle’s coordinates:
describe("rotation animation", () => {
const rotationPerformed = {
script: { drawCommands: [rotate90] },
};
it("rotates the turtle", () => {
renderWithStore(<Drawing />, rotationPerformed);
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(500);
expect(Turtle).toBeRenderedWithProps({
x: 0,
y: 0,
angle: 90
});
});
});
const isRotateCommand = command =>
command.drawCommand === "rotate";
const isRotating =
commandToAnimate &&
isRotateCommand(commandToAnimate);
const handleRotationFrame = time => {
setTurtle(turtle => ({
...turtle,
angle: commandToAnimate.newAngle
}));
};
useEffect(() => {
...
if (isDrawingLine) {
...
} else if (isRotating) {
requestAnimationFrame(handleRotationFrame);
}
}, [commandToAnimate, isDrawingLine, isRotating]);
it("rotates part-way at a speed of 1s per 180 degrees", () => {
renderWithStore(<Drawing />, rotationPerformed);
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(250);
expect(Turtle).toBeRenderedWithProps({
x: 0,
y: 0,
angle: 45
});
});
const rotateSpeed = 1000 / 180;
} else if (isRotating) {
duration =
rotateSpeed *
Math.abs(
commandToAnimate.newAngle -
commandToAnimate.previousAngle
);
requestAnimationFrame(handleRotationFrame);
}
const handleRotationFrame = (time) => {
const {
previousAngle, newAngle
} = commandToAnimate;
setTurtle(turtle => ({
...turtle,
angle:
previousAngle +
(newAngle - previousAngle) * (time / duration)
}));
};
it("calculates rotation with a non-zero animation start time", () => {
const startTime = 12345;
renderWithStore(<Drawing />, rotationPerformed);
triggerRequestAnimationFrame(startTime);
triggerRequestAnimationFrame(startTime + 250);
expect(Turtle).toBeRenderedWithProps({
x: 0,
y: 0,
angle: 45
});
});
const handleRotationFrame = (time) => {
if (start === undefined) start = time;
const elapsed = time - start;
const {
previousAngle, newAngle
} = commandToAnimate;
setTurtle(turtle => ({
...turtle,
angle:
previousAngle +
(newAngle - previousAngle) *
(elapsed / duration)
}));
};
it("invokes requestAnimationFrame repeatedly until the duration is reached", () => {
renderWithStore(<Drawing />, rotationPerformed);
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(250);
triggerRequestAnimationFrame(500);
expect(
window.requestAnimationFrame.mock.calls
).toHaveLength(3);
});
const handleRotationFrame = (time) => {
if (start === undefined) start = time;
if (time < start + duration) {
...
} else {
setTurtle(turtle => ({
...turtle,
angle: commandToAnimate.newAngle
}));
}
};
Handling the end animation state
This else clause wasn’t necessary with the drawLine handler because, as soon as a line finishes animating, it will be passed to StaticLines, which renders all lines with their full length. This isn’t the case with the rotation angle: it remains fixed until the next rotation. Therefore, we need to ensure it’s at its correct final value.
it("animates the next command once rotation is complete", async () => {
renderWithStore(<Drawing />, {
script: {
drawCommands: [rotate90, horizontalLine]
}
});
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(500);
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(250);
expect(Turtle).toBeRenderedWithProps({
x: 150,
y: 100,
angle: 90
});
});
} else {
setTurtle(turtle => ({
...turtle,
angle: commandToAnimate.newAngle
}));
setAnimatingCommandIndex(
(animatingCommandToIndex) =>
animatingCommandToIndex + 1
);
}
That’s it! If you haven’t done so already, it’s worth running the app to try it out.
In this chapter, we’ve explored how to test the requestAnimationFrame browser API. It’s not a straightforward process, and there are multiple tests that need to be written if you wish to be fully covered.
Nevertheless, you’ve seen that it is entirely possible to write automated tests for onscreen animation. The benefit of doing so is that the complex production code is fully documented via the tests.
In the next chapter, we’ll look at adding WebSocket communication into Spec Logo.