八、Jest 单元测试

用诸如理性这样的打字语言进行测试是一个有些争议的话题。一些人认为一个好的测试套件减少了对类型系统的需求。另一方面,有些人更看重类型系统,而不是他们的测试套件。这些意见分歧会导致一些相当激烈的辩论。

当然,类型和测试并不相互排斥。我们可以有类型测试。或许理智的核心团队成员之一程露说得最好。

Tests. That's an easy one, right? Types kill a category of tests—not all of the tests. And this is a discussion that people don't appreciate enough. They all pit tests against types. The point is: if you have types, and you add tests, your tests will be able to express much more with less energy. You don't need to assert on invalid input anymore. You can assert on something more important. Tests can be there if you want; you're just saying much more with them. - Cheng Lou You can watch Cheng Lou's talk at React Conf 2017 on the following URL: https://youtu.be/_0T5OSSzxms

在本章中,我们将通过bs-jest BuckleScript 绑定来设置流行的 JavaScript 测试框架 Jest。我们将执行以下操作:

  • 了解如何使用es6commonjs模块格式设置bs-jest
  • 单一原因函数
  • 看看编写测试如何帮助我们改进代码

接下来,克隆本书的 GitHub 存储库,并使用以下代码从Chapter08/app-start开始:

git clone https://github.com/PacktPublishing/ReasonML-Quick-Start-Guide.git
cd ReasonML-Quick-Start-Guide
cd Chapter08/app-start
npm install

用笑话测试

同样由脸书创建的 Jest 可以说是最受欢迎的 JavaScript 测试框架之一。如果你熟悉 React,你很可能也熟悉 Jest。因此,我们将跳过正式的介绍,开始在理性中使用 Jest。

装置

就像任何其他包一样,我们从原因包索引(或简称为 Redex )开始。

Reason Package Index:

https://redex.github.io/

键入jest会显示bs-jest对 Jest 的绑定。按照bs-jest的安装说明,我们首先用 npm 安装bs-jest:

npm install --save-dev @glennsl/bs-jest

然后我们让 BuckleScript 知道这个开发依赖,把它包含在bsconfig.json中。请注意,关键是"bs-dev-dependencies"而不是"bs-dependencies":

"bs-dev-dependencies": ["@glennsl/bs-jest"]

由于bs-jestjest列为依赖项,npm 也会安装jest,我们不需要将jest作为应用的直接依赖项。

现在让我们创建一个__tests__目录作为src目录的兄弟:

cd Chapter08/app-start
mkdir __tests__

告诉 BuckleScript 查找这个目录:

/* bsconfig.json */
...
"sources": [
  {
    "dir": "src",
    "subdirs": true
  },
  {
    "dir": "__tests__",
    "type": "dev"
  }
],
...

最后,我们将在package.json中更新我们的test脚本以使用 Jest:

/* package.json */
"test": "jest"

我们的第一次测试

现在让我们用一些简单的东西来创建我们在__tests__/First_test.re中的第一个测试:

/* __tests__/First_test.re */
open Jest;

describe("Expect", () =>
  Expect.(test("toBe", () =>
            expect(1 + 2) |> toBe(3)
          ))
);

现在运行npm test失败,出现以下错误:

 FAIL lib/es6/__tests__/First_test.bs.js
   Test suite failed to run

    Jest encountered an unexpected token

    This usually means that you are trying to import a file which Jest
    cannot parse, e.g. it's not plain JavaScript.

    By default, if Jest sees a Babel config, it will use that to transform
    your files, ignoring "node_modules".

    Here's what you can do:
      To have some of your "node_modules" files transformed, you can
       specify a custom "transformIgnorePatterns" in your config.
      If you need a custom transformation specify a "transform" option in
       your config.
      If you simply want to mock your non-JS modules (e.g. binary assets)
       you can stub them out with the "moduleNameMapper" config option.

    You'll find more details and examples of these config options in the
    docs:
    https://jestjs.io/docs/en/configuration.html

    Details:

    .../lib/es6/__tests__/First_test.bs.js:3
    import * as Jest from "@glennsl/bs-jest/lib/es6/src/jest.js";
           ^

    SyntaxError: Unexpected token *

      at ScriptTransformer._transformAndBuildScript (node_modules/jest-
      runtime/build/script_transformer.js:403:17)

Test Suites: 1 failed, 1 total
Tests: 0 total
Snapshots: 0 total
Time: 1.43s
Ran all test suites.
npm ERR! Test failed. See above for more details.

这里的问题是 Jest 不能直接理解 ES 模块格式。请记住,我们已经通过以下配置将 BuckleScript 配置为使用 ES 模块(参见第 2 章设置开发环境):

/* bsconfig.json */
...
"package-specs": [
  {
    "module": "es6"
  }
],
...

解决此问题的一种方法是将 BuckleScript 配置为使用"commonjs"模块格式:

/* bsconfig.json */
...
"package-specs": [
  {
    "module": "commonjs"
  }
],
...

然后,我们还需要更新 webpack 的entry字段:

/* webpack.config.js */
...
entry: "./lib/js/src/Index.bs.js", /* changed es6 to js */
...

现在,运行npm test会产生一个通过的测试:

 PASS lib/js/__tests__/First_test.bs.js
  Expect
     toBe (4ms)

Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.322s
Ran all test suites.

或者,如果我们想继续使用 ES 模块格式,我们需要确保 Jest 首先通过 Babel 运行*test.bs.js文件。为此,我们需要遵循以下步骤:

  1. 安装babel-jestbabel-preset-env:
npm install babel-core@6.26.3 babel-jest@23.6.0 babel-preset-env@1.7.0
  1. .babelrc中添加相应的巴别塔配置:
/* .babelrc */
{
  "presets": ["env"]
}
  1. 确保 Jest 通过 Babel 在node_modules内运行某些第三方依赖项。出于性能原因,Jest 默认情况下不运行node_modules到巴贝尔中的任何内容。我们可以通过在package.json中提供自定义的 Jest 配置来覆盖这个行为。在这里,我们将告诉 Jest 只忽略与/node_modules/glennsl*/node_modules/bs-platform*等不匹配的第三方依赖项:
/* package.json */
...
"jest": {
 "transformIgnorePatterns": [
 "/node_modules/(?!@glennsl|bs-platform|bs-css|reason-react)"
 ]
}

现在,运行npm test可以使用专家系统模块格式:

 PASS lib/es6/__tests__/First_test.bs.js
  Expect
     toBe (7ms)

Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.041s
Ran all test suites.

测试业务逻辑

让我们写一个测试,验证我们能够通过其id获得正确的客户。在Customer.re中,有一个名为getCustomer的函数接受customers的数组,并通过调用getId强制获取idgetId功能接受存在于getCustomer范围之外的pathname:

let getCustomer = customers => {
  let id = getId(pathname);
  customers |> Js.Array.find(customer => customer.CustomerType.id == id);
};

马上,我们注意到这是不理想的。如果getCustomer接受一系列的customersid,专注于通过他们的id获得客户,那就更好了。否则,只为getCustomer写一个测试会更难。**

**所以,我们重构getCustomer来接受一个id:

let getCustomerById = (customers, id) => {
 customers |> Js.Array.find(customer => customer.CustomerType.id == id);
};

现在,我们可以更容易地编写测试。跟踪编译器错误,确保您已经将getCustomer替换为getCustomerById。对于id的论点,传入getId(pathname)

让我们将我们的测试重新命名为__tests__/Customers_test.re,并包括以下测试:

open Jest;

describe("Customer", () =>
  Expect.(
    test("can create a customer", () => {
      let customers: array(CustomerType.t) = [|
        {
          id: 1,
          name: "Irita Camsey",
          address: {
            street: "69 Ryan Parkway",
            city: "Kansas City",
            state: "MO",
            zip: "00494",
          },
          phone: "8169271752",
          email: "icamsey0@over-blog.com",
        },
        {
          id: 2,
          name: "Luise Grayson",
          address: {
            street: "2756 Gale Trail",
            city: "Jacksonville",
            state: "FL",
            zip: "23566",
          },
          phone: "9044985243",
          email: "lgrayson1@netlog.com",
        },
        {
          id: 3,
          name: "Derick Whitelaw",
          address: {
            street: "45 Southridge Par",
            city: "Lexington",
            state: "KY",
            zip: "08037",
          },
          phone: "4079634850",
          email: "dwhitelaw2@fema.gov",
        },
      |];
      let customer: CustomerType.t =
        Customer.getCustomerById(customers, 2) |> Belt.Option.getExn;
      expect((customer.id, customer.name)) |> toEqual((2, "Luise 
       Grayson"));
    })
  )
);

用我们现有的代码运行这个测试(通过npm test)会导致以下错误:

 FAIL lib/es6/__tests__/Customers_test.bs.js
   Test suite failed to run

    Error: No message was provided

Test Suites: 1 failed, 1 total
Tests: 0 total
Snapshots: 0 total
Time: 1.711s
Ran all test suites.

错误的原因是Customers.re打电话给顶层的localStorage

/* Customer.re */
let customers = DataBsJson.(parse(getItem("customers"))); /* this is the problem */

由于 Jest 在 Node.js 中运行,我们无法访问浏览器 API。为了解决这个问题,我们可以将这个调用包装在一个函数中:

/* Customer.re */
let getCustomers = () => DataBsJson.(parse(getItem("customers")));

我们可以在initialState中调用这个getCustomers函数。这将使我们避免从笑话中召唤localStorage

让我们更新Customer.re以使客户群进入状态:

/* Customer.re */
...
type state = {
  mode,
  customer: CustomerType.t,
  customers: array(CustomerType.t),
};

...

let getCustomers = () => DataBsJson.(parse(getItem("customers")));

let getCustomerById = (customers, id) => {
 customers |> Js.Array.find(customer => customer.CustomerType.id == id);
};

...

initialState: () => {
  let mode = Js.String.includes("create", pathname) ? Create : Update;
  let customers = getCustomers();
  {
    mode,
    customer:
      switch (mode) {
      | Create => getDefault(customers)
      | Update =>
        Belt.Option.getWithDefault(
          getCustomerById(customers, getId(pathname)),
          getDefault(customers),
        )
      },
    customers,
  };
},

...

/* within the reducer */
ReasonReact.UpdateWithSideEffects(
  {
    ...state,
    customer: {
      id: state.customer.id,
      name: getInputValue("input[name=name]"),
      address: {
        street: getInputValue("input[name=street]"),
        city: getInputValue("input[name=city]"),
        state: getInputValue("input[name=state]"),
        zip: getInputValue("input[name=zip]"),
      },
      phone: getInputValue("input[name=phone]"),
      email: getInputValue("input[name=email]"),
    },
  },
  self => {
    let customers =
      switch (self.state.mode) {
      | Create =>
        Belt.Array.concat(state.customers, [|self.state.customer|])
      | Update =>
        Belt.Array.setExn(
          state.customers,
          Js.Array.findIndex(
            customer =>
              customer.CustomerType.id == self.state.customer.id,
            state.customers,
          ),
          self.state.customer,
        );
        state.customers;
      };

    let json = customers->DataBsJson.toJson;
    DataBsJson.setItem("customers", json);
  },
);

在这些变化之后,我们的测试成功了:

 PASS lib/es6/__tests__/Customers_test.bs.js
  Customer
     can create a customer (5ms)

Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.179s
Ran all test suites.

反射的

在本章中,我们学习了使用 CommonJS 和 ES 模块格式设置bs-jest的基础知识。我们还了解到,单元测试可以帮助我们编写更好的代码,因为在大多数情况下,更容易测试的代码也更好。我们将getCustomer重构为getCustomerById,并将客户群转移到该组件的状态。

由于我们已经在 Reason 中编写了单元测试,编译器也会检查我们的测试。例如,如果Customer_test.re使用getCustomer,而我们在Customer.re中将getCustomerById更改为getCustomer,我们会得到一个编译时错误:

We've found a bug for you!
/__tests__/Customers_test.re 45:9-28

43  |];
44  let customer: CustomerType.t =
45  Customer.getCustomer(customers, 2) |> Belt.Option.getExn;
46  expect((customer.id, customer.name)) |> toEqual((2, "Luise Grayson")
      );
47  })

The value getCustomer can't be found in Customer

Hint: Did you mean getCustomers?

这意味着我们也不能编写某些单元测试。例如,如果我们想要测试第 5 章有效 ML 代码,其中我们使用类型系统来保证一张发票不会打折两次,那么测试甚至不会编译。多可爱啊。

摘要

因为理性的范围如此之广,所以学习它有如此多不同的方法。这本书着重从前端开发人员的角度学习理性。我们采用了我们已经熟悉的技能和概念(例如用 ReactJS 构建网络应用),并探索了我们如何用 ReactJS 做同样的事情。在这个旅程中,我们了解了理性的类型系统、工具链和生态系统。

我相信理性的未来是光明的。我们学到的许多技能可以直接转移到针对原生平台。Reason 的前端故事目前比它的原生故事更加精炼,但是它已经可以编译成 web 和原生。从现在开始只会越来越好。从我第一次开始使用 Reason 以来,已经有了巨大的改进,看到未来会怎样,我非常兴奋。

希望这本书已经激起了你对理性、OCaml 和 ml 语言家族的兴趣。理性的类型系统经历了几十年的工程。结果这本书有很多没涉及到的,我自己还在学习。然而,你现在应该有一个坚实的基础继续你的学习。我鼓励你通过在 Discord 频道上写提问、写博客、指导他人、在会议中分享你的旅程等方式公开学习。

非常感谢你走到这一步,我们将在不和谐频道再见!

Reason Discord channel:

https://discord.gg/reasonml**