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
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 側も起動する。動作が少し遅いので、そのあたりは改善の余地がある。
成果物
以下に今回の成果をアップしている。
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_todo
に POST
リクエストを送ると、来たデータをそのまま返すことを確認できた。(実際は来たデータから 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.tsx
に dispatch
関数を渡す方法に苦戦した。結局は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.ts
に completeTodo
という関数を追加する
export const completeTodo = (id:number) => ({ type: 'COMPLETE_TODO', id: id, })
次にレデューサー、src/reducers/todos.ts
に COMPLETE_TODO
の case
を追加する。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.tsx の on_click_li
に ディスパッチャーを使うように変更する
on_click_li = (e: React.MouseEvent<HTMLDivElement>) => { var id:number; id = +(e.currentTarget.id) this.props.dispatch(completeTodo(id)); }
削除ボタンも同じように変更する。大体 COMPLETE_TODO
の時と同じなので省略。
最終的には以下のようになった。(動作は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
を定義していくだけだったので、特に書くこともないと思い、成果物のみリンクしておく。