摘要:對于每個案例,我們插入所需要的測試數據,調用需要測試的函數并對結果作出斷言。我們將這個套接字和用戶返回以供我們其他的測試使用。
原文地址:Elixir, Phoenix, Absinthe, GraphQL, React, and Apollo: an absurdly deep dive - Part 2
原文作者:Zach Schneider
譯文出自:掘金翻譯計劃
本文永久鏈接:github.com/xitu/gold-m…
譯者:Fengziyin1234
校對者:Xuyuey, portandbridge
如果你沒有看過本系列文章的第一部分,建議你先去看第一部分:
Elixir、Phoenix、Absinthe、GraphQL、React 和 Apollo:一次近乎瘋狂的深度實踐 —— 第一部分
Elixir、Phoenix、Absinthe、GraphQL、React 和 Apollo:一次近乎瘋狂的深度實踐 —— 第二部分
測試 —— 服務器端現在我們已經完成了所有的代碼部分,那我們如何確保我的代碼總能正常的工作呢?我們需要對下面幾種不同的層次進行測試。首先,我們需要對 model 層進行單元測試 —— 這些 model 是否能正確的驗證(數據)?這些 model 的 helper 函數是否能返回預期的結果?第二,我們需要對 resolver 層進行單元測試 —— resolver 是否能處理不同的(成功和失敗)的情況?是否能返回正確的結果或者根據結果作出正確的數據庫更新?第三,我們應該編寫一些完整的 integration test(集成測試),例如發送向服務器一個查詢請求并期待返回正確的結果。這可以讓我們更好地從全局上把控我們的應用,并且確保這些測試涵蓋認證邏輯等案例。第四,我們希望對我們的 subscription 層進行測試 —— 當相關的變化發生時,它們可否可以正確地通知套接字。
Elixir 有一個非常基本的內置測試庫,叫做 ExUnit。ExUnit 包括簡單的 assert/refute 函數,也可以幫助你運行你的測試。在 Phoenix 中建立一系列 “case” support 文件的方法也很常見。這些文件在測試中被引用,用于運行常見的初始化任務,例如連接數據庫。此外,在我的測試中,我發現 ex_spec 和 ex_machina 這兩個庫非常有幫助。ex_spec 加入了簡單的 describe 和 it,對于有 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 (
幾個值得注意的點 —— util/apollo 這里對外輸出了一個 createClient 函數。這個函數會創建并返回一個 Apollo 客戶端的實例(我們將在下文中進行著重地介紹)。將 createClient 包裝在 useRef 中,就能讓該實例在應用的生命周期內(即,所有的 rerenders)中均可使用。ApolloProvider 這個高階組件會使 client 可以在所有子組件/查詢的 context 中使用。在我們瀏覽該應用的過程中,BrowserRouter 使用 HTML5 的 history API 來保持 URL 的狀態同步。
這里的 Switch 和 Route 需要多帶帶進行討論。React Router 是圍繞動態路由的概念建立的。大部分的網站使用靜態路由,也就是說你的 URL 將匹配唯一的路由,并且根據所匹配的路由來渲染一整個頁面。使用動態路由,路由將被分布到整個應用中,一個 URL 可以匹配多個路由。這聽起來可能有些令人困惑,但事實上,當你掌握了它以后,你會覺得它非常棒。它可以輕松地構建一個包含不同組件頁面,這些組件可以對路由的不同部分做出反應。例如,想象一個類似臉書的 messenger 的頁面(Socializer 的聊天界面也非常相似)—— 左邊是對話的列表,右邊是所選擇的對話。動態路由允許我這樣表達:
const App = () => {
return (
// ...
"/chat/:id"); component={Chat} />
// ...
);
};
const Chat = () => {
return (
);
};
如果路徑以 /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 (
Feed
{({ loading, error, data, subscribeToMore }) => {
if (loading) return ;
if (error) return ;
return (
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);
});
},
})
}
/>
);
}}
);
};
現在我們將實現真正的組件部分。首先,執行基本的查詢,我們先渲染 Apollo 的
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 (
Socializer | Log in
setIsInvalid(true)}>
{(login, { data, loading, error }) => {
if (data) {
const {
authenticate: { id, token },
} = data;
setAuth({ id, token });
}
return (
Email address
{
setEmail(e.target.value);
setIsInvalid(false);
}}
isInvalid={isInvalid}
/>
{renderIf(error)(
Email or password is invalid
,
)}
Password
{
setPassword(e.target.value);
setIsInvalid(false);
}}
isInvalid={isInvalid}
/>
這里的代碼看起來很洋氣,但是不要懵 —— 這里大部分的代碼只是為表單做一個 Bootstrap 組件。我們從一個叫做 Helmet(react-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);
這里有很多東西。首先,我們為認證用戶的 token 和 userId 建立 state。我們通過讀 cookie 來初始化 state,那樣我們就可以在頁面刷新后保證用戶的登陸狀態。接下來我們實現了我們的 setAuth 函數。用 null 來調用該函數會將用戶登出;否則就使用提供的 token 和 userId來讓用戶登陸。不管哪種方法,這個函數都會更新本地的 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`] = `
Loading...
`;
在這個例子中,因為 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 在我們所不希望地情況下被
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/6830.html
摘要:這個速查表主要是分享互聯網上一些比較常用的工具和技術常用內容,如編輯器的快捷鍵的命令行的選擇器的屬性等,這個列表簡單收集了常用的工具,可以收藏用于平時的備忘錄,需要用到的時候可以及時查閱。 這個速查表主要是分享互聯網上一些比較常用的工具和技術常用內容,如編輯器的快捷鍵、git的命令行、jQuery的API選擇器、CSS的flexbox屬性等,這個列表簡單收集了常用的工具,可以收藏用于平...
摘要:這個速查表主要是分享互聯網上一些比較常用的工具和技術常用內容,如編輯器的快捷鍵的命令行的選擇器的屬性等,這個列表簡單收集了常用的工具,可以收藏用于平時的備忘錄,需要用到的時候可以及時查閱。 這個速查表主要是分享互聯網上一些比較常用的工具和技術常用內容,如編輯器的快捷鍵、git的命令行、jQuery的API選擇器、CSS的flexbox屬性等,這個列表簡單收集了常用的工具,可以收藏用于平...
摘要:這個速查表主要是分享互聯網上一些比較常用的工具和技術常用內容,如編輯器的快捷鍵的命令行的選擇器的屬性等,這個列表簡單收集了常用的工具,可以收藏用于平時的備忘錄,需要用到的時候可以及時查閱。 這個速查表主要是分享互聯網上一些比較常用的工具和技術常用內容,如編輯器的快捷鍵、git的命令行、jQuery的API選擇器、CSS的flexbox屬性等,這個列表簡單收集了常用的工具,可以收藏用于平...
摘要:關注業務,而不是技術將數據需求放在它們所屬的客戶端。技術棧中的每一部分都起著作用技術棧中所有部分之間的協作可以借助緩存來完成。現在,我們來看看另一個貫穿整個技術棧的功能的例子。你可以認為是首個內置細粒度查看的技術。 本文整理自2017年 GraphQL 峰會上的演講,詳述緩存、追蹤、模式拼接和 GraphQL 未來發展等有關話題。 Facebook 開源 GraphQL 至今已兩年有余...
摘要:然而,盡管使用有諸多好處,但邁出第一步可能并不容易。為了簡化初始教程,我們今天只構建一個簡單的列表視圖。是我們將在本教程系列中使用的客戶端的名稱。我們將列表組件命名為。在本教程的其余部分中,你將了解到我們構建一個真正的通信應用的基礎。 首發于眾成翻譯 Part 1——前端:使用 Apollo 聲明式地請求和 mock 數據 showImg(http://p0.qhimg.com/t0...
閱讀 2290·2023-04-26 00:01
閱讀 796·2021-10-27 14:13
閱讀 1810·2021-09-02 15:11
閱讀 3381·2019-08-29 12:52
閱讀 528·2019-08-26 12:00
閱讀 2569·2019-08-26 10:57
閱讀 3405·2019-08-26 10:32
閱讀 2848·2019-08-23 18:29