[譯] Elixir、Phoenix、Absinthe、GraphQL、React 和 Apollo:一次近乎瘋狂的深度實踐 —— 第二部分(測試相關部分)

掘金翻譯計劃發表於2019-05-22

如果你沒有看過本系列文章的第一部分,建議你先去看第一部分:

測試 —— 伺服器端

現在我們已經完成了所有的程式碼部分,那我們如何確保我的程式碼總能正常的工作呢?我們需要對下面幾種不同的層次進行測試。首先,我們需要對 model 層進行單元測試 —— 這些 model 是否能正確的驗證(資料)?這些 model 的 helper 函式是否能返回預期的結果?第二,我們需要對 resolver 層進行單元測試 —— resolver 是否能處理不同的(成功和失敗)的情況?是否能返回正確的結果或者根據結果作出正確的資料庫更新?第三,我們應該編寫一些完整的 integration test(整合測試),例如傳送向伺服器一個查詢請求並期待返回正確的結果。這可以讓我們更好地從全域性上把控我們的應用,並且確保這些測試涵蓋認證邏輯等案例。第四,我們希望對我們的 subscription 層進行測試 —— 當相關的變化發生時,它們可否可以正確地通知套接字。

Elixir 有一個非常基本的內建測試庫,叫做 ExUnit。ExUnit 包括簡單的 assert/refute 函式,也可以幫助你執行你的測試。在 Phoenix 中建立一系列 “case” support 檔案的方法也很常見。這些檔案在測試中被引用,用於執行常見的初始化任務,例如連線資料庫。此外,在我的測試中,我發現 ex_specex_machina 這兩個庫非常有幫助。ex_spec 加入了簡單的 describeit,對於有 ruby 相關背景的我來說,ex_spec 可以讓編寫測試所用的語法更加的友好。ex_machina 提供了函式工廠(factory),這些函式工廠可以讓動態插入測試資料變得更簡單。

我建立的函式工廠長這樣:

# 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
複製程式碼

在環境的搭建中匯入函式工廠後,你就可以在測試案例中使用一些非常直觀的語法了:

# 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 長這樣:

# 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)。

接下來,讓我們一起看一下下面這個 resolver 的測試案例:

# 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,然後期待正確的結果被返回。

整合測試有一點點小複雜。我們首先需要建立和伺服器端的連線(可能需要認證),接著傳送一個查詢語句並且確保我們得到正確的結果。我找到了這篇帖子,它對學習如何為 Absinthe 構建整合測試非常有幫助。

首先,我們建立一個 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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章