ReasonML 快速启动指南(三)

原文:zh.annas-archive.org/md5/EBC7126C5733D51726286A656704EE51

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:Reason 中的 JSON

在本章中,我们将学习如何通过构建一个简单的客户管理应用程序来处理 JSON。此应用程序位于现有应用程序的/customers路由中,并且可以创建、读取和更新客户。JSON 数据持久保存在localStorage中。在本章中,我们以两种不同的方式将外部 JSON 转换为 Reason 可以理解的类型化数据结构:

  • 使用纯 Reason

  • 使用bs-json

我们将在本章末比较和对比每种方法。我们还将讨论GraphQL如何在静态类型语言(如 Reason)中处理 JSON 时,可以提供愉快的开发人员体验。

要跟随构建客户管理应用程序的过程,请克隆本书的 GitHub 存储库,并从Chapter07/app-start开始:

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

在本章中,我们将研究以下主题:

  • 构建视图

  • 与 localStorage 集成

  • 使用 bs-json

  • 使用 GraphQL

构建视图

总共,我们将有三个视图:

  • 列表视图

  • 创建视图

  • 更新视图

每个视图都有自己的路由。创建和更新视图共享一个公共组件,因为它们非常相似。

文件结构

由于我们的bsconfig.json包含子目录,我们可以创建一个src/customers目录来容纳相关组件,BuckleScript 将递归查找src子目录中的 Reason(和 OCaml)文件:

/* bsconfig.json */
...
"sources": {
  "dir": "src",
  "subdirs": true
},
...

让我们继续并将src/Page1.re组件重命名为src/customers/CustomerList.re。在同一目录中,我们稍后将创建Customer.re,用于创建和更新单个客户。

更新路由器和导航菜单

Router.re中,我们将用以下内容替换/page1路由:

/* Router.re */
let routes = [
  ...
  {href: "/customers", title: "Customer List", component: <CustomerList />}
  ...
];

我们还将添加/customers/create/customers/:id的路由:

/* Router.re */
let routes = [
  ...
  {href: "/customers/create", title: "Create Customer", component: <Customer />,},
  {href: "/customers/:id", title: "Update Customer", component: <Customer />}
  ...
];

路由器已更新,以便处理路由变量(例如/customers/:id)。此更改已在Chapter07/app-start中为您进行了。

最后,请确保还更新<App.re />中的导航菜单:

/* App.re */
render: self =>
  ...
  <ul>
    <li>
      <NavLink href="/customers">
        {ReasonReact.string("Customers")}
      </NavLink>
    </li>
  ...

CustomerType.re

此文件将保存由<CustomerList /><Customer />使用的客户类型。这样做是为了避免任何循环依赖的编译器错误:

/* CustomerType.re */
type address = {
  street: string,
  city: string,
  state: string,
  zip: string,
};

type t = {
  id: int,
  name: string,
  address,
  phone: string,
  email: string,
};

CustomerList.re

现在,我们将使用一个硬编码的客户数组。很快,我们将从localStorage中检索这些数据。以下组件呈现了一个经过样式化的客户数组。每个客户都包含在一个<Link />组件中。单击客户会导航到更新视图:

let component = ReasonReact.statelessComponent("CustomerList");

let customers: array(CustomerType.t) = [
  {
    id: 1,
    name: "Christina Langworth",
    address: {
      street: "81 Casey Stravenue",
      city: "Beattyview",
      state: "TX",
      zip: "57918",
    },
    phone: "877-549-1362",
    email: "Christina.Langworth@gmail.com",
  },
  ...
];

module Styles = {
  open Css;

  let list =
    style([
      ...
    ]);
};

let make = _children => {
  ...component,
  render: _self =>
    <div>
      <ul className=Styles.list>
        {
          ReasonReact.array(
            Belt.Array.map(customers, customer =>
              <li key={string_of_int(customer.id)}>
                <Link href={"/customers/" ++ string_of_int(customer.id)}>
                  <p> {ReasonReact.string(customer.name)} </p>
                  <p> {ReasonReact.string(customer.address.street)} </p>
                  <p> {ReasonReact.string(customer.phone)} </p>
                  <p> {ReasonReact.string(customer.email)} </p>
                </Link>
              </li>
            )
          )
        }
      </ul>
    </div>,
};

Customer.re

此 reducer 组件呈现一个表单,其中每个客户字段都可以在输入元素内进行编辑。该组件有两种模式——CreateUpdate——基于window.location.pathname

我们首先绑定到window.location.pathname,并定义我们组件的操作和状态:

/* Customer.re */
[@bs.val] external pathname: string = "window.location.pathname";

type mode =
  | Create
  | Update;

type state = {
  mode,
  customer: CustomerType.t,
};

type action =
  | Save(ReactEvent.Form.t);

let component = ReasonReact.reducerComponent("Customer");

接下来,我们使用bs-css添加组件样式。要查看样式,请查看Chapter07/app-end/src/customers/Customer.re

/* Customer.re */
module Styles = {
  open Css;

  let form =
    style([
      ...
    ]);
};

现在,我们将使用一个硬编码的客户数组,我们在以下片段中创建。完整的数组可以在Chapter07/app-end/src/customers/Customer.re中找到:

/* Customer.re */
let customers: array(CustomerType.t) = [|
  {
    id: 1,
    name: "Christina Langworth",
    address: {
      street: "81 Casey Stravenue",
      city: "Beattyview",
      state: "TX",
      zip: "57918",
    },
    phone: "877-549-1362",
    email: "Christina.Langworth@gmail.com",
  },
  ...
|];

我们还有一些辅助函数,原因如下:

  • window.location.pathname中提取客户 ID

  • 通过 ID 获取客户

  • 生成默认客户:

let getId = pathname =>
  try (Js.String.replaceByRe([%bs.re "/\\D/g"], "", pathname)->int_of_string) {
  | _ => (-1)
  };

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

let getDefault = customers: CustomerType.t => {
  id: Belt.Array.length(customers) + 1,
  name: "",
  address: {
    street: "",
    city: "",
    state: "",
    zip: "",
  },
  phone: "",
  email: "",
};

当然,以下是我们组件的make函数:

let make = _children => {
  ...component,
  initialState: () => {
    let mode = Js.String.includes("create", pathname) ? Create : Update;
    {
      mode,
      customer:
        switch (mode) {
        | Create => getDefault(customers)
        | Update =>
          Belt.Option.getWithDefault(
            getCustomer(customers),
            getDefault(customers),
          )
        },
    };
  },
  reducer: (action, state) =>
    switch (action) {
    | Save(event) =>
      ReactEvent.Form.preventDefault(event);
      ReasonReact.Update(state);
    },
  render: self =>
    <form
      className=Styles.form
      onSubmit={
        event => {
          ReactEvent.Form.persist(event);
          self.send(Save(event));
        }
      }>
      <label>
        {ReasonReact.string("Name")}
        <input type_="text" defaultValue={self.state.customer.name} />
      </label>
      <label>
        {ReasonReact.string("Street Address")}
        <input
          type_="text"
          defaultValue={self.state.customer.address.street}
        />
      </label>
      <label>
        {ReasonReact.string("City")}
        <input type_="text" defaultValue={self.state.customer.address.city} />
      </label>
      <label>
        {ReasonReact.string("State")}
        <input type_="text" defaultValue={self.state.customer.address.state} />
      </label>
      <label>
        {ReasonReact.string("Zip")}
        <input type_="text" defaultValue={self.state.customer.address.zip} />
      </label>
      <label>
        {ReasonReact.string("Phone")}
        <input type_="text" defaultValue={self.state.customer.phone} />
      </label>
      <label>
        {ReasonReact.string("Email")}
        <input type_="text" defaultValue={self.state.customer.email} />
      </label>
      <input
        type_="submit"
        value={
          switch (self.state.mode) {
          | Create => "Create"
          | Update => "Update"
          }
        }
      />
    </form>,
};

Save操作尚未保存到localStorage。导航到/customers/create时表单为空,并且导航到例如/customers/1时填充。

与 localStorage 集成

让我们创建一个单独的模块来与数据层交互,我们将称之为DataPureReason.re。在这里,我们公开了对localStorage.getItemlocalStorage.setItem的绑定,以及一个解析函数,将 JSON 字符串解析为之前定义的CustomerType.t记录。

填充 localStorage

您将在Chapter07/app-end/src/customers/data.json中找到一些初始数据。请在浏览器控制台中运行localStorage.setItem("customers", JSON.stringify(/*在此粘贴 JSON 数据*/))来填充localStorage中的初始数据。

DataPureReason.re

还记得 BuckleScript 绑定有点晦涩吗?希望现在它们开始变得更加直观:

[@bs.val] [@bs.scope "localStorage"] external getItem: string => string = "";
[@bs.val] [@bs.scope "localStorage"]
external setItem: (string, string) => unit = "";

为了解析 JSON,我们将使用Js.Json模块。

Js.Json 文档可以在以下 URL 找到:

bucklescript.github.io/bucklescript/api/Js_json.html

很快,您将看到一种使用Js.Json模块解析 JSON 字符串的方法。不过,有一个警告:这有点繁琐。但是了解发生了什么以及为什么我们需要为 Reason 等类型化语言做这些是很重要的。在高层次上,我们将验证 JSON 字符串以确保它是有效的 JSON,如果是,则使用Js.Json.classify函数将 JSON 字符串(Js.Json.t)转换为标记类型(Js.Json.tagged_t)。可用的标记如下:

type tagged_t =
  | JSONFalse
  | JSONTrue
  | JSONNull
  | JSONString(string)
  | JSONNumber(float)
  | JSONObject(Js_dict.t(t))
  | JSONArray(array(t));

这样,我们可以将 JSON 字符串转换为类型化的 Reason 数据结构。

验证 JSON 字符串

在前一节中定义的getItem绑定将返回一个字符串:

let unvalidated = DataPureReason.getItem("customers");

我们可以这样验证 JSON 字符串:

let validated =
  try (Js.Json.parseExn(unvalidated)) {
  | _ => failwith("Error parsing JSON string")
  };

如果 JSON 无效,它将生成一个运行时错误。在本章结束时,我们将学习 GraphQL 如何帮助改善这种情况。

使用 Js.Json.classify

让我们假设我们已经验证了以下 JSON(它是一个对象数组):

[
  {
    "id": 1,
    "name": "Christina Langworth",
    "address": {
      "street": "81 Casey Stravenue",
      "city": "Beattyview",
      "state": "TX",
      "zip": "57918"
    },
    "phone": "877-549-1362",
    "email": "Christina.Langworth@gmail.com"
  },
  {
    "id": 2,
    "name": "Victor Tillman",
    "address": {
      "street": "2811 Toby Gardens",
      "city": "West Enrique",
      "state": "NV",
      "zip": "40465"
    },
    "phone": "(502) 091-2292",
    "email": "Victor.Tillman30@gmail.com"
  }
]

现在我们已经验证了 JSON,我们准备对其进行分类:

switch (Js.Json.classify(validated)) {
| Js.Json.JSONArray(array) =>
  Belt.Array.map(array, customer => ...)
| _ => failwith("Expected an array")
};

我们对Js.Json.tagged_t的可能标签进行模式匹配。如果它是一个数组,我们就使用Belt.Array.map(或Js.Array.map)对其进行映射。否则,在我们的应用程序环境中会出现运行时错误。

map函数传递了数组中每个对象的引用。但是 Reason 还不知道每个元素是一个对象。在map内部,我们再次对数组的每个元素进行分类。分类后,Reason 现在知道每个元素实际上是一个对象。我们将定义一个名为parseCustomer的自定义辅助函数,用于map函数:

switch (Js.Json.classify(validated)) {
| Js.Json.JSONArray(array) =>
  Belt.Array.map(array, customer => parseCustomer(customer))
| _ => failwith("Expected an array")
};

let parseCustomer = json =>
  switch (Js.Json.classify(json)) {
  | Js.Json.JSONObject(json) => (
      ...
    )
  | _ => failwith("Expected an object")
  };

现在,如果数组的每个元素都是对象,我们希望返回一个新的记录。这个记录将是CustomerType.t类型。否则,我们会得到一个运行时错误:

let parseCustomer = json =>
  switch (Js.Json.classify(json)) {
  | Js.Json.JSONObject(json) => (
      {
        id: ...,
        name: ...,
        address: ...,
        phone: ...,
        email: ...,
      }: CustomerType.t
    )
  | _ => failwith("Expected an object")
  };

现在,对于每个字段(即idnameaddress等),我们使用Js.Dict.get来获取和分类每个字段:

Js.Dict文档可以在以下 URL 找到:

bucklescript.github.io/bucklescript/api/Js.Dict.html

let parseCustomer = json =>
  switch (Js.Json.classify(json)) {
  | Js.Json.JSONObject(json) => (
      {
        id:
          switch (Js.Dict.get(json, "id")) {
          | Some(id) =>
            switch (Js.Json.classify(id)) {
            | Js.Json.JSONNumber(id) => int_of_float(id)
            | _ => failwith("Field 'id' should be a number")
            }
          | None => failwith("Missing field: id")
          },
        name:
          switch (Js.Dict.get(json, "name")) {
          | Some(name) =>
            switch (Js.Json.classify(name)) {
            | Js.Json.JSONString(name) => name
            | _ => failwith("Field 'name' should be a string")
            }
          | None => failwith("Missing field: name")
          },
        address:
          switch (Js.Dict.get(json, "address")) {
          | Some(address) =>
            switch (Js.Json.classify(address)) {
            | Js.Json.JSONObject(address) => {
                street:
                  switch (Js.Dict.get(address, "street")) {
                  | Some(street) =>
                    switch (Js.Json.classify(street)) {
                    | Js.Json.JSONString(street) => street
                    | _ => failwith("Field 'street' should be a string")
                    }
                  | None => failwith("Missing field: street")
                  },
                city: ...,
                state: ...,
                zip: ...,
              }
            | _ => failwith("Field 'address' should be a object")
            }
          | None => failwith("Missing field: address")
          },
        phone: ...,
        email: ...,
      }: CustomerType.t
    )
  | _ => failwith("Expected an object")
  };

查看src/customers/DataPureReason.re以获取完整的实现。DataPureReason.rei隐藏了实现细节,只公开了localStorage绑定和一个解析函数。

哎呀,这有点繁琐,不是吗?不过现在已经完成了,我们可以用以下内容替换CustomerList.reCustomer.re中的硬编码客户数组:

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

到目前为止,一切顺利!JSON 数据被动态地拉取和解析,现在的工作方式与硬编码时相同。

写入到 localStorage

现在让我们添加创建和更新客户的功能。为此,我们需要将我们的 Reason 数据结构转换为 JSON。在接口文件DataPureReason.rei中,我们将公开一个toJson函数:

/* DataPureReason.rei */
let parse: string => array(CustomerType.t);
let toJson: array(CustomerType.t) => string;

然后我们将实现它:

/* DataPureReason.re */
let customerToJson = (customer: CustomerType.t) => {
  let id = customer.id;
  let name = customer.name;
  let street = customer.address.street;
  let city = customer.address.city;
  let state = customer.address.state;
  let zip = customer.address.zip;
  let phone = customer.phone;
  let email = customer.email;

  {j|
    {
      "id": $id,
      "name": "$name",
      "address": {
        "street": "$street",
        "city": "$city",
        "state": "$state",
        "zip": "$zip"
      },
      "phone": "$phone",
      "email": "$email"
    }
  |j};
};

let toJson = (customers: array(CustomerType.t)) =>
  Belt.Array.map(customers, customer => customerToJson(customer))
  ->Belt.Array.reduce("[", (acc, customer) => acc ++ customer ++ ",")
  ->Js.String.replaceByRe([%bs.re "/,$/"], "", _)
  ++ "]"
     ->Js.String.split("/n", _)
     ->Js.Array.map(line => Js.String.trim(line), _)
     ->Js.Array.joinWith("", _);

然后我们将在Customer.re的 reducer 中使用toJson函数:

reducer: (action, state) =>
  switch (action) {
  | Save(event) =>
    let getInputValue: string => string = [%raw
      (selector => "return document.querySelector(selector).value")
    ];
    ReactEvent.Form.preventDefault(event);
    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(customers, [|self.state.customer|])
            | Update =>
              Belt.Array.setExn(
                customers,
                Js.Array.findIndex(
                  customer =>
                    customer.CustomerType.id == self.state.customer.id,
                  customers,
                ),
                self.state.customer,
              );
              customers;
            };

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

在 reducer 中,我们使用 DOM 中的值更新self.state.customer,然后调用一个更新localStorage的函数。现在,我们可以通过创建或更新客户来写入localStorage。转到/customers/create创建一个新客户,然后返回到/customers查看您新添加的客户。单击客户以导航到更新视图,更新客户,单击更新按钮,然后刷新页面。

使用 bs-json

现在我们确切地了解了如何将 JSON 字符串转换为类型化的 Reason 数据结构,我们注意到这个过程有点繁琐。这比人们从 JavaScript 等动态语言中预期的代码行数要多。此外,有相当多的重复代码。作为替代方案,Reason 社区中的许多人采用了bs-json作为编码和解码 JSON 的“官方”解决方案。

让我们创建一个名为DataBsJson.re的新模块和一个新的接口文件DataBsJson.rei。我们将复制与DataPureReason.rei中相同的接口,以便知道一旦完成,我们将能够将所有引用DataPureReason替换为DataBsJson,并且一切都应该正常工作。

公开的接口如下:

/* DataBsJson.rei */
[@bs.val] [@bs.scope "localStorage"] external getItem: string => string = "";
[@bs.val] [@bs.scope "localStorage"]
external setItem: (string, string) => unit = "";

let parse: string => array(CustomerType.t);
let toJson: array(CustomerType.t) => string;

让我们专注于parse函数:

let parse = json =>
  json |> Json.parseOrRaise |> Json.Decode.array(customerDecoder);

在这里,我们接受与之前相同的 JSON 字符串,对其进行验证,将其转换为Js.Json.t(通过Json.parseOrRaise),然后将结果传递到这个新的Json.Decode.array(customerDecoder)函数中。Json.Decode.array将尝试将 JSON 字符串解码为数组,并使用名为customerDecoder的自定义函数解码数组的每个元素-接下来我们将看到:

let customerDecoder = json =>
  Json.Decode.(
    (
      {
        id: json |> field("id", int),
        name: json |> field("name", string),
        address: json |> field("address", addressDecoder),
        phone: json |> field("phone", string),
        email: json |> field("email", string),
      }: CustomerType.t
    )
  );

customerDecoder函数接受与数组的每个元素相关联的 JSON,并尝试将其解码为CustomerType.t类型的记录。这几乎与我们之前所做的完全相同,但要简洁得多,阅读起来也更容易。正如您所看到的,我们还有另一个客户解码器,称为addressDecoder,用于解码CustomerType.address类型:

let addressDecoder = json =>
  Json.Decode.(
    (
      {
        street: json |> field("street", string),
        city: json |> field("city", string),
        state: json |> field("state", string),
        zip: json |> field("zip", string),
      }: CustomerType.address
    )
  );

请注意,自定义解码器很容易组合。通过调用Json.Decode.field来解码每个记录字段,传递字段的名称(在 JSON 端),并传入一个Json.Decode函数,最终将 JSON 字段转换为 Reason 可以理解的类型。

编码工作方式类似,但顺序相反:

let toJson = (customers: array(CustomerType.t)) =>
  customers->Belt.Array.map(customer =>
    Json.Encode.(
      object_([
        ("id", int(customer.id)),
        ("name", string(customer.name)),
        (
          "address",
          object_([
            ("street", string(customer.address.street)),
            ("city", string(customer.address.city)),
            ("state", string(customer.address.state)),
            ("zip", string(customer.address.zip)),
          ]),
        ),
        ("phone", string(customer.phone)),
        ("email", string(customer.email)),
      ])
    )
  )
  |> Json.Encode.jsonArray
  |> Json.stringify;

客户数组被映射,并且每个客户都被编码为 JSON 对象。结果是一个 JSON 对象数组,然后被编码为 JSON,并被字符串化。比我们以前的实现要好得多。

在从DataPureReason.re复制相同的localStorage绑定之后,我们的接口现在已经实现。在将所有引用DataPureReason替换为DataBsJson之后,我们看到我们的应用程序仍然可以正常工作。

使用 GraphQL

在 ReactiveConf 2018 上,Sean Grove 发表了一篇关于 Reason 和 GraphQL 的精彩演讲,题为*ReactiveMeetups w/ Sean Grove | ReasonML GraphQL.*以下是这次演讲的摘录,它很好地总结了在 Reason 中使用 JSON 的问题和解决方案:

因此,我认为,在像 Reason 这样的类型化语言中,当您想要与现实世界进行交互时,有三个非常大的问题。首先是将数据输入和输出到您的类型系统中所需的所有样板文件。

其次,即使您可以通过样板文件编程,您仍然担心转换的准确性和安全性。

最后,即使您完全理解了所有这些,并且绝对确定已经捕捉到了所有的变化,有人仍然可以在您不知情的情况下对其进行更改。

每当服务器更改字段时,我们会得到多少次更改日志?在理想的世界中,我们会得到。但大多数时候我们不会。我们需要逆向工程我们的服务器更改了什么。

因此,我认为,为了以广泛适用的方式解决这个问题,我们需要四件事:

1)以编程方式访问 API 可以提供给我们的所有数据类型。

2)保证安全的自动转换。

3)我们希望有一个合同。我们希望服务器保证如果它说一个字段是不可为空的,它们永远不会给我们 null。如果他们更改字段名称,那么我们立刻知道他们知道。

4)我们希望所有这些都以编程方式实现。

这就是 GraphQL。

-Sean Grove

您可以在以下网址找到ReactiveMeetups w/ Sean Grove | ReasonML GraphQL的视频:

youtu.be/t9a-_VnNilE

这是 ReactiveConf 的 Youtube 频道:

www.youtube.com/channel/UCBHdUnixTWymmXBIw12Y8Qg

这本书的范围不允许深入讨论 GraphQL,但鉴于我们正在讨论在 Reason 中使用 JSON,高层次的介绍似乎很合适。

什么是 GraphQL?

如果你是 ReactJS 社区的一员,那么你可能已经听说过 GraphQL。GraphQL 是一种查询语言和运行时,我们可以用它来满足这些查询,它也是由 Facebook 创建的。使用 GraphQL,ReactJS 组件可以包含 GraphQL 片段,用于组件所需的数据,这意味着一个组件可以将 HTML、CSS、JavaScript 和外部数据都耦合在一个文件中。

在使用 GraphQL 时,我需要创建 JSON 解码器吗?

由于 GraphQL 深入了解您的应用程序的外部数据,GraphQL 客户端(reason-apollo)将自动生成解码器。当然,解码器必须自动生成,以便我们确信它们反映了外部数据的当前形状。这只是在需要处理外部数据时考虑在 Reason 应用程序中使用 GraphQL 的另一个原因。

总结

只要我们在 Reason 中工作,类型系统将阻止您遇到运行时类型错误。然而,当与外部世界交互时,无论是 JavaScript 还是外部数据,我们都会失去这些保证。为了能够在 Reason 的边界内保留这些保证,我们需要在使用 Reason 之外的东西时帮助类型系统。我们之前学习了如何在 Reason 中使用外部 JavaScript,在本章中我们学习了如何在 Reason 中使用外部数据。尽管编写解码器和编码器更具挑战性,但它与编写 JavaScript 绑定非常相似。最终,我们只是告诉 Reason 某些外部于 Reason 的类型。使用 GraphQL,我们可以扩展 Reason 的边界以包含外部数据。存在权衡,没有什么是完美的,但尝试使用 GraphQL 绝对是值得的。

在下一章中,我们将探讨在 Reason 环境中进行测试。我们应该编写什么样的测试?我们应该避免哪些测试?我们还将探讨单元测试如何帮助我们改进本章中编写的代码。

第八章:Reason 中的单元测试

在像 Reason 这样的类型化语言中进行测试是一个颇具争议的话题。有些人认为一个良好的测试套件会减少对类型系统的需求。另一方面,有些人更看重类型系统而不是他们的测试套件。这些意见上的差异可能导致一些激烈的辩论。

当然,类型和测试并不是互斥的。我们可以同时拥有类型和测试。也许 Reason 核心团队成员之一郑楼说得最好。

测试。这很容易,对吧?类型会减少一类测试的数量,但并不是所有测试。这是一个人们不够重视的讨论。他们总是把测试和类型对立起来。关键是:如果你有类型,并且添加了测试,你的测试将能够用更少的精力表达更多。你不再需要断言无效的输入。你可以断言更重要的东西。如果你想要,测试可以存在;你只是用它们表达了更多。

  • 郑楼

您可以在以下 URL 上观看郑楼在 2017 年 React Conf 的演讲:

youtu.be/_0T5OSSzxms

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

  • 学习如何使用es6commonjs模块格式设置bs-jest

  • 对 Reason 函数进行单元测试

  • 看看编写测试如何帮助我们改进我们的代码

要跟着做,克隆这本书的 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 进行测试

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

安装

就像任何其他包一样,我们从Reason Package Index(或简称为Redex)开始。

Reason 包索引:

redex.github.io/

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

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

然后我们通过在bsconfig.json中包含它来让 BuckleScript 知道这个开发依赖项。请注意,键是"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 模块:

/* 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中添加相应的 Babel 配置:
/* .babelrc */
{
  "presets": ["env"]
}
  1. 确保 Jest 通过 Babel 运行node_modules中的某些第三方依赖。出于性能原因,Jest 默认不会通过 Babel 运行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)"
 ]
}

现在,使用 ES 模块格式运行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接受一个customers数组一个id,并专注于通过他们的id获取顾客,那将会更好。否则,仅仅为getCustomer编写测试会更加困难。

因此,我们重构getCustomer,也接受一个id

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

现在,我们可以更容易地编写测试。遵循编译器错误,确保你已经用getCustomerById替换了getCustomer。对于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函数。这将使我们能够在 Jest 中避免对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?

这意味着我们也无法编写某些单元测试。例如,如果我们想要测试第五章中的Effective ML代码,我们在那里使用类型系统来保证发票不会被打折两次,测试甚至无法编译。多么美好。

总结

由于 Reason 的影响如此广泛,有许多不同的学习方法。本书侧重于从前端开发人员的角度学习 Reason。我们学习了我们已经熟悉的技能和概念(如使用 ReactJS 构建 Web 应用程序),并探讨了如何在 Reason 中做同样的事情。在这个过程中,我们了解了 Reason 的类型系统、工具链和生态系统。

我相信 Reason 的未来是光明的。我们学到的许多技能都可以直接转移到针对本机平台。Reason 的前端故事目前比其本机故事更加完善,但已经可以编译到 Web 和本机。而且从现在开始,它只会变得更好。从我开始使用 Reason 的时候,已经有了巨大的改进,我很期待看到未来会有什么。

希望这本书能引起您对 Reason、OCaml 和 ML 语言家族的兴趣。Reason 的类型系统经过数十年的工程技术。因此,这本书没有涵盖的内容很多,我自己也在不断学习。然而,您现在应该已经建立了坚实的基础,可以继续学习。我鼓励您通过在 Discord 频道上提问、撰写博客文章、指导他人、在聚会中分享您的学习经历等公开学习。

非常感谢您能走到这一步,并在 Discord 频道上见到您!

Reason Discord 频道:

discord.gg/reasonml

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值