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

React 入門 (11) ~ TODOアプリを作る(6) ~ リファクタリング

ある程度実装できたので、ここらへんでコードの整理をしようと思う。NumberList とかいう名前もおかしいし、Redux を導入したので、コンポーネント間でstateを受け渡しできるようになったこで、コンポーネントの分離もしていきたい。ただ、その前にある程度テストを書く必要があるかなと思ったので、action と reducer の部分のテストを少しだけ書いた。参考にしたのは Redux の Todo サンプルにあるテスト

ある程度テストを書いて、すべてパスするようにしてからプロジェクト構造を変更する。主にやったことは以下の5つ

  • NumberList 内にあった サーバーアクセス用の関数を別ファイルに移動
  • NumberList の名前を TodoList に変更
  • AddTodo クラスを作成し、追加ボタンとテキストボックスをそちらに移動
  • スタイルオブジェクトを別ファイルに移動
  • VisibleTodoList を作成し、TodoList をラップ。TodoList から redux 関連の依存を取り除く
  • sendToApiServer 関数を prop 経由で渡すようにする。

sendToApiServer 関数を prop 経由で渡すことでテストがしやすくなったはずなんだが、まだ検証できていない。 次回はsendToApiServer をどのようにテストしていこうか。というところにフォーカスを当てていきたい。


成果物は以下から参照できる。

GitHub - bamchoh/react-study at 0165569b06a2cbca843722dea7bb97a82ef27ad7

React 入門 (10) ~ TODOアプリを作る(5) ~ データの永続化(2)

今回は残りのアクションに関して実装していく。残りのアクションは以下の3つ

  • タスク完了時のアクション
  • 削除時のアクション
  • 初期描画時のアクション

初めの2つについては既存のアクションだが、最後の1つは今回新たに追加するアクションだ。

タスク完了時のアクション

フロント側

前回の追加時のアクションと同様、フロント側はアクションクリエイターとディスパッチャーの間に通信用のタスクを噛ます形で作成する。sendToApiServer にタスク完了時のアクション用のコードを追加する。

    case 'COMPLETE_TODO':
      {
        const method = 'POST'
        const obj = action;
        const body = JSON.stringify(obj);
        const res: Response = await fetch('/api/complete_todo', {method, headers, body})

        if(res.ok) {
          const json = await res.json()
          dispatch(json)
        }
        break
      }

バック側

エンドポイント /api/complete_todo に対してハンドラを追加する。

// /api/complete_todo に対するハンドラ
func postCompleteTodoHandler(c *gin.Context) {
    var action AddTodoAction
    if err := c.ShouldBindJSON(&action); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    var err error
    if _, err = completeTodo(action.ID); err != nil {
        log.Printf("[COMPLETE] %v", err.Error())
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    // completeTodo でエラーが発生しなかったということは正常に値の変更ができたと仮定して
    // ここでは送られてきたデータを使ってレスポンスを作成
    c.JSON(http.StatusOK, gin.H{"type": action.Type, "id": action.ID})
}

// Complete アクションをDBに伝える
func completeTodo(id int64) (sql.Result, error) {
    act, err := fetchAddTodoAction(id)
    if err != nil {
        return nil, err
    }

    format := "UPDATE todo SET completed = %d WHERE id = %d"
    stmt := ""

    // sqlite3 は boolean タイプがないため、boolean を数値にして格納する必要がある
    if act.Completed {
        stmt = fmt.Sprintf(format, 0, id)
    } else {
        stmt = fmt.Sprintf(format, 1, id)
    }
    return db.Exec(stmt)
}

削除時のアクション

フロント側

完了時のアクション同様に sendToApiServer 関数にアクションを追加する。完了時のアクションと異なるのはエンドポイント名のみ(`/api/delete_todo') なので、コードは省略する。

バック側

こちらも特筆すべきことはないので省略する。

初期描画時のアクション

フロント側

初回のアクセスの際にすでにDBにあるTODOリストを取得して表示させる方法を考える。Reactを使わない場合は window.on_load 時にやることだが、React では コンポーネントがマウントされた時点で呼ばれる関数 componentWillMount() がある。そこで APIと通信することで state を更新する。

componentWillMount() {
  sendToApiServer(this.props.dispatch, fetchTodo());
}

アクションクリエイターは単純にtypeを指定しているだけ

export const fetchTodo = () => ({
  type: 'FETCH_TODO',
})

sendToApiServer は GET リクエストで指定している (POST でもいいと思うが、なんとなく)

    case 'FETCH_TODO':
      {
        const method = 'GET'
        const ep = '/api/fetch_todo'
        const res: Response = await fetch(ep, {method, headers})

        if(res.ok) {
          const json = await res.json()
          dispatch(json)
        }
        break
      }

レデューサーは複数のデータが返ってくることを期待してステートを更新している。(アクションクリエイターで作成したアクションの構造と異なってもいいのか? という疑問はある) また、DBが空の場合はnullチェックで現在のstateを返すようにしている。

      if(action.data === null) {
        return state
      }
      return action.data.map((todo:any) => {
        return ({
          id: todo.id,
          text: todo.text,
          completed: todo.completed,
        })
      })

サーバー側

/api/fetch_todo のハンドラを作成する

// /api/fetch_todo のハンドラ
func postFetchTodoHandler(c *gin.Context) {
    var acts []AddTodoAction
    var err error
    if acts, err = fetchAllTodo(); err != nil {
        log.Printf("[SELECT] %v", err.Error())
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "type": "FETCH_TODO",
        "data": acts,
    })
}

// データベースから全データ取得
func fetchAllTodo() ([]AddTodoAction, error) {
    var stmt = ""
    stmt += fmt.Sprintf("SELECT * FROM todo")
    rows, err := db.Query(stmt)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var acts []AddTodoAction
    for rows.Next() {
        var act AddTodoAction
        var complete int64
        if err := rows.Scan(&act.ID, &act.Text, &complete); err != nil {
            return nil, err
        }
        if complete == 0 {
            act.Completed = false
        } else {
            act.Completed = true
        }
        acts = append(acts, act)
    }
    return acts, nil
}

実行結果

APIサーバー側を go run main.go で起動し、そのあと npm start で React 側も起動する。動作が少し遅いので、そのあたりは改善の余地がある。

f:id:bamch0h:20190203134403g:plain
TODO DB通信

成果物

以下に今回の成果をアップしている。

GitHub - bamchoh/react-study at 64c4cdccb4590d8aa906955d48b1320068da4ba6

React 入門 (9) ~ TODOアプリを作る(4) ~ データの永続化(1)

さて、今までは React & Redux & Typescript によってTODOアプリを作成してきたわけだが、更新ボタンを押すとすべてのデータが消えてしまう。なんとかサーバーにデータを保持しておきたい。フロントエンドからバックエンドにデータを送ってDBと通信するような形でデータの永続化を試みる。

Redux 通信 で調べると、React + Reduxでサーバー通信ってどこでやればいいの? - TIS ENGINEER NOTE がトップでヒットする。が、その下の記事 ReduxでのMiddleware不要論 - Qiita も気になるところ。

色々記事を見て回ったけど、やはり、redux の middleware に頼らず、まずは ReduxでのMiddleware不要論 - Qiita みたいに、自前のクラスでどうにかしてみようと思う。そのほうが処理の流れが分かりやすいかなと。それでうまくいかない場合は redux-saga だとか redux-thunk だとかを使っていけばいいと思う。(redux-saga とかがよくわからなかったわけではないぞ!ちがうんだからな!)


ある程度の方向性は決まったので、まずはバックエンドサーバーを作る。作りこみは後にして、とりあえずアクセスできるエンドポイントを用意してある程度のサーバーを作成する。今回はgo言語で作成する。

package main

import (
    "fmt"
    "net/http"

    "github.com/gin-gonic/gin"
)

type AddTodoAction struct {
    Type string `json:"type"`
    Text string `json:"text"`
    ID   int64  `json:"id"`
}

func postAddTodoHandler(c *gin.Context) {
    var action AddTodoAction
    if err := c.ShouldBindJSON(&action); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    fmt.Println(action)

    c.JSON(http.StatusOK, gin.H{
        "type": action.Type,
        "text": action.Text + " from server",
        "id":   action.ID,
    })
}

func main() {
    r := gin.Default()
    r.POST("/api/add_todo", postAddTodoHandler)
    r.Run(":3000")
}

Chrome 拡張 の Advanced REST client を インストールして、実際に http://localhost:3000/api/add_todoPOST リクエストを送ると、来たデータをそのまま返すことを確認できた。(実際は来たデータから type, text, id を抜き出して、詰めなおして返している)


次にリバースプロキシサーバーを作成する。ここ を見ると、 webpack-dev-server を apiサーバーのリバースプロキシサーバーとして動作させることができるようだ。webpack.config.js に以下の設定を追記する。

module.exports = {
  devServer: {
    proxy: {
      "/api": {
        target: "http://localhost:3000"
      }
    },
    historyApiFallback: {
      index: "index.html"
    }
  },
}

これでAPIサーバーに接続する準備はできたので、フロント側に通信部分を追加する。最初に挙げた記事 では、アクションクリエイターとディスパッチャーの間に通信用のクラスを挟むことをしているようなので、そのように作成する。今回は単純にラップ関数を一つ追加してADD_TODOアクションに対して通信させてみる。

import { Dispatch} from "redux";

// 通信用関数
const sendToApiServer = async (dispatch: Dispatch<any>, action : any): Promise<void> => {
  switch (action.type) {
    case 'ADD_TODO':
      const obj = action;
      const method = 'POST';
      const body = JSON.stringify(obj);
      const headers = {
        'Accept': 'application/json',
        'Content-Type': 'applicatoin/json'
      };
      const res: Response = await fetch('/api/add_todo', {method, headers, body})

      if(res.ok) {
        const json = await res.json()
        dispatch(json)
      }
    default:
      dispatch(action)
  }
}

渡ってくるアクションのタイプをトラップして、ADD_TODO だったら通信するようにしている。ディスパッチャーには普通にアクションが渡るようにしたかったので、アクションをそのまま戻り値としている。送信するデータはアクションそのものを渡してみる。今後都合が悪くなったらその都度修正しよう。そして、この関数をアクションクリエイターをラップする形で修正する。

sendToApiServer(this.props.dispatch, addTodo(this.textRef.current!.value));

これで追加ボタンを押すと、通信させることができるはずだ。


さて、これでデータベースとアクセスできるインターフェースができたので、サーバーサイドを作りこんでいく。永続化にはsqlite3を使用する。

package main

import (
    "database/sql"
    "fmt"
    "log"
    "net/http"
    "os"

    "github.com/gin-gonic/gin"
    _ "github.com/mattn/go-sqlite3"
)

var db *sql.DB

var dbfile = "./data.db"

func init() {
    if _, err := os.Stat(dbfile); err != nil {
        os.Create(dbfile)
    }

    var err error
    db, err = sql.Open("sqlite3", dbfile)
    if err != nil {
        log.Fatal(err)
    }
    if _, err := createTable(); err != nil {
        log.Fatal(err)
    }
}

func createTable() (sql.Result, error) {
    var stmt = ""
    stmt += "CREATE TABLE IF NOT EXISTS todo ("
    stmt += " id INTEGER PRIMARY KEY AUTOINCREMENT"
    stmt += ", text TEXT NOT NULL "
    stmt += ", completed INTEGER NOT NULL)"
    return db.Exec(stmt)
}

func insertTable(act AddTodoAction) (sql.Result, error) {
    var stmt = ""
    stmt += "INSERT INTO todo "
    stmt += " (text, completed) "
    stmt += " VALUES("
    stmt += fmt.Sprintf("\"%v\"", act.Text)
    stmt += ",0)"
    return db.Exec(stmt)
}

func fetchAddTodoAction(id int64) (*AddTodoAction, error) {
    var stmt = ""
    stmt += fmt.Sprintf("SELECT * FROM todo where id = %v", id)
    rows, err := db.Query(stmt)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    var act AddTodoAction
    for rows.Next() {
        if err := rows.Scan(&act.ID, &act.Text, &act.Completed); err != nil {
            return nil, err
        }
        break
    }
    return &act, nil
}

type AddTodoAction struct {
    Type      string `json:"type"`
    Text      string `json:"text"`
    Completed bool   `json:"completed"`
    ID        int64  `json:"id"`
}

func postAddTodoHandler(c *gin.Context) {
    var action AddTodoAction
    if err := c.ShouldBindJSON(&action); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    var ret sql.Result
    var err error
    if ret, err = insertTable(action); err != nil {
        log.Printf("[INSERT] %v", err.Error())
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    var id int64
    if id, err = ret.LastInsertId(); err != nil {
        log.Printf("[LAST INSERT ID] %v", err.Error())
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    var act *AddTodoAction
    if act, err = fetchAddTodoAction(id); err != nil {
        log.Printf("[SELECT] %v", err.Error())
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "type":      action.Type,
        "text":      act.Text,
        "completed": act.Completed,
        "id":        act.ID,
    })
}

func main() {
    r := gin.Default()
    r.POST("/api/add_todo", postAddTodoHandler)
    r.Run(":3000")
}

渡ってきたアクションをインサートして、最後にインサートしたレコードのIDで取得したレコードから新たにアクションを作成してクライアントサイドにレスポンスを返している。各アクションに対して、これらを繰り返していけばDBとの連携ができるはずだ。それはまた次回ということにしよう。


成果物

上記の内容は以下ののリポジトリにアップしている。

GitHub - bamchoh/react-study at 5a5e2f04ba30a840e3af792d592e0a4deb67d801

React 入門 (8) ~ TODOアプリを作る(3) ~ Reduxの導入

ある程度 Redux を理解できたので、自分のTODOアプリに組み込んでみる。

Redux インストール

$ npm i --save redux react-redux
$ npm i -D @types/redux @types/react-redux

基本的に前回作ったものをマイグレーションする感じでもってくる。だが、NumberList.tsxdispatch 関数を渡す方法に苦戦した。結局はPropsをinterfaceで定義してそれを型として使うようにした。

interface PropsWithDispatch {
  dispatch: any
}

const NumberList = withStyles(styles)(
  class extends React.Component<PropsWithDispatch & WithStyles<typeof styles>, {}> {
  ...

使うときは this.props.dispatch() として使う。


次に、TODOの完了時の動作を実装する。

まずはアクションから、src/actions/index.tscompleteTodo という関数を追加する

export const completeTodo = (id:number) => ({
  type: 'COMPLETE_TODO',
  id: id,
})

次にレデューサー、src/reducers/todos.tsCOMPLETE_TODOcase を追加する。Redux の Example では 三項演算子を使っていたが、あまり使いたくなかったので、if文で作成した。javascript界隈だと三項演算子はメジャーなのだろうか?

case 'COMPLETE_TODO':
  return state.map(todo => {
    if(todo.id === action.id) {
      return { ...todo, completed: !todo.completed }
    } else {
      return todo
    }
  })

最後に NumberList.tsxon_click_li に ディスパッチャーを使うように変更する

on_click_li = (e: React.MouseEvent<HTMLDivElement>) => {
  var id:number;
  id = +(e.currentTarget.id)
  this.props.dispatch(completeTodo(id));
}

削除ボタンも同じように変更する。大体 COMPLETE_TODO の時と同じなので省略。


最終的には以下のようになった。(動作はRedux使う前と変わらない)

f:id:bamch0h:20190201223638g:plain
React + Redux + TypeScript + Material UI


成果物は以下にアップしている。

GitHub - bamchoh/react-study at 2019-02-01_add_redux

Redux 入門 (2) ~ Redux & Typescript

昨日は Redux を typescript なしで入門した。今日は Typescript を使って入門する。

ここらへんやここらへん を参考にする。

記事を見てると、redux と typescript の相性はあまりよくないみたいで、それを補助するライブラリが別途必要になるようだった。


一旦上の話は忘れて、昨日作成したRedux アプリに Typescript を入れてみる。React 入門(2) を参考にして入れた後、npm run build する、色々エラーが出るので、エラーメッセージに基づいてエラーを消していく。import関連のエラーはどうやったらいいのかわからなかったのでここの3番目の(import ではなく requireを使う)方法で解決させた。あとはもう一つよくわからないエラーが発生していて Binding element 'dispatch' implicitly has an 'any' type というやつがそれだ。調べるとここ に答えそのものが書いてあった。なんだか冗長なような気もするが、一旦動かすという意味では問題ないかな。

エラーが全部消えたので npm start で実行してみるものの、Expected the reducer to be a function.というエラーがブラウザ上で発生。色々調べた結果、require を使っていることが原因のようだったので、import に戻して、仕方なく、d.ts 定義ファイルを書こうかな。と思ってふと考えなおし、npm i @types/redux を実行。しかし解決せず。ふと reducers 下のファイルを見てみると .js であることに気付く。.ts に変更して、再ビルドすると、タイプエラーがいくつかでたのでそれも修正して、npm start する。先ほどのエラーは消えていて、タスク追加のボタンとテキストボックスも表示されている。しかし、次の問題が発生する。タスクを追加しようとしても何も反応がない。DevToolsを見てみると、Uncaught TypeError: addTodo is not a function が発生していた。そういえばaddTodoもrequireしていたことを思い出す。その部分を修正し、actions/index.js も ts に変更し、タイプエラー関係を修正し再度 npm start。今度はちゃんとタスクも追加されてちゃんと動いているようだ。


ただこれだと代替が any 型になってしまっていて、typescript を使っている恩恵を得られない気がするので、次回はそのあたりをどうにかしたいですね。

成果物は以下にアップしている。

GitHub - bamchoh/redux-study at ts


2019/1/31 追記

anyの型をある程度修正したものを以下にアップした。普通に interfaceを定義していくだけだったので、特に書くこともないと思い、成果物のみリンクしておく。

GitHub - bamchoh/redux-study at revise_types