精通-React-测试驱动开发第二版-六-

37 阅读45分钟

精通 React 测试驱动开发第二版(六)

原文:zh.annas-archive.org/md5/5e6d20182dc7eee4198d982cf82680c0

译者:飞龙

协议:CC BY-NC-SA 4.0

第十五章:添加动画

动画与其他任何功能一样,也适合测试驱动开发。在本章中,我们将根据用户输入的命令动画化 Logo 乌龟的移动。

Spec Logo 中有两种类型的动画:

  • 首先,当乌龟向前移动时。例如,当用户输入forward 100作为指令时,乌龟应该以固定速度沿 100 个单位移动。在移动过程中,它将在后面画一条线。

  • 其次,当乌龟旋转时。例如,如果用户输入rotate 90,那么乌龟应该缓慢旋转,直到它完成四分之一转弯。

本章的大部分内容是关于测试驱动window.requestAnimationFrame函数。这是浏览器 API,允许我们在屏幕上动画化视觉元素,例如乌龟的位置或线的长度。这个函数的机制在本章的第三部分使用 requestAnimationFrame 进行动画中解释。

手动测试的重要性

在编写动画代码时,自然想要直观地检查我们正在构建的内容。自动测试是不够的。手动测试也很重要,因为动画不是大多数程序员每天都会做的事情。当某事是新的时,通常最好进行大量的手动测试来验证行为,除了你的自动测试之外。

事实上,在准备本章时,我进行了大量的手动测试。这里展示的试验了几种不同的方法。有很多次我打开浏览器输入forward 100right 90来直观地验证发生了什么。

本章涵盖了以下主题:

  • 设计动画

  • 构建动画线条组件

  • 使用requestAnimationFrame进行动画

  • 使用cancelAnimationFrame取消动画

  • 变化动画行为

我们将要编写的代码与本书中其他部分的代码相比相对复杂,因此我们需要先做一些前期设计。

到本章结束时,你将深入理解如何测试驱动更复杂的浏览器 API 之一。

技术要求

本章的代码文件可以在这里找到:

github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter15

设计动画

在阅读本节时,你可能希望打开src/Drawing.js并阅读现有代码,以了解它在做什么。

当前的Drawing组件显示了在此点绘图的外观的静态快照。它渲染一组可缩放矢量图形SVG)线条来表示乌龟到达此点的路径,以及一个三角形来表示乌龟。

组件使用了两个子组件:

  • Turtle组件只显示一次,并在指定位置绘制一个 SVG 三角形

  • StaticLines 组件是一组在屏幕上绘制的 SVG 线条,用于表示绘制的命令

我们将添加一个新的 AnimatedLine 组件,表示正在动画化的当前线条。当线条完成动画后,它们将移动到 StaticLines 集合中。

我们需要做一些工作来将这个静态视图转换为动画表示。

目前,该组件接受一个 turtle 属性和一个 drawCommands 属性。turtle 属性是乌龟的当前位置,前提是所有绘图命令都已经绘制完成。

在我们新的动画绘图中,我们仍然将 drawCommands 视为一组要执行的命令。但不是依赖于 turtle 属性来告诉我们乌龟的位置,我们将乌龟的 当前 位置存储为组件状态。我们将逐条指令通过 drawCommands 数组,每次一个指令,并在动画过程中更新乌龟组件状态。一旦所有指令都完成,乌龟组件状态将匹配最初为 turtle 属性设置的值。

乌龟始终从 0,0 坐标以 0 角度开始。

我们需要跟踪哪些命令已经被动画化。我们将创建另一个组件状态变量 animatingCommandIndex,以表示当前正在动画化的数组项的索引。

我们从索引 0 开始动画。一旦该命令被动画化,我们就将索引增加 1,移动到下一个命令,并对其动画化。这个过程会重复进行,直到我们达到数组的末尾。

这种设计意味着用户可以在动画运行时在提示符中输入新的 drawCommands。组件将确保在离开的点重新绘制带有动画的图形。

最后,有两种类型的绘图命令:drawLinerotate。以下是一些将在 drawCommands 数组中出现的命令示例:

{
  drawCommand: "drawLine",
  id: 123,
  x1: 100,
  y1: 100,
  x2: 200,
  y2: 100
}
{
  drawCommand: "rotate",
  id: 234,
  previousAngle: 0,
  newAngle: 90
}

每种类型的动画都需要不同的处理方式。例如,当乌龟旋转时,AnimatedLine 组件将被隐藏。

大概就是这样。我们将遵循以下方法:

  • 从构建 AnimatedLine 组件开始

  • Drawing 中创建一个 useEffect 钩子,调用 window.requestAnimationFrame 函数来动画化 drawLine 命令

  • 当添加新指令时取消 drawLine 命令的动画

  • 添加乌龟旋转的动画

让我们从 AnimatedLine 组件开始。

构建一个动画线条组件

在本节中,我们将创建一个新的 AnimatedLine 组件。

此组件本身不包含动画逻辑,而是从动画线条的起点绘制到当前乌龟位置的一条线。因此,它需要两个属性:commandToAnimate,这将是之前显示的 drawLine 命令结构之一,以及包含位置的 turtle 属性。

让我们开始:

  1. 创建一个新的文件,test/AnimatedLine.test.js,并使用以下导入和 describe 块设置初始化它。注意包括 horizontalLine 的样本指令定义:

    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");
    });
    
  2. 现在添加第一个测试,用于检查线的起始位置:

    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()
      );
    });
    
  3. 创建一个新的文件,src/AnimatedLine.js,并通过以下实现使测试通过:

    import React from "react";
    export const AnimatedLine = ({
      commandToAnimate: { x1, y1 }
    }) => (
      <line x1={x1} y1={y1} />
    );
    
  4. 接下来是下一个测试。在这个测试中,我们明确设置海龟值,以便清楚地看到预期值来自何处:

    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");
    });
    
  5. 为了使其通过,我们只需要在线元素上设置 x2y2 属性,从海龟那里拉取这些值:

    export const AnimatedLine = ({
      commandToAnimate: { x1, y1 },
      turtle: { x, y }
    }) => (
      <line x1={x1} y1={y1} x2={x} y2={y} />
    );
    
  6. 然后我们需要两个测试来设置 strokeWidthstroke 属性:

    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");
    });
    
  7. 通过添加这两个属性来完成组件:

    export const AnimatedLine = ({
      commandToAnimate: { x1, y1 },
      turtle: { x, y }
    }) => (
      <line
        x1={x1}
        y1={y1}
        x2={x}
        y2={y}
        strokeWidth="2"
        stroke="black"
      />
    );
    

这就完成了 AnimatedLine 组件。

接下来,是时候将其添加到 Drawing 中了,通过将 commandToAnimate 属性设置为当前正在动画化的线条,并使用 requestAnimationFrame 来改变 turtle 属性的位置。

使用 requestAnimationFrame 进行动画

在本节中,你将结合使用 useEffect 钩子和 window.requestAnimationFrame 来调整 AnimatedLineTurtle 的位置。

window.requestAnimationFrame 函数用于动画视觉属性。例如,你可以用它在一个给定的时间段内,比如 2 秒内,将一条线的长度从 0 单位增加到 200 单位。

为了使这工作,你提供一个回调,该回调将在下一个重绘间隔运行。当调用时,该回调提供了当前的动画时间:

const myCallback = time => {
  // animating code here
};
window.requestAnimationFrame(myCallback);

如果你已知动画的开始时间,你可以计算出已过的动画时间,并使用这个时间来计算动画属性的当前值。

浏览器可以以非常高的刷新率调用你的回调,例如每秒 60 次。因为这些非常小的时间间隔,你的更改看起来像是一个平滑的动画。

注意,浏览器只为每个请求的帧调用一次你的回调。这意味着你有责任重复调用 requestAnimationFrame 函数,直到动画时间达到你定义的结束时间,如下例所示。浏览器负责仅在屏幕需要重绘时调用你的回调:

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);

随着我们进入本节,你会看到如何使用这个来修改组件状态(例如 AnimatedLine 的位置),这会导致你的组件重新渲染。

让我们从 Redux 存储中移除现有的海龟值开始——我们不再使用这个值,而是依赖于从 drawCommands 数组中计算出的海龟位置:

  1. 打开 test/Drawing.test.js 并找到名为 passes the turtle x, y and angle as props to Turtle 的测试。用以下内容替换它:

    it("initially places the turtle at 0,0 with angle 0", () => {
      renderWithStore(<Drawing />);
      expect(Turtle).toBeRenderedWithProps({
        x: 0,
        y: 0,
        angle: 0
      });
    });
    
  2. 现在,在 src/Drawing.js 文件中,你可以通过替换 useSelector 调用,移除从 Redux 存储中提取的海龟值:

    const { drawCommands } = useSelector(
      ({ script }) => script
    );
    
  3. 我们将用一个新的状态变量替换现有的乌龟值。当我们开始移动乌龟的位置时,这将非常有用。首先,将useState导入到src/Drawing.js中:

    import React, { useState } from "react";
    
  4. 然后,在useSelector调用下方添加另一个useState调用。在此更改之后,你的测试应该可以通过:

    const [turtle, setTurtle] = useState({
      x: 0,
      y: 0,
      angle: 0
    });
    
  5. test/Drawing.test.js中,在describe块的beforeEach中模拟requestAnimationFrame函数:

    beforeEach(() => {
      ...
      jest
        .spyOn(window, "requestAnimationFrame");
    });
    
  6. 将以下新的describe块和测试添加到现有describe块的底部,在现有describe块内部(因此它是嵌套的)。它定义了一个初始状态horizontalLineDrawn,它只有一条线——这条线在sampleInstructions文件中定义。测试表明我们期望在组件挂载时调用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();
      });
    });
    
  7. 要使这个测试通过,打开src/Drawing.js并首先导入useEffect钩子:

    import React, { useState, useEffect } from "react";
    
  8. 然后,将新的useEffect钩子添加到Drawing组件中。在return语句 JSX 上方添加以下三行:

    export const Drawing = () => {
      ...
      useEffect(() => {
        requestAnimationFrame();
      }, []);
      return ...
    };
    
  9. 由于我们现在处于useEffect的领域,任何导致组件状态更新的操作都必须在act块内发生。这包括任何触发的动画帧,我们即将触发一些。因此,回到test/Drawing.test.js,现在添加act导入:

    import { act } from "react-dom/test-utils";
    
  10. 我们还需要导入AnimatedLine,因为在下一个测试中,我们将断言我们渲染了它。添加以下导入,以及其间谍设置,如下所示:

    import { AnimatedLine } from "../src/AnimatedLine";
    jest.mock("../src/AnimatedLine", () => ({
      AnimatedLine: jest.fn(
        () => <div id="AnimatedLine" />
      ),
    }));
    
  11. requestAnimationFrame的调用需要一个handler函数作为参数。然后浏览器将在下一个动画帧期间调用此函数。对于下一个测试,我们将检查当计时器第一次触发时,乌龟是否位于第一条线的起点。我们需要定义一个新的辅助函数来完成这个任务,即triggerRequestAnimationFrame。在浏览器环境中,这个调用会自动发生,但在我们的测试中,我们扮演浏览器的角色,并在代码中触发它。正是这个调用必须被act函数调用包裹,因为我们的处理程序将导致组件状态改变:

    const triggerRequestAnimationFrame = time => {
      act(() => {
        const mock = window.requestAnimationFrame.mock
        const lastCallFirstArg =
          mock.calls[mock.calls.length - 1][0]
        lastCallFirstArg(time);
      });
    };
    
  12. 现在,我们准备好编写动画周期的测试了。第一个是一个简单的测试:在时间零时,乌龟位置被放置在线的起点。如果你检查test/sampleInstructions.js中的定义,你会看到horizontalLine从位置100,100开始:

    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 }
      });
    });
    

使用乌龟位置进行动画

记住,AnimatedLine组件从drawLine指令的起始位置绘制到当前乌龟位置。然后,这个乌龟位置被动画化,这产生了AnimatedLine实例长度增长直到找到drawLine指令的终点位置的效果:

  1. 使这个测试通过将是一个小小的“大爆炸”。首先,按照所示扩展 useEffect。我们定义了两个变量,commandToAnimateisDrawingLine,我们使用它们来确定是否应该进行动画。isDrawingLine 测试是必要的,因为一些现有的测试根本不会向组件发送任何绘图命令,在这种情况下 commandToAnimate 将是 null。另一个测试将一个未知类型的命令传递到组件中,如果我们尝试从中提取 x1y1,它也会崩溃。这就是为什么需要调用 isDrawLineCommand 的原因——这是一个已经在文件顶部定义好的函数:

    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]);
    

使用函数式更新设置器

这段代码使用了 setTurtle函数式更新 变体,它接受一个函数而不是一个值。当新的状态值依赖于旧值时,会使用这种形式的设置器。使用这种形式的设置器意味着乌龟不需要在 useEffect 的依赖列表中,并且不会导致 useEffect 钩子重置自己。

  1. 到目前为止,我们还没有渲染 AnimatedLine,这正是我们的测试所期望的。现在让我们修复这个问题。首先,添加导入:

    import { AnimatedLine } from "./AnimatedLine";
    
  2. StaticLines 的 JSX 下方插入此代码。此时,你的测试应该可以通过:

    <AnimatedLine
      commandToAnimate={commandToAnimate}
      turtle={turtle}
    />
    
  3. 我们需要进一步的测试来确保在没有动画线条时不会渲染 AnimatedLine。按照所示添加下一个测试,但不要将其添加到 movement animation 块中;相反,将其放入父上下文中:

    it("does not render AnimatedLine when not moving", () => {
      renderWithStore(<Drawing />, {
        script: { drawCommands: [] }
      });
      expect(AnimatedLine).not.toBeRendered();
    });
    
  4. 通过将 AnimatedLine 组件用三元运算符包裹来实现这个过渡。如果 isDrawingLine 为假,我们简单地返回 null

    {isDrawingLine ? (
      <AnimatedLine
        commandToAnimate={commandToAnimate}
        turtle={turtle}
    /> : null}
    
  5. 我们已经处理了 第一个 动画帧应该做什么;现在让我们编写 下一个 动画帧的代码。在下面的测试中,有 两个 调用 triggerRequestAnimationFrame。第一个用于表示动画已经开始;第二个允许我们移动。我们需要第一个调用(时间索引为 0)来标记动画开始的时间:

    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 }
      });
    });
    

使用动画持续时间来计算移动的距离

当浏览器调用 handleDrawLineFrame 函数时,会传递一个时间参数。这是动画的当前持续时间。乌龟以恒定的速度移动,因此知道持续时间可以让我们计算出乌龟的位置。

  1. 为了实现这个过渡,首先,我们需要定义几个函数。滚动到 src/Drawing.js 的顶部,直到你看到 isDrawLineCommand 的定义,然后在那里添加这两个新的定义。distancemovementSpeed 函数用于计算动画的持续时间:

    const distance = ({ x1, y1, x2, y2 }) =>
      Math.sqrt(
        (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)
      );
    const movementSpeed = 5;
    
  2. 现在我们可以计算动画的持续时间;按照所示修改 useEffect

    useEffect(() => {
      let duration;
      const handleDrawLineFrame = time => {
        setTurtle(...);
      };
      if (isDrawingLine) {
        duration =
          movementSpeed * distance(commandToAnimate);
        requestAnimationFrame(handleDrawLineFrame);
      }
    }, [commandToAnimate, isDrawingLine]);
    
  3. 通过将 duration 声明为 useEffect 块中的第一行,该变量在 requestAnimationFrame 处理程序的作用域内,以便读取它来计算距离。为此,我们取经过的时间并将其除以总持续时间:

    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]);
    
  4. 我们取得了很大的进展!在之前的测试中,我们假设起始时间是0,但实际上,浏览器可以给我们任何时间作为起始时间(它给出的时间被称为时间原点)。因此,让我们确保我们的计算对于非零起始时间也是有效的。添加以下测试:

    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 }
      });
    });
    
  5. 通过引入startelapsed时间,实现这个过渡,如下所示:

    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]);
    
  6. 我们需要确保我们的组件在达到持续时间之前重复调用requestAnimationFrame。到那时,线条应该已经完全绘制。在这个测试中,我们触发了三个动画帧,并期望requestAnimationFrame被调用了三次:

    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);
    });
    
  7. 为了实现这个过渡,我们需要确保handleDrawLineFrame在运行时触发另一个requestAnimationFrame。然而,我们只应该在持续时间到达之前这样做。通过以下条件将setTurtlerequestAnimationFrame调用包装起来,以实现这个过渡:

    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);
      }
    };
    
  8. 对于下一个测试,我们将检查当一条线“完成”绘制后,如果还有下一条线,我们将继续绘制下一条(如果没有,则停止)。在刚刚实现的describe块下方添加一个新的describe块,并添加以下测试。第二个时间戳500是在horizontalLine绘制所需的时间之后,因此AnimatedLine应该显示verticalLine

    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,
          })
        );
      });
    });
    
  9. 为了实现这个过渡,我们需要引入一个指向当前正在动画化的命令的指针。这个指针将从0索引开始,每次动画完成后都会递增。在组件顶部添加以下新的状态变量:

    const [
      animatingCommandIndex,
      setAnimatingCommandIndex
    ] = useState(0);
    
  10. commandToAnimate常量更新为使用这个新变量:

    const commandToAnimate = 
      drawCommands[animatingCommandIndex];
    
  11. handleDrawLineFrame中的条件语句中添加一个else子句来增加值:

    if (time < start + duration) {
      ...
    } else {
      setAnimatingCommandIndex(
        animatingCommandIndex => animatingCommandIndex + 1
      );
    }
    
  12. 对于最后的测试,我们想要确保只有之前已经动画化的命令被发送到StaticLines。当前正在动画化的线条将由AnimatedLine渲染,而尚未动画化的线条根本不应被渲染:

    it("places line in StaticLines", () => {
      renderWithStore(<Drawing />, {
        script: {
          drawCommands: [horizontalLine, verticalLine]
        }
      });
      triggerRequestAnimationFrame(0);
      triggerRequestAnimationFrame(500);
      expect(StaticLines).toBeRenderedWithProps({ 
        lineCommands: [horizontalLine]
      });
    });
    
  13. 为了实现这个过渡,将lineCommands更新为只包含drawCommands中直到当前animatingCommandIndex值的部分:

    const lineCommands = drawCommands
      .slice(0, animatingCommandIndex)
      .filter(isDrawLineCommand);
    
  14. 虽然最新的测试现在会通过,但现有的测试sends only line commands to StaticLines现在会失败。由于我们最新的测试覆盖了基本相同的功能,你现在可以安全地删除那个测试了。

如果你运行应用程序,你现在将能够看到线条在屏幕上被动画化。

在下一节中,我们将确保当用户同时输入多个命令时,动画表现良好。

使用cancelAnimationFrame取消动画

我们编写的 useEffect 钩子在其依赖列表中有 commandToAnimateisDrawingLine。这意味着当这两个值中的任何一个更新时,useEffect 钩子将被销毁并重新启动。但还有其他情况下我们想要取消动画。其中一种情况是当用户重置他们的屏幕时。

如果用户点击 重置 按钮时,命令正在动画化,我们不想让当前的动画帧继续。我们想要清理它。

现在让我们为这个功能添加一个测试:

  1. test/Drawing.test.js 的底部添加以下测试:

    it("calls cancelAnimationFrame on reset", () => {
      renderWithStore(<Drawing />, {
        script: { drawCommands: [horizontalLine] }
      });
      renderWithStore(<Drawing />, {
        script: { drawCommands: [] }
      });
      expect(window.cancelAnimationFrame).toBeCalledWith(
        cancelToken
      );
    });
    
  2. 你还需要更改 beforeEach 块,使 requestAnimationFrame 模拟返回一个虚拟的取消令牌,并为 cancelAnimationFrame 函数添加一个新的模拟:

    describe("Drawing", () => {
      const cancelToken = "cancelToken";
      beforeEach(() => {
        ...
        jest
          .spyOn(window, "requestAnimationFrame")
          .mockReturnValue(cancelToken);
        jest.spyOn(window, "cancelAnimationFrame");
      });
    });
    
  3. 为了使测试通过,更新 useEffect 钩子以存储 requestAnimationFrame 函数在调用时返回的 cancelToken 值。然后从 useEffect 钩子返回一个清理函数,该函数使用该令牌取消下一个请求的帧。这个函数将在 React 销毁钩子时被调用:

    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);
      }
    });
    
  4. 最后,我们不想在没有设置 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();
    });
    

卸载组件

这个测试展示了如何在 React 中模拟组件的卸载,这仅仅是通过在测试组件的位置渲染 <React.Fragment /> 来实现的。当发生这种情况时,React 将卸载你的组件。

  1. 为了使测试通过,只需将返回的清理函数包裹在一个条件语句中:

    return () => {
      if (cancelToken) {
        cancelAnimationFrame(cancelToken);
      }
    };
    

那就是我们为动画化 drawLine 命令需要做的所有事情。接下来是旋转海龟。

变化动画行为

我们现在可以看到线条和海龟正在很好地动画化。然而,我们仍然需要处理第二种类型的绘制命令:旋转。当海龟旋转到新的角度时,它将以恒定的速度移动。一个完整的旋转应该需要 1 秒来完成,我们可以用这个来计算旋转的持续时间。例如,四分之一旋转将需要 0.25 秒来完成。

在最后一节中,我们从一个测试开始,检查我们是否调用了 requestAnimationFrame。这次,这个测试不是必需的,因为我们已经通过绘制线条证明了相同的设计。我们可以直接进入更复杂的测试,使用之前相同的 triggerRequestAnimationFrame 辅助函数。

让我们更新 Drawing 以使海龟坐标动画化:

  1. Drawingdescribe 块底部添加以下测试。在另一个嵌套的 describe 块中创建它,位于你刚刚编写的最后一个测试下面。这个测试遵循我们绘制线条测试的相同原则:我们触发两个动画帧,一个在 0 毫秒,一个在 500 毫秒,然后期望旋转发生。除了 角度 之外,还测试了 xy 坐标;这是为了确保我们继续传递这些值:

    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
        });
      });
    });
    
  2. 移动到src/Drawing.js,首先在isDrawLineCommand定义下方添加isRotateCommand的定义:

    const isRotateCommand = command =>
      command.drawCommand === "rotate";
    
  3. Drawing组件中,在isDrawingLine定义下方添加一个新的常量,isRotating

    const isRotating =
      commandToAnimate &&
        isRotateCommand(commandToAnimate);
    
  4. useEffect钩子中,在handleDrawLineFrame定义下方定义一个新的旋转处理器,handleRotationFrame。为了这个测试的目的,它不需要做太多,只需将角度设置为新的值:

    const handleRotationFrame = time => {
      setTurtle(turtle => ({
        ...turtle,
        angle: commandToAnimate.newAngle
      }));
    };
    
  5. 我们可以利用这个来在旋转命令动画时调用requestAnimationFrame。修改useEffect钩子的最后部分,使其看起来如下,确保你将isRotating添加到依赖列表中。更改后测试应该通过:

    useEffect(() => {
      ...
      if (isDrawingLine) {
        ...
      } else if (isRotating) {
        requestAnimationFrame(handleRotationFrame);
      }
    }, [commandToAnimate, isDrawingLine, isRotating]);
    
  6. 让我们添加一个测试来获取持续时间并在我们的计算中使用它。这基本上与上一个测试相同,但具有不同的持续时间,因此预期的旋转也不同:

    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
      });
    });
    
  7. 为了使这个通过,首先,我们需要定义rotateSpeed。你可以在movementSpeed定义下方添加这个定义:

    const rotateSpeed = 1000 / 180;
    
  8. 接下来,更新useEffect处理器底部的条件,以计算rotate命令的持续时间:

    } else if (isRotating) {
      duration =
        rotateSpeed *
        Math.abs(
          commandToAnimate.newAngle -
            commandToAnimate.previousAngle
        );
     requestAnimationFrame(handleRotationFrame);
    }
    
  9. 更新handleRotationFrame以使用持续时间来计算一个成比例的角度来移动:

    const handleRotationFrame = (time) => {
      const {
        previousAngle, newAngle
      } = commandToAnimate;
      setTurtle(turtle => ({
        ...turtle,
        angle:
          previousAngle +
          (newAngle - previousAngle) * (time / duration)
      }));
    };
    
  10. 就像handleDrawLineFrame一样,我们需要确保我们可以处理除0之外的其他起始时间。添加以下测试:

    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
      });
    });
    
  11. 通过添加startelapsed变量来使那个通过。之后,测试应该通过。你会注意到handleDrawLineFramehandleRotationFrame之间的相似性:

    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)
      }));
    };
    
  12. 添加一个测试以确保我们反复调用requestAnimationFrame。这个测试与用于drawLine处理器的测试相同,但现在我们传递的是rotate90命令。请确保测试属于嵌套上下文,这样你可以确保没有名称冲突:

    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);
    });
    
  13. 为了使这个通过,我们需要做几件事情。首先,我们需要像修改handleDrawLineFrame一样修改handleRotationFrame,通过添加一个条件,在持续时间到达后停止动画。其次,我们还需要填写条件的第二部分,以设置动画完成后乌龟的位置:

    const handleRotationFrame = (time) => {
      if (start === undefined) start = time;
      if (time < start + duration) {
        ...
      } else {
        setTurtle(turtle => ({
          ...turtle,
          angle: commandToAnimate.newAngle
        }));
      }
    };
    

处理结束动画状态

这个else子句在drawLine处理器中不是必要的,因为一旦线条动画完成,它将被传递到StaticLines,渲染所有线条的全长。但这与旋转角度不同:它保持固定,直到下一次旋转。因此,我们需要确保它处于正确的最终值。

  1. 我们还有一个最后的测试。一旦动画完成,我们需要增加当前动画命令。与上一节中的相同测试一样,这个测试应该位于我们刚刚使用的describe块之外,因为它有不同的测试设置:

    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
      });
    });
    
  2. 为了使那个通过,将setNextCommandToAnimate的调用添加到else条件中:

    } else {
      setTurtle(turtle => ({
        ...turtle,
        angle: commandToAnimate.newAngle
      }));
      setAnimatingCommandIndex(
        (animatingCommandToIndex) =>
          animatingCommandToIndex + 1
      );
    }
    

就这样!如果你还没有这样做,运行应用尝试一下是值得的。

摘要

在本章中,我们探讨了如何测试requestAnimationFrame浏览器 API。这不是一个简单的过程,如果你希望完全覆盖,需要编写多个测试。

尽管如此,你已经看到为屏幕上的动画编写自动化测试是完全可能的。这样做的好处是,复杂的生产代码通过测试得到了完全的文档记录。

在下一章中,我们将探讨如何将 WebSocket 通信添加到 Spec Logo 中。

练习

  1. 更新Drawing,以便当用户使用重置按钮清除屏幕时,重置海龟位置。

  2. 我们的测试有很多重复,因为重复调用triggerRequestAnimationFrame。通过创建一个名为triggerAnimationSequence的包装函数来简化调用方式,该函数接受一个帧时间数组,并为这些时间中的每一个调用triggerRequestAnimationFrame

  3. 加载现有脚本(例如,在启动时)将花费很长时间来动画化所有指令,粘贴代码片段也是如此。添加一个跳过动画按钮,可以用来跳过所有排队的动画。

  4. 确保在动画进行时撤销按钮能正确工作。

第十六章:与 WebSocket 一起工作

在本章中,我们将探讨如何在我们的 React 应用程序中测试驱动 WebSocket API。我们将使用它来构建一种教学机制,其中一个人可以共享他们的屏幕,其他人可以观看他们输入命令。

WebSocket API 并不简单。它使用了许多不同的回调,并要求以特定的顺序调用函数。为了使事情更复杂,我们将在 Redux saga 中这样做:这意味着我们需要做一些工作来将回调 API 转换为可以与生成器函数一起工作的 API。

因为这是最后一章介绍单元测试技术,所以它做了一些不同的处理。它不遵循严格的 TDD 过程。本章的起点是我们已经完成的功能框架。你需要完善这些功能,专注于学习 WebSocket 连接的测试驱动技术。

本章涵盖了以下主题:

  • 设计 WebSocket 交互

  • 测试 WebSocket 连接

  • 使用 redux-saga 进行流式事件

  • 更新应用程序

到本章结束时,你将学会 WebSocket API 是如何工作的,以及它的单元测试机制。

技术要求

本章的代码文件可以在以下位置找到:

github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter16

设计 WebSocket 交互

在本节中,我们将首先描述共享工作流程,然后我们将查看支持此工作流程的新 UI 元素,最后我们将介绍你在本章中将进行的代码更改。

共享工作流程

一个共享会话由一个演示者和零个或多个观察者组成。这意味着应用程序可以处于两种模式之一:演示观看

当应用程序处于演示模式时,所有观看者都将收到你的 Spec Logo 指令的副本。所有指令都通过 WebSocket 发送到服务器。

当你的应用程序处于观看模式时,WebSocket 从服务器接收指令并立即将它们输出到你的屏幕上。

发送到和从服务器发送的消息是简单的 JSON 格式数据结构。

图 16.1显示了界面在演示模式下的外观。

图 16.1 – 演示模式下的 Spec Logo

图 16.1 – 演示模式下的 Spec Logo

那么,它是如何工作的?

  1. 演示者点击开始共享按钮。服务器收到以下消息:

    { type: "START_SHARING" }
    
  2. 服务器随后响应会话的 ID:

    { status: "STARTED", id: 123 }
    
  3. 这个 ID 用于构建一个 URL,该 URL 以观看模式打开应用程序,例如:

    http://localhost:3000/index.xhtml?watching=123
    
  4. URL 可以在任何地方共享和打开。当应用程序以这种模式打开时,应用程序立即向服务器打开一个 WebSocket 并发送此消息:

    { type: "START_WATCHING", id: 123 }
    
  5. 可以有任意数量的连接的监视器。在初始连接时,演示者已经发送给服务器的任何命令都将被重新播放。这些命令是演示者发送给任何类型为 SUBMIT_EDIT_LINE 的 Redux 动作的命令,并且像这样通过 WebSocket 发送到服务器:

    {
      type: "NEW_ACTION",
      innerAction: {
        type: "SUBMIT_EDIT_LINE",
        text: "forward 10\n"
      }
    }
    
  6. 当服务器接收到演示者的 WebSocket 动作时,它会立即将动作转发给每个订阅者:

    { type: "SUBMIT_EDIT_LINE", text: "forward 10\n" } }
    
  7. 服务器还将接收到的动作存储在历史记录中,因此新加入者可以重新播放这些动作。

  8. 当监视器完成时,他们只需关闭浏览器窗口,他们的 WebSocket 将关闭。

  9. 当演示者完成演示后,他们可以关闭浏览器窗口或点击停止共享按钮。这将关闭连接,服务器清除其内部状态。

新的 UI 元素

这就是您将在 UI 中找到的内容;所有这些都已经为您构建好了:

  • 一个新的菜单按钮来切换共享的开关。它被命名为开始共享,但一旦开始共享,名称将切换到停止共享

  • 当 Spec Logo 处于共享模式时,菜单按钮栏中会出现一条新消息。它包含一个消息,告诉用户他们是正在演示还是观看。如果他们正在演示,它还包含一个他们可以复制并与他人分享的 URL。

  • 您现在可以通过在 Spec Logo URL 的末尾添加搜索参数 ?watching=<id> 来以观看模式启动应用程序。

接下来,让我们看看您将要填充的 Redux saga 的框架。

分离 saga

在文件 src/middleware/sharingSagas.js 中存在一个新的 Redux 中间件。这个文件包含两部分。首先,有一个名为 duplicateForSharing 的中间件函数。这是一个过滤器,为我们提供了所有希望广播的动作:

export const duplicateForSharing =
  store => next => action => {
    if (action.type === "SUBMIT_EDIT_LINE") {
      store.dispatch({
        type: "SHARE_NEW_ACTION",
        innerAction: action,
      });
    }
    return next(action);
  };

其次,还有根 saga 本身。它分为四个更小的函数,这些是我们将在本章中填充的函数,使用测试驱动的方法:

export function* sharingSaga() {
  yield takeLatest("TRY_START_WATCHING", startWatching);
  yield takeLatest("START_SHARING", startSharing);
  yield takeLatest("STOP_SHARING", stopSharing);
  yield takeLatest("SHARE_NEW_ACTION", shareNewAction);
}

在设计完成足够多的部分后,让我们开始实施。

测试驱动 WebSocket 连接

我们首先填充那个第一个函数,startSharing。当接收到 START_SHARING 动作时,将调用此函数。该动作是在用户点击开始共享按钮时触发的:

  1. 打开 test/middleware/sharingSagas.test.js 文件,并在顶部添加以下导入:

    import { storeSpy, expectRedux } from "expect-redux";
    import { act } from "react-dom/test-utils";
    import { configureStore } from "../../src/store";
    
  2. 在文件底部,添加一个新的 describe 块及其设置。我们将将其分为几个步骤:首先,设置 Redux 存储和 WebSocket 间谍。因为 window.WebSocket 是一个构造函数,我们使用 mockImplementation 来模拟它:

    describe("sharingSaga", () => {
      let store;
      let socketSpyFactory;
      beforeEach(() => {
        store = configureStore([storeSpy]);
        socketSpyFactory = spyOn(window, "WebSocket");
        socketSpyFactory.mockImplementation(() => {
          return {};
        });
      });
    });
    

理解 WebSocket API

WebSocket 构造函数返回一个具有sendclose方法的对象,以及onopenonmessageoncloseonerror事件处理程序。在我们构建测试套件时,我们将实现这些中的大多数。如果你想了解更多关于 WebSocket API 的信息,请查看本章末尾的进一步阅读部分。

  1. 接下来,因为我们还关心窗口位置,所以我们还需要模拟window.location对象。由于在 JSDOM 环境中这是一个只读对象,我们需要使用Object.defineProperty函数来覆盖它。这有点笨拙,所以你可能更喜欢将其提取到自己的函数中,并给它一个好名字。将以下内容添加到相同的beforeEach块中:

    beforeEach(() => {
      ...
      Object.defineProperty(window, "location", {
        writable: true,
          value: {
            protocol: "http:",
            host: "test:1234",
            pathname: "/index.xhtml",
          },
      });
    });
    
  2. 在嵌套的describe块中添加第一个测试。这检查我们是否使用正确的 URL 建立 WebSocket 连接:

    describe("START_SHARING", () => {
      it("opens a websocket when starting to share", () => {
        store.dispatch({ type: "START_SHARING" });
        expect(socketSpyFactory).toBeCalledWith(
          "ws://test:1234/share"
        );
      });
    });
    
  3. 通过在文件src/middleware/sharingSagas.js中填充startSharing生成器函数来使测试通过(记住,已经为你创建了骨架)。这段代码构建了一个带有正确主机的新 URL:

    function* startSharing() {
      const { host } = window.location;
      new WebSocket(`ws://${host}/share`);
    }
    
  4. 在测试套件中,修改 WebSocket 模拟实现以添加一个内部间谍,sendSpy,当用户在 WebSocket 上调用send函数时会被调用。我们还需要存储创建的socketSpy函数的引用,以便我们可以调用用户附加到其事件处理程序(如onopenonmessage)的回调。这将在我们编写下一个测试时变得有意义:

    let sendSpy;
    let socketSpy;
    beforeEach(() => {
      sendSpy = jest.fn();
      socketSpyFactory = spyOn(window, "WebSocket");
      socketSpyFactory.mockImplementation(() => {
        socketSpy = {
          send: sendSpy,
        };
        return socketSpy;
      });
    ...
    }
    
  5. 当使用回调驱动的 API 进行测试驱动开发时,例如 WebSocket API,模拟每个回调的确切行为非常重要。我们将从onopen回调开始。下一个测试将触发它,就像服务器发送消息一样。因为我们期望在接收到onopen时发生一系列异步操作,所以我们可以使用async act等待操作完成。因此,在下一个测试之前,定义以下函数,该函数触发onopen回调:

    const notifySocketOpened = async () => {
      await act(async () => {
        socketSpy.onopen();
      });
    };
    

使用 act 与非 React 代码

async act函数即使在处理 React 组件时也能帮助我们,因为它在返回之前会等待 promise 执行。

  1. 然后,我们可以在下一个测试中使用notifySocketOpened函数,该函数检查当客户端接收到START_SHARING动作时,它会立即将其转发到服务器:

    it("dispatches a START_SHARING action to the socket", async () => {
      store.dispatch({ type: "START_SHARING" });
      await notifySocketOpened();
      expect(sendSpy).toBeCalledWith(
        JSON.stringify({ type: "START_SHARING" })
     );
    });
    
  2. 要使测试通过,首先将startSharing函数中的现有代码提取到一个名为openWebsocket的新函数中。然后,添加代码来调用一个Promise对象,当在套接字上接收到onopen消息时,它会解析。这段代码相当困难——我们正在构建一个Promise对象,专门用于将基于回调的 API 转换为可以使用生成器yield关键字的东西:

    const openWebSocket = () => {
      const { host } = window.location;
      const socket = new WebSocket(`ws://${host}/share`);
      return new Promise(resolve => {
        socket.onopen = () => {
          resolve(socket)
        };
      });
    };
    
  3. 现在,你可以在startSharing中使用那个openWebSocket函数。之后,你的测试应该会通过:

    function* startSharing() {
      const presenterSocket = yield openWebSocket();
      presenterSocket.send(
        JSON.stringify({ type: "START_SHARING" })
      );
    }
    
  4. 下一个测试将从服务器通过套接字向应用发送消息。为此,我们需要一个辅助函数来模拟发送消息并等待清空当前任务队列。将此辅助函数添加到test/middleware/sharingSagas.test.js中,在notifySocketOpened下方:

    const sendSocketMessage = async message => {
      await act(async () => {
        socketSpy.onmessage({
         data: JSON.stringify(message)
        });
      });
    };
    
  5. 添加下一个测试,使用你刚刚定义的函数:

    it("dispatches an action of STARTED_SHARING with a URL containing the id that is returned from the server",   async () => {
      store.dispatch({ type: "START_SHARING" });
      await notifySocketOpened();
      await sendSocketMessage({
        type: "UNKNOWN",
        id: 123,
      });
      return expectRedux(store)
        .toDispatchAnAction()
        .matching({
          type: "STARTED_SHARING",
          url: "http://test:1234/index.xhtml?watching=123",
        });
    });
    
  6. 为了使这个通过,我们将从套接字读取消息。一旦完成,我们可以将检索到的信息传递回 Redux 存储。首先在src/middleware/sharingSagas.js顶部添加以下新函数:

    const receiveMessage = (socket) =>
      new Promise(resolve => {
        socket.onmessage = evt => {
          resolve(evt.data)
        };
      });
    const buildUrl = (id) => {
      const {
        protocol, host, pathname
      } = window.location;
      return (
        `${protocol}//${host}${pathname}?watching=${id}`
      );
    };
    
  7. 现在,你可以使用这些函数来完成startSharing的实现:

    function* startSharing() {
      const presenterSocket = yield openWebSocket();
      presenterSocket.send(
        JSON.stringify({ type: "START_SHARING" })
      );
      const message = yield receiveMessage(
        presenterSocket
      );
      const presenterSessionId = JSON.parse(message).id;
      yield put({
        type: "STARTED_SHARING",
        url: buildUrl(presenterSessionId),
      });
    }
    

开始共享的过程到此结束。现在让我们处理用户点击停止共享按钮时会发生什么:

  1. describe块内部创建一个名为sharingSaga的辅助函数,如下所示。这个函数将系统状态更改为STARTED_SHARING

    const startSharing = async () => {
      store.dispatch({ type: "START_SHARING" });
      await notifySocketOpened();
      await sendSocketMessage({
        type: "UNKNOWN",
        id: 123,
      });
    };
    
  2. 更新间谍以包括一个closeSpy变量,我们以与sendSpy相同的方式设置它:

    let closeSpy;
    beforeEach(() => {
      sendSpy = jest.fn();
      closeSpy = jest.fn();
      socketSpyFactory = spyOn(window, "WebSocket");
      socketSpyFactory.mockImplementation(() => {
        socketSpy = {
          send: sendSpy,
          close: closeSpy,
        };
        return socketSpy;
      });
      ...
    });
    
  3. 在新的嵌套上下文中添加第一个测试。它首先开始共享,然后分发STOP_SHARING动作:

    describe("STOP_SHARING", () => {
      it("calls close on the open socket", async () => {
        await startSharing();
        store.dispatch({ type: "STOP_SHARING" });
        expect(closeSpy).toBeCalled();
      });
    });
    
  4. 为了使这个通过,我们需要填写stopSharing生成器函数。首先,然而,我们需要获取在startSharing函数中创建的套接字。将这个变量提取到顶级命名空间中:

    let presenterSocket;
    function* startSharing() {
      presenterSocket = yield openWebSocket();
      ...
    }
    
  5. 然后,在stopSharing函数中添加以下定义。然后你可以运行你的测试,一切应该通过;然而,如果你正在运行整个测试套件(使用npm test),你会看到几个控制台错误出现。这些错误来自MenuButtons测试套件中的一个测试——我们将在稍后的更新应用部分修复这个问题:

    function* stopSharing() {
      presenterSocket.close();
    }
    

仅在一个测试套件中运行测试

为了避免看到控制台错误,请记住你可以选择仅使用命令npm test test/middleware/sharingSagas.test.js为此测试套件运行测试。

  1. 接下来进行下一个测试,我们想要更新 Redux 存储以包含新的stopped状态。这将允许我们移除用户开始共享时出现的消息:

    it("dispatches an action of STOPPED_SHARING", async () => {
      await startSharing();
      store.dispatch({ type: "STOP_SHARING" });
      return expectRedux(store)
        .toDispatchAnAction()
        .matching({ type: "STOPPED_SHARING" });
    });
    
  2. 这是一个简单的单行代码来使其通过:

    function* stopSharing() {
      presenterSocket.close();
      yield put({ type: "STOPPED_SHARING" });
    }
    

接下来是向服务器广播从演示者发出的动作:

  1. 创建一个新的嵌套describe块,包含以下测试:

    describe("SHARE_NEW_ACTION", () => {
      it("forwards the same action on to the socket", async () => {
        const innerAction = { a: 123 };
        await startSharing(123);
        store.dispatch({
          type: "SHARE_NEW_ACTION",
          innerAction,
        });
        expect(sendSpy).toHaveBeenLastCalledWith(
          JSON.stringify({
            type: "NEW_ACTION",
            innerAction,
          })
        );
      });
    });
    
  2. 通过填写以下内容来使shareNewAction函数通过:

    const shareNewAction = ({ innerAction }) => {
      presenterSocket.send(
        JSON.stringify({
          type: "NEW_ACTION",
          innerAction,
        })
      );
    }
    
  3. 添加下一个测试,该测试检查如果用户没有演示,则不会发送任何动作:

    it("does not forward if the socket is not set yet", () => {
      store.dispatch({ type: "SHARE_NEW_ACTION" });
      expect(sendSpy).not.toBeCalled();
    });
    

在异步环境中使用 not.toBeCalled

这个测试有一个微妙的问题。尽管它将帮助你添加到软件的设计中,但它作为回归测试的实用性略低,因为它可能会导致假阳性。这个测试保证测试的开始和结束之间没有发生任何事情,但它对之后发生的事情没有任何保证。这就是异步环境的本质。

  1. 使这个测试通过只是简单地添加一段代码的判断条件:

    function* shareNewAction({ innerAction } ) {
      if (presenterSocket) {
        presenterSocket.send(
          JSON.stringify({
            type: "NEW_ACTION",
            innerAction,
          })
        );
      }
    }
    
  2. 我们也不希望当用户停止共享时共享动作——所以让我们添加这个功能:

    it("does not forward if the socket has been closed", async () => {
      await startSharing();
      socketSpy.readyState = WebSocket.CLOSED;
      store.dispatch({ type: "SHARE_NEW_ACTION" });
      expect(sendSpy.mock.calls).toHaveLength(1);
    });
    

WebSocket 规范

前一个测试中的常量WebSocket.CLOSED和以下代码中的常量WebSocket.OPEN在 WebSocket 规范中定义。

  1. 将测试文件顶部移动,并定义以下两个常量,在导入下面。这是因为当我们监视 WebSocket 构造函数时,我们会覆盖这些值。因此,我们需要将它们重新添加。首先保存真实值:

    const WEB_SOCKET_OPEN = WebSocket.OPEN;
    const WEB_SOCKET_CLOSED = WebSocket.CLOSED;
    
  2. 更新您的监视器,在WebSocket被模拟后设置这些常量。当我们在这里时,让我们也将套接字的默认readyState设置为WebSocket.OPEN,这样其他测试就不会失败:

    socketSpyFactory = jest.spyOn(window, "WebSocket");
    Object.defineProperty(socketSpyFactory, "OPEN", {
      value: WEB_SOCKET_OPEN
    });
    Object.defineProperty(socketSpyFactory, "CLOSED", {
      value: WEB_SOCKET_CLOSED
    });
    socketSpyFactory.mockImplementation(() => {
      socketSpy = {
        send: sendSpy,
        close: closeSpy,
        readyState: WebSocket.OPEN,
      };
      return socketSpy;
    });
    
  3. 最后,回到生产代码中,通过检查readyState是否为WebSocket.OPEN来使测试通过,这并不完全符合测试的指定,但足够好,可以使它通过:

    const shareNewAction = ({ innerAction }) => {
      if (
        presenterSocket &&
        presenterSocket.readyState === WebSocket.OPEN
      ) {
        presenterSocket.send(
          JSON.stringify({
            type: "NEW_ACTION",
            innerAction,
          })
        );
      }
    }
    

这就是演示者的行为:我们已经通过测试驱动了onopenoncloseonmessage回调。在实际应用中,您会希望对onerror回调执行相同的流程。

现在,让我们看看监视器的行为。

使用 redux-saga 进行事件流

在本节中,我们将重复很多相同的技巧。有两个新概念:首先,提取监视器 ID 的search参数,其次,使用eventChannel订阅onmessage回调。这用于从 WebSocket 持续地将消息流到 Redux 存储。

让我们从指定新的 URL 行为开始:

  1. test/middleware/sharingSagas.test.js的底部写一个新的describe块,但仍然嵌套在主describe块中:

    describe("watching", () => {
      beforeEach(() => {
        Object.defineProperty(window, "location", {
          writable: true,
          value: {
            host: "test:1234",
            pathname: "/index.xhtml",
            search: "?watching=234"
          }
        });
      });
      it("opens a socket when the page loads", () => {
        store.dispatch({ type: "TRY_START_WATCHING" });
        expect(socketSpyFactory).toBeCalledWith(
          "ws://test:1234/share"
        );
      });
    });
    
  2. 通过在您的生产代码中填写startWatching函数来使其通过。您可以使用现有的openWebSocket函数:

    function* startWatching() {
      yield openWebSocket();
    }
    
  3. 在下一个测试中,我们将开始使用search参数:

    it("does not open socket if the watching field is not set", () => {
      window.location.search = "?";
      store.dispatch({ type: "TRY_START_WATCHING" });
      expect(socketSpyFactory).not.toBeCalled();
    });
    
  4. 通过使用URLSearchParams对象提取search参数来使其通过:

    function* startWatching() {
      const sessionId = new URLSearchParams(
        window.location.search.substring(1)
      ).get("watching");
      if (sessionId) {
        yield openWebSocket();
      }
    }
    
  5. 在我们编写下一个测试之前,添加以下辅助函数,该函数模拟真实 WebSocket 上将要发生的动作,确保onopen被调用:

    const startWatching = async () => {
      await act(async () => {
        store.dispatch({ type: "TRY_START_WATCHING" });
        socketSpy.onopen();
      });
    };
    
  6. 当一个新的观察会话开始时,我们需要重置用户的输出,使其为空:

    it("dispatches a RESET action", async () => {
      await startWatching();
      return expectRedux(store)
        .toDispatchAnAction()
        .matching({ type: "RESET" });
    });
    
  7. 通过添加一个put函数调用使其通过:

    function* startWatching() {
      const sessionId = new URLSearchParams(
        location.search.substring(1)
      ).get("watching");
      if (sessionId) {
        yield openWebSocket();
        yield put({ type: "RESET" });
      }
    }
    
  8. 接下来,我们需要向服务器发送一条消息,包括我们希望观察的会话 ID:

    it("sends the session id to the socket with an action type of START_WATCHING", async () => {
      await startWatching();
      expect(sendSpy).toBeCalledWith(
        JSON.stringify({
          type: "START_WATCHING",
          id: "234",
        })
      );
    });
    
  9. 我们已经从上一节中设置了监视器,所以这是一个快速修复:

    function* startWatching() {
      const sessionId = new URLSearchParams(
        window.location.search.substring(1)
      ).get("watching");
      if (sessionId) {
        const watcherSocket = yield openWebSocket();
        yield put({ type: "RESET" });
        watcherSocket.send(
          JSON.stringify({
            type: "START_WATCHING",
            id: sessionId,
          })
    );
      }
    }
    
  10. 下一个测试告诉 Redux 存储我们已经开始观察。这样,React UI 就可以向用户显示一条消息,告诉他们他们已经连接:

    it("dispatches a STARTED_WATCHING action", async () => {
      await startWatching();
      return expectRedux(store)
        .toDispatchAnAction()
        .matching({ type: "STARTED_WATCHING" });
    });
    
  11. 通过添加一个新的put调用使其通过,如下所示:

    function* startWatching() {
      ...
      if (sessionId) {
        ...
        yield put({ type: "STARTED_WATCHING" });
      }
    }
    
  12. 现在是最大的一个。我们需要添加允许我们从服务器接收多条消息并读取它们的行为:

    it("relays multiple actions from the websocket", async () => {
      const message1 = { type: "ABC" };
      const message2 = { type: "BCD" };
      const message3 = { type: "CDE" };
      await startWatching();
      await sendSocketMessage(message1);
      await sendSocketMessage(message2);
      await sendSocketMessage(message3);
      await expectRedux(store)
        .toDispatchAnAction()
        .matching(message1);
      await expectRedux(store)
        .toDispatchAnAction()
        .matching(message2);
      await expectRedux(store)
        .toDispatchAnAction()
        .matching(message3);
      socketSpy.onclose();
    });
    

长测试

你可能会认为有一个只处理一条消息的小测试会很有帮助。然而,对于多条消息来说,这并不能帮助我们,因为我们需要为多条消息使用一个完全不同的实现,正如你将在下一步看到的那样。

  1. 我们将使用 eventChannel 函数来完成这个任务。它的用法与之前将回调转换为可以使用 yield 等待的操作的 Promise 对象用法相似。在使用 Promise 对象时,我们在回调收到时调用 resolve。在使用 eventChannel 时,当回调收到时,我们调用 emitter(END)。这一点的意义将在下一步变得明显:

    import { eventChannel, END } from "redux-saga";
    const webSocketListener = socket =>
      eventChannel(emitter => {
        socket.onmessage = emitter;
        socket.onclose = () => emitter(END);
        return () => {
          socket.onmessage = undefined;
          socket.onclose = undefined;
        };
      });
    

理解 eventChannel 函数

来自 redux-sagaeventChannel 函数是一个用于消费发生在 Redux 之外的事件流的机制。在上一个例子中,WebSocket 提供了事件流。当被调用时,eventChannel 会调用提供的函数来初始化通道,然后每次收到事件时都必须调用提供的 emmitter 函数。在我们的情况下,我们直接将消息传递给 emmitter 函数而不做任何修改。当 WebSocket 关闭时,我们传递特殊的 END 事件来通知 redux-saga 将不再接收更多事件,从而允许它关闭通道。

  1. 现在,你可以使用 websocketListener 函数创建一个通道,我们可以通过循环反复从该通道获取事件。这个循环需要被 try 语句包围。当达到 emitter(END) 指令时,将调用 finally 块。创建一个新的生成器函数来完成这个任务,如下所示:

    function* watchUntilStopRequest(chan) {
      try {
        while (true) {
          let evt = yield take(chan);
          yield put(JSON.parse(evt.data));
        }
      } finally {
      }
    };
    
  2. 通过在 startWatching 中调用这两个函数来将 webSocketListener 函数和 watchUntilStopRequest 生成器函数链接起来。完成这一步后,你的测试应该通过:

    function* startWatching() {
      ...
      if (sessionId) {
        ...
        yield put({ type: "STARTED_WATCHING" });
        const channel = yield call(
          webSocketListener, watcherSocket
        );
        yield call(watchUntilStopRequest(channel);
      }
    }
    
  3. 最后的测试是向 Redux 存储器发出警报,表明我们已经停止了监听,这样它就可以从 React UI 中删除出现的消息:

    it("dispatches a STOPPED_WATCHING action when the connection is closed", async () => {
      await startWatching();
      socketSpy.onclose();
      return expectRedux(store)
        .toDispatchAnAction()
        .matching({ type: "STOPPED_WATCHING" });
    });
    
  4. 通过在 watchUntilStopRequest 中的 finally 块中添加这一行代码来实现这一点:

    try {
      ...
    } finally {
      yield put({ type: "STOPPED_WATCHING" });
    }
    

你现在已经完成了整个故事:你的应用程序现在正在接收事件,你也看到了如何使用 eventChannel 函数来监听消息流。

剩下的工作就是将这个功能整合到我们的 React 组件中。

更新应用程序

我们已经完成了构建 sagas 的工作,但我们在应用程序的其余部分只需要做一些调整。

MenuButtons 组件已经功能完整,但我们需要更新测试以正确地测试中间件,有两种方式:首先,我们必须模拟 WebSocket 构造函数,其次,我们需要在应用程序启动时立即触发一个 TRY_START_WATCHING 动作:

  1. 打开 test/MenuButtons.test.js 并首先导入 act 函数。我们需要这个函数来等待我们的 socket saga 动作:

    import { act } from "react-dom/test-utils";
    
  2. 接下来,找到名为 sharing buttondescribe 块,并插入以下 beforeEach 块,它与你在 saga 测试中使用的模拟构造函数类似:

    describe("sharing button", () => {
      let socketSpyFactory;
      let socketSpy;
      beforeEach(() => {
        socketSpyFactory = jest.spyOn(
          window,
          "WebSocket"
        );
        socketSpyFactory.mockImplementation(() => {
          socketSpy = {
            close: () => {},
            send: () => {},
          };
          return socketSpy;
        });
      });
    });
    
  3. 接下来,在相同的 describe 块中,添加以下 notifySocketOpened 实现方式。这与 saga 测试中的 notifySocketOpened 实现方式不同,因为它同时调用了 onopenonmessage,并附带一个示例消息。所有这些对于 startSharing saga 正确运行都是必要的:它模拟了 WebSocket 的打开,然后服务器发送第一条消息,这将导致发送 STARTED_SHARING 消息:

    const notifySocketOpened = async () => {
      const data = JSON.stringify({ id: 1 });
      await act(async () => {
        socketSpy.onopen();
        socketSpy.onmessage({ data });
      });
    };
    
  4. 我们现在可以使用这个来更新导致控制台错误的测试。这个测试的描述是 当点击停止共享时,触发 STOP_SHARING 动作。为了避免错误,我们必须调整几行。首先,我们发送一个 START_SHARING 消息,而不是 STARTED_SHARING 消息。然后,我们使用 notifySocketOpened 来模拟服务器对套接字打开的响应。这将触发 saga 发送 STARTED_SHARING 事件,导致 MenuButtons 改变为名为 STOP_SHARING 的事件被发送:

    it("dispatches an action of STOP_SHARING when stop sharing is clicked", async () => {
      renderWithStore(<MenuButtons />);
      dispatchToStore({ type: "START_SHARING" });
      await notifySocketOpened();
      click(buttonWithLabel("Stop sharing"));
      return expectRedux(store)
        .toDispatchAnAction()
        .matching({ type: "STOP_SHARING" });
    });
    
  5. 测试通过后,更新 src/index.js 以在应用首次加载时调用 TRY_START_WATCHING 动作:

    const store = configureStoreWithLocalStorage();
    store.dispatch({ type: "TRY_START_WATCHING" });
    ReactDOM
      .createRoot(document.getElementById("root"))
      .render(
        <Provider store={store}>
          <App />
        </Provider);
    

你现在可以运行应用并尝试它。以下是一个你可以尝试的手动测试:

  1. 在浏览器窗口中打开一个会话并点击 开始共享

  2. 右键单击出现的链接,并选择在新窗口中打开它。

  3. 将你的两个窗口移动到并排的位置。

  4. 在原始窗口中输入一些命令,例如 forward 100right 90。你应该看到命令已更新。

  5. 现在,在原始窗口中点击 停止共享。你应该看到共享消息从两个屏幕上消失。

这就涵盖了测试驱动 WebSocket 的内容。

摘要

在本章中,我们介绍了如何针对 WebSocket API 进行测试。

你已经看到了如何模拟 WebSocket 构造函数,以及如何测试其 onopenoncloseonmessage 回调。

你还看到了如何使用 Promise 对象将回调转换为可以在生成器函数中产生的对象,以及如何使用 eventChannel 将事件流发送到 Redux 存储。

在下一章中,我们将探讨如何使用 Cucumber 测试来推动共享功能的改进。

练习

你可以添加哪些测试来确保套接字错误能够优雅地处理?

进一步阅读

WebSocket 规范:

www.w3.org/TR/websockets/

第四部分 – 使用 Cucumber 进行行为驱动开发

这一部分是关于使用 Cucumber 测试进行 行为驱动开发BDD)。前三个部分侧重于在组件级别构建 Jest 单元测试,而这一部分则关注在 系统 级别编写测试——你也可以将这些视为端到端测试。目标是展示 TDD 工作流程如何应用于单元测试之外,并且可以被整个团队使用,而不仅仅是开发者。

最后,我们以讨论 TDD 如何在更广泛的测试领域中适用以及如何继续您的 TDD 之旅的建议来结束本书。

本部分包括以下章节:

  • 第十七章, 编写您的第一个 Cucumber 测试

  • 第十八章, 由 Cucumber 测试引导添加功能

  • 第十九章, 在更广泛的测试领域中理解 TDD

第十七章:编写您的第一个 Cucumber 测试

测试驱动开发主要是一个面向开发者的过程。有时,客户和产品所有者也想看到自动化测试的结果。不幸的是,作为 TDD 基础的谦逊的单元测试太低级,对非开发者没有帮助。这就是**行为驱动开发(BDD)**理念出现的地方。

BDD 测试有一些特性,使它们与您迄今为止看到的单元测试有所不同:

  • 它们是端到端测试,在整个系统中运行。

  • 它们是用自然语言而不是代码编写的,既可被非编码者理解,也可被编码者理解。

  • 他们避免提及内部机制,而是专注于系统的外部行为。

  • 测试定义描述了自身(与单元测试不同,您需要编写一个与代码匹配的测试描述)。

  • 语法设计是为了确保您的测试被编写为示例,并且作为行为的离散规范。

BDD 工具与 TDD 和单元测试的比较

您在这本书中迄今为止看到的 TDD 风格将测试(在大多数情况下)视为指定行为的示例。此外,我们的测试始终遵循**安排-行动-断言(AAA)**模式。然而,请注意,单元测试工具如 Jest 并不强迫您以这种方式编写测试。

这就是为什么存在 BDD 工具的原因之一:在您指定系统行为时,迫使您非常明确。

本章介绍了两个新的软件包:Cucumber 和 Puppeteer。

我们将使用 Cucumber 来构建我们的 BDD 测试。Cucumber 是一个存在于许多不同编程环境中的系统,包括 Node.js。它由一个测试运行器组成,该运行器运行包含在特性文件中的测试。特性是用一种称为Gherkin的普通英语编写的。当 Cucumber 运行您的测试时,它将这些特性文件转换为函数调用;这些函数调用是用 JavaScript 支持脚本编写的。

由于 Cucumber 有自己的测试运行器,因此它不使用 Jest。然而,我们将在一些测试中利用 Jest 的expect包。

Cucumber 不是编写系统测试的唯一方式

另一个流行的测试库是 Cypress,它可能更适合您和/或您的团队。Cypress 强调结果的视觉呈现。我倾向于避免使用它,因为它的 API 与行业标准测试模式非常不同,这增加了开发者需要具备的知识量。Cucumber 是跨平台的,测试看起来与您在这本书中看到的标准单元测试非常相似。

Puppeteer与 JSDOM 库执行类似的功能。然而,虽然 JSDOM 在 Node.js 环境中实现了一个假的 DOM API,Puppeteer 则使用真实的网络浏览器 Chromium。在这本书中,我们将以无头模式使用它,这意味着你不会在屏幕上看到应用程序的运行;但如果你愿意,你也可以关闭无头模式。Puppeteer 附带了许多附加功能,例如截图功能。

跨浏览器测试

如果你想测试你应用程序的跨浏览器支持,你可能更倾向于查看像 Selenium 这样的替代方案,这本书中没有涵盖 Selenium。然而,当为 Selenium 编写测试时,相同的测试原则同样适用。

本章涵盖了以下主题:

  • 将 Cucumber 和 Puppeteer 集成到你的代码库中

  • 编写你的第一个 Cucumber 测试

  • 使用数据表进行设置

到本章结束时,你将很好地了解 Cucumber 测试是如何构建和运行的。

技术要求

本章的代码文件可以在以下位置找到:

github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter17

将 Cucumber 和 Puppeteer 集成到你的代码库中

让我们向我们的项目添加必要的包:

  1. 首先,安装我们需要的包。除了 Cucumber 和 Puppeteer,我们还将引入@babel/register,这将使我们能够在支持文件中使用 ES6 功能:

    $ npm install --save-dev @cucumber/cucumber puppeteer 
    $ npm install --save-dev @babel/register
    
  2. 接下来,创建一个名为cucumber.json的新文件,并包含以下内容。这有两个设置;publishQuiet关闭了在运行测试时出现的许多噪音,而requireModule在运行测试之前连接@babel/register

    {
      "default": {
        "publishQuiet": true,
        "requireModule": [
          "@babel/register"
        ]
      }
    }
    
  3. 创建一个名为features的新文件夹。这个文件夹应该与srctest处于同一级别。

  4. 在其中创建一个名为features/support的文件夹。

你现在可以使用以下命令运行测试:

$ npx cucumber-js

你会看到如下输出:

0 scenarios
0 steps
0m00.000s

在本章和下一章中,缩小你正在运行的测试范围可能会有所帮助。你可以通过提供测试运行器的场景文件名和起始行号来运行单个场景:

$ npx cucumber-js features/drawing.feature:5

这就是使用 Cucumber 和 Puppeteer 设置的全部内容——现在是我们编写测试的时候了。

编写你的第一个 Cucumber 测试

在本节中,你将为我们已经构建的 Spec Logo 应用程序的一部分构建一个 Cucumber 功能文件。

关于 Gherkin 代码样本的警告

如果你正在阅读这本书的电子版,在复制粘贴功能定义时要小心。你可能会发现代码中插入了 Cucumber 无法识别的额外换行符。在运行测试之前,请检查你粘贴的代码片段,并删除任何不应该存在的换行符。

让我们开始吧!

  1. 在运行任何 Cucumber 测试之前,确保通过运行 npm run build 来更新你的构建输出是很重要的。你的 Cucumber 规范将针对 dist 目录中构建的代码运行,而不是 src 目录中的源代码。

利用 package.json 脚本的优势

你也可以修改 package.json 脚本来在运行 Cucumber 规范之前调用构建,或者以监视模式运行 webpack。

  1. 创建一个名为 features/sharing.feature 的新文件,并输入以下文本。一个功能有一个名称和简短描述,以及一系列按顺序列出的场景。我们现在只从一个场景开始:

    Feature: Sharing
      A user can choose to present their session to any 
      number of other users, who observe what the 
      presenter is doing via their own browser.
      Scenario: Observer joins a session
        Given the presenter navigated to the application page
        And the presenter clicked the button 'startSharing'
        When the observer navigates to the presenter's sharing link
        Then the observer should see a message saying 'You are now watching the session'
    

Gherkin 语法

Given, When, 和 Then 与你的 Jest 测试的 Arrange, Act, 和 Assert 阶段类似:given 所有这些条件都为真,when 我执行这个操作,then 我期望所有这些事情发生。

理想情况下,你每个场景中只有一个 When 子句。

你会注意到,我已经将 Given 子句写成过去时,When 子句写成现在时,而 Then 子句中有一个“should”。

  1. 在命令行中键入 npx cucumber-js 来运行该功能。你会看到一条警告信息,如下面的代码块所示。Cucumber 在第一个 Given... 语句处停止处理,因为它找不到与之对应的 JavaScript 支持函数。在警告信息中,Cucumber 有助于为你提供了定义的起点:

    ? Given the presenter navigated to the application page
       Undefined. Implement with the following snippet:
         Given('the presenter navigated to the application page', function () {
           // Write code here that turns the phrase above
           // into concrete actions
           return 'pending';
         });
    
  2. 让我们按照它建议的去做。创建一个名为 features/support/sharing.steps.js 的文件,并添加以下代码。它定义了一个步骤定义,该定义调用 Puppeteer 的 API 来启动一个新的浏览器,然后打开一个新页面,然后导航到提供的 URL。步骤定义描述与我们的测试场景中的 Given 子句相匹配。

  3. async 关键字的第二个参数。这是对 Cucumber 在其建议函数定义中告诉我们的内容的补充。我们需要 async,因为 Puppeteer 的 API 调用都返回 promises,我们需要对它们进行 await

    import {
      Given, When, Then
    } from "@cucumber/cucumber";
    import puppeteer from "puppeteer";
    const port = process.env.PORT || 3000;
    const appPage = `http://localhost:${port}/index.xhtml`;
    Given(
      "the presenter navigated to the application page",
      async function () {
        const browser = await puppeteer.launch();
        const page = await browser.newPage();
        await page.goto(appPage);
      }
    );
    

匿名函数,而非 lambda 表达式

你可能想知道为什么我们定义匿名函数(async function (...) { ... })而不是 lambda 表达式(async (...) => { ... })。这使我们能够利用匿名函数发生的隐式上下文绑定。如果我们使用 lambda 表达式,我们就需要在它们上调用 .bind(this)

  1. 再次运行你的测试。Cucumber 现在指定了下一个需要工作的子句。对于这个子句,并且演示者点击了按钮 'startSharing',我们需要访问我们在上一个步骤中刚刚创建的page对象。要做到这一点,我们需要访问所谓的World对象,它是当前场景中所有子句的上下文。我们必须现在构建它。创建features/support/world.js文件并添加以下内容。它定义了两个方法,setPagegetPage,允许我们在世界中保存多个页面。对于这个测试来说,能够保存多个页面是非常重要的,因为我们至少有两个页面——演示者页面和观察者页面:

    import {
      setWorldConstructor
    } from "@cucumber/cucumber";
    class World {
      constructor() {
        this.pages = {};
      }
      setPage(name, page) {
        this.pages[name] = page;
      }
      getPage(name) {
        return this.pages[name];
      }
    };
    setWorldConstructor(World);
    
  2. 我们现在可以在我们的步骤定义中使用setPagegetPage函数。我们的方法将是首先从第一个步骤定义——我们在步骤 3中编写的——调用setPage,然后使用getPage在后续步骤中检索它。现在修改第一个步骤定义,包括对setPage的调用,如下面的代码块所示:

    Given(
      "the presenter navigated to the application page",
      async function () {
        const browser = await puppeteer.launch();
        const page = await browser.newPage();
        await page.goto(appPage);
        this.setPage("presenter", page);
      }
    );
    
  3. 接下来进行下一步,演示者点击了按钮 'startSharing',我们将通过使用Page.click Puppeteer 函数来查找一个 ID 为startSharing的按钮来解决此问题。就像上一个测试一样,我们使用buttonId参数,这样这个步骤定义就可以在未来场景中用于其他按钮:

    Given(
      "the presenter clicked the button {string}",
      async function (buttonId) {
        await this.getPage(
          "presenter"
        ).click(`button#${buttonId}`);
      }
    );
    
  4. 下一步,观察者导航到演示者的分享链接,就像第一步那样,我们想要打开一个新的浏览器。不同的是,这是为观察者准备的,我们首先需要查找要遵循的路径。路径是通过演示者在开始搜索时显示的 URL 给出的。我们可以使用Page.$eval函数来查找:

    When(
      "the observer navigates to the presenter's sharing link",
      async function () {
        await this.getPage(
          "presenter"
        ).waitForSelector("a");
        const link = await this.getPage(
          "presenter"
        ).$eval("a", a => a.getAttribute("href"));
        const url = new URL(link);
        const browser = await puppeteer.launch();
        const page = await browser.newPage();
        await page.goto(url);
        this.setPage("observer", page);
      }
    );
    

步骤定义重复

在我们的步骤定义之间正在积累一些重复。稍后,我们将把这个共性提取到它自己的函数中。

  1. 最后一个步骤定义再次使用Page.$eval Puppeteer 函数,这次是为了找到一个 HTML 节点并将其转换为一个普通的 JavaScript 对象。然后我们使用expect函数以正常方式测试该对象。确保将列出的import语句放置在文件顶部:

    import expect from "expect";
    ...
    Then(
      "the observer should see a message saying {string}",
      async function (message) {
        const pageText = await this.getPage(
          "observer"
        ).$eval("body", e => e.outerHTML);
        expect(pageText).toContain(message);
      }
    );
    
  2. 使用npx cucumber-js运行你的测试。你的测试运行输出将如下所示。虽然我们的步骤定义是完整的,但似乎有些不对劲:

    1) Scenario: Observer joins a session
       ✖ Given the presenter navigated to the application page
            Error: net::ERR_CONNECTION_REFUSED at http://localhost:3000/index.xhtml
    
  3. 虽然我们的应用已经加载,但我们仍然需要启动服务器来处理我们的请求。为此,将以下两个函数添加到 features/support/world.js 中的 World 类,包括在文件顶部添加对应用的 import 语句。startServer 函数相当于我们在 server/src/server.js 中启动服务器的方式。closeServer 函数停止服务器,但在这样做之前,它会关闭所有 Puppeteer 浏览器实例。这样做很重要,因为当调用 close 方法时,服务器不会杀死任何活跃的 websocket 连接。我们需要确保它们首先关闭;否则,服务器将无法停止:

在同一项目中启动服务器

我们很幸运,所有代码都位于同一个项目中,因此可以在同一个进程中启动。如果你的代码库分布在多个项目中,你可能会发现自己需要处理多个进程。

import { app } from "../../server/src/app";
class World {
  ...
  startServer() {
    const port = process.env.PORT || 3000;
    this.server = app.listen(port);
  }
  closeServer() {
    Object.keys(this.pages).forEach(name =>
      this.pages[name].browser().close()
    );
    this.server.close();
  }
}
  1. 利用这些新函数通过 BeforeAfter 钩子。创建一个新文件 features/support/hooks.js 并添加以下代码:

    import { Before, After } from "@cucumber/cucumber";
    Before(function() {
      this.startServer();
    });
    After(function() {
      this.closeServer();
    });
    
  2. 运行 npx cucumber-js 命令并观察输出。你的场景现在应该通过(如果没有通过,请再次检查你是否已经运行了 npm run build):

    > npx cucumber-js
    ......
    1 scenario (1 passed)
    4 steps (4 passed)
    0m00.848s
    
  3. 让我们回到代码中,整理一下重复的部分。我们将提取一个名为 browseToPageFor 的函数,并将其放置在我们的 World 类中。打开 features/support/world.js 并在类底部添加以下方法:

    async browseToPageFor(role, url) {
      const browser = await puppeteer.launch();
      const page = await browser.newPage();
      await page.goto(url);
      this.setPage(role, page);
    }
    
  4. 此外,将 Puppeteer 的 import 语句从 features/support/sharing.steps.js 移动到 features/support/world.js

    import puppeteer from "puppeteer";
    
  5. 最后,用 browseToPageFor 重新编写两个导航步骤:

    Given(
      "the presenter navigated to the application page",
      async function () {
        await this.browseToPageFor("presenter", appPage);
      }
    );
    When(
      "the observer navigates to the presenter's sharing link",
      async function () {
        await this.getPage(
          "presenter"
        ).waitForSelector("a");
        const link = await this.getPage(
          "presenter"
        ).$eval("a", a => a.getAttribute("href"));
        const url = new URL(link);
        await this.browseToPageFor("observer", url);
      }
    );
    

在浏览器内和通过控制台日志进行观察

我们编写的测试以无头模式运行 Puppeteer,这意味着不会启动实际的 Chrome 浏览器窗口。如果你想看到这种情况发生,可以通过修改启动命令(记住在之前的步骤定义中有两个)来关闭无头模式,如下所示:

const browser = await puppeteer.launch(

{ headless: false }

);

如果你正在使用控制台日志来协助调试,你需要提供另一个参数来将控制台输出重定向到 stdout

const browser = await puppeteer.launch(

{ dumpio: true }

);

你现在已经使用 Cucumber 和 Puppeteer 编写了一个 BDD 测试。接下来,让我们看看一个更高级的 Cucumber 场景。

使用数据表进行设置

在本节中,我们将探讨 Cucumber 的一个有用的节省时间特性:数据表。我们将编写第二个场景,就像之前的场景一样,将已经通过 Spec Logo 的现有实现:

  1. 创建一个名为 features/drawing.feature 的新功能文件,内容如下。它包含一系列使用 Logo 函数绘制正方形的指令。使用小的边长 10,以确保动画快速完成:

    Feature: Drawing
      A user can draw shapes by entering commands
      at the prompt.
      Scenario: Drawing functions
        Given the user navigated to the application page
        When the user enters the following instructions at the prompt:
          | to drawsquare |
          |   repeat 4 [ forward 10 right 90 ] |
          | end |
          | drawsquare |
        Then these lines should have been drawn:
          | x1 | y1 | x2 | y2 |
          | 0  | 0  | 10 | 0  |
          | 10 | 0  | 10 | 10 |
          | 10 | 10 | 0  | 10 |
          | 0  | 10 | 0  | 0  |
    
  2. 第一个短语与我们的上一个步骤定义做的是同样的事情,只是我们将 presenter 重命名为 user。在这种情况下,使用更通用的名称是有意义的,因为演示者的角色对这个测试不再相关。我们可以使用 World 函数 browseToPageFor 来完成这个第一步。在共享功能中,我们使用了这个函数与一个包含要导航到的 URL 的 appPage 常量。现在让我们将这个常量拉入 World。在 features/support/world.js 文件中,在 World 类之上添加以下常量:

    const port = process.env.PORT || 3000;
    
  3. 将以下方法添加到 World 类中:

    appPage() {
      return `http://localhost:${port}/index.xhtml`;
    }
    
  4. features/support/sharing.steps.js 文件中,删除 portappPage 的定义,并更新第一个步骤定义,如下所示:

    Given(
      "the presenter navigated to the application page",
      async function () {
        await this.browseToPageFor(
          "presenter",
          this.appPage()
        );
      }
    );
    
  5. 是时候为用户页面创建一个新的步骤定义了。打开 features/support/drawing.steps.js 文件并添加以下代码:

    import {
      Given,
      When,
      Then
    } from "@cucumber/cucumber";
    import expect from "expect";
    Given("the user navigated to the application page",
      async function () {
        await this.browseToPageFor(
          "user",
          this.appPage()
        );
      }
    );
    
  6. 那么,关于带有数据表的第二行,我们的步骤定义应该是什么样子呢?让我们问问 Cucumber。运行 npx cucumber-js 命令并查看输出。它给出了我们定义的起点:

    1) Scenario: Drawing functions
      ✔ Before # features/support/sharing.steps.js:5
      ✔ Given the user navigated to the application page
      ? When the user enters the following instructions at the prompt:
        | to drawsquare |
        |   repeat 4 [ forward 10 right 90 ] |
        | end |
        | drawsquare |
      Undefined. Implement with the following snippet:
      When('the user enters the following instructions at the prompt:',
        function (dataTable) {
          // Write code here that turns the phrase above
          // into concrete actions
          return 'pending';
        }
      );
    
  7. 现在将建议的代码添加到 features/supports/drawing.steps.js 文件中。如果你现在运行 npx cucumber-js,你会注意到 Cucumber 成功地注意到步骤定义是挂起的:

    When(
      "the user enters the following instructions at the prompt:",
      function (dataTable) {
        // Write code here that turns the phrase above
        //into concrete actions
        return "pending";
      }
    );
    
  8. dataTable 变量是一个具有 raw() 函数的 DataTable 对象,该函数返回一个数组的数组。外层数组代表每一行,内层数组代表每一行的列。在下一步定义中,我们希望将每一行都插入到编辑提示中。每一行后面应该跟着一个按下 Enter 键的操作。现在就创建它:

    When(
      "the user enters the following instructions at the prompt:",
      async function (dataTable) {
        for (let instruction of dataTable.raw()) {
          await this.getPage("user").type(
            "textarea",
            `${instruction}\n`
          );
        }
      }
    );
    
  9. 最后一步需要我们查找具有正确属性值的行元素,并将它们与我们的第二个数据表中的值进行比较。以下代码正是这样做的。现在复制这段代码并运行你的测试,以确保它工作并且测试能够通过。所有详细点的解释将会随后提供:

    Then("these lines should have been drawn:",
      async function(dataTable) {
        await this.getPage("user").waitForTimeout(2000);
        const lines = await this.getPage("user").$$eval(
          "line",
          lines => lines.map(line => {
            return {
              x1: parseFloat(line.getAttribute("x1")),
              y1: parseFloat(line.getAttribute("y1")),
              x2: parseFloat(line.getAttribute("x2")),
              y2: parseFloat(line.getAttribute("y2"))
            };
          })
        );
        for (let i = 0; i < lines.length; ++i) {
          expect(lines[i].x1).toBeCloseTo(
            parseInt(dataTable.hashes()[i].x1)
          );
          expect(lines[i].y1).toBeCloseTo(
            parseInt(dataTable.hashes()[i].y1)
          );
          expect(lines[i].x2).toBeCloseTo(
            parseInt(dataTable.hashes()[i].x2)
          );
          expect(lines[i].y2).toBeCloseTo(
            parseInt(dataTable.hashes()[i].y2)
          );
        }
      }
    });
    

最后一个测试包含了一些值得深入研究的复杂性:

  • 我们使用了 Page.waitForTimeout 来等待 2 秒,这给了系统完成动画的时间。包含这样的超时并不是一个好的做法,但暂时它是可行的。我们将在下一章中探讨使其更具体的方法。

  • Page.$$eval 函数类似于 Page.$eval,但在底层返回一个数组,并且调用 document.querySelector 而不是 document.querySelectorAll

  • 重要的是,我们所有的属性转换逻辑——从 HTML 行元素和属性移动到“纯”整数 x1y1 等值——都应该在 Page.$$evaltransform 函数内完成。这是因为 Puppeteer 会在 $$eval 调用完成后回收任何 DOM 节点对象。

  • 我们的行值需要用 parseFloat 来解析,因为我们所编写的 requestAnimationFrame 逻辑与整数端点不完全对齐——它们有非常微小的分数差异。

  • 这也意味着我们需要使用 toBeCloseTo Jest 匹配器而不是 toBe,这是因为我们之前描述的分数值差异所必需的。

  • 最后,我们在这里使用 DataTablehashes() 函数来提取一个对象数组,该数组具有数据表中每列的一个键,基于我们在功能定义中提供的标题行。例如,我们可以调用 hashes()[0].x1 来提取第一行 x1 列的值。

继续使用 npx cucumber-js 运行你的测试。一切都应该通过。

你现在已经很好地理解了如何使用 Cucumber 数据表来制作更具说服力的 BDD 测试。

摘要

Cucumber 测试(以及一般的 BDD 测试)与我们在这本书的其余部分所编写的单元测试类似。它们专注于指定行为的 示例。它们应该使用真实的数据和数字作为测试一般概念的手段,就像我们在本章的两个例子中所做的那样。

BDD 测试与单元测试的不同之处在于,它们是系统测试(具有更广泛的测试范围)并且使用自然语言编写。

就像单元测试一样,在编写 BDD 测试时,找到简化代码的方法很重要。首要规则是尝试编写通用的 World 类或其它模块。我们已经在本章中看到了如何做到这一点的例子。

在下一章中,我们将使用 BDD 测试来驱动 Spec Logo 中新功能的实现。