30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

JestでReactのテストをする(5) テキストボックスやテキストエリアのテスト

クリックイベントのテスト方法は以前書いた。

JestでReactのテストをする(2) クリックイベントのテスト

今回は、テキストボックスやテキストエリアのチェンジイベントのテスト方法について書く。

テストの対象となるコードを準備する

まず、サンプルとして以下のコンポーネントを作成した。

// input.js
import React from 'react';

export default class InputField extends React.Component {
  constructor(props){
    super(props);
    this.state = {remainCount:10, value:''};
  };
  reflectInput(length, value){
    this.setState({remainCount:10-length, value});
  };
  render(){
    return(
      <div>
        <input type="text" onChange={e=>{
          this.reflectInput.bind(this)(e.target.value.length, e.target.value);
        }}></input><br />
        <span>{this.state.remainCount}</span><br />
        <button type="button" onClick={()=>{
          if(this.state.remainCount === 10 || this.state.remainCount < 0){ return; };
          this.props.clickFunc(this.state.value);
        }}>submit</button>
      </div>
    );
  };
};

これをレンダリングすると、以下のようになる。

f:id:numb_86:20170120204938p:plain:w160

文字を入力すると、数字が減っていく。

f:id:numb_86:20170120204948p:plain:w160

10文字まで入力可能で、それ以上入力された状態だと、submitボタンを押しても何も起こらない。
また、何も入力されていない状態でも、submitボタンによる関数の呼び出しは起こらない。

以下のような仕組み。

  • submitボタンで実行する関数を、this.props.clickFuncとして外から渡す
  • stateで、入力可能文字数と入力内容をremainCountvalueとして保持する
  • テキストボックスでチェンジイベントが発生する度、setState()remainCountvalueの値を更新する
  • submitボタンを押すとまず、remainCountの値をチェックして、問題があればその時点で処理を終了する
  • 問題がなければ、valueを引数として関数を実行する

なお、ボタン押下時の文字数のチェックはstateの値ではなく、その都度DOMから取得して行ったほうがいいと思うが、サンプルとしての分かりやすさを優先して、このような形にした。

テストを書く

この場合、以下の2つをチェックすればいいと思う。

  • onChangeを検知して正しくstateを更新しているかどうか
  • state.remainCountによるバリデーションが正しく機能しているか

以下がテストコード。

// __tests__/input.test.js
import React from 'react';
import {shallow} from 'enzyme';
import InputField from '../input.js';

describe('inputのテスト', ()=>{
  test('state', ()=>{
    const subject = shallow(<InputField />);
    let remainCount = subject.state().remainCount;
    expect(remainCount).toBe(10);
    subject.find('input').simulate('change', {target: {value: 'abc'}});
    remainCount = subject.state().remainCount;
    expect(remainCount).toBe(7);
  });
  test('click', ()=>{
    const mock = jest.fn();
    const subject = shallow(<InputField clickFunc={mock} />);
    subject.find('button').simulate('click');
    expect(mock).not.toHaveBeenCalled();
    subject.find('input').simulate('change', {target: {value: 'abc'}});
    subject.find('button').simulate('click');
    expect(mock).toHaveBeenCalledTimes(1);
    subject.find('input').simulate('change', {target: {value: 'qwerty'}});
    subject.find('button').simulate('click');
    expect(mock).toHaveBeenCalledWith('qwerty');
    expect(mock).toHaveBeenCalledTimes(2);
    subject.find('input').simulate('change', {target: {value: '01234567890'}});
    let remainCount = subject.state().remainCount;
    expect(remainCount).toBe(-1);
    subject.find('button').simulate('click');
    expect(mock).toHaveBeenCalledTimes(2);
  });
});

shallowtoHaveBeenCalledについては、冒頭のクリックイベントの記事を参照。

shallowの戻り値.find(対象となる要素).simulate(イベント名);

上記のように書くことで対象となる要素にイベントを起こせるのだが、これはchangeイベントも同様。
そして、simulate()の第二引数に{target: {value: 任意の文字列}}を渡すことで、対象の要素に任意の文字列をセットすることが出来る。

つまり以下は、subjectshallowレンダリングしたもの)のinput要素にabcという文字列をセットしたのと同じ意味を持つ。

subject.find('input').simulate('change', {target: {value: 'abc'}});

テストの結果、stateによるバリデーションは正しく機能しており、関数の呼び出しにも問題がないことを確認できた。

参考資料

JavaScriptの末尾呼び出し最適化(TCO)

JavaScriptには、再帰が実装されている。
再帰とは、関数のなかでその関数自身を呼び出すこと。

下記のrecursion()では、再帰を行っている。

function recursion(num, limit){
    console.log(num);
    if(num === limit){ return; };
    num++;
    recursion(num, limit);
};

recursion(0, 10);を実行すると、0から10の数字が順番に表示される。
仮引数numlimitに到達するまでrecursion()は呼ばれ続ける。

だが、limitの数を大きくすると、途中でエラーになりプログラムが終了してしまう。

function recursion(num, limit){
    console.log(num);
    if(num === limit){ return; };
    num++;
    recursion(num, limit);
};

// RangeError: Maximum call stack size exceeded
// 17000前後で終了
// limitに到達する前に
recursion(0, 30000);

これは、関数を何度も繰り返し呼び出しているうちに、メモリを食い尽くしてしまうために起きる。
何回再帰すればエラーになるかは環境によるが、いつか必ずエラーになる。

末尾呼び出し最適化

このように、何度も再帰を繰り返すとエラーになってしまうが、言語によってはそれを避ける仕組みが用意されている。
それが、末尾呼び出し最適化(tail call optimization)である。

末尾呼び出しとは、その関数の最後の処理として再帰を行うことである。
先程のrecursion()も末尾呼び出しである。

そして、末尾呼び出しの際にメモリを解放し、いくら再帰してもエラーにならないようにする仕組みのことを、末尾呼び出し最適化と呼ぶ。

JavaScriptには末尾呼び出し最適化の仕組みは用意されていなかったが、ES2015で導入された。

2017/08/19 追記

とのことなので、改めて確認した。

処理の最後での呼び出しを、末尾呼び出しという。再帰かどうかを問わない。
そして、末尾呼び出しのうち再帰であるものが、末尾再帰
そして、末尾再帰となっている関数、つまり、自分自身を末尾呼び出ししている関数が、末尾再帰関数

この記事が分かりやすい気がする。

こうしてみると、末尾呼び出しとは、その関数の最後の処理として再帰を行うことである。という自分の説明は滅茶苦茶であることが分かる……。

追記終わり

使い方

strictモードで、return fn()という形で末尾呼び出しを行う。

'use strict'

function recursion(num, limit){
    console.log(num);
    if(num === limit){ return; };
    num++;
    return recursion(num, limit);
};

// 30000になるまで実行される
recursion(0, 30000);

このようにすると末尾呼び出し最適化が行われ、エラーにならず最後まで再帰が続けられる。

対応状況

ES2015は仕様であり、実際にそれを使えるかどうかはブラウザ等の実装に依存する。
そして、末尾呼び出し最適化を実装している環境は、かなり少ない。

Safari Node.js Chrome Firefox Babel
対応状況 × × ×
確認したバージョン 10.0.2 6.9.2 55.0.2883.95 50.1.0 6.18.0
備考 - harmonyのみ - - v6で削除された

詳細はこちらを参照。
ECMAScript 6 compatibility table

Node.jsでは、--harmonyオプションをつけた場合のみ、動作する。

Babelについては、かつては動作したようだが、現在のバージョンである6では削除されている。
Babelを使えば動くという記事がいくつかあるが、それは過去のものであり、本日現在では使えないので注意が必要。
BabelのGitHubのIssueを漁ってみると、いろいろと書かれてある。

TCO has since been removed from Babel 6 due to issues and will be redeveloped in the future.
https://github.com/babel/babel/issues/2547#issuecomment-155588021

実装されている環境がほとんどなく、Babelによるトランスパイルも出来ないので、現時点ではほとんど実用性がない。
だがES2015の仕様で定められているのだから、いずれ実装が進んで使えるようになるはず。

参考資料