[译] Elixir、Phoenix、Absinthe、GraphQL、React 和 Apollo:一次近乎疯狂的深度实践 —— 第二部分(测试相关部分)

若是你没有看过本系列文章的第一部分,建议你先去看第一部分:javascript

测试 —— 服务器端

如今咱们已经完成了全部的代码部分,那咱们如何确保个人代码总能正常的工做呢?咱们须要对下面几种不一样的层次进行测试。首先,咱们须要对 model 层进行单元测试 —— 这些 model 是否能正确的验证(数据)?这些 model 的 helper 函数是否能返回预期的结果?第二,咱们须要对 resolver 层进行单元测试 —— resolver 是否能处理不一样的(成功和失败)的状况?是否能返回正确的结果或者根据结果做出正确的数据库更新?第三,咱们应该编写一些完整的 integration test(集成测试),例如发送向服务器一个查询请求并期待返回正确的结果。这可让咱们更好地从全局上把控咱们的应用,而且确保这些测试涵盖认证逻辑等案例。第四,咱们但愿对咱们的 subscription 层进行测试 —— 当相关的变化发生时,它们能否能够正确地通知套接字。html

Elixir 有一个很是基本的内置测试库,叫作 ExUnit。ExUnit 包括简单的 assert/refute 函数,也能够帮助你运行你的测试。在 Phoenix 中创建一系列 “case” support 文件的方法也很常见。这些文件在测试中被引用,用于运行常见的初始化任务,例如链接数据库。此外,在个人测试中,我发现 ex_specex_machina 这两个库很是有帮助。ex_spec 加入了简单的 describeit,对于有 ruby 相关背景的我来讲,ex_spec 可让编写测试所用的语法更加的友好。ex_machina 提供了函数工厂(factory),这些函数工厂可让动态插入测试数据变得更简单。前端

我建立的函数工厂长这样:java

# test/support/factories.ex
defmodule Socializer.Factory do
  use ExMachina.Ecto, repo: Socializer.Repo

  def user_factory do
    %Socializer.User{
      name: Faker.Name.name(),
      email: Faker.Internet.email(),
      password: "password",
      password_hash: Bcrypt.hash_pwd_salt("password")
    }
  end

  def post_factory do
    %Socializer.Post{
      body: Faker.Lorem.paragraph(),
      user: build(:user)
    }
  end

  # ...factories for other models
end
复制代码

在环境的搭建中导入函数工厂后,你就能够在测试案例中使用一些很是直观的语法了:react

# Insert a user
user = insert(:user)

# Insert a user with a specific name
user_named = insert(:user, name: "John Smith")

# Insert a post for the user
post = insert(:post, user: user)
复制代码

在搭建完成后,你的 Post model 长这样:android

# test/socializer/post_test.exs
defmodule Socializer.PostTest do
  use SocializerWeb.ConnCase

  alias Socializer.Post

  describe "#all" do
    it "finds all posts" do
      post_a = insert(:post)
      post_b = insert(:post)
      results = Post.all()
      assert length(results) == 2
      assert List.first(results).id == post_b.id
      assert List.last(results).id == post_a.id
    end
  end

  describe "#find" do
    it "finds post" do
      post = insert(:post)
      found = Post.find(post.id)
      assert found.id == post.id
    end
  end

  describe "#create" do
    it "creates post" do
      user = insert(:user)
      valid_attrs = %{user_id: user.id, body: "New discussion"}
      {:ok, post} = Post.create(valid_attrs)
      assert post.body == "New discussion"
    end
  end

  describe "#changeset" do
    it "validates with correct attributes" do
      user = insert(:user)
      valid_attrs = %{user_id: user.id, body: "New discussion"}
      changeset = Post.changeset(%Post{}, valid_attrs)
      assert changeset.valid?
    end

    it "does not validate with missing attrs" do
      changeset =
        Post.changeset(
          %Post{},
          %{}
        )

      refute changeset.valid?
    end
  end
end
复制代码

这个测试案例很直观。对于每一个案例,咱们插入所须要的测试数据,调用须要测试的函数并对结果做出断言(assertion)。ios

接下来,让咱们一块儿看一下下面这个 resolver 的测试案例:git

# test/socializer_web/resolvers/post_resolver_test.exs
defmodule SocializerWeb.PostResolverTest do
  use SocializerWeb.ConnCase

  alias SocializerWeb.Resolvers.PostResolver

  describe "#list" do
    it "returns posts" do
      post_a = insert(:post)
      post_b = insert(:post)
      {:ok, results} = PostResolver.list(nil, nil, nil)
      assert length(results) == 2
      assert List.first(results).id == post_b.id
      assert List.last(results).id == post_a.id
    end
  end

  describe "#show" do
    it "returns specific post" do
      post = insert(:post)
      {:ok, found} = PostResolver.show(nil, %{id: post.id}, nil)
      assert found.id == post.id
    end

    it "returns not found when post does not exist" do
      {:error, error} = PostResolver.show(nil, %{id: 1}, nil)
      assert error == "Not found"
    end
  end

  describe "#create" do
    it "creates valid post with authenticated user" do
      user = insert(:user)

      {:ok, post} =
        PostResolver.create(nil, %{body: "Hello"}, %{
          context: %{current_user: user}
        })

      assert post.body == "Hello"
      assert post.user_id == user.id
    end

    it "returns error for missing params" do
      user = insert(:user)

      {:error, error} =
        PostResolver.create(nil, %{}, %{
          context: %{current_user: user}
        })

      assert error == [[field: :body, message: "Can't be blank"]]
    end

    it "returns error for unauthenticated user" do
      {:error, error} = PostResolver.create(nil, %{body: "Hello"}, nil)

      assert error == "Unauthenticated"
    end
  end
end
复制代码

对于 resolver 的测试也至关的简单 —— 它们也是单元测试,运行于 model 之上的一层。这里咱们插入任意的测试数据,调用所测试的 resolver,而后期待正确的结果被返回。github

集成测试有一点点小复杂。咱们首先须要创建和服务器端的链接(可能须要认证),接着发送一个查询语句而且确保咱们获得正确的结果。我找到了这篇帖子,它对学习如何为 Absinthe 构建集成测试很是有帮助。web

首先,咱们创建一个 helper 文件,这个文件将包含一些进行集成测试所须要的常见功能:

# test/support/absinthe_helpers.ex
defmodule Socializer.AbsintheHelpers do
  alias Socializer.Guardian

  def authenticate_conn(conn, user) do
    {:ok, token, _claims} = Guardian.encode_and_sign(user)
    Plug.Conn.put_req_header(conn, "authorization", "Bearer #{token}")
  end

  def query_skeleton(query, query_name) do
    %{
      "operationName" => "#{query_name}",
      "query" => "query #{query_name} #{query}",
      "variables" => "{}"
    }
  end

  def mutation_skeleton(query) do
    %{
      "operationName" => "",
      "query" => "mutation #{query}",
      "variables" => ""
    }
  end
end
复制代码

这个文件里包括了三个 helper 函数。第一个函数接受一个链接对象和一个用户对象做为参数,经过在 HTTP 的 header 中加入已认证的用户 token 来认证链接。第二个和第三个函数都接受一个查询语句做为参数,当你经过网络链接发送查询语句给服务器时,这两个函数会返回一个包含该查询语句结果在内的 JSON 结构对象。

而后回到测试自己:

# test/socializer_web/integration/post_resolver_test.exs
defmodule SocializerWeb.Integration.PostResolverTest do
  use SocializerWeb.ConnCase
  alias Socializer.AbsintheHelpers

  describe "#list" do
    it "returns posts" do
      post_a = insert(:post)
      post_b = insert(:post)

      query = """ { posts { id body } } """

      res =
        build_conn()
        |> post("/graphiql", AbsintheHelpers.query_skeleton(query, "posts"))

      posts = json_response(res, 200)["data"]["posts"]
      assert List.first(posts)["id"] == to_string(post_b.id)
      assert List.last(posts)["id"] == to_string(post_a.id)
    end
  end

  # ...
end
复制代码

这个测试案例,经过查询来获得一组帖子信息的方式来测试咱们的终端。咱们首先在数据库中插入一些帖子的记录,而后写一个查询语句,接着经过 POST 方法将语句发送给服务器,最后检查服务器的回复,确保返回的结果符合预期。

这里还有一个很是类似的案例,测试是否能查询获得单个帖子信息。这里咱们就再也不赘述(若是你想了解全部的集成测试,你能够查看这里)。下面让咱们看一下为建立帖子的 Mutation 所作的的集成测试。

# test/socializer_web/integration/post_resolver_test.exs
defmodule SocializerWeb.Integration.PostResolverTest do
  # ...

  describe "#create" do
    it "creates post" do
      user = insert(:user)

      mutation = """ { createPost(body: "A few thoughts") { body user { id } } } """

      res =
        build_conn()
        |> AbsintheHelpers.authenticate_conn(user)
        |> post("/graphiql", AbsintheHelpers.mutation_skeleton(mutation))

      post = json_response(res, 200)["data"]["createPost"]
      assert post["body"] == "A few thoughts"
      assert post["user"]["id"] == to_string(user.id)
    end
  end
end
复制代码

很是类似,只有两点不一样 —— 此次咱们是经过 AbsintheHelpers.authenticate_conn(user) 将用户的 token 加入头字段的方式来创建链接,而且咱们调用的是 mutation_skeleton,而非以前的 query_skeleton

那对于 subscription 的测试呢?对于 subscription 的测试也须要经过一些基本的搭建,来创建一个套接字链接,而后就能够创建并测试咱们的 subscription。我找到了这篇文章,它对咱们理解如何为 subscription 构建测试很是有帮助。

首先,咱们创建一个新的 case 文件来为 subscription 的测试作基本的搭建。代码长这样:

# test/support/subscription_case.ex
defmodule SocializerWeb.SubscriptionCase do
  use ExUnit.CaseTemplate

  alias Socializer.Guardian

  using do
    quote do
      use SocializerWeb.ChannelCase
      use Absinthe.Phoenix.SubscriptionTest, schema: SocializerWeb.Schema
      use ExSpec
      import Socializer.Factory

      setup do
        user = insert(:user)

        # When connecting to a socket, if you pass a token we will set the context's `current_user`
        params = %{
          "token" => sign_auth_token(user)
        }

        {:ok, socket} = Phoenix.ChannelTest.connect(SocializerWeb.AbsintheSocket, params)
        {:ok, socket} = Absinthe.Phoenix.SubscriptionTest.join_absinthe(socket)

        {:ok, socket: socket, user: user}
      end

      defp sign_auth_token(user) do
        {:ok, token, _claims} = Guardian.encode_and_sign(user)
        token
      end
    end
  end
end
复制代码

在一些常见的导入后,咱们定义一个 setup 的步骤。这一步会插入一个新的用户,并经过这个用户的 token 来创建一个 websocket 链接。咱们将这个套接字和用户返回以供咱们其余的测试使用。

下一步,让咱们一块儿来看一看测试自己:

defmodule SocializerWeb.PostSubscriptionsTest do
  use SocializerWeb.SubscriptionCase

  describe "Post subscription" do
    it "updates on new post", %{socket: socket} do
      # Query to establish the subscription.
      subscription_query = """ subscription { postCreated { id body } } """

      # Push the query onto the socket.
      ref = push_doc(socket, subscription_query)

      # Assert that the subscription was successfully created.
      assert_reply(ref, :ok, %{subscriptionId: _subscription_id})

      # Query to create a new post to invoke the subscription.
      create_post_mutation = """ mutation CreatePost { createPost(body: "Big discussion") { id body } } """

      # Push the mutation onto the socket.
      ref =
        push_doc(
          socket,
          create_post_mutation
        )

      # Assert that the mutation successfully created the post.
      assert_reply(ref, :ok, reply)
      data = reply.data["createPost"]
      assert data["body"] == "Big discussion"

      # Assert that the subscription notified us of the new post.
      assert_push("subscription:data", push)
      data = push.result.data["postCreated"]
      assert data["body"] == "Big discussion"
    end
  end
end
复制代码

首先,咱们先写一个 subscription 的查询语句,而且推送到咱们在上一步已经创建好的套接字上。接着,咱们写一个会触发 subscription 的 mutation 语句(例如,建立一个新帖子)并推送到套接字上。最后,咱们检查 push 的回复,并断言一个帖子的被新建的更新信息将被推送给咱们。这其中设计了更多的前期搭建,但这也让咱们对 subscription 的生命周期的创建的更好的集成测试。

客户端

以上就是对服务端所发生的一切的大体的描述 —— 服务器经过在 types 中定义,在 resolvers 中实现,在 model 查询和固化(persist)数据的方法来处理 GraphQL 查询语句。接下来,让咱们一块儿来看一看客户端是如何创建的。

咱们首先使用 create-react-app,这是从 0 到 1 搭建 React 项目的好方法 —— 它会搭建一个 “hello world” React 应用,包含默认的设定和结构,而且简化了大量配置。

这里我使用了 React Router 来实现应用的路由;它将容许用户在帖子列表页面、单一帖子页面和聊天页面等进行浏览。咱们的应用的根组件应该长这样:

// client/src/App.js
import React, { useRef } from "react";
import { ApolloProvider } from "react-apollo";
import { BrowserRouter, Switch, Route } from "react-router-dom";
import { createClient } from "util/apollo";
import { Meta, Nav } from "components";
import { Chat, Home, Login, Post, Signup } from "pages";

const App = () => {
  const client = useRef(createClient());

  return (
    <ApolloProvider client={client.current}>
      <BrowserRouter>
        <Meta />
        <Nav />

        <Switch>
          <Route path="/login" component={Login} />
          <Route path="/signup" component={Signup} />
          <Route path="/posts/:id" component={Post} />
          <Route path="/chat/:id?" component={Chat} />
          <Route component={Home} />
        </Switch>
      </BrowserRouter>
    </ApolloProvider>
  );
};
复制代码

几个值得注意的点 —— util/apollo 这里对外输出了一个 createClient 函数。这个函数会建立并返回一个 Apollo 客户端的实例(咱们将在下文中进行着重地介绍)。将 createClient 包装在 useRef 中,就能让该实例在应用的生命周期内(即,全部的 rerenders)中都可使用。ApolloProvider 这个高阶组件会使 client 能够在全部子组件/查询的 context 中使用。在咱们浏览该应用的过程当中,BrowserRouter 使用 HTML5 的 history API 来保持 URL 的状态同步。

这里的 SwitchRoute 须要单独进行讨论。React Router 是围绕动态路由的概念创建的。大部分的网站使用静态路由,也就是说你的 URL 将匹配惟一的路由,而且根据所匹配的路由来渲染一整个页面。使用动态路由,路由将被分布到整个应用中,一个 URL 能够匹配多个路由。这听起来可能有些使人困惑,但事实上,当你掌握了它之后,你会以为它很是棒。它能够轻松地构建一个包含不一样组件页面,这些组件能够对路由的不一样部分作出反应。例如,想象一个相似脸书的 messenger 的页面(Socializer 的聊天界面也很是类似)—— 左边是对话的列表,右边是所选择的对话。动态路由容许我这样表达:

const App = () => {
  return (
    // ...
    <Route path="/chat/:id?" component={Chat} />
    // ...
  );
};

const Chat = () => {
  return (
    <div>
      <ChatSidebar />

      <Switch>
        <Route path="/chat/:id" component={Conversation} />
        <Route component={EmptyState} />
      </Switch>
    </div>
  );
};
复制代码

若是路径以 /chat 开头(可能以 ID 结尾,例如,/chat/123),根层次的 App 会渲染 Chat 组件。Chat 会渲染对话列表栏(对话列表栏老是可见的),而后会渲染它的路由,若是路径有 ID,则显示一个 Conversation 组件,不然就会显示 EmptyState(请注意,若是缺乏了 ?,那么 :id 参数就再也不是可选参数)。这就是动态路由的力量 —— 它让你能够基于当前的 URL 渐进地渲染界面的不一样组件,将基于路径的问题本地化到相关的组件中。

即便使用了动态路由,有时你也只想要渲染一条路径(相似于传统的静态路由)。这时 Switch 组件就登上了舞台。若是没有 Switch,React Router 会渲染每个匹配当前 URL 的组件,那么在上面的 Chat 组件中,咱们就会既有 Conversation 组件,又有 EmptyState 组件。Switch 会告诉 React Router,让它只渲染第一个匹配当前 URL 的路由并忽视掉其它的。

Apollo 客户端

如今,让咱们更进一步,深刻了解一下 Apollo 的客户端 —— 特别是上文已经说起的 createClient 函数。util/apollo.js 文件长这样:

// client/src/util.apollo.js
import ApolloClient from "apollo-client";
import { InMemoryCache } from "apollo-cache-inmemory";
import * as AbsintheSocket from "@absinthe/socket";
import { createAbsintheSocketLink } from "@absinthe/socket-apollo-link";
import { Socket as PhoenixSocket } from "phoenix";
import { createHttpLink } from "apollo-link-http";
import { hasSubscription } from "@jumpn/utils-graphql";
import { split } from "apollo-link";
import { setContext } from "apollo-link-context";
import Cookies from "js-cookie";

const HTTP_URI =
  process.env.NODE_ENV === "production"
    ? "https://brisk-hospitable-indianelephant.gigalixirapp.com"
    : "http://localhost:4000";

const WS_URI =
  process.env.NODE_ENV === "production"
    ? "wss://brisk-hospitable-indianelephant.gigalixirapp.com/socket"
    : "ws://localhost:4000/socket";

// ...
复制代码

开始很简单,导入一堆咱们接下来须要用到的依赖,而且根据当前的环境,将 HTTP URL 和 websocket URL 设置为常量 —— 在 production 环境中指向个人 Gigalixir 实例,在 development 环境中指向 localhost。

// client/src/util.apollo.js
// ...

export const createClient = () => {
  // Create the basic HTTP link.
  const httpLink = createHttpLink({ uri: HTTP_URI });

  // Create an Absinthe socket wrapped around a standard
  // Phoenix websocket connection.
  const absintheSocket = AbsintheSocket.create(
    new PhoenixSocket(WS_URI, {
      params: () => {
        if (Cookies.get("token")) {
          return { token: Cookies.get("token") };
        } else {
          return {};
        }
      },
    }),
  );

  // Use the Absinthe helper to create a websocket link around
  // the socket.
  const socketLink = createAbsintheSocketLink(absintheSocket);

  // ...
});
复制代码

Apollo 的客户端要求你提供一个连接 —— 本质上说,就是你的 Apollo 客户端所请求的 GraphQL 服务器的链接。一般有两种类型的连接 —— HTTP 连接,经过标准的 HTTP 来向 GraphQL 服务器发送请求,和 websocket 连接,开放一个 websocket 链接并经过套接字来发送请求。在咱们的例子中,咱们两种都使用了。对于一般的 query 和 mutation,咱们将使用 HTTP 连接,对于 subscription,咱们将使用 websocket 连接。

// client/src/util.apollo.js
export const createClient = () => {
  //...

  // Split traffic based on type -- queries and mutations go
  // through the HTTP link, subscriptions go through the
  // websocket link.
  const splitLink = split(
    (operation) => hasSubscription(operation.query),
    socketLink,
    httpLink,
  );

  // Add a wrapper to set the auth token (if any) to the
  // authorization header on HTTP requests.
  const authLink = setContext((_, { headers }) => {
    // Get the authentication token from the cookie if it exists.
    const token = Cookies.get("token");

    // Return the headers to the context so httpLink can read them.
    return {
      headers: {
        ...headers,
        authorization: token ? `Bearer ${token}` : "",
      },
    };
  });

  const link = authLink.concat(splitLink);

  // ...
};
复制代码

Apollo 提供了 split 函数,它可让你根据你选择的标准,将不一样的查询请求路由到不一样的连接上 —— 你能够把它想成一个三项式:若是请求有 subscription,就经过套接字连接来发送,其余状况(Query 或者 Mutation)则使用 HTTP 连接传送。

若是用户已经登录,咱们可能还须要给两个连接都提供认证。当用户登录之后,咱们将其认证令牌设置到 token 的 cookie 中(下文会详细介绍)。与 Phoenix 创建 websocket 链接时,咱们使用token 做为参数,在 HTTP 连接中,这里咱们使用 setContext 包装器,将token 设置在请求的头字段中。

// client/src/util.apollo.js
export const createClient = () => {
  // ...

  return new ApolloClient({
    cache: new InMemoryCache(),
    link,
  });
});
复制代码

如上所示,除了连接之外,一个 Apollo 的客户端还须要一个缓存的实例。GraphQL 会自动缓存请求的结果来避免对相同的数据进行重复请求。基本的 InMemoryCache 已经能够适用大部分的用户案例了 —— 它就是将查询的数据存在浏览器的本地状态中。

客户端的使用 —— 咱们的第一个请求

好哒,咱们已经搭建好了 Apollo 的客户端实例,而且经过 ApolloProvider 的高阶函数让这个实例在整个应用中均可用。如今让咱们来看一看如何运行 query 和 mutation。咱们从 Posts 组件开始,Posts 组件将在咱们的首页渲染一个帖子的列表。

// client/src/components/Posts.js
import React, { Fragment } from "react";
import { Query } from "react-apollo";
import gql from "graphql-tag";
import produce from "immer";
import { ErrorMessage, Feed, Loading } from "components";

export const GET_POSTS = gql` { posts { id body insertedAt user { id name gravatarMd5 } } } `;

export const POSTS_SUBSCRIPTION = gql` subscription onPostCreated { postCreated { id body insertedAt user { id name gravatarMd5 } } } `;

// ...
复制代码

首先是各类库的引入,接着咱们须要为咱们想要渲染的帖子写一些查询。这里有两个 —— 首先是一个基础的获取帖子列表的 query(也包括帖子做者的信息),而后是一个 subscription,用来告知咱们新帖子的出现,让咱们能够实时地更新屏幕,保证咱们的列表处于最新。

// client/src/components/Posts.js
// ...

const Posts = () => {
  return (
    <Fragment>
      <h4>Feed</h4>
      <Query query={GET_POSTS}>
        {({ loading, error, data, subscribeToMore }) => {
          if (loading) return <Loading />;
          if (error) return <ErrorMessage message={error.message} />;
          return (
            <Feed
              feedType="post"
              items={data.posts}
              subscribeToNew={() =>
                subscribeToMore({
                  document: POSTS_SUBSCRIPTION,
                  updateQuery: (prev, { subscriptionData }) => {
                    if (!subscriptionData.data) return prev;
                    const newPost = subscriptionData.data.postCreated;

                    return produce(prev, (next) => {
                      next.posts.unshift(newPost);
                    });
                  },
                })
              }
            />
          );
        }}
      </Query>
    </Fragment>
  );
};
复制代码

如今咱们将实现真正的组件部分。首先,执行基本的查询,咱们先渲染 Apollo 的 <Query query={GET_POSTS}>。它给它的子组件提供了一些渲染的 props —— loadingerrordatasubscribeToMore。若是查询正在加载,咱们就渲染一个简单的加载图片。若是有错误存在,咱们渲染一个通用的 ErrorMessage 组件给用户。不然,就说明查询成果,咱们就渲染一个 Feed 组件(data.posts 中包含着须要渲染的帖子,结构和 query 中的结构一致)。

subscribeToMore 是一个 Apollo 帮助函数,用于实现一个只须要从用户正在浏览的集合中获取新数据的 subscription。它应该在子组件的 componentDidMount 阶段被渲染,这也是它被做为 props 传递给 Feed 的缘由 —— 一旦 Feed 被渲染,Feed 负责调用 subscribeToNew。咱们给 subscribeToMore 提供了咱们的 subscription 查询和一个 updateQuery 的回调函数,该函数会在 Apollo 接收到新帖子被创建的通知时被调用。当那发生时,咱们只须要简单将新帖子推入咱们当前的帖子数组,使用 immer 能够返回一个新数组来确保组件能够正确地渲染。

认证(和 mutation)

如今咱们已经有了一个带帖子列表的首页啦,这个首页还能够实时的对新建的帖子进行响应 —— 那咱们应该如何新建帖子呢?首先,咱们须要容许用户用他们的帐户登录,那么咱们就能够把他的帐户和帖子联系起来。咱们须要为此写一个 mutation —— 咱们须要将电子邮件和密码发送到服务器,服务器会发送一个新的认证该用户的令牌。咱们从登录页面开始:

// client/src/pages/Login.js
import React, { Fragment, useContext, useState } from "react";
import { Mutation } from "react-apollo";
import { Button, Col, Container, Form, Row } from "react-bootstrap";
import Helmet from "react-helmet";
import gql from "graphql-tag";
import { Redirect } from "react-router-dom";
import renderIf from "render-if";
import { AuthContext } from "util/context";

export const LOGIN = gql` mutation Login($email: String!, $password: String!) { authenticate(email: $email, password: $password) { id token } } `;
复制代码

第一部分和 query 组件十分类似 —— 咱们导入须要的依赖文件,而后完成登录的 mutation。这个 mutation 接受电子邮件和密码做为参数,而后咱们但愿获得认证用户的 ID 和他们的认证令牌。

// client/src/pages/Login.js
// ...

const Login = () => {
  const { token, setAuth } = useContext(AuthContext);
  const [isInvalid, setIsInvalid] = useState(false);
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  if (token) {
    return <Redirect to="/" />; } // ... }; 复制代码

在组件中,咱们首先去从 context 中获取当前的 token 和一个叫 setAuth 的函数(咱们会在下文中介绍 setAuth)。咱们也须要使用 useState 来设置一些本地的状态,那样咱们就能够为用户的电子邮件,密码以及他们的证书是否有效来存储临时值(这样咱们就能够在表单中显示错误状态)。最后,若是用户已经有了认证令牌,说明他们已经登录,那么咱们就直接让他们跳转去首页。

// client/src/pages/Login.js
// ...

const Login = () => {
  // ...

  return (
    <Fragment>
      <Helmet>
        <title>Socializer | Log in</title>
        <meta property="og:title" content="Socializer | Log in" />
      </Helmet>
      <Mutation mutation={LOGIN} onError={() => setIsInvalid(true)}>
        {(login, { data, loading, error }) => {
          if (data) {
            const {
              authenticate: { id, token },
            } = data;
            setAuth({ id, token });
          }

          return (
            <Container>
              <Row>
                <Col md={6} xs={12}>
                  <Form
                    data-testid="login-form"
                    onSubmit={(e) => {
                      e.preventDefault();
                      login({ variables: { email, password } });
                    }}
                  >
                    <Form.Group controlId="formEmail">
                      <Form.Label>Email address</Form.Label>
                      <Form.Control
                        type="email"
                        placeholder="you@gmail.com"
                        value={email}
                        onChange={(e) => {
                          setEmail(e.target.value);
                          setIsInvalid(false);
                        }}
                        isInvalid={isInvalid}
                      />
                      {renderIf(error)(
                        <Form.Control.Feedback type="invalid">
                          Email or password is invalid
                        </Form.Control.Feedback>,
                      )}
                    </Form.Group>

                    <Form.Group controlId="formPassword">
                      <Form.Label>Password</Form.Label>
                      <Form.Control
                        type="password"
                        placeholder="Password"
                        value={password}
                        onChange={(e) => {
                          setPassword(e.target.value);
                          setIsInvalid(false);
                        }}
                        isInvalid={isInvalid}
                      />
                    </Form.Group>

                    <Button variant="primary" type="submit" disabled={loading}>
                      {loading ? "Logging in..." : "Log in"}
                    </Button>
                  </Form>
                </Col>
              </Row>
            </Container>
          );
        }}
      </Mutation>
    </Fragment>
  );
};

export default Login;
复制代码

这里的代码看起来很洋气,可是不要懵 —— 这里大部分的代码只是为表单作一个 Bootstrap 组件。咱们从一个叫作 Helmetreact-helmet) 组件开始 —— 这是一个顶层的表单组件(相较而言,Posts 组件只是 Home 页面渲染的一个子组件),因此咱们但愿给他一个浏览器标题和一些 metadata。下一步咱们来渲染 Mutation 组件,将咱们的 mutation 语句传递给他。若是 mutation 返回一个错误,咱们使用 onError 回调函数来将状态设为无效,来将错误显示在表单中。Mutation 将一个函数传将会递给调用他的子组件(这里是 login),第二个参数是和咱们从 Query 组件中获得的同样的数组。若是 data 存在,那就意味着 mutation 被成功执行,那么咱们就能够将咱们的认证令牌和用户 ID 经过 setAuth 函数来储存起来。剩余的部分就是很标准的 React 组件啦 —— 咱们渲染 input 并在变化时更新 state 值,在用户试图登录,而邮件密码却无效时显示错误信息。

AuthContext 是干吗的呢?当用户被成功认证后,咱们须要将他们的认证令牌以某种方式存储在客户端。这里 GraphQL 并不能帮上忙,由于这就像是个鸡生蛋问题 —— 发出请求才能获取认证令牌,而认证这个请求自己就要用到认证令牌。咱们能够用 Redux 在本地状态中来存储令牌,但若是我只须要储存这一个值时,感受这样作就太过于复杂了。咱们可使用 React 的 context API 来将 token 储存在咱们应用的根目录,在须要时调用便可。

首先,让咱们创建一个帮助函数来帮咱们创建和导出 context:

// client/src/util/context.js
import { createContext } from "react";

export const AuthContext = createContext(null);
复制代码

接下来咱们来新建一个 StateProvider 高阶函数,这个函数会在应用的根组件被渲染 —— 它将帮助咱们保存和更新认证状态。

// client/src/containers/StateProvider.js
import React, { useEffect, useState } from "react";
import { withApollo } from "react-apollo";
import Cookies from "js-cookie";
import { refreshSocket } from "util/apollo";
import { AuthContext } from "util/context";

const StateProvider = ({ client, socket, children }) => {
  const [token, setToken] = useState(Cookies.get("token"));
  const [userId, setUserId] = useState(Cookies.get("userId"));

  // If the token changed (i.e. the user logged in
  // or out), clear the Apollo store and refresh the
  // websocket connection.
  useEffect(() => {
    if (!token) client.clearStore();
    if (socket) refreshSocket(socket);
  }, [token]);

  const setAuth = (data) => {
    if (data) {
      const { id, token } = data;
      Cookies.set("token", token);
      Cookies.set("userId", id);
      setToken(token);
      setUserId(id);
    } else {
      Cookies.remove("token");
      Cookies.remove("userId");
      setToken(null);
      setUserId(null);
    }
  };

  return (
    <AuthContext.Provider value={{ token, userId, setAuth }}> {children} </AuthContext.Provider> ); }; export default withApollo(StateProvider); 复制代码

这里有不少东西。首先,咱们为认证用户的 tokenuserId 创建 state。咱们经过读 cookie 来初始化 state,那样咱们就能够在页面刷新后保证用户的登录状态。接下来咱们实现了咱们的 setAuth 函数。用 null 来调用该函数会将用户登出;不然就使用提供的 tokenuserId来让用户登录。无论哪一种方法,这个函数都会更新本地的 state 和 cookie。

在同时使用认证和 Apollo websocket link 时存在一个很大的难题。咱们在初始化 websocket 时,若是用户被认证,咱们就使用令牌,反之,若是用户登出,则不是用令牌。可是当认证状态发生变化时,咱们须要根据状态重置 websocket 链接来。若是用户是先登出再登入,咱们须要用户新的令牌来重置 websocket,这样他们就能够实时地接受到须要登录的活动的更新,好比说一个聊天对话。若是用户是先登入再登出,咱们则须要将 websocket 重置成未经验证状态,那么他们就再也不会实时地接受到他们已经登出的帐户的更新。事实证实这真的很难 —— 由于没有一个详细记录的下的解决方案,这花了我好几个小时才解决。我最终手动地为套接字实现了一个重置函数:

// client/src/util.apollo.js
export const refreshSocket = (socket) => {
  socket.phoenixSocket.disconnect();
  socket.phoenixSocket.channels[0].leave();
  socket.channel = socket.phoenixSocket.channel("__absinthe__:control");
  socket.channelJoinCreated = false;
  socket.phoenixSocket.connect();
};
复制代码

这个会断开 Phoenix 套接字,将当前存在的 Phoenix 频道留给 GraphQL 更新,建立一个新的 Phoenix 频道(和 Abisnthe 建立的默认频道一个名字),并将这个频道标记为链接(那样 Absinthe 会在链接时将它从新加入),接着从新链接套接字。在文件中,Phoenix 套接字被配置为在每次链接前动态的在 cookie 中查找令牌,那样每当它重联时,它将会使用新的认证状态。让我崩溃的是,对这样一个看着很普通的问题,却并无一个好的解决方法,固然,经过一些手动的努力,它工做得还不错。

最后,在咱们的 StateProvider 中使用的 useEffect 是调用 refreshSocket 的地方。第二个参数 [token]告诉了 React 在每次 token 值变化时,去从新评估该函数。若是用户只是登出,咱们也要执行 client.clearStore() 函数来确保 Apollo 客户端不会继续缓存包含着须要权限才能获得的数据的查询结果,好比说用户的对话或者消息。

这就大概是客户端的所有了。你能够查看余下的组件来获得更多的关于 query,mutation 和 subscription 的例子,固然,它们的模式都和咱们所提到的大致一致。

测试 —— 客户端

让咱们来写一些测试,来覆盖咱们的 React 代码。咱们的应用内置了 jest(create-react-app 默认包括它);jest 是针对 JavaScript 的一个很是简单和直观的测试运行器。它也包括了一些高级功能,好比快照测试。咱们将在咱们的第一个测试案例里使用它。

我很是喜欢使用 react-testing-library 来写 React 的测试案例 —— 它提供了一个很是简单的 API,能够帮助你从一个用户的角度来渲染和测试表单(而无需在乎组件的具体实现)。此外,它的帮助函数能够在必定程度上的帮助你确保组件的可读性,由于若是你的 DOM 节点很难访问,那么你也很难经过直接操控 DOM 节点来与之交互(例如给文本提供正确的标签等等)。

咱们首先开始为 Loading 组件写一个简单的测试。该组件只是渲染一些静态的 HTML,因此并无什么逻辑须要测试;咱们只是想确保 HTML 按照咱们的预期来渲染。

// client/src/components/Loading.test.js
import React from "react";
import { render } from "react-testing-library";
import Loading from "./Loading";

describe("Loading", () => {
  it("renders correctly", () => {
    const { container } = render(<Loading />); expect(container.firstChild).toMatchSnapshot(); }); }); 复制代码

当你调用 .toMatchSnapshot() 时,jest 将会在 __snapshots__/Loading.test.js.snap 的相对路径下创建一个文件,来记录当前的状态。随后的测试会比较输出和咱们所记录的快照(snapshot),若是与快照不匹配则测试失败。快照文件长这样:

// client/src/components/__snapshots__/Loading.test.js.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Loading renders correctly 1`] = ` <div class="d-flex justify-content-center" > <div class="spinner-border" role="status" > <span class="sr-only" > Loading... </span> </div> </div> `;
复制代码

在这个例子中,由于 HTML 永远不会改变,因此这个快照测试并非那么有效 —— 固然它达到了确认该组件是否渲染成功没有任何错误的目的。在更高级的测试案例中,快照测试在确保组件只会在你想改变它的时候才会改变时很是的有效 —— 好比说,若是你在优化组件内的逻辑,但并不但愿组件的输出改变时,一个快照测将会告诉你,你是否犯了错误。

下一步,让咱们一块儿来看一个对与 Apollo 链接的组件的测试。从这里开始,会变得有些复杂;组件会期待在它的上下文中有 Apollo 的客户端,咱们须要模拟一个 query 查询语句来确保组件正确地处理响应。

// client/src/components/Posts.test.js
import React from "react";
import { render, wait } from "react-testing-library";
import { MockedProvider } from "react-apollo/test-utils";
import { MemoryRouter } from "react-router-dom";
import tk from "timekeeper";
import { Subscriber } from "containers";
import { AuthContext } from "util/context";
import Posts, { GET_POSTS, POSTS_SUBSCRIPTION } from "./Posts";

jest.mock("containers/Subscriber", () =>
  jest.fn().mockImplementation(({ children }) => children),
);

describe("Posts", () => {
  beforeEach(() => {
    tk.freeze("2019-04-20");
  });

  afterEach(() => {
    tk.reset();
  });

  // ...
});
复制代码

首先是一些导入和模拟。这里的模拟是避免 Posts 组件地 subscription 在咱们所不但愿地状况下被注册。在这里我很崩溃 —— Apollo 有关于有模拟 query 和 mutation 的文档,可是并无不少关于模拟 subscription 文档,而且我还会常常遇到各类神秘的,内部的,十分难解决的问题。当我只是想要组件执行它初始的 query 查询时(而不是模拟收到来自它的 subscription 的更新),我彻底没能想到一种可靠的方法来模拟 query 查询。

但这确实也给了一个来讨论 jest 的好机会 —— 这样的案例很是有效。我有一个 Subscriber 组件,一般在装载(mount)时会调用 subscribeToNew,而后返回它的子组件:

// client/src/containers/Subscriber.js
import { useEffect } from "react";

const Subscriber = ({ subscribeToNew, children }) => {
  useEffect(() => {
    subscribeToNew();
  }, []);

  return children;
};

export default Subscriber;
复制代码

因此,在个人测试中,我只须要模拟这个组件的实现来返回子组件,而无需真正地调用 subscribeToNew

最后,我是用了 timekeeper 来固定每个测试案例的时间 —— Posts 根据帖子发布时间和当前时间(例如,两天之前)渲染了一些文本,那么我须要确保这个测试老是在“相同”的时间运行,不然快照测试就会由于时间推移而失败。

// client/src/components/Posts.test.js
// ...

describe("Posts", () => {
  // ...

  it("renders correctly when loading", () => {
    const { container } = render(
      <MemoryRouter> <AuthContext.Provider value={{}}> <MockedProvider mocks={[]} addTypename={false}> <Posts /> </MockedProvider> </AuthContext.Provider> </MemoryRouter>, ); expect(container).toMatchSnapshot(); }); // ... }); 复制代码

咱们的第一个测试检查了加载的的状态。咱们必须把它包裹在几个高阶函数里 —— MemoryRouter,给 React Router 的 LinkRoute 提供了一个模拟的路由;AuthContext.Provider,提供了认证的状态,和 Apollo 的 MockedProvider。由于咱们已拍了一个即时的快照并返回,咱们事实上不须要模拟任何事情;一个即时的快照会在 Apollo 有机会执行 query 查询以前捕捉到加载的状态。

// client/src/components/Posts.test.js
// ...

describe("Posts", () => {
  // ...

  it("renders correctly when loaded", async () => {
    const mocks = [
      {
        request: {
          query: GET_POSTS,
        },
        result: {
          data: {
            posts: [
              {
                id: 1,
                body: "Thoughts",
                insertedAt: "2019-04-18T00:00:00",
                user: {
                  id: 1,
                  name: "John Smith",
                  gravatarMd5: "abc",
                },
              },
            ],
          },
        },
      },
    ];
    const { container, getByText } = render(
      <MemoryRouter> <AuthContext.Provider value={{}}> <MockedProvider mocks={mocks} addTypename={false}> <Posts /> </MockedProvider> </AuthContext.Provider> </MemoryRouter>, ); await wait(() => getByText("Thoughts")); expect(container).toMatchSnapshot(); }); // ... }); 复制代码

对于这个测试,咱们但愿一旦加载结束帖子被显示出来,就马上快照。为了达到这个,咱们必须让测试 async,而后使用 react-testing-library 的 wait 来 await 加载状态的结束。wait(() => ...) 将会简单的重试这个函数直到结果再也不错误 —— 一般状况下不会超过 0.1 秒。一旦文本显现出来,咱们就马上对整个组件快照以确保那是咱们所期待的结果。

// client/src/components/Posts.test.js
// ...

describe("Posts", () => {
  // ...

  it("renders correctly after created post", async () => {
    Subscriber.mockImplementation((props) => {
      const { default: ActualSubscriber } = jest.requireActual(
        "containers/Subscriber",
      );
      return <ActualSubscriber {...props} />;
    });

    const mocks = [
      {
        request: {
          query: GET_POSTS,
        },
        result: {
          data: {
            posts: [
              {
                id: 1,
                body: "Thoughts",
                insertedAt: "2019-04-18T00:00:00",
                user: {
                  id: 1,
                  name: "John Smith",
                  gravatarMd5: "abc",
                },
              },
            ],
          },
        },
      },
      {
        request: {
          query: POSTS_SUBSCRIPTION,
        },
        result: {
          data: {
            postCreated: {
              id: 2,
              body: "Opinions",
              insertedAt: "2019-04-19T00:00:00",
              user: {
                id: 2,
                name: "Jane Thompson",
                gravatarMd5: "def",
              },
            },
          },
        },
      },
    ];
    const { container, getByText } = render(
      <MemoryRouter>
        <AuthContext.Provider value={{}}>
          <MockedProvider mocks={mocks} addTypename={false}>
            <Posts />
          </MockedProvider>
        </AuthContext.Provider>
      </MemoryRouter>,
    );
    await wait(() => getByText("Opinions"));
    expect(container).toMatchSnapshot();
  });
});
复制代码

最后,咱们将会来测试 subscription,来确保当组件收到一个新的帖子时,它可以按照所期待地结果进行正确地渲染。在这个测试案例中,咱们须要更新 Subscription 的模拟,以便它实际地返回原始的实现,并为组件订阅所发生的变化(新建帖子)。咱们同时模拟了一个叫 POSTS_SUBSCRIPTION 地查询来模拟 subscription 接收到一个新的帖子。最后,同上面的测试同样,咱们等待查询语句的结束(而且新帖子的文本出现)并对 HTML 进行快照。

以上就差很少是所有的内容了。jest 和 react-testing-library 都很是的强大,它们使咱们对组件的测试变得简单。测试 Apollo 有一点点困难,可是经过明智地使用模拟数据,咱们也可以写出一些很是完整的测试来测试全部主要组件的状态。

服务器端渲染

如今咱们的客户端只有一个问题了 —— 全部的 HTML 都是在客户端被渲染的。从服务器返回的 HTML 只是一个空的 index.html 文件和一个 <script> 标签,所载入的 JavaScript 渲染了所有的内容。在开发模式下,这样能够,但这样对生产环境并很差 —— 例如说,不少的搜索引擎并不擅长运行 JavaScript 来根据客户端渲染的内容构建索引(index)。咱们真正但愿的是服务器能返回该页面的彻底渲染的 HTML,而后 React 能够接管客户端,处理用户的加护的路由。

这里,服务器端渲染(SSR)的概念被引入进来。本质上来讲,相比于提供静态的 HTML 索引文件,咱们将请求路由到 Node.js 服务器端。服务器渲染组件(解析对 GraphQL 端点的任何查询)而且返回输出的 HTML,和 <script> 标签来加载 JavaScript。当 JavaScript 在客户端加载,它会使用 hydrate 函数而不是从头开始渲染 —— 意味着它会保存已存在的,服务器端提供的 HTML 并将它和相匹配的 React 树联系起来。这种方法将容许搜索引擎简单的索引服务器渲染的 HTML,而且由于用户再也不须要在页面可视以前等待 JavaScript 文件的下载,执行和进行查询,这也会为用户提供了一个更快的体验。

不幸的是,我发现配置 SSR 真正的并无一个通用的方法 —— 他们的基础是相同的(都是运行一个能够渲染组件的 Node.js 服务器)可是存在一些不一样的实现,而且没有任何实现被标准化。个人应用的大部分的配置都来自于 cra-ssr,它为 create-react-app 搭建的应用用提供了很是易于理解的 SSR 的实现。由于 cra-ssr 的教程提供至关完善的介绍,我不会在这里作更加深刻的剖析。我是想说,SSR 很棒而且使得应用加载的很是快,尽管实现它确实有点点困难。

结论和收获

感谢你们看到这里!这里内容超多,由于我想要真正地深刻一个复杂的应用,来从头至尾地练习全部的技术,而且来解决一些在现实世界中真正遇到的问题。若是你已经读到这里了,但愿你能对如何将这全部的技术用在一块儿有了一些不错的理解。你能够在 Github 上看到完整版的代码。或者试用这个在线演示。这个演示是部署在免费版的 Heroku dyno 上的,因此在你访问的时候,可能会须要 30 秒来唤醒服务器。若是你有任何问题,能够在演示下面的评论里留言,我会尽个人可能来回答。

我部署的体验也充满了挫折和问题。有些是意料之中,包括一些新的框架和库的学习曲线 —— 但也有一些地方,若是有更好的文档和工具,能够节省我不少的时间,让我不那么头疼。特别是 Apollo,我在理解若是让 websocket 在认证变化后从新初始化它的链接上遇到了一大堆问题;一般状况下这些都应该在文档里写下来,可是显然我啥也找不到。类似的,我在测试 subscriptions 时也遇到不少问题,而且最终不得不放弃转而使用 mock 测试。测试的文档对于基本的测试来讲是很是够的,可是我发现当我想要写更高级的测试案例时,文档太过于浅显。我怕也常常由于缺乏 API 的文档而感到困惑,主要是 Apollo 和 Absinthe 客户端库的一部分文档。例如说,当我研究若是重置 websocket 链接时,我找不到任何 Absinthe socket 实例和 Apollo link 实例的文档。我惟一能作的就是把 GitHub 上面的源代码从头至尾读一遍。我使用 Apollo 的体验比起几年前使用 Relay 的体验要好不少 —— 可是下一次我使用它时,我不得不接受,若是我想要另辟蹊径的话,就须要花更多的时间来破解改造代码的事实。

总而言之,我给这套技术栈很高的评分,并且我很是的喜欢这个项目。Elixir 和 Phoenix 用起来让人耳目一新;若是你来自 Rails,会有一些学习的曲线,可是我真的很是喜欢 Elixir 的一些语言特色,例如模式匹配和通道运算符(pipe operator)。Elixir 有不少新颖的想法(以及来许多来自函数式编程,通过实战考验的概念),让编写有意义的,好看的代码这件事变得十分简单。Absinthe 的使用就像是一阵春风拂面;它实现的很好,文档极佳,几乎涵盖了全部实现 GraphQL 服务器的合理用例,而且从整体上来讲,我发现 GraphQL 的核心概念也被很好地传递。查询每个页面我须要的数据十分简单,经过 subscription 实现实时地更新也很是容易。我一直都很是喜欢使用 React 和 React Router,这一次也不例外 —— 它们使得构建复杂,交互的前端用户界面变得简单。最后,我十分满意总体的结果 —— 做为一名用户,应用的加载和浏览很是快,全部的东西都是实时的因此能够一直保持同步。若是说对技术栈的终极衡量标准是用户的体验,那这个组合必定是一个巨大的成功。



若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索