使用状态机的 React 基于模型的测试

Avatar of David Khourshid
David Khourshid 发布

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

应用程序测试对于确保代码无错误且满足逻辑需求至关重要。但是,手动编写测试既乏味又容易受到人为偏差和错误的影响。此外,维护可能是一场噩梦,尤其是在添加功能或更改业务逻辑时。我们将学习基于模型的测试如何通过自动生成与任何应用程序的抽象模型保持同步的完整测试,从而消除手动编写集成和端到端测试的需要。

从单元测试到集成测试、端到端测试等等,在开发非平凡的软件应用程序中,许多不同的测试方法都很重要。它们都共享一个共同的目标,但在不同的层面上:确保任何人在使用应用程序时,它的行为都完全符合预期,没有任何意外状态、错误或更糟糕的情况,例如崩溃。

Testing Trophy
测试奖杯(来自 testingjavascript.com)展示了不同类型测试的重要性

Kent C. Dodds 在他的文章 编写测试。不要太多。主要是集成。 中描述了编写这些测试的实际重要性。一些测试,例如静态测试和单元测试,很容易编写,但不能完全确保每个单元都能协同工作。其他测试,例如集成测试和端到端 (E2E) 测试,编写起来需要更多时间,但能让你更有信心应用程序能够按照用户预期工作,因为它们复制了类似于用户在现实生活中使用应用程序的场景。

那么,为什么如今的应用程序中很少有集成测试或 E2E 测试,却有数百(如果不是数千)个单元测试呢?原因包括资源不足、时间不足或对编写这些测试重要性的理解不足。此外,即使编写了大量的集成/ E2E 测试,如果应用程序的一部分发生更改,大多数这些冗长且复杂的测试都需要重写,并且需要编写新的测试。在截止日期的压力下,这很快变得不可行。

从自动化到自动生成

应用程序测试的现状是

  1. 手动测试,其中不存在自动化测试,并且应用程序功能和用户流程是手动测试的
  2. 编写自动化测试,这些测试是脚本化的测试,可以由程序自动执行,而不是由人工手动测试
  3. 测试自动化,这是在开发周期中执行这些自动化测试的策略。

不用说,测试自动化节省了大量执行测试的时间,但测试仍然需要手动编写。当然,如果能告诉某种工具:“这是应用程序应该如何运行的描述。现在生成所有测试,包括边缘情况。”那就太好了。

值得庆幸的是,这个想法已经存在(并且已经研究了几十年),它被称为 基于模型的测试。以下是它的工作原理

  1. 创建了一个描述应用程序行为的抽象“模型”(以有向图的形式)
  2. 从有向图生成测试路径
  3. 测试路径中的每个“步骤”都映射到可以在应用程序上执行的测试。

每个集成和 E2E 测试本质上是一系列步骤,这些步骤在以下之间交替进行

  1. 验证应用程序外观是否正确(一个状态
  2. 模拟某些操作(以产生一个事件
  3. 验证操作后应用程序外观是否正确(另一个状态

如果您熟悉行为测试的 Given-When-Then 样式,这看起来会很熟悉

  1. 给定某个初始状态(前提条件)
  2. 某些操作发生时(行为)
  3. 那么预期某个新状态(后置条件)。

一个模型可以描述所有可能的状态和事件,并自动生成从一个状态转换到另一个状态所需的“路径”,就像 Google Maps 可以生成两个位置之间可能的路线一样。就像地图路线一样,每条路径都是从 A 点到 B 点所需的一系列步骤。

无需模型的集成测试

为了更好地解释这一点,请考虑一个简单的“反馈”应用程序。我们可以这样描述它

  • 出现一个面板,询问用户:“您的体验如何?”
  • 用户可以点击“好”或“坏”
  • 当用户点击“好”时,会出现一个显示“感谢您的反馈”的屏幕。
  • 当用户点击“坏”时,会出现一个表单,要求提供更多信息。
  • 用户可以选择填写表单并提交反馈。
  • 提交表单后,会出现感谢屏幕。
  • 用户可以点击“关闭”或按 Escape 键在任何屏幕上关闭反馈应用程序。

查看 CodePen 上的 Pen
Untitled by David Khourshid(
@davidkpiano)
CodePen 上。

手动测试应用程序

@testing-library/react 库使用其 render() 函数使在测试环境中渲染 React 应用程序变得简单明了。这将返回有用的方法,例如

  • getByText,它通过包含在其中的文本识别 DOM 元素
  • baseElement,它表示根 document.documentElement 并将用于触发 keyDown 事件
  • queryByText,如果缺少包含指定文本的 DOM 元素,它不会抛出错误(因此我们可以断言没有任何内容呈现)
import Feedback from './App';
import { render, fireEvent, cleanup } from 'react-testing-library';

// ...

// Render the feedback app
const {
  getByText,
  getByTitle,
  getByPlaceholderText,
  baseElement,
  queryByText
} = render(<Feedback />);

// ...

更多信息可以在 @testing-library/react 文档 中找到。让我们使用 Jest(或 Mocha)和 @testing-library/react 为此编写一些集成测试

import { render, fireEvent, cleanup } from '@testing-library/react';

describe('feedback app', () => {
  afterEach(cleanup);

  it('should show the thanks screen when "Good" is clicked', () => {
    const { getByText } = render(<Feedback />);

    // The question screen should be visible at first
    assert.ok(getByText('How was your experience?'));

    // Click the "Good" button
    fireEvent.click(getByText('Good'));

    // Now the thanks screen should be visible
    assert.ok(getByText('Thanks for your feedback.'));
  });

  it('should show the form screen when "Bad" is clicked', () => {
    const { getByText } = render(<Feedback />);

    // The question screen should be visible at first
    assert.ok(getByText('How was your experience?'));

    // Click the "Bad" button
    fireEvent.click(getByText('Bad'));

    // Now the form screen should be visible
    assert.ok(getByText('Care to tell us why?'));
  });
});

还不错,但你会注意到有一些重复的操作。最初,这不是什么大问题(测试不一定要 DRY),但当以下情况发生时,这些测试的可维护性会降低

  • 应用程序行为发生变化,例如添加新的步骤或删除步骤
  • 用户界面元素发生变化,这种变化可能甚至不是简单的组件变化(例如用键盘快捷键或手势替换按钮)
  • 开始出现边缘情况,需要考虑在内。

此外,E2E 测试将测试完全相同的行为(尽管在更真实的测试环境中,例如使用 Puppeteer 或 Selenium 的实时浏览器),但它们无法重用相同的测试,因为执行测试的代码与这些环境不兼容。

状态机作为抽象模型

还记得我们上面对反馈应用程序的非正式描述吗?我们可以将其转换为一个模型,该模型表示应用程序可能处于的不同状态、事件以及状态之间的转换;换句话说,一个 有限状态机。有限状态机是以下内容的表示

  • 应用程序中的有限状态(例如,questionformthanksclosed
  • 初始状态(例如,question
  • 应用程序中可能发生的事件(例如,CLICK_GOODCLICK_BAD 用于点击好/坏按钮,CLOSE 用于点击关闭按钮,以及 SUBMIT 用于提交表单)
  • 转换,或者由于事件而导致一个状态如何转换到另一个状态(例如,当处于 question 状态并执行 CLICK_GOOD 操作时,用户现在处于 thanks 状态)
  • 最终状态(例如,closed),如果适用。

反馈应用程序的行为可以用这些状态、事件和转换在有限状态机中表示,如下所示

State diagram of example Feedback app

可以使用 XState 从状态机的 JSON 类描述生成可视化表示

import { Machine } from 'xstate';

const feedbackMachine = Machine({
  id: 'feedback',
  initial: 'question',
  states: {
    question: {
      on: {
        CLICK_GOOD: 'thanks',
        CLICK_BAD: 'form',
        CLOSE: 'closed'
      }
    },
    form: {
      on: {
        SUBMIT: 'thanks',
        CLOSE: 'closed'
      }
    },
    thanks: {
      on: {
        CLOSE: 'closed'
      }
    },
    closed: {
      type: 'final'
    }
  }
});

https://xstate.js.org/viz/?gist=e711330f8aad8b52da76419282555820

如果您有兴趣深入了解 XState,您可以阅读 XState 文档,或者阅读一篇关于 将 XState 与 React 一起使用 的优秀文章,作者是 Jon Bellah。请注意,此有限状态机仅用于测试,而不是在我们的实际应用程序中使用——这是基于模型的测试的一个重要原则,因为它表示用户期望应用程序的行为,而不是其实际的实现细节。应用程序不一定需要以有限状态机的理念来创建(尽管这是一种非常有帮助的做法)。

创建测试模型

应用程序的行为现在被描述为一个有向图,其中节点是状态,边(或箭头)是表示状态之间转换的事件。我们可以使用该状态机(行为的抽象表示)来创建一个**测试模型**。@xstate/graph库包含一个createModel函数来执行此操作。

import { Machine } from 'xstate';
import { createModel } from '@xstate/test';

const feedbackMachine = Machine({/* ... */});

const feedbackModel = createModel(feedbackMachine);

这个测试模型是一个抽象模型,它表示**被测系统(SUT)**(在本例中为我们的应用程序)的期望行为。使用此测试模型,可以创建测试计划,我们可以用它来测试SUT能否到达模型中的每个状态。测试计划描述了可以采取的到达目标状态的测试路径。

验证状态

目前,这个模型有点没用。它可以生成测试路径(我们将在下一节中看到),但要发挥其作为测试模型的作用,我们需要为每个状态添加一个测试。@xstate/test包将从meta.test读取这些测试函数。

const feedbackMachine = Machine({
  id: 'feedback',
  initial: 'question',
  states: {
    question: {
      on: {
        CLICK_GOOD: 'thanks',
        CLICK_BAD: 'form',
        CLOSE: 'closed'
      },
      meta: {
        // getByTestId, etc. will be passed into path.test(...) later.
        test: ({ getByTestId }) => {
          assert.ok(getByTestId('question-screen'));
        }
      }
    },
    // ... etc.
  }
});

请注意,这些与我们之前使用@testing-library/react编写的测试中的断言相同。这些测试的目的是验证SUT在执行事件之前处于给定状态的**先决条件**。

执行事件

为了使我们的测试模型完整,我们需要使每个事件(例如CLICK_GOODCLOSE)变得“真实”且可执行。也就是说,我们必须将这些事件映射到将在SUT中执行的实际操作。每个事件的执行函数在createModel(…).withEvents(…)中指定。

import { Machine } from 'xstate';
import { createModel } from '@xstate/test';

const feedbackMachine = Machine({/* ... */});

const feedbackModel = createModel(feedbackMachine)
  .withEvents({
    // getByTestId, etc. will be passed into path.test(...) later.
    CLICK_GOOD: ({ getByText }) => {
      fireEvent.click(getByText('Good'));
    },
    CLICK_BAD: ({ getByText }) => {
      fireEvent.click(getByText('Bad'));
    },
    CLOSE: ({ getByTestId }) => {
      fireEvent.click(getByTestId('close-button'));
    },
    SUBMIT: {
      exec: async ({ getByTestId }, event) => {
        fireEvent.change(getByTestId('response-input'), {
          target: { value: event.value }
        });
        fireEvent.click(getByTestId('submit-button'));
      },
      cases: [{ value: 'something' }, { value: '' }]
    }
  });

请注意,您可以将每个事件指定为执行函数,或者(在SUBMIT的情况下)指定为一个对象,其中执行函数在exec中指定,示例事件用例在cases中指定。

从模型到测试路径

再次查看可视化并跟随箭头,从初始的question状态开始。您会注意到,您可以采取许多可能的路径来到达任何其他状态。例如

  • question状态,CLICK_GOOD事件转换到…
  • form状态,然后SUBMIT事件转换到…
  • thanks状态,然后CLOSE事件转换到…
  • closed状态。

由于应用程序的行为是有向图,我们可以生成从初始状态到任何其他状态的所有可能的简单路径最短路径。简单路径是指没有节点重复的路径。也就是说,我们假设用户不会多次访问同一个状态(尽管这将来可能是一项有效的测试内容)。最短路径是这些简单路径中最短的路径。

我们无需解释遍历图以查找最短路径的算法(如果您对此感兴趣,Vaidehi Joshi撰写了关于图遍历的精彩文章),我们使用@xstate/test创建的测试模型有一个.getSimplePathPlans(…)方法可以生成**测试计划**。

每个测试计划都表示一个目标状态以及从初始状态到该目标状态的简单路径。每个测试路径都表示一系列到达该目标状态的步骤,每个步骤都包括一个state(先决条件)和一个event(操作),该操作在验证应用程序处于state状态后执行。

例如,单个测试计划可以表示到达thanks状态,并且该测试计划可以具有一个或多个到达该状态的路径,例如question -- CLICK_BAD → form -- SUBMIT → thanksquestion -- CLICK_GOOD → thanks

testPlans.forEach(plan => {
  describe(plan.description, () => {
    // ...
  });
});

然后,我们可以循环遍历这些计划来describe每个状态。plan.description@xstate/test提供,例如reaches state: "question"

// Get test plans to all states via simple paths
const testPlans = testModel.getSimplePathPlans();

并且可以测试plan.paths中的每个path,还可以使用提供的path.description,例如via CLICK_GOOD → CLOSE

testPlans.forEach(plan => {
  describe(plan.description, () => {
    // Do any cleanup work after testing each path
    afterEach(cleanup);

    plan.paths.forEach(path => {
      it(path.description, async () => {
        // Test setup
        const rendered = render(<Feedback />);

        // Test execution
        await path.test(rendered);
      });
    });
  });
});

使用path.test(…)测试路径涉及

  1. 验证应用程序是否处于路径步骤的某个state状态
  2. 执行与路径步骤的event关联的操作
  3. 重复步骤1.和2.,直到没有更多步骤
  4. 最后,验证应用程序是否处于目标plan.state状态。

最后,我们要确保测试模型中的每个状态都已测试。在运行测试时,测试模型会跟踪已测试的状态,并提供一个testModel.testCoverage()函数,如果未覆盖所有状态,则该函数将失败。

it('coverage', () => {
  testModel.testCoverage();
});

总体而言,我们的测试套件如下所示

import React from 'react';
import Feedback from './App';
import { Machine } from 'xstate';
import { render, fireEvent, cleanup } from '@testing-library/react';
import { assert } from 'chai';
import { createModel } from '@xstate/test';

describe('feedback app', () => {
  const feedbackMachine = Machine({/* ... */});
  const testModel = createModel(feedbackMachine)
    .withEvents({/* ... */});

  const testPlans = testModel.getSimplePathPlans();
  testPlans.forEach(plan => {
    describe(plan.description, () => {
      afterEach(cleanup);
      plan.paths.forEach(path => {
        it(path.description, () => {
          const rendered = render(<Feedback />);
          return path.test(rendered);
        });
      });
    });
  });

  it('coverage', () => {
    testModel.testCoverage();
  });
});

这看起来可能需要一些设置,但手动编写的集成测试无论如何都需要所有这些设置,只是方式不那么抽象。基于模型的测试的主要优势之一是,无论您生成10个测试还是1000个测试,您只需要设置**一次**。

运行测试

create-react-app中,测试是使用Jest通过命令npm test(或yarn test)运行的。在运行测试时,假设所有测试都通过,输出将如下所示

PASS  src/App.test.js
feedback app
  ✓ coverage
  reaches state: "question" 
    ✓ via  (44ms)
  reaches state: "thanks" 
    ✓ via CLICK_GOOD (17ms)
    ✓ via CLICK_BAD → SUBMIT ({"value":"something"}) (13ms)
  reaches state: "closed" 
    ✓ via CLICK_GOOD → CLOSE (6ms)
    ✓ via CLICK_BAD → SUBMIT ({"value":"something"}) → CLOSE (11ms)
    ✓ via CLICK_BAD → CLOSE (10ms)
    ✓ via CLOSE (4ms)
  reaches state: "form" 
    ✓ via CLICK_BAD (5ms)

Test Suites: 1 passed, 1 total
Tests:       9 passed, 9 total
Snapshots:   0 total
Time:        2.834s

使用我们应用程序的有限状态机模型自动生成了9个测试!每个测试都断言应用程序处于正确状态,并且执行(并验证)了正确的操作以在每个步骤转换到下一个状态,最后断言应用程序处于正确的目标状态。

随着应用程序变得越来越复杂,这些测试可能会快速增长;例如,如果您向每个屏幕添加后退按钮,或向表单页面添加一些验证逻辑(请不要这样做;感谢用户甚至浏览反馈表单),或在提交表单时添加加载状态,则可能的路径数量将增加。

基于模型的测试的优势

基于模型的测试通过根据模型(如有限状态机)自动生成集成和E2E测试,极大地简化了它们的创建,如上所示。由于测试创建过程中消除了手动编写完整测试的过程,因此添加或删除新功能不再成为测试维护负担。只需要更新抽象模型,而无需触及测试代码的任何其他部分。

例如,如果您想添加表单在用户点击“好”或“坏”按钮时都会显示的功能,则只需在有限状态机中进行一行更改即可。

// ...
    question: {
      on: {
//      CLICK_GOOD: 'thanks',
        CLICK_GOOD: 'form',
        CLICK_BAD: 'form',
        CLOSE: 'closed',
        ESC: 'closed'
      },
      meta: {/* ... */}
    },
// ...

受新行为影响的所有测试都将更新。测试维护简化为维护模型,这节省了时间并避免了在手动更新测试时可能出现的错误。这已被证明可以提高开发和测试生产应用程序的效率,尤其是在微软最近的客户项目中使用时——当添加新功能或进行更改时,自动生成的测试会立即反馈应用程序逻辑的哪些部分受到影响,而无需手动回归测试各种流程。

此外,由于模型是抽象的,并且不与实现细节绑定,因此可以使用完全相同的模型以及大部分测试代码来编写E2E测试。唯一会发生变化的是用于验证状态和执行操作的测试。例如,如果您使用的是Puppeteer,您可以更新状态机

// ...
question: {
  on: {
    CLICK_GOOD: 'thanks',
    CLICK_BAD: 'form',
    CLOSE: 'closed'
  },
  meta: {
    test: async (page) => {
      await page.waitFor('[data-testid="question-screen"]');
    }
  }
},
// ...
const testModel = createModel(/* ... */)
  .withEvents({
    CLICK_GOOD: async (page) => {
      const goodButton = await page.$('[data-testid="good-button"]');
      await goodButton.click();
    },
    // ...
  });

然后,这些测试可以在活动的Chromium浏览器实例上运行。

End-to-end tests for Feedback app being run in a browser

测试是自动生成的,这一点需要强调。虽然这看起来像是创建DRY测试代码的一种花哨方法,但它远远不止于此——自动生成的测试可以详尽地表示探索用户在应用程序所有可能状态下可以执行的所有可能操作的路径,这可以很容易地暴露您可能甚至没有想到的边缘情况。

使用@testing-library/react的集成测试和使用Puppeteer的E2E测试的代码都可以在XState测试演示存储库中找到。

基于模型的测试的挑战

由于基于模型的测试将工作从手动编写测试转移到手动编写模型,因此存在学习曲线。创建模型需要理解有限状态机,甚至可能需要理解状态图。学习这些知识的好处远不止测试,因为有限状态机是计算机科学的核心原理之一,而状态图使状态机在复杂应用和软件开发中更加灵活和可扩展。Erik Mogensen 的《状态图的世界》 是一个了解和学习状态图工作原理的绝佳资源。

另一个问题是遍历有限状态机的算法可能会生成指数级数量的测试路径。这可以被认为是一个好的问题,因为这些路径中的每一个都代表了用户可能与应用程序交互的有效方式。但是,这在计算上也可能代价高昂,并导致团队宁愿跳过的半冗余测试,以节省测试时间。也有一些方法可以限制这些测试路径,例如,使用最短路径而不是简单路径,或者通过重构模型。过多的测试可能是模型过于复杂(甚至应用程序过于复杂😉)的标志。

编写更少的测试!

对应用程序行为进行建模并非易事,但将应用程序表示为声明性和抽象模型(例如有限状态机或状态图)有很多好处。尽管基于模型的测试的概念已经存在了二十多年,但它仍然是一个不断发展的领域。但是,通过以上技术,您可以立即开始,并利用生成集成和端到端测试的优势,而不是手动编写每一个测试。

更多资源

我在 2019 年的 React Rally 大会上做了一个关于在 React 应用中使用基于模型测试的演讲。

幻灯片:幻灯片:编写更少的测试!从自动化到自动生成

测试愉快!