บันทึกการใช้งาน Query/Mutation Component และทำ Testing
เมื่อไม่นานมานี้ได้ลองใช้ Query/Mutation component และได้ลองเขียนเทสดู คิดว่าน่าจะเป็นประโยชน์ก็เลยเขียนบล็อกเก็บเอาไว้
Query/Mutation Component คืออะไร ?
โดยปกติแล้ว วิธีที่เราจะเรียกใช้ query และ mutation มักจะใช้ Higher Order Components (HOC) ที่ชื่อว่า graphql จาก react-apollo ยกตัวอย่างเป็น todo list ตามโค้ดด้านล่าง
import React, { Component } from 'react'
import { graphql } from 'react-apollo'
import gql from 'graphql-tag'const GET_ALL_TODOES = gql`
query allTodoes {
allTodoes {
id
content
}
}
`const TodoList = (props) => {
const { loading, error, allTodoes } = props.data
if (loading) return <p>Loading...</p>
if (error) return <p>Error :(</p> return (
allTodoes.map(({ id, content }) => (
<div key={id}>
<p>{content}</p>
</div>
))
)
}export default graphql(GET_ALL_TODOES)(TodoList)
โดยวิธีนี้ graphql จะทำการส่ง props ที่ชื่อว่า data ให้กับ component ของเราครับ ทำให้เราสามารถดูข้อมูล query, สถานะการ loading รวมไปถึง error เมื่อ query ไม่สำเร็จได้
ซึ่งใน react-apollo เวอร์ชัน 2.1 ได้ออกฟีจเจอร์ใหม่ก็คือ Query component โดยแทนที่เราจะเรียกใช้ HOC graphql ตามวิธีข้างต้น เราจะใช้ component ที่ชื่อว่า <Query />
และ <Mutation />
ในการขอข้อมูลและแก้ไข้ข้อมูลตามลำดับ
Query Component
เรามาดูการใช้งาน Query component กันก่อนเลย
import React, { Component } from 'react'
import { Query } from 'react-apollo'
import gql from 'graphql-tag'const GET_ALL_TODOES = gql`
query allTodoes {
allTodoes {
id
content
}
}
`const TodoList = (props) => (
<Query query={GET_ALL_TODOES}>
{({ loading, error, data }) => {
if (loading) return <p>Loading...</p>
if (error) return <p>Error :(</p>
return data.allTodoes.map(({ id, content }) => (
<div key={id}>
<p>{content}</p>
</div>
))
}}
</Query>
)export default TodoList
การใช้งาน Query component ก็ง่ายมากเพียงแค่ส่ง props สองตัว นั่นก็คือ query และ children เข้าไป ก็สามารถ query ข้อมูลได้เลย
- query — ก็ตรงตัวครับส่ง GraphQL query เข้าไป
- children — เป็น props ที่ใช้บอกว่าเราจะ render อะไรออกไปบ้าง โดยใช้วิธีส่งเป็น function ที่รับ argument เป็น response จากการ query และ return เป็น UI กลับไป
ปล. วิธีที่ใช้กับ children props ข้างต้น คือเทคนิคที่เรียกว่า render props ครับ
จริงๆแล้วเราสามารถส่ง props อื่น ๆ เข้าไปได้อีก เช่น variables สำหรับ query ที่เป็น dynamic, pollInterval สำหรับให้เรียก query ซ้ำ ๆ ตามเวลาที่เรากำหนด เป็นต้น
โดยการทำงานเบื้องหลังคือ Query component จะไปดูข้อมูลใน cache ก่อนครับ ถ้าไม่มีก็จะทำการ fetch ข้อมูลตาม query ที่เราส่งไป นี่ก็เป็นอีกหนึ่งข้อดีของการใช้ Apollo Client ครับ
Mutation Component
การใช้งาน Mutation component ก็คล้ายๆกันครับ เราสามารถใช้งานผ่าน component ที่ชื่อว่า <Mutation />
ได้เลย เพียงแค่เปลี่ยนเป็นส่ง props ชื่อ mutation ไปแทน และใน function ของ children props ก็จะรับเป็น mutation และ response จากการทำ mutation ตามลำดับ
import React, { Component, Fragment } from 'react'
import { Mutation } from 'react-apollo'
import gql from 'graphql-tag'const CREATE_TODO = gql`
mutation createTodo($content: String!, $isDone: Boolean!) {
createTodo(content: $content, isDone: $isDone) {
id
content
}
}
`const TodoList = (props) => (
<Mutation mutation={CREATE_TODO}>
{(createTodo, { data }) => (
<form
onSubmit={(e) => {
e.preventDefault()
createTodo({ variables: {
content: e.target.text.value
}})
}}
>
<input name="text" />
<button type="submit">Add Todo</button>
</form>
)}
</Mutation>
)export default TodoList
ใน <Mutation />
เราสามารถส่ง props อื่น ๆ เข้าไปได้อีก เช่น variables สำหรับส่งตัวแปรสำหรับ mutation ที่เป็น dynamic, หรือ refetchQueries ไว้ fetch query อีกรอบหลังทำ mutation เสร็จ เป็นต้น
ข้อดีเมื่อเทียบกับ Higher Order Components
โดยปกติแล้วหากเราซ้อน HOC หลาย ๆ ชั้น มีโอกาสเกิด props collision ได้ หรือพูดง่าย ๆ คือ อาจมีการเติม props ชื่อเดียวกันเข้ามาจาก HOC หลาย ๆ ตัวก็ได้ เช่น HOC ตัวแรกเพิ่ม props ชื่อว่า data เข้ามา แล้ว HOC ตัวที่สองก็เพิ่ม props ชื่อ data เข้ามาอีกเช่นกัน ทำให้ props ชนกันนั่นเอง (ซึ่งมันไม่เกิด error นะครับ น่ากลัวมาก)
ในกรณีเราซ้อน HOC หลาย ๆ ชั้น มันทำให้ยากที่จะรู้ว่า props ที่ถูกเติมเข้ามาเนี่ย มันมาจาก HOC ตัวไหน ซึ่งปัญหานี้ใช้ render props ก็ช่วยได้เช่นกันครับ
Query/Mutation Component Testing
จากตัวอย่างหากเราเอา <TodoList />
ไปเทสเลย จะพบว่ามี error เนื่องจากตัว Query component ต้องการ client ของ Apollo เพื่อใช้ในการขอข้อมูล
import React from 'react'
import renderer from 'react-test-renderer'
import TodoList, { GET_ALL_TODOES } from './TodoList'it('Should render todo list', () => {
// บรรทัดนี้จะพังเนื่องจาก <Query /> ต้องการ client ของ Apollo
const component = renderer.create(<TodoList />) expect(component).toMatchSnapshot()
})
สาเหตุเพราะตอนเราเขียนโค้ด เรามี <ApolloProvider />
ที่ทำให้ child component ทั้งหมดสามารถเข้าถึง client ของ Apollo ได้นั่นเอง
import React from 'react'
import ReactDOM from 'react-dom'
import ApolloClient from 'apollo-boost'
import { ApolloProvider } from 'react-apollo'
import TodoList from './TodoList'const client = new ApolloClient({
uri: "https://api.graphql.com"
})ReactDOM.render(
<ApolloProvider client={client}>
<TodoList />
</ApolloProvider>
, document.getElementById('root'))
แต่ช้าก่อน หากใครคิดจะเอา ApolloProvider ไปใช้ในการเทสละก็ใจเย็นก่อน เพราะถ้าใช้วิธีนี้หมายความว่าเราจะขอข้อมูลผ่าน GraphQL endpoint ของจริง ซึ่งมีผลกระทบต่อการเทสแน่นอน เช่น เราไม่สามารถควบคุมให้ปลายทางมันเกิด success case หรือ error case ตามที่เราต้องการได้ (เพราะเราควรเทสทั้งสองกรณีเนอะ), หรือไม่ถ้า GraphQL backend ของเราพัง เทสก็จะพังไปด้วยสินะ ไม่ดีเลย
MockedProvider ผู้ช่วยชีวิต
เราสามารถใช้ <MockedProvider />
จาก react-apollo/test-utils เพื่อเป็นตัวช่วยในการกำหนด response ของแต่ละ query ให้เป็นไปตามที่เราต้องการได้ มาลองดูตัวอย่างการใช้งานกัน
import React from 'react'
import { MockedProvider } from 'react-apollo/test-utils'
import renderer from 'react-test-renderer'
import waait from 'waait'
import TodoList, { GET_ALL_TODOES } from './TodoList'it('Should render todo list', async () => {
const mocks = [
{
request: {
query: GET_ALL_TODOES,
},
result: {
data: {
allTodoes: [
{
id: 'cjiugybsw23z8019717p0cgwb',
content: 'Write a blog',
},
{
id: 'cjiugyxca23zc0197lxs4uh29',
content: 'Practice guitar',
},
],
},
},
},
] const component = renderer.create(
<MockedProvider mocks={mocks} addTypename={false}>
<TodoList />
</MockedProvider>,
) await waait(0) expect(component).toMatchSnapshot()
})
เมื่อใช้ <MockedProvider />
เราสามารถ mock response ที่เราต้องการได้ผ่าน props ชื่อว่า mocks โดยส่งเป็น array ของคู่ query และ response ที่เราจะจำลองได้เลย
ปล. ส่วนสาเหตุที่ต้องใส่
addTypename={false}
เพราะปกติแล้วเวลามีการ query ตัว client จะทำการเติม__typename
เข้าไปในทุก ๆ object type แต่กลับกันเวลาเรา mock เราไม่ได้มี__typename
ใน query ดังนั้นจะทำให้ query ไม่ match กับที่เรา mock ไว้ได้
ในกรณีที่อยากจำลองให้ request นั้นทำงานไม่สำเร็จ ก็สามารถจำลอง error ผ่าน props ที่ชื่อว่า mocks ได้เหมือนกัน โดยแทนที่เราจะส่ง field result ไป ก็จะเปลี่ยนเป็นส่ง error ไปแทน
import React from 'react'
import { MockedProvider } from 'react-apollo/test-utils'
import renderer from 'react-test-renderer'
import waait from 'waait'
import TodoList, { GET_ALL_TODOES } from './TodoList'it('Should contain error when request failed', async () => {
const mocks = [
{
request: {
query: GET_ALL_TODOES,
},
error: new Error('Noooooooo!!!!')
},
]
const component = renderer.create(
<MockedProvider mocks={mocks} addTypename={false}>
<TodoList />
</MockedProvider>,
) await waait(0) expect(component).toMatchSnapshot()
})
คหสต.
จากที่ลองใช้งานมา ผมชอบกว่าวิธีเดิมที่ใช้ HOC นะ ด้วยความที่ Query component ใช้ render props ทำให้เรากลับมาเขียนโค้ดอยู่ในรูปของ component อีกครั้งหนึ่ง อยากส่งเงื่อนไขอะไรในการทำ query ก็แค่ส่งเป็น props เข้าไป
ในการเทสถือว่าสบายตัวขึ้นเยอะเมื่อมี <MockedProvider />
ครับ นี่คือจุดประสงค์หลักที่ผมเขียนบทความนี้เลย การมี <MockedProvider />
ช่วยให้เราสามารถควบคุมหน้าตาของผลลัพธ์ที่ใช้ในการเทสได้ครับ ทำให้เทสเราน่าเชื่อถือขึ้น
แต่สิ่งที่อาจจะทำให้ลำบากหน่อยก็คือ จะต้องหาวิธีหน่วงให้ผลลัพธ์ของการ query ถูก render เสียก่อน แล้วค่อยทำการ assert จากตัวอย่างผมใช้ waait (ซึ่งเบื้องหลังก็คือ Promise + setTimeout นั่นแหละ)
ยังไงหากใครลองมาแล้วก็สามารถพูดคุยกันได้นะครับ ไว้เจอกันใหม่บทความหน้าครับผม