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 FirestoreRealtime 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 を修正。 コンポーネント側で Firebase の処理を行うことにする。

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://.firebaseapp.com にアクセスすると、public フォルダにある静的コンテンツがデプロイされていることがわかる。


ローカルで確認するには firebase serve を使用する。

$ firebase serve

http://localhost:5000 にアクセスすると、やはり public フォルダにある静的コンテンツがデプロイされている。


webpack でビルドしたファイルをデプロイする

React 入門 (1) ~ 基本環境構築 ~ を同フォルダに展開。

webpack.config.jsoutput の項目を足す

module.exports = {
  // ここから
  output: {
    filename: "bundle.js",
    path: __dirname + "/public"
  },
  // ここまでを追加
  module: {

npm run build すると public フォルダにデプロイされる

この状態で firebase serve をしたが、前のページが表示される。firebase deploy しないとダメみたい。

$ firebase deploy
$ firebase serve

localhost:5000Hello React が表示されていれば成功。


というところで今日のところは終了。明日以降に完全なTodoアプリのデプロイをしていく。あと、サーバ-サイドをどうするか考えないといけないので、このシリーズはもうあと数回続くと思う。


成果物

GitHub - bamchoh/firebase-study at 1c2c12978a1bbafa4416781d5288e9255a0b793c

React16.8 というものがリリースされたらしい

React16.8 がリリースされたらしい。巷で噂になっている。 なんでも Hooks という新しい機能が入ってそれが便利なんだとか。 ということで、もう一度環境構築から初めて Hooks がどういうものなのか 使ってみようとおもう。 ベースは ここ をベースにした。ただ、reactreact-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】新機能hooks - Qiita

🎉React 16.8: 正式版となったReact Hooksを今さら総ざらいする - Qiita

Hooks API Reference – React

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

f:id:bamch0h:20190207014403g:plain
useState_example1

所感

普通なら 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

f:id:bamch0h:20190207205903g:plain
Functional Updates

所感

前回値を保持していなくても関数で引き回せるっていうのがメリットなんだろうか?でも 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)
    }} />
  )
};

実行

f:id:bamch0h:20190207211533g:plain
lazy_initial_state

所感

複雑な初期値を要求されるケースには重宝するんだろうなー。という感じだけど、リアルユースケースとしてはパッと思いつかないので、忘れそう。あと、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} />
  );
};

実行

f:id:bamch0h:20190207223506g:plain
useReducer

所感

今作ってる 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 />
    </>
  );
};

実行

f:id:bamch0h:20190207234805g:plain
useLayoutEffect

f:id:bamch0h:20190207234828g:plain
useEffect

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_TODODELETE_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-uiButton コンポーネント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