Firebase 入門(3) ~ セキュリティの向上
Authentication
Database ルール修正
今のままだと、データベースがどのユーザーでもアクセスできてしまうので、アクセス制限をする。まず、Database のルールを以下のように変更。内容としては、認証されたuidと同じ場合のみアクセスを許可している。
{ "rules": { "users": { "$uid": { ".read": "$uid === auth.uid", ".write": "$uid === auth.uid" } } } }
Firebase Authentication
Firebase には認証を管理する Authentication というサービスがある。それを使ってユーザーを認証し、uidを取得し、データベースにアクセスできるようにする。今回は Authentication を有効にして、「ログイン方法」の「メール/パスワード」のみを有効にした。
firebaseui
Firebase はデフォルトでログインフォームUIを提供している。それが firebaseui だ。まずは npmモジュールをインストールする。
$ npm i firebaseui --save
firebaseui 自体の使い方は簡単で、FirebaseUI インスタンスを Auth インスタンスで初期化して、インスタンスを設定オブジェクトでスタートしてやるだけでいい。
import firebase from 'firebase/app'; import 'firebase/database'; import 'firebase/auth'; import firebaseui from 'firebaseui' const config = { apiKey: "<API_KEY>", authDomain: "<PROJECT_ID>.firebaseapp.com", databaseURL: "https://<DATABASE_NAME>.firebaseio.com", projectId: "<PROJECT_ID>", storageBucket: "<BUCKET>.appspot.com", messagingSenderId: "<SENDER_ID>", }; firebase.initializeApp(config); var uiConfig = { callbacks: { signInSuccessWithAuthResult: function(authResult, redirectUrl) { return true; }, uiShown: function() { document.getElementById('loading').style.display = 'none'; } }, signInFlow: 'popup', signInSuccessUrl: '/', signInOptions: [ firebase.auth.EmailAuthProvider.PROVIDER_ID ], tosUrl: '/term_of_service', privacyPolicyUrl: '/privacy_policy' }; var ui = new firebaseui.auth.AuthUI(firebase.auth()); ui.start('#firebaseui-auth-container', uiConfig);
ui.start()
の第一引数で指定したIDの要素にログインフォームが表示される。
React で使う
React で使うなら、以下のような感じだろうか? LoginUI 関数の中が本体。多重読み込みを防ぐために、useEffect の 第二引数にダミーのstateを突っ込んでいるのがちょっとダサい。あと、この後データベースとの連携もあるので useReducer を使ってアクションを作っているが、なくても動く。
import * as React from 'react'; const { useState, useEffect, createContext, useReducer, useContext } = React; import firebase from "firebase/app"; import 'firebase/database'; import 'firebase/auth'; import firebaseui from 'firebaseui'; const DispatchContext = createContext(() => {}); const reducer = (state, action) => { switch(action.type) { case 'change_user': if(state.uid === action.payload.uid) { return state } return { username: action.payload.username, uid: action.payload.uid } case 'change_username': if(state.username === action.payload.username) { return state } return { username: action.payload.username, uid: state.uid } } }; const LoginUI = () => { const dispatch = useContext(DispatchContext); const [temp, _] = useState(0); useEffect(() => { const deffer = () => {} firebase.auth().onAuthStateChanged(function(user) { if(firebase.auth().currentUser !== null) { dispatch({type: 'change_user', payload: { username: user.displayName, uid: user.uid }}) return deffer } var uiConfig = { callbacks: { signInSuccessWithAuthResult: function(authResult, redirectUrl) { return true; }, uiShown: function() { // document.getElementById('loading').style.display = 'none'; } }, signInFlow: 'popup', signInSuccessUrl: '/', signInOptions: [ firebase.auth.EmailAuthProvider.PROVIDER_ID ], tosUrl: '/term_of_service', privacyPolicyUrl: '/privacy_policy' }; var ui = new firebaseui.auth.AuthUI(firebase.auth()); ui.start('#firebaseui-auth-container', uiConfig); return deffer }); }, [temp]); return ( <> <div id="firebaseui-auth-container"></div> </> ); } export default () => { const [state, dispatch] = useReducer(reducer, { username: "", uid: "", }); const config = { apiKey: "<API_KEY>", authDomain: "<PROJECT_ID>.firebaseapp.com", databaseURL: "https://<DATABASE_NAME>.firebaseio.com", projectId: "<PROJECT_ID>", storageBucket: "<BUCKET>.appspot.com", messagingSenderId: "<SENDER_ID>", }; if (!firebase.apps.length) { firebase.initializeApp(config); } return ( <> <div>{state.username}</div> <DispatchContext.Provider value={dispatch}> <LoginUI /> </DispatchContext.Provider> </> ); };
Database と連携
App コンポーネントがレンダリングされたタイミングでデータベースにアクセスしてユーザー情報を取得するようなものを作成した。先ほどの例の default 関数に useEffect Hook で実装する。users/<uid>
のパスにアクセスして何も取得できない場合は、stateの情報をもとに箱を作成する。state が更新されるたびにデータベースにアクセスしにいかないように、state.uidが更新されたタイミングでアクセスするようにもした。
useEffect(() => { if(state.uid =="") { return () => {} } database.ref('users/' + state.uid).once('value').then((snapshot) => { if(snapshot.val() === null) { database.ref('users/' + state.uid).set({ username: state.username }); dispatch({type: 'change_username', payload: { username: state.username}}) } else { const newUsername = snapshot.val().username; dispatch({type: 'change_username', payload: { username: newUsername}}) } }) }, [state.uid])
おまけ (Signout ボタン)
デバッグで必要だったのでSignoutボタンをつけた。
export default 関数に Logout コンポーネントを追加して、Logoutコンポーネント側でボタンを作って onClickイベント関数の中で firebase.auth().signOut() メソッドを読んでいるだけ。
const LogoutUI = () => { const signOut = () => { firebase.auth().signOut().then(function() { console.log("signed out") }).catch(function(error) { console.log(error) }); } return ( <button onClick={signOut}>Sign out</button> ) } export default () => { // 省略 return ( <> <div>{state.username}</div> <DispatchContext.Provider value={dispatch}> <LoginUI /> <LogoutUI /> </DispatchContext.Provider> </> ); }
Firebase 入門(2) ~ Firebase Realtime Database
作ったTodo アプリではサーバー側をgoで作成していたが、Firebaseにはサーバーを置くような機能はないので、Firebase Realtime Database でどうにかしたい。(できるのかもわからない)
Database を作成
todo プロジェクトから Database を選択し、データベースを作成する。ロックモードで作成した。
Firebase JavaScript クライアント SDK を追加
ここ を参考に以下のindex.html を作成。API_KEY とかは、Firebase のダッシュボードの Authentication
から移動した先の「ウェブ設定」ボタンを押したところに記載されているので、自身のキーに置き換えること。
<html> <head> <title>Firebase Database Test</title> </head> <body> <div id="name"></div> <div id="email"></div> <script src="https://www.gstatic.com/firebasejs/5.8.2/firebase.js"></script> <script src="https://www.gstatic.com/firebasejs/5.5.2/firebase-app.js"></script> <script src="https://www.gstatic.com/firebasejs/5.5.2/firebase-database.js"></script> <script> var config = { apiKey: "<API_KEY>", authDomain: "<PROJECT_ID>.firebaseapp.com", databaseURL: "https://<DATABASE_NAME>.firebaseio.com", projectId: "<PROJECT_ID>", storageBucket: "<BUCKET>.appspot.com", messagingSenderId: "<SENDER_ID>", }; firebase.initializeApp(config); </script> </body> </html>
Firebase Database にアクセス
以下のように index.html を修正。ただ、このままだとアクセスできないので先ほどロックモードで作成したデータベースを公開モードに修正する必要がある。公開モードにするには、Firebase Database の ダッシュボードから「ルール」を選択し、.read
と .write
の設定を true にする。Firebase Database には Could Firestore
と Realtime Database
があり、デフォルトでは Could Firestore
になっているので、Realtime Database
に変更してから「ルール」の設定を行うこと。
<html> <head> <title>Firebase Database Test</title> </head> <body> <div id="name"></div> <script src="https://www.gstatic.com/firebasejs/5.8.2/firebase.js"></script> <script src="https://www.gstatic.com/firebasejs/5.5.2/firebase-app.js"></script> <script src="https://www.gstatic.com/firebasejs/5.5.2/firebase-database.js"></script> <script> var config = { apiKey: "<API_KEY>", authDomain: "<PROJECT_ID>.firebaseapp.com", databaseURL: "https://<DATABASE_NAME>.firebaseio.com", projectId: "<PROJECT_ID>", storageBucket: "<BUCKET>.appspot.com", messagingSenderId: "<SENDER_ID>", }; firebase.initializeApp(config); var database = firebase.database(); database.ref('users/1').set({ username: "bamchoh" }); var username = "Anonymouse"; database.ref('users/1').once('value').then(function(snapshot) { username = (snapshot.val() && snapshot.val().username); document.getElementById("name").innerHTML = username; }); document.getElementById("name").innerHTML = username; </script> </body> </html>
index.html をブラウザで開くと、bamchoh
と表示される。
React で firebase
database へのアクセスはできるようになったので、React でアクセスできるようにする。まずは npm パッケージのインストール。
npm install --save firebase
次に src/index.js
を修正。
import React from 'react' import ReactDOM from 'react-dom' import App from './components/App' ReactDOM.render( <App />, document.getElementById("root") )
src/components/App.js
を作成。React16.8 で入った Hooks を使用していることに注意。関数内にべた書きになっているのをもうちょっとどうにかしたい。
import * as React from 'react'; const { useState, useEffect } = React; import firebase from "firebase/app"; import 'firebase/database'; export default () => { const [username, setState] = useState('Now loading...'); const config = { apiKey: "<API_KEY>", authDomain: "<PROJECT_ID>.firebaseapp.com", databaseURL: "https://<DATABASE_NAME>.firebaseio.com", projectId: "<PROJECT_ID>", storageBucket: "<BUCKET>.appspot.com", messagingSenderId: "<SENDER_ID>", }; if (!firebase.apps.length) { firebase.initializeApp(config); } const database = firebase.database(); const init = () => { database.ref('users/1').once('value').then((snapshot) => { const newUsername = (snapshot.val() && snapshot.val().username); setState(newUsername) }) } init() return ( <> username: {username} </> ); };
サーバーを起動してアクセスすると。Now Loading...
の後に ユーザー名が表示される。Now Loading...
を表示させずにユーザー名を表示させるのは今後の課題にする。
Firebase 入門(1) ~ Todo アプリをデプロイできるのか? (1)
今まで作ってきた Todo アプリを FIrebase にデプロイしてみる。
まず、新たにプロジェクトを作成して、Hosting に js をデプロイしてみる。作成したときの Project ID を覚えておく。あとで使う。
まず、Firebase ツールをインストールする
$ npm install -g firebase-tools
続いて Google にログイン
$ firebase login
んで、プロジェクトを初期化
$ firebase init
設定は以下
? Are you ready to proceed? Yes ? Which Firebase CLI features do you want to setup for this folder? Press Space to select features, then Enter to confirm your choices. Hosting: Configure and deploy Firebase Hosting sites === Project Setup ? Select a default Firebase project for this directory: [create a new project] === Hosting Setup ? What do you want to use as your public directory? public ? Configure as a single-page app (rewrite all urls to /index.html)? No
はじめに作成したプロジェクトのプロジェクトIDを今回作成したプロジェクトに追加
$ firebase use --add <Project ID>
デプロイしてみる。
$ firebase deploy
http://
ローカルで確認するには firebase serve
を使用する。
$ firebase serve
http://localhost:5000 にアクセスすると、やはり public フォルダにある静的コンテンツがデプロイされている。
webpack でビルドしたファイルをデプロイする
React 入門 (1) ~ 基本環境構築 ~ を同フォルダに展開。
webpack.config.js
に output
の項目を足す
module.exports = { // ここから output: { filename: "bundle.js", path: __dirname + "/public" }, // ここまでを追加 module: {
npm run build
すると public
フォルダにデプロイされる
この状態で firebase serve
をしたが、前のページが表示される。firebase deploy
しないとダメみたい。
$ firebase deploy $ firebase serve
localhost:5000
に Hello React
が表示されていれば成功。
というところで今日のところは終了。明日以降に完全なTodoアプリのデプロイをしていく。あと、サーバ-サイドをどうするか考えないといけないので、このシリーズはもうあと数回続くと思う。
成果物
GitHub - bamchoh/firebase-study at 1c2c12978a1bbafa4416781d5288e9255a0b793c
React16.8 というものがリリースされたらしい
React16.8 がリリースされたらしい。巷で噂になっている。
なんでも Hooks という新しい機能が入ってそれが便利なんだとか。
ということで、もう一度環境構築から初めて Hooks がどういうものなのか
使ってみようとおもう。
ベースは ここ をベースにした。ただ、react
、react-dom
はバージョンを指定してインストールし、src/index.js
も修正する。
パッケージの再インストール
$ npm i --save react@16.8 react-dom@16.8
src/index.js の作成
import React from 'react' import ReactDOM from 'react-dom' import App from './components/App' ReactDOM.render( <App />, document.getElementById("root") )
参考サイト
🎉React 16.8: 正式版となったReact Hooksを今さら総ざらいする - Qiita
useState
現在のstateとsetState関数を返す関数らしい。
src/components/App.js の作成
import React, { useState } from 'react'; export default () => { const [a, b] = useState(0); return ( <div> <button onClick={() => { b(a + 1); }}> \ovo/ {"<"}{a} times!! </button> </div> ) };
実行
npx webpack-dev-server --open --mode production
所感
普通なら function で state は管理できないけど、useSate() を使うことで管理できるようになって便利ってことなんだと思う。 たしかに class を書くよりも記述量が減ってると思うし、ちょっとしたコンポーネントで且つ state が必要そうな場合に重宝するのかもしれない。
後、初期値として渡している引数は初回呼び出しの一回だけしか有効ではないらしい。実際にクリックしても値がカウントするということはそういうことなんだろう。なぜそうなるのかは全く分からないけど。。。
useState(Functional updates)
setState
の引数に関数を渡すと、前回値を受けて現在値を作成することが可能らしい。
src/components/App.js の修正
import React, { useState } from 'react'; function Counter({initialCount}) { const [count, setCount] = useState(initialCount); return ( <> <h1>Count: {count}</h1> <button onClick={() => setCount(initialCount)}>Reset</button> <button onClick={() => setCount(prevCount => prevCount + 1)}>+</button> <button onClick={() => setCount(prevCount => prevCount - 1)}>-</button> </> ); } export default () => { const [a, b] = useState(0); return ( <Counter initialCount={10} /> ) };
実行
npx webpack-dev-server --open --mode production
所感
前回値を保持していなくても関数で引き回せるっていうのがメリットなんだろうか?でも setCount(count + 1)
とかでも同じ動作するし、使いどころがいまいちわからない。
あと、ここ の注意書きに、useState はオブジェクトのマージを自動的には行ってくれないから自前でやってね。的なことが書いてる。setCount(prev => { return { ...prev, ...updated }; });
みたいにするか、useReducer
を使えばいいみたい。
useState(Lazy initial state)
initialState
には関数も指定できるようだ。しかもその関数は初回のみ実行される。
src/components/App.js の修正
今回はページを更新する度に値がランダムに変化するようにしてみた。
import React, { useState } from 'react'; function getRandomInit(max) { return Math.floor(Math.random() * Math.floor(max)); } function Counter({initialCount}) { const [count, setCount] = useState(initialCount); return ( <> <h1>Count: {count}</h1> <button onClick={() => setCount(initialCount)}>Reset</button> <button onClick={() => setCount(count + 1)}>+</button> <button onClick={() => setCount(count - 1)}>-</button> </> ); } export default () => { const [a, b] = useState(0); return ( <Counter initialCount={() => { return getRandomInit(100) }} /> ) };
実行
所感
複雑な初期値を要求されるケースには重宝するんだろうなー。という感じだけど、リアルユースケースとしてはパッと思いつかないので、忘れそう。あと、Resetは普通に動作するので、初回実行時の値を保持しておかないと、リセット的な動作は実現できなそうだった。
useState(Bailing out of a state update)
同じ値を現在値として使って state を更新しようとしても、state は更新されないしレンダリングもしませんよ。的なことが書いてあるみたい。
useEffect
レンダリングが完了した後に呼び出される関数らしい。再描画時も完了すると呼び出される。
useEffect(Cleaning up an effect)
return で返す関数によってクリーンナップができるとのこと。go言語言うところのdefer みたいな使い方かな?
import * as React from 'react'; const { useState, useEffect } = React; export default () => { const [a, b] = useState(0); useEffect(() => { const timerid = setTimeout(() => { console.log("triggered!!"); // レンダリングが完了してから1秒後に表示される }, 1000); console.log("timer"); // レンダリングが完了した時点で表示される。 return () => { clearTimeout(timerid); console.log("clean up useEffect") // ボタンをクリックして state が更新されることによって描画がリセットされるので、そのタイミングで表示される(?) } }, [a]); return ( <> time: <b>{a}</b> <button onClick={() => b(a+1)}>+</button> </> ) };
所感
上の例ではあまりメリットがないような感じだけど、再描画に伴って何か処理を行うときに前回のオブジェクトを破棄しないといけない場合に重宝しそう
useEffect(Timing of effects)
特記することはなさそうだったのでスキップします。
useEffect(Conditionally firing an effect)
useEffectの第二引数に配列を渡すとその中のオブジェクトに変化があったときのみuseEffect()関数が呼び出されるようになるらしい。
useContext
React.createContext() で作ったコンテキストを取得できるようになるらしい。コンポーネント間の値のやり取りに使うと便利で、コンポーネント間の距離が遠いほどメリットを享受できるとかなんとか。
詳しい説明は こちら が詳しい。
useReducer
Reduxみたいなことができるらしい。
import * as React from 'react'; const { useReducer } = React; const initialState = {count: 0}; function reducer(state, action) { switch(action.type) { case 'increment': return {count: state.count + 1}; case 'decrement': return {count: state.count - 1}; default: throw new Error(); } } function Counter({initialCount}) { const [state, dispatch] = useReducer(reducer, initialState); return ( <> Count: {state.count} <button onClick={() => dispatch({type: 'increment'})}>+</button> <button onClick={() => dispatch({type: 'decrement'})}>-</button> </> ); } export default () => { return ( <Counter initialCount={10} /> ); };
実行
所感
今作ってる Todo アプリくらいであれば置き換えられそう。Redux 使わずに React だけで完結できるから覚えることが減ってよさそうではある。
useMemo
値がメモ化できるらしい。複雑な計算を必要とする関数があり、でも前回値と同じ場合に計算をスキップして結果を再利用することができるとのこと。
import * as React from 'react'; const { useState, useMemo } = React; function Counter({a, b}) { const [cv, setState] = useState(0); const add = useMemo(() => { console.log("test") return a + b; }, [a,b]); const add2 = () => { console.log("test2") return a + b; } return ( <> <p>Count1: {add}</p> <p>Count2: {add2()}</p> <p>Count3: {cv}</p> <button onClick={() => setState(prev => prev + 1)}>+</button> </> ); } export default () => { const [a, setA] = useState(1); const [b, setB] = useState(2); return ( <> <Counter a={a} b={b}/> <button onClick={() => setA(prev => prev + 1)}>a++</button> <button onClick={() => setB(prev => prev + 1)}>b++</button> </> ); };
所感
上記の例では、+ボタンを押すたびに、"test2"がコンソール上に表示されるが、メモ化された関数は1回しか呼ばれない。でも、a++やb++ボタンを押すと"test"も表示され、値も更新されるので値が更新される場合に限り再メモ化がなされて便利。という感じらしい。確かにパフォーマンスを気にしないといけないような処理の場合にはメモ化は便利かもしれない。
useCallback
useMemo(() => fn, inputs) と同等の処理を別名にして、すこし書きやすくしている感じらしい。
詳しくは こちら
useRef
React.createRef のオブジェクトを返すものらしい。
import * as React from 'react'; const { useRef } = React; function TextInputWithFocusButton() { const inputEl = useRef(null); const onButtonClick = () => { inputEl.current.focus(); }; return ( <> <input ref={inputEl} type="text" /> <button onClick={onButtonClick}>Focus the input</button> </> ); } export default () => { return ( <> <TextInputWithFocusButton /> </> ); };
所感
React.createRef と何が違うのだろう? という疑問もありつつ、こちら によると、レンダリング間の変数として使用することで今までにないrefの使い方ができるらしい。
useLayoutEffect
useEffect のようなフックで、レンダリングが完了して描画をする前に呼び出される関数。useEffect では、useEffectフック内で描画に関係するような処理を行っていた場合はuseEffectより先に描画がなされるので一瞬ちらついてしまうが、useLayoutEffectだと描画する前に実行されるので、そのようなことにはならない。ただ、useLayoutEffectは描画をブロックするので描画が遅れてしまう。不必要に使うべきではないフックらしい。
以下に こちら から引用したコードを載せておく。useLayoutEffect の部分を useEffect にすると確かに一瞬ちらついて見える。
import * as React from 'react'; const { useRef, useEffect, useLayoutEffect } = React; const UseLayoutEffectSample = () => { const displayAreaRef = useRef(); const renderCountRef = useRef(0); useLayoutEffect(() => { renderCountRef.current++; displayAreaRef.current.textContent = String(renderCountRef.current); }); return ( <p> このコンポーネントは <b ref={displayAreaRef} /> 回描画されました。 </p> ); }; export default () => { return ( <> <UseLayoutEffectSample /> </> ); };
実行
useDebugValue
React DevTools 内にカスタムフックに関するラベル付きデバッグメッセージを表示できるフックらしい。カスタムフックがよくわからないけど、まぁ、デバッグメッセージがだせるのかな。程度に思っておく。
useImperativeHandle
親から渡ってきたref に対してメソッドをはやすことができるようになる?
import * as React from 'react'; const { useRef, forwardRef, useImperativeHandle } = React; function FancyInput(props, ref) { const inputRef = useRef(); useImperativeHandle(ref, () => ({ focus: () => { inputRef.current.focus(); } })); return <input ref={inputRef} />; } FancyInput = forwardRef(FancyInput); export default () => { const inputRef = useRef(); const focus = () => { inputRef.current.focus(); } return ( <> <FancyInput ref={inputRef} /> <button onClick={focus} >Focus</button> </> ); };
所感
親コンポーネントが簡素化されて、子コンポーネントに責務を移譲させることができる。っていうのがメリットなのかなぁ??
まとめ
useReducer は使いどころがあるかなぁという感じで、それ以外はまだReactに慣れてからもう一度再勉強かなという感じ。
いつものようにここに載せたコードは Github にもアップしている。
GitHub - bamchoh/react16_8_study: My study repo for react 16.8
React 入門 (14) ~ TODOアプリを作る(9) ~ sendToApiServer のテスト
今日は sendToApiServer のテスト。fetch が絡むので mock ライブラリを導入する。
$ npm i --save fetch-mock $ npm i --save @types/fetch-mock
fetchMock は import * as fetchMock from 'fetch-mock'
でインポート可能。
一旦以下のように作ってnpm test
を実行してみる。
import sendToApiServer from './sendToApiServer' describe('sendToApiServer', () => { it('ADD_TODO', () => { const action = { type: 'ADD_TODO', text: "test", } sendToApiServer({}, action) }) })
すると、以下のようなエラーが表示された。
FAIL src/utils/sendToApiServer.test.tsx ● Test suite failed to run TypeScript diagnostics (customize using `[jest-config].globals.ts-jest.diagnostics` option): src/utils/sendToApiServer.test.tsx:9:21 - error TS2345: Argument of type '{}' is not assignable to parameter of type 'Dispatch<any>'. Type '{}' provides no match for the signature '<T extends any>(action: T): T'. 9 sendToApiServer({}, action) ~~
どうも型が一致していないようだ。ここはいったん sendToApiServer.tsx
側の型を any
にして無理やり通すことにした。
続いて、fetchMock を足してテストを通す。
import * as fetchMock from 'fetch-mock' import sendToApiServer from './sendToApiServer' describe('sendToApiServer', () => { it('ADD_TODO', async () => { const action = { type: 'ADD_TODO', text: "test", } fetchMock.post('/api/add_todo', {body: {'type':'ADD_TODO', 'text':'test'}, status: 200}); const mockDispatch = jest.fn() await sendToApiServer(mockDispatch, action) expect(mockDispatch.mock.calls.length).toBe(1) expect(mockDispatch.mock.calls[0][0]).toEqual(action) }) })
sendToApiServer() 自体は非同期で動作するので、await
をつける。つけないと mockDispatch
が呼ばれる前にexpect() の評価がされてしまう。あと、await
をつけるなら テスト関数自体を async
にしないと怒られるのでつけておくこと。
fetchMock は post 関数を呼ぶことで post がきたときのみ動作するようになる。body には返り値を指定する。
私の設計では、渡したアクションをそのまま返すので、はじめはactionのオブジェクトをbodyに設定していたが、元のアクションが間違っていた場合にテストが通ってしまうな。と思ったので、めんどくさいが分けて書くことにした。
COMPLETE_TODO
、DELETE_TODO
に関しても同様にテストを記載した。ADD_TODO
と変わるところはないので、省略
FETCH_TODO
もテストを追加する。FETCH_TODO
は返り値がDBの状況によって変化するため、いくつかテストパターンを書いたほうがいいと思い、describe
でネストしてその下にテストを追加した。そうした場合に fetchMock がエラーを出力するようになった。原因はテストケースをまたいでfetchMock が使用されてしまい、関数が再定義されてしまうためのようだ。各テストケースの終わりに fetchMock.restore()
で解放してやることで問題なく動作するようになった。
成果物は以下。
GitHub - bamchoh/react-study at c711a96976f8f9df82f0b17bacd334535c339cb0
次回は サーバーサイドのテストを書く予定。
React 入門 (13) ~ TODOアプリを作る(8) ~ AddTodo コンポーネントのテスト
昨日に引き続き、コンポーネントのテストを書く。昨日よりも簡単に書けると高をくくっていたが、意外とハマった。
第一に AddTodo コンポーネントが shallow
出来なかったのだ。
調べてみるとなんてことはなく、TodoList コンポーネントは React の素のコンポーネントだったが
AddTodo コンポーネントは Redux の connect
でラップされたものだったので、渡す引数が違うからだった。
今回はAddTodoコンポーネントを素の状態にし、connectでラップするための別のコンテナ AddTodoContainer
を用意して
テスト自体は AddTodo コンポーネントに対して行うようにコードを修正した。
次に、@material-ui
の Button
コンポーネントが shallow
で取得したオブジェクトから find
できなかった。
Input
に関しては、TodoList でやっている方法で取得できるのだが、Button
に関してそれはできないのだ。
調べた結果、WithStyles(Button)
を文字列として find
に渡してやることで取得できた。なぜそれで取得できるのかは
いまだ不明だ。
wrapper.find('WithStyles(Button)').simulate(click');
ADD_TODO
アクションを実行するためには テキストボックスに値を入力する必要があったが、そのやり方がわからなかった。
Input
コンポーネントに対して simulate('change')
を呼べばいいだとか、'keydown'
イベントで一文字ずつ入れろだとか
色々書いてあったが、結局はプロパティのvalue
に直接値を入れる方法でできた。
wrapper.find(Input).at(0).props().inputRef.current.value = "test"
成果物は以下。
GitHub - bamchoh/react-study at f904503750ab47cdf2a57ef647e2b51ab6123e35
React 入門 (12) ~ TODOアプリを作る(7) ~ TodoList コンポーネントのテスト
今日は enzyme
を使って TodoList コンポーネントのテストを追加する。ここ の Counter-test.js
のようなものを作成していく。
まず、sinon
をインストール
$ npm i sinon --save $ npm i @types/sinon --save
記事のサンプルを参考にある程度似せて書いてみるも。いくつかエラーが発生した。
1つは sinon が見つからないというもの。import sinon from 'sinon'
では見つけられなかったのと、sinon の公式サイト を見ると var sinon = require('sinon');
と記述されていたので、const sinon = require("sinon")
として事なきを得る。
2つ目は shallow に渡している コンポーネントの引数が一致しないというもの。結果的に、TodoList.tsx
に定義している Props
のメンバーをすべて引数として渡すことで解決した。(ちょっとどんくさい感じがするので後でどうにかしたい)
const spy = sinon.spy(); const state: TodoState[] = [{ id: 0, text: "aaa", completed: false }] const wrapper = shallow(<TodoList action={spy} todos={state} dispatch={{}} />);
上記を対応してテストを走らせてみるも、wrapper.find()
でどうも要素を見つけられないでいるようだ。色々試行錯誤の結果 material-ui を使用している場合は、wrapper.dive().find()
としなければならないようだ。この件は、こちら を参考にした。
また、検索する要素もmaterial-uiで使用しているオブジェクト名でなければならないようで、今回はタスクのクリックのテストを追加したかったのでListItem
を import してそれを検索要素としている。
import ListItem from '@material-ui/core/ListItem'; // ... 中略 wrapper.dive().find(ListItem).at(0).simulate('click')
ただ、これでもまだ問題がある。クリックイベント引数 e が undefined で渡ってくるのだ。そのせいで、id = +(e.currentTarget.id)
が動作せずにこける。
TypeError: Cannot read property 'currentTarget' of undefined 22 | var id:number; 23 | console.log(e) > 24 | id = +(e.currentTarget.id) | ^ 25 | this.props.action(this.props.dispatch, completeTodo(id)); 26 | } 27 |
この問題の答えは ここ にあった。simulate() 関数の第二引数にモック用イベントオブジェクトを渡せば解決する。
const mockedEvent = { currentTarget: { id: "0" }}; wrapper.dive().find(ListItem).at(0).simulate('click', mockedEvent);
次は、clickが呼ばれた後、actionが正しい引数で呼ばれたかを確認する。spy
でチェックする。callCount
で何回呼ばれたかをチェックし、getCall().args[]
で呼ばれたときの引数をチェックする。
// componentWillMount() と on_click_li() の両方でactionが呼ばれるため2になる expect(spy.callCount).toEqual(2) expect(spy.getCall(1).args[0]).toEqual({}) expect(spy.getCall(1).args[1]).toEqual({"id": 0, "type": "COMPLETE_TODO"})
成果物は以下。
GitHub - bamchoh/react-study at 2429903815b3bb4fe2a17358da6b3cf4b8ad1925