16
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Reactで一人TDD修行

Last updated at Posted at 2018-09-09

Reactで一人TDD修行したので紹介

TDDとは

TDD(Test Driven Development:テスト駆動開発)は、実装を進めるより先にテストコードを書くことです。はじめにテストを書き、そのテストを実行し失敗させます。その後、目的部分のコードを書き、テストを成功する形にします。次にテストが通るままでリファクタリングを行っていきます。

TDDでは、この失敗(Red) -> 成功(Greed) -> リファクタリング(Refactor)を繰り返して、少しずつ作っていきます(一気に複数の機能は実装しない)。

Red

TDDのスタート地点の状態。テストが、失敗に終わる・コンパイルエラー(プロダクトコードが無い初期状態に限る)になる状態。書いたテストに対して、想定通りにエラーになることを確認。このとき、プロダクトコードがなくてもテストのみに注力。

Greed

Redで失敗しているテストを成功にさせた状態。このときテストを成功させるため必要最低限の実装しか行わない。Greedにするためにマジックナンバーや固定値返却などのみにとどまり、とにかく失敗しているテストを成功させるための最低限の実装しか行わない(最速最短でテストが通るクソコードを書く)。

そのため、今後必要になるであろう変数や関数の追加やリファクタリング、汎用的なコード記述などは一切行わない。テスト1つだけを成功させる以上のプロダクトコードは一切書かない。

Refactor

Greed状態を保ちつつ、きれいなコードにする状態。Greedにした時点では、必要最低限の実装で、マジックナンバー・イミフな変数名・冗長なコードなどなど様々な問題を抱えているはず。この状態で放置するとプログラマの精神衛生上よろしくなかったり、仕様追加・変更があったときに対応が困難になりますよね。なので汚いコード(プロダクトコード・テストコード)はキレイキレイします。

環境

今回はテストにJestとenzymeを使っていく。
まずは、プロジェクトを作成。名前はreact-tddでenzymeとアダプターにenzyme-adapter-react-16をインストール。

  • node v10.9.0
  • npm v6.4.1
  • yarn v1.9.4
  • react v16.4.1
$ npx create-react-app react-tdd
$ cd react-tdd
$ yarn add -D enzyme enzyme-adapter-react-16 

必要に応じてインストール

$ npm i -g create-react-app
$ npm i -g yarn
$ npm i -g npx
$ npm i -g npm

修行で作るもの

今回のテスト対象として簡単なログイン画面を作る。見た目のイメージは以下の感じ。

スクリーンショット 2018-09-02 12.21.23.png

仕様は以下とする

  • ログイン仕様
    • ID欄にはemailアドレスが入力されること
    • Passwordは6文字以上であること
    • 今回はID,Passwordが正しく入力されている状態でLoginButtonを押したら成功とする
    • (今回は決めでID:test@example.com, Password:password123#)
  • 画面項目
    • IDを入力するテキストボックス
      • 必須入力
      • type=text
      • emailの入力を想定
      • 入力値はemailのパターンに沿っていない場合、テキストボックスのすぐ下にパターンに沿っていない旨を表示
      • emailのパターンチェックタイミングは、onchange時
      • emailのパターンチェックが正しいときは、ターンに沿っていない旨のメッセージは表示しない
    • Passwordを入力するテキストボックス
      • 必須入力
      • type=password
      • 入力値が6文字未満の場合、テキストボックスのすぐ下に文字数不足の旨を表示
      • Passwordの文字数チェックタイミングは、onchange時
      • Passwordの桁数に問題ないときは、文字数不足の旨のメッセージは表示しない
    • Loginボタン
      • IDとパスワードがValidation処理を突破していない場合は、ボタンが押せないこと
      • 問題がなければボタン下に成功の旨を表示
      • ログインできなかった場合は、できなかった旨をボタン下にメッセージを表示する(今回は決めでID:test@example.com, Password:password123#以外は失敗扱い)

テスト方針

テストとしては、トップダウンでもボトムアップでも行える(はず)。実際は、トップダウンは簡単だが、大きいプロジェクトの場合はトップダウンでのテストは難しくなるので、ボトムアップでテストしながらやっていくのが良いでしょう。

テスト対象

最低限以下はチェックしていく

  • レンダリングのテスト
    • エラーが出ていないこと
    • 構文エラーが出ていないこと
    • アウトプットがnullでないこと
  • 状態のテスト
    • ログイン失敗時や成功時の動作
  • イベントのテスト
    • onclick, focusoutなどのイベント

TDDで実装してみる

Jestはデフォルトで*.test.js*.spec.jsもしくは__tests__という名前のディレクトリ以下のファイルをテストファイルとみなすので、今回はsrcに__tests__ディレクトリを作り、そこにテストファイルをおいていきます。

トップダウンでTDD

大きいコンポーネントを先に考え、あとから再利用できそうな部分を別コンポーネントに分割する感じでやってみる。
はじめに、src/components/topdownディレクトリを作成し、Login.spec.jsを作成。

最初に、クラッシュせずにレンダリングできることをテストしてみます。

__tests__/Login.spec.js
import React from 'react';
import ReactDOM from 'react-dom';
import { configure } from 'enzyme';
import Login from '../components/topdown/Login'
import Adapter from 'enzyme-adapter-react-16';

configure({ adapter: new Adapter() });

describe('Login', () => {
        console.error = jest.fn()
        const div = document.createElement('div');
        ReactDOM.render(<Login />, div);
        ReactDOM.unmountComponentAtNode(div);
        expect(console.error).not.toHaveBeenCalled();
});

(要件にないのでLoginコンポーネントはdiv始まりにしてます)

エラーやワーニングなくレンダリングできているかをconsole.errorが呼ばれているかで判断しています。
console.errorをjest.fn()でモックにしてtoHaveBeenCalledで呼ばれているかで判定しています。

実装後テストを実行してみます。

$ yarn test

このコマンドでテストをwatchモードで立ち上がります。

 PASS  src/App.test.js
 FAIL  src/__tests__/Login.spec.js
  ● Test suite failed to run

    Cannot find module '../components/topdown/Login' from 'Login.spec.js'

      at Resolver.resolveModule (node_modules/jest-resolve/build/index.js:179:17)
      at Object.<anonymous> (src/__tests__/Login.spec.js:5:14)

Test Suites: 1 failed, 1 passed, 2 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.19s
Ran all test suites.

Watch Usage: Press w to show more.

結果は、見て分かる通り、失敗です。Loginモジュールがなくエラーになっているようなので、作成します。

components/topdown/Login.js
import React from 'react';

export default class Login extends React.Component {
    render(){
        return (
            <div>
            </div>
        );
    };
}

必要最低限の実装のみにします。

テスト結果は見事成功です。

 PASS  src/__tests__/Login.spec.js
 PASS  src/App.test.js

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   1 passed, 1 total
Time:        1.414s, estimated 2s
Ran all test suites.

次にIDを入力するテキストボックスについてテストしていきます。
以下のテストを追加して、idのテキストボックスがあるか見ます。

__tests__/Login.spec.js
    ・・・
    it('should render ID text field', () => {
        const wrapper = mount(<Login/>);
        expect( wrapper.find('input#id').length ).toBe(1)
    });
 FAIL  src/__tests__/Login.spec.js
  ● Login › should render ID text field

    expect(received).toBe(expected)

    Expected value to be (using ===):
      1
    Received:
      0

      at Object.it (src/__tests__/Login.spec.js:18:49)
          at new Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:46:16)
      at process._tickCallback (internal/process/next_tick.js:68:7)

 PASS  src/App.test.js

Snapshot Summary
 › 1 snapshot written in 1 test suite.

Test Suites: 1 failed, 1 passed, 2 total
Tests:       1 failed, 2 passed, 3 total
Snapshots:   1 added, 1 total
Time:        1.4s

もちろんLoginコンポーネントにテキストボックスなど存在しないので失敗します。
最小限の実装で動くようにします。

components/topdown/Login.js
import React from 'react';

export default class Login extends React.Component {
    render() {
        return (
            <div>
                <form >
                    <div class="inset">
                        <div>
                            <label for="id">ID</label>
                            <input type="text" name="id" id="id"/>
                        </div>
                    </div>
                </form>
            </div>
        );
    };
}

この実装で、新しく追加したテストはPassになりますが、最初に書いたテストがFAILになりました。

 FAIL  src/__tests__/Login.spec.js
  ● Login › renders without crashing

    expect(jest.fn()).not.toHaveBeenCalled()

    Expected mock function not to be called but it was called with:
      ["Warning: Invalid DOM property `class`. Did you mean `className`?
        in div (at Login.js:8)
        in form (at Login.js:7)
        in div (at Login.js:6)
        in Login (at Login.spec.js:13)"], ["Warning: Invalid DOM property `for`. Did you mean `htmlFor`?
        in label (at Login.js:10)
        in p (at Login.js:9)
        in div (at Login.js:8)
        in form (at Login.js:7)
        in div (at Login.js:6)
        in Login (at Login.spec.js:13)"]

      at Object.it (src/__tests__/Login.spec.js:15:35)
          at new Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:46:16)
      at process._tickCallback (internal/process/next_tick.js:68:7)

  ● Login › should render Password field

    expect(received).toBe(expected)

    Expected value to be (using ===):
      1
    Received:
      0

      at Object.it (src/__tests__/Login.spec.js:25:55)
          at new Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:46:16)
      at process._tickCallback (internal/process/next_tick.js:68:7)

 PASS  src/App.test.js

Test Suites: 1 failed, 1 passed, 2 total
Tests:       2 failed, 2 passed, 4 total
Snapshots:   0 total
Time:        1.32s, estimated 2s
Ran all test suites.

理由は、classとforがjsxの構文にしたがっていないから。jsxだとclassはclassName、forはhtmlForに修正。

 PASS  src/__tests__/Login.spec.js
 PASS  src/App.test.js

Test Suites: 2 passed, 2 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        1.302s, estimated 2s
Ran all test suites.

Passできたので、Passwordフィールドのテストを追加します。

__tests__/Login.spec.js
    it('should render Password field', () => {
        const wrapper = mount(<Login/>);
        expect( wrapper.find('input#password').length ).toBe(1)
    });
 FAIL  src/__tests__/Login.spec.js
  ● Login › should render Password field

    expect(received).toBe(expected)

    Expected value to be (using ===):
      1
    Received:
      0

      at Object.it (src/__tests__/Login.spec.js:23:55)
          at new Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:46:16)
      at process._tickCallback (internal/process/next_tick.js:68:7)

と出たので、最小限の実装をします。

components/topdown/Login.js
import React from 'react';

export default class Login extends React.Component {
    render() {
        return (
            <div>
                <form >
                    <div className="inset">
                        <div>
                            <label htmlFor="id">ID</label>
                            <input type="text" name="id" id="id"/>
                        </div>
                        <div>
                            <label htmlFor="password">PASSWORD</label>
                            <input type="password" name="password" id="password"/>
                        </div>
                    </div>
                </form>
            </div>
        );
    };
}

これでPassになりました。

 PASS  src/__tests__/Login.spec.js
 PASS  src/App.test.js

Test Suites: 2 passed, 2 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        1.437s, estimated 2s

Loginコンポーネントを見ると以下の部分がコンポーネントに分けれそうなので、リファクタリングしていきます。

<div>
    <label htmlFor="id">ID</label>
    <input type="text" name="id" id="id"/>
</div>
<div>
    <label htmlFor="password">PASSWORD</label>
    <input type="password" name="password" id="password"/>
</div>

リファクタリングでlabelとinputがセットのコンポーネントを作成して見ます。

components/topdown/LabelInput.js
import React from 'react';
import PropTypes from 'prop-types';

export default class LabelInput extends React.Component {
    static propTypes = {
        id: PropTypes.string.isRequired,
        name: PropTypes.string.isRequired,
        type: PropTypes.string.isRequired,
        labelText: PropTypes.string.isRequired,
    }
    render() {
        const {id, name, type, labelText } = this.props;
        return (
            <React.Fragment>
                <label htmlFor={id}>{labelText}</label>
                <input type={type} name={name} id={id}/>
            </React.Fragment>
        );
    };
}

上記がlabelとinputがセットのLabelInputコンポーネントになります。テスト対象のLoginコンポーネントにLabelInputコンポーネントを埋め込んで、今まで作成したテストが無事通ることを確認します。

components/topdown/Login.js
import React from 'react';
import LabelInput from './LabelInput'

export default class Login extends React.Component {
    render() {
        return (
            <div>
                <form >
                    <div className="inset">
                        <div>
                            <LabelInput id="id" name="id" type="text" labelText="ID"/>
                        </div>
                        <div>
                            <LabelInput id="password" name="password" type="password"  labelText="PASSWORD"/>
                        </div>
                    </div>
                </form>
            </div>
        );
    };
}
 PASS  src/__tests__/Login.spec.js
 PASS  src/App.test.js

Test Suites: 2 passed, 2 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        1.43s, estimated 2s
Ran all test suites.

置き換えて、今までのテストが全部Passしていることが確認できました。
では、次にLoginボタンのテストを書いていきます。

__tests__/Login.spec.js
    it('should render Loginb Button', () => {
        const wrapper = mount(<Login/>);
        expect( wrapper.find('input#login').length ).toBe(1)
    });

LoginコンポーネントにはまだLoginボタンは存在しないので、エラーになります。
最小の実装でPassさせます。

components/topdown/Login.js
import React from 'react';
import LabelInput from './LabelInput'

export default class Login extends React.Component {
    render() {
        return (
            <div>
                <form >
                    <div className="inset">
                        <div>
                            <LabelInput id="id" name="id" type="text" labelText="ID"/>
                        </div>
                        <div>
                            <LabelInput id="password" name="password" type="password"  labelText="PASSWORD"/>
                        </div>
                    </div>
                    <div className="p-container">
                        <input type="submit" id="login" value="Login"/>
                    </div>
                </form>
            </div>
        );
    };
}

これで、テストはPassできました。
次にIDの入力についてのテストを書いて行きます。最初にemail以外の入力があったときのテストを書きます。

__tests__/Login.spec.js
    it('unappropriate input to ID', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#id').first().simulate('change', { target: { value: 'Hello' } });
        expect( wrapper.find('div#id-error').text()).toBe("emailアドレスを入力してください。")
    });

テストとしては、IDにHelloを入力し、Validationエラー「emailアドレスを入力してください。」を表示するid-errorのidがついているdivタグを探しています。実装は一切していないのでFAILになります。テストをPassするように実装していきます。入力はLabelInputコンポーネントに移ったので、そこにValidation処理を実装していきます。本来であれば、LabelInputではなくほかの場所(Actionとかミドルウェアとか)に処理を書くのが正しいと思いますが、このテストをPassするための最小の実装でやります。その後でリファクタリングして解消していきます。

components/topdown/LabelInput.js
import React from 'react';
import PropTypes from 'prop-types';

export default class LabelInput extends React.Component {
    static propTypes = {
        id: PropTypes.string.isRequired,
        name: PropTypes.string.isRequired,
        type: PropTypes.string.isRequired,
        labelText: PropTypes.string.isRequired,
    }
    render() {
        const {id, name, type, labelText } = this.props;
        return (
            <React.Fragment>
                <label htmlFor={id}>{labelText}</label>
                <input type={type} name={name} id={id} />
                <div id={id +"-error"}>emailアドレスを入力してください</div>
            </React.Fragment>
        );
    };
}

これでテストはPassできました。
次に正しいメールアドレスを入力したときのテストも書いていきます。

__tests__/Login.spec.js
    it('appropriate input to ID', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#id').first().simulate('change', { target: { value: 'hello@example.com' } });
        expect( wrapper.find('div#id-error').length ).toBe(0)
    });

IDにhello@example.comを入力したときはエラーメッセージが表示されていないことをチェックしていきます。もちろんテスト結果はFAILです。PASSできるようにしていきます。

components/topdown/LabelInput.js
import React from 'react';
import PropTypes from 'prop-types';

export default class LabelInput extends React.Component {

    constructor(props){
        super(props);
        this.state = {
            value: "",
            hasError: false,
        }
        this.handleChange = this.handleChange.bind(this);
    }

    static propTypes = {
        id: PropTypes.string.isRequired,
        name: PropTypes.string.isRequired,
        type: PropTypes.string.isRequired,
        labelText: PropTypes.string.isRequired,
    }

    handleChange(event) {
        if(event.target.value === 'hello@example.com') {
            this.setState({value: event.target.value, hasError: false});
        }
    }

    render() {
        const {id, name, type, labelText } = this.props;
        return (
            <React.Fragment>
                <label htmlFor={id}>{labelText}</label>
                <input type={type} name={name} id={id} onChange={this.handleChange}/>
                {this.state.hasError && (<div id={id +"-error"}>emailアドレスを入力してください</div>)}
            </React.Fragment>
        );
    };
}

これでPASSできました。が、メールアドレスの判定はべた書きなのでもう一つテストを書いてみます。IDにhelloreact@hoge.comを入力したときはエラーメッセージが表示されていないことをチェックしていきます。もちろんテスト結果はFAILです。PASSできるようにしていきます。

__tests__/Login.spec.js
    it('appropriate input to ID(2)', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#id').first().simulate('change', { target: { value: 'helloreact@hoge.com' } });
        expect( wrapper.find('div#id-error').length ).toBe(0)
    });

メールアドレスを汎用的にチェックする必要ができてきたので、PASSできるように修正していきます。

components/topdown/LabelInput.js
import React from 'react';
import PropTypes from 'prop-types';

export default class LabelInput extends React.Component {

    constructor(props){
        super(props);
        this.state = {
            value: "",
            hasError: false,
        }
        this.handleChange = this.handleChange.bind(this);
    }

    static propTypes = {
        id: PropTypes.string.isRequired,
        name: PropTypes.string.isRequired,
        type: PropTypes.string.isRequired,
        labelText: PropTypes.string.isRequired,
    }

    handleChange(event) {
        const regex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
        const isEmail = regex.test(event.target.value);
        if(!isEmail) {
            this.setState({value: event.target.value, hasError: true});
        }
    }

    render() {
        const {id, name, type, labelText } = this.props;
        return (
            <React.Fragment>
                <label htmlFor={id}>{labelText}</label>
                <input type={type} name={name} id={id} onChange={this.handleChange}/>
                {this.state.hasError && (<div id={id +"-error"}>emailアドレスを入力してください</div>)}
            </React.Fragment>
        );
    };
}

この実装でPASSできました。このままだとPassword入力時もemailのチェックが行われてしまうのでリファクタリングしていきます。

components/topdown/LabelInput.js
import React from 'react';
import PropTypes from 'prop-types';

export default class LabelInput extends React.Component {

    constructor(props){
        super(props);
        this.state = {
            value: "",
            hasError: false,
            errorMessage: "",
        }
        this.handleChange = this.handleChange.bind(this);
    }

    static propTypes = {
        id: PropTypes.string.isRequired,
        name: PropTypes.string.isRequired,
        type: PropTypes.string.isRequired,
        labelText: PropTypes.string.isRequired,
    }

    handleChange(event) {
        this.setState({value: event.target.value});
    }

    render() {
        const {id, name, type, labelText } = this.props;
        return (
            <React.Fragment>
                <label htmlFor={id}>{labelText}</label>
                <input type={type} name={name} id={id} onChange={this.handleChange}/>
                {this.state.hasError && (<div id={id +"-error"}>{this.state.errorMessage}</div>)}
            </React.Fragment>
        );
    };
}

LabelInputからValidation処理をなくして、LabelInputを継承するコンポーネントを作成します。

components/topdown/EmailInput.js
import LabelInput from './LabelInput';

export default class EmailInput extends LabelInput {

    handleChange(event) {
        const regex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
        const isEmail = regex.test(event.target.value);
        if(!isEmail) {
            this.setState({value: event.target.value, hasError: true, errorMessage: "emailアドレスを入力してください。"});
        }
    }
}

LabelInputを継承したコンポーネントEmailInputにValidation処理を移しました。
これに合わせて、LoginコンポーネントのIDの入力もLabelInputからEmailInputに変更します。

components/topdown/Login.js
import React from 'react';
import LabelInput from './LabelInput'
import EmailInput from './EmailInput'

export default class Login extends React.Component {

    render() {
        return (
            <div>
                <form >
                    <div className="inset">
                        <div>
                            <EmailInput id="id" name="id" type="text" labelText="ID"/>
                        </div>
                        <div>
                            <LabelInput id="password" name="password" type="password"  labelText="PASSWORD"/>
                        </div>
                    </div>
                    <div className="p-container">
                        <input type="submit" id="login" value="Login"/>
                    </div>
                </form>
            </div>
        );
    };
}

では、テストの続きを書いていきます。次のテストとして一度不適切なIDを入力したあとに、正しいIDを入力したテストを行います。

__tests__/Login.spec.js
    it('ID message disappears', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#id').first().simulate('change', { target: { value: 'hello' } });
        wrapper.find('input#id').first().simulate('change', { target: { value: 'hello@example.com' } });
        expect( wrapper.find('div#id-error').length ).toBe(0)
    });
 FAIL  src/__tests__/Login.spec.js
  ● Login › ID message disappears

    expect(received).toBe(expected)

    Expected value to be (using ===):
      0
    Received:
      1

      at Object.it (src/__tests__/Login.spec.js:55:53)
          at new Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:46:16)
      at process._tickCallback (internal/process/next_tick.js:68:7)

 PASS  src/App.test.js

Test Suites: 1 failed, 1 passed, 2 total
Tests:       1 failed, 9 passed, 10 total
Snapshots:   0 total
Time:        1.392s, estimated 2s
Ran all test suites.

FAILになったので、PASSできるように実装します。

components/topdown/EmailInput.js
import LabelInput from './LabelInput';

export default class EmailInput extends LabelInput {

    handleChange(event) {
        const regex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
        const isEmail = regex.test(event.target.value);
        if(isEmail) {
            this.setState({value: event.target.value, hasError: false, errorMessage: ""});
        } else {
            this.setState({value: event.target.value, hasError: true, errorMessage: "emailアドレスを入力してください。"});
        }
    }
}

emailパターンが正しいときの条件分岐を追加しました。これでPASSできました。

次にパスワードの入力が4文字のときエラーメッセージができることをテストします。Failなので動くように実装します。

__tests__/Login.spec.js
    it('unappropriate input to Password', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#password').first().simulate('change', { target: { value: 'pass' } });
        expect( wrapper.find('div#password-error').text()).toBe("パスワードは6文字以上入力してください。")
    });
 FAIL  src/__tests__/Login.spec.js
  ● Login › unappropriate input to Password

    Method “text” is only meant to be run on a single node. 0 found instead.

      at ReactWrapper.single (node_modules/enzyme/build/ReactWrapper.js:1595:17)
      at ReactWrapper.text (node_modules/enzyme/build/ReactWrapper.js:841:21)
      at Object.it (src/__tests__/Login.spec.js:54:51)
          at new Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:46:16)
      at process._tickCallback (internal/process/next_tick.js:68:7)

 PASS  src/App.test.js

Test Suites: 1 failed, 1 passed, 2 total
Tests:       1 failed, 8 passed, 9 total
Snapshots:   0 total
Time:        1.488s, estimated 2s
Ran all test suites.

Failなので動くように実装します。

components/topdown/LabelInput.js
import React from 'react';
import PropTypes from 'prop-types';

export default class LabelInput extends React.Component {

    constructor(props){
        super(props);
        this.state = {
            value: "",
            hasError: false,
            errorMessage: "",
        }
        this.handleChange = this.handleChange.bind(this);
    }

    static propTypes = {
        id: PropTypes.string.isRequired,
        name: PropTypes.string.isRequired,
        type: PropTypes.string.isRequired,
        labelText: PropTypes.string.isRequired,
    }

    handleChange(event) {
        this.setState({value: event.target.value, hasError: true, errorMessage: "パスワードは6文字以上入力してください。"});
    }

    render() {
        const {id, name, type, labelText } = this.props;
        return (
            <React.Fragment>
                <label htmlFor={id}>{labelText}</label>
                <input type={type} name={name} id={id} onChange={this.handleChange}/>
                {this.state.hasError && (<div id={id +"-error"}>{this.state.errorMessage}</div>)}
            </React.Fragment>
        );
    };
}

この実装でPASSできました。このままだと、コンポーネントの役割がよくわからなくなるのでリファクタリングします。先程のリファクタリングでEmailInputを作ったのと同じように、パスワードのチェック処理を持ったPasswordInputコンポーネントを作成します。

components/topdown/PasswordInput
import LabelInput from './LabelInput';

export default class PasswordInput extends LabelInput {

    handleChange(event) {
        this.setState({value: event.target.value, hasError: true, errorMessage: "パスワードは6文字以上入力してください。"});
    }
}

次に汚くした、LabelInputをリファクタリングします。Passwordのテストを実装する前の段階に戻します(handleChangeだけ戻す)。これでテストはFailになってしまうので、LoginコンポーネントのLabelInputをPAsswordInputに変更します。

components/topdown/Login.js
import React from 'react';
import PasswordInput from './PasswordInput'
import EmailInput from './EmailInput'

export default class Login extends React.Component {

    render() {
        return (
            <div>
                <form >
                    <div className="inset">
                        <div>
                            <EmailInput id="id" name="id" type="text" labelText="ID"/>
                        </div>
                        <div>
                            <PasswordInput id="password" name="password" type="password"  labelText="PASSWORD"/>
                        </div>
                    </div>
                    <div className="p-container">
                        <input type="submit" id="login" value="Login"/>
                    </div>
                </form>
            </div>
        );
    };
}

これでテストはPASSされました。今度はパスワードに6文字以上入れたときのテストを実装します。

__tests__/Login.spec.js
    it('appropriate input to Password', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#password').first().simulate('change', { target: { value: 'password' } });
        expect( wrapper.find('div#password-error').length).toBe(0)
    });
 FAIL  src/__tests__/Login.spec.js
  ● Login › appropriate input to Password

    expect(received).toBe(expected)

    Expected value to be (using ===):
      0
    Received:
      1

      at Object.it (src/__tests__/Login.spec.js:67:59)
          at new Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:46:16)
      at process._tickCallback (internal/process/next_tick.js:68:7)

 PASS  src/App.test.js

Test Suites: 1 failed, 1 passed, 2 total
Tests:       1 failed, 10 passed, 11 total
Snapshots:   0 total
Time:        1.413s, estimated 2s
Ran all test suites.

FAILになりました。PASSできるようします。

components/topdown/PasswordInput
import LabelInput from './LabelInput';

export default class PasswordInput extends LabelInput {

    handleChange(event) {
        if(event.target.value.length < 6){
            this.setState({value: event.target.value, hasError: true, errorMessage: "パスワードは6文字以上入力してください。"});
        }
    }
}

これで、PASSできました。次のテストとして一度6桁未満のパスワードを入力したあとに、6桁以上のパスワードを入力したテストを行います。

__tests__/Login.spec.js
    it('PASSWORD message disappears', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#password').first().simulate('change', { target: { value: 'pa' } });
        wrapper.find('input#password').first().simulate('change', { target: { value: 'password' } });
        expect( wrapper.find('div#password-error').length ).toBe(0)
    });
 FAIL  src/__tests__/Login.spec.js
  ● Login › PASSWORD message disappears

    expect(received).toBe(expected)

    Expected value to be (using ===):
      0
    Received:
      1

      at Object.it (src/__tests__/Login.spec.js:74:59)
          at new Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:46:16)
      at process._tickCallback (internal/process/next_tick.js:68:7)

 PASS  src/App.test.js

Test Suites: 1 failed, 1 passed, 2 total
Tests:       1 failed, 11 passed, 12 total
Snapshots:   0 total
Time:        1.358s, estimated 2s
Ran all test suites.

FAILなのでPASSできるようにします。

components/topdown/PasswordInput
import LabelInput from './LabelInput';

export default class PasswordInput extends LabelInput {

    handleChange(event) {
        if(event.target.value.length < 6){
            this.setState({value: event.target.value, hasError: true, errorMessage: "パスワードは6文字以上入力してください。"});
        } else {
            this.setState({value: event.target.value, hasError: false, errorMessage: ""});
        }
    }
}

PasswordInputのhandleChangeに条件分岐を追加してPASSできました。

次にIDとPASSWORDの必須入力のテストします。

__tests__/Login.spec.js
    it('required', () => {
        const wrapper = mount(<Login/>);
        expect( wrapper.find('input#id[required]').length ).toBe(1)
        expect( wrapper.find('input#password[required]').length ).toBe(1)
    });
 FAIL  src/__tests__/Login.spec.js
  ● Login › ID required

    expect(received).toBe(expected)

    Expected value to be (using ===):
      1
    Received:
      0

      at Object.it (src/__tests__/Login.spec.js:79:59)
          at new Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:46:16)
      at process._tickCallback (internal/process/next_tick.js:68:7)

 PASS  src/App.test.js

Test Suites: 1 failed, 1 passed, 2 total
Tests:       1 failed, 12 passed, 13 total
Snapshots:   0 total
Time:        2.015s
Ran all test suites.

PASSできるようにします。

components/topdown/LabelInput.js
import React from 'react';
import PropTypes from 'prop-types';

export default class LabelInput extends React.Component {

    constructor(props){
        super(props);
        this.state = {
            value: "",
            hasError: false,
            errorMessage: "",
        }
        this.handleChange = this.handleChange.bind(this);
    }

    static propTypes = {
        id: PropTypes.string.isRequired,
        name: PropTypes.string.isRequired,
        type: PropTypes.string.isRequired,
        labelText: PropTypes.string.isRequired,
    }

    handleChange(event) {
        this.setState({value: event.target.value});
    }

    render() {
        const {id, name, type, labelText } = this.props;
        return (
            <React.Fragment>
                <label htmlFor={id}>{labelText}</label>
                <input type={type} name={name} id={id} onChange={this.handleChange} required/>
                {this.state.hasError && (<div id={id +"-error"}>{this.state.errorMessage}</div>)}
            </React.Fragment>
        );
    };
}

一旦リファクタリングしていきます。
リファクタリングの内容は、EmailInputとPasswordInputに好きな属性を入れれるように修正をかけます。また、LoginコンポーネントからだとEmailInputとPasswordInputのStateがどうなっているのかわからないので、onchangeの処理の一部を外に出します。

Login
import React from 'react';
import PasswordInput from './PasswordInput'
import EmailInput from './EmailInput'

export default class Login extends React.Component {

    constructor(props){
        super(props);
        this.submit = this.submit.bind(this);
        this.idInput = this.idInput.bind(this);
        this.passwordInput = this.passwordInput.bind(this);
        this.state = {
            id: "",
            password: "",
        }
    }

    submit(event){
        event.preventDefault();
    }

    idInput(event){
        this.setState({id: event.target.value});
    }

    passwordInput(event){
        this.setState({password: event.target.value});
    }

    render() {
        return (
            <div>
                <form onSubmit={this.submit}>
                    <div className="inset">
                        <div>
                            <EmailInput id="id" name="id" type="text" labelText="ID" required onChangeEvent={this.idInput}/>
                        </div>
                        <div>
                            <PasswordInput id="password" name="password" type="password"  labelText="PASSWORD" required onChangeEvent={this.passwordInput}/>
                        </div>
                    </div>
                    <div className="p-container">
                        <input type="submit" id="login" value="Login"/>
                    </div>
                </form>
            </div>
        );
    };
}
LabelInput
import React from 'react';
import PropTypes from 'prop-types';

export default class LabelInput extends React.Component {

    constructor(props){
        super(props);
        this.state = {
            hasError: false,
            errorMessage: "",
        }
        this.handleChange = this.handleChange.bind(this);
    }

    static propTypes = {
        id: PropTypes.string.isRequired,
        name: PropTypes.string.isRequired,
        type: PropTypes.string.isRequired,
        labelText: PropTypes.string.isRequired,
        onChangeEvent: PropTypes.func,
    }

    handleChange(event) {
        this.setState({value: event.target.value});
    }

    render() {
        const {id, name, type, labelText, onChangeEvent, ...props} = this.props;
        return (
            <React.Fragment>
                <label htmlFor={id}>{labelText}</label>
                <input type={type} name={name} id={id} onChange={this.handleChange} {...props}/>
                {this.state.hasError && (<div id={id +"-error"}>{this.state.errorMessage}</div>)}
            </React.Fragment>
        );
    };
}
EmailInput
import LabelInput from './LabelInput';

export default class EmailInput extends LabelInput {

    handleChange(event) {
        const val = event.target.value;
        const callback = this.props.onChangeEvent;
        const regex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
        const isEmail = regex.test(val);

        if(isEmail) {
            this.setState({hasError: false, errorMessage: ""});
        } else {
            this.setState({hasError: true, errorMessage: "emailアドレスを入力してください。"});
        }
        if(callback){
            callback.call(this, event);
        }
    }
}
PasswordInput
import LabelInput from './LabelInput';

export default class PasswordInput extends LabelInput {

    handleChange(event) {
        const callback = this.props.onChangeEvent;
        if(event.target.value.length < 6){
            this.setState({hasError: true, errorMessage: "パスワードは6文字以上入力してください。"});
        } else {
            this.setState({hasError: false, errorMessage: ""});
        }
        if(callback){
            callback.call(this, event);
        }
    }
}

テストがすべて通る状態で少しきれいにできました。

テストの続きを書きます。適当なIDとパスワードを入力して、ログインボタンが押せないことのテストを実装します。

__tests__/Login.spec.js
    it('cannot  press button', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#password').first().simulate('change', { target: { value: 'password' } });
        wrapper.find('input#id').first().simulate('change', { target: { value: 'hello' } });
        expect( wrapper.find('input#login[disabled=true]').length ).toBe(1)
    });
 FAIL  src/__tests__/Login.spec.js
  ● Login › cannot  press button

    expect(received).toBe(expected)

    Expected value to be (using ===):
      1
    Received:
      0

      at Object.it (src/__tests__/Login.spec.js:87:62)
          at new Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:46:16)
      at process._tickCallback (internal/process/next_tick.js:68:7)

 PASS  src/App.test.js

Test Suites: 1 failed, 1 passed, 2 total
Tests:       1 failed, 13 passed, 14 total
Snapshots:   0 total
Time:        1.455s, estimated 2s
Ran all test suites.

FAILなのでPASSできるようにします。ログインボタンにdisabledを追加するだけでPASSできました

Login.js
import React from 'react';
import PasswordInput from './PasswordInput'
import EmailInput from './EmailInput'

export default class Login extends React.Component {

    constructor(props){
        super(props);
        this.submit = this.submit.bind(this);
        this.idInput = this.idInput.bind(this);
        this.passwordInput = this.passwordInput.bind(this);
        this.state = {
            id: "",
            password: "",
        }
    }

    submit(event){
        event.preventDefault();
    }

    idInput(event){
        this.setState({id: event.target.value});
    }

    passwordInput(event){
        this.setState({password: event.target.value});
    }

    render() {
        return (
            <div>
                <form onSubmit={this.submit}>
                    <div className="inset">
                        <div>
                            <EmailInput id="id" name="id" type="text" labelText="ID" required onChangeEvent={this.idInput}/>
                        </div>
                        <div>
                            <PasswordInput id="password" name="password" type="password"  labelText="PASSWORD" required onChangeEvent={this.passwordInput}/>
                        </div>
                    </div>
                    <div className="p-container">
                        <input type="submit" id="login" value="Login" disabled/>
                    </div>
                </form>
            </div>
        );
    };
}

次のテストとして、入力のValidationを突破しているときログインボタンが押せることをテストします。

__tests__/Login.spec.js
    it('can press button', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#password').first().simulate('change', { target: { value: 'password' } });
        wrapper.find('input#id').first().simulate('change', { target: { value: 'hello' } });
        expect( wrapper.find('input#login').not('[disabled=true]').length ).toBe(1)
    });

テストはもちろんFAILです。

Login
import React from 'react';
import PasswordInput from './PasswordInput'
import EmailInput from './EmailInput'

export default class Login extends React.Component {

    constructor(props){
        super(props);
        this.submit = this.submit.bind(this);
        this.idInput = this.idInput.bind(this);
        this.passwordInput = this.passwordInput.bind(this);
        this.state = {
            id: "",
            password: "",
            idHasError: true,
            passHasError: true,
        }
    }

    submit(event){
        event.preventDefault();
    }

    idInput(event, hasError){
        this.setState({id: event.target.value, idHasError: this.state.idHasError&&hasError});
    }

    passwordInput(event, hasError){
        this.setState({password: event.target.value, passHasError: this.state.passHasError&&hasError});
    }

    render() {
        const {idHasError, passHasError} = this.state
        const hasError = idHasError || passHasError
        return (
            <div>
                <form onSubmit={this.submit}>
                    <div className="inset">
                        <div>
                            <EmailInput id="id" name="id" type="text" labelText="ID" required onChangeEvent={this.idInput}/>
                        </div>
                        <div>
                            <PasswordInput id="password" name="password" type="password"  labelText="PASSWORD" required onChangeEvent={this.passwordInput}/>
                        </div>
                    </div>
                    <div className="p-container">
                        <input type="submit" id="login" value="Login" disabled={hasError}/>
                    </div>
                </form>
            </div>
        );
    };
}
EmailInput
import LabelInput from './LabelInput';

export default class EmailInput extends LabelInput {

    handleChange(event) {
        const val = event.target.value;
        const callback = this.props.onChangeEvent;
        const regex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
        const isEmail = regex.test(val);
        let hasError = false;
        if(isEmail) {
            this.setState({hasError: false, errorMessage: ""});
        } else {
            hasError = true;
            this.setState({hasError: true, errorMessage: "emailアドレスを入力してください。"});
        }
        if(callback){
            callback.call(this, event, hasError);
        }
    }
}

PasswordInput
import LabelInput from './LabelInput';

export default class PasswordInput extends LabelInput {

    handleChange(event) {
        const callback = this.props.onChangeEvent;
        let hasError = false;
        if(event.target.value.length < 6){
            hasError = true
            this.setState({hasError: true, errorMessage: "パスワードは6文字以上入力してください。"});
        } else {
            this.setState({hasError: false, errorMessage: ""});
        }
        if(callback){
            callback.call(this, event, hasError);
        }
    }
}

これでPAssできました。

普通にログイン失敗したときのテストを実装します。

__tests__/Login.spec.js
    it('login fail', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#password').first().simulate('change', { target: { value: 'password' } });
        wrapper.find('input#id').first().simulate('change', { target: { value: 'hello@example.com' } });
        wrapper.find('input#login').first().simulate('submit');
        expect( wrapper.find('div#login-error').text()).toBe("IDまたはパスワードが間違っています。")
    });
 FAIL  src/__tests__/Login.spec.js
  ● Login › login fail

    Method “text” is only meant to be run on a single node. 0 found instead.

      at ReactWrapper.single (node_modules/enzyme/build/ReactWrapper.js:1595:17)
      at ReactWrapper.text (node_modules/enzyme/build/ReactWrapper.js:841:21)
      at Object.it (src/__tests__/Login.spec.js:102:48)
          at new Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:46:16)
      at process._tickCallback (internal/process/next_tick.js:68:7)

 PASS  src/App.test.js

Test Suites: 1 failed, 1 passed, 2 total
Tests:       1 failed, 15 passed, 16 total
Snapshots:   0 total
Time:        1.466s, estimated 2s
Ran all test suites.

PASSできるようにします。

Login.js
import React from 'react';
import PasswordInput from './PasswordInput'
import EmailInput from './EmailInput'

export default class Login extends React.Component {

    constructor(props){
        super(props);
        this.submit = this.submit.bind(this);
        this.idInput = this.idInput.bind(this);
        this.passwordInput = this.passwordInput.bind(this);
        this.state = {
            id: "",
            password: "",
            idHasError: true,
            passHasError: true,
        }
    }

    submit(event){
        event.preventDefault();
    }

    idInput(event, hasError){
        this.setState({id: event.target.value, idHasError: hasError});
    }

    passwordInput(event, hasError){
        this.setState({password: event.target.value, passHasError: hasError});
    }

    render() {
        const {idHasError, passHasError} = this.state
        const hasError = idHasError || passHasError
        return (
            <div>
                <form onSubmit={this.submit}>
                    <div className="inset">
                        <div>
                            <EmailInput id="id" name="id" type="text" labelText="ID" required onChangeEvent={this.idInput}/>
                        </div>
                        <div>
                            <PasswordInput id="password" name="password" type="password"  labelText="PASSWORD" required onChangeEvent={this.passwordInput}/>
                        </div>
                    </div>
                    <div className="p-container">
                        <input type="submit" id="login" value="Login" disabled={hasError}/>
                        <div id="login-error">IDまたはパスワードが間違っています</div>
                    </div>
                </form>
            </div>
        );
    };
}

PASSできました。

またテストを書きます。入力後、ボタン押下前をテストします。

__tests__/Login.spec.js
    it('before pressing the button', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#password').first().simulate('change', { target: { value: 'password' } });
        wrapper.find('input#id').first().simulate('change', { target: { value: 'hello@example.com' } });
        expect( wrapper.find('div#login-error').length).toBe(0)
    });
Login.js
import React from 'react';
import PasswordInput from './PasswordInput'
import EmailInput from './EmailInput'

export default class Login extends React.Component {

    constructor(props){
        super(props);
        this.submit = this.submit.bind(this);
        this.idInput = this.idInput.bind(this);
        this.passwordInput = this.passwordInput.bind(this);
        this.state = {
            id: "",
            password: "",
            idHasError: true,
            passHasError: true,
            loginFail: false,
        }
    }

    submit(event){
        event.preventDefault();
        this.setState({loginFail: true});
    }

    idInput(event, hasError){
        this.setState({id: event.target.value, idHasError: hasError});
    }

    passwordInput(event, hasError){
        this.setState({password: event.target.value, passHasError: hasError});
    }

    render() {
        const {idHasError, passHasError, loginFail} = this.state
        const hasError = idHasError || passHasError
        return (
            <div>
                <form onSubmit={this.submit}>
                    <div className="inset">
                        <div>
                            <EmailInput id="id" name="id" type="text" labelText="ID" required onChangeEvent={this.idInput}/>
                        </div>
                        <div>
                            <PasswordInput id="password" name="password" type="password"  labelText="PASSWORD" required onChangeEvent={this.passwordInput}/>
                        </div>
                    </div>
                    <div className="p-container">
                        <input type="submit" id="login" value="Login" disabled={hasError}/>
                        {loginFail &&(<div id="login-error">IDまたはパスワードが間違っています</div>)}
                    </div>
                </form>
            </div>
        );
    };
}

これでPASSできました。

さいごのテストです。ログインが正常にできたときのテストです。

Login.spec.js
    it('login success', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#password').first().simulate('change', { target: { value: 'password123#' } });
        wrapper.find('input#id').first().simulate('change', { target: { value: 'test@example.com' } });
        wrapper.find('input#login').first().simulate('submit');
        expect( wrapper.find('div#login-error').text()).toBe("ログイン成功")
    });
 FAIL  src/__tests__/Login.spec.js
  ● Login › login success

    expect(received).toBe(expected)

    Expected value to be (using ===):
      "ログイン成功"
    Received:
      "IDまたはパスワードが間違っています。"

      at Object.it (src/__tests__/Login.spec.js:117:56)
          at new Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:46:16)
      at process._tickCallback (internal/process/next_tick.js:68:7)

 PASS  src/App.test.js

Test Suites: 1 failed, 1 passed, 2 total
Tests:       1 failed, 17 passed, 18 total
Snapshots:   0 total
Time:        1.442s, estimated 2s
Ran all test suites.

FAILからPASSにします。

Login.js
import React from 'react';
import PasswordInput from './PasswordInput'
import EmailInput from './EmailInput'

export default class Login extends React.Component {

    constructor(props){
        super(props);
        this.submit = this.submit.bind(this);
        this.idInput = this.idInput.bind(this);
        this.passwordInput = this.passwordInput.bind(this);
        this.state = {
            id: "",
            password: "",
            idHasError: true,
            passHasError: true,
            loginFail: false,
            message: "",
        }
    }

    submit(event){
        event.preventDefault();
        const success = this.state.id ==='test@example.com' && this.state.password === 'password123#'
        if(success){
            this.setState({message: 'ログイン成功'})
        } else {
            this.setState({message: 'IDまたはパスワードが間違っています。'})
        }
        this.setState({loginFail: true});
    }

    idInput(event, hasError){
        this.setState({id: event.target.value, idHasError: hasError});
    }

    passwordInput(event, hasError){
        this.setState({password: event.target.value, passHasError: hasError});
    }

    render() {
        const {idHasError, passHasError, loginFail, message} = this.state
        const hasError = idHasError || passHasError
        return (
            <div>
                <form onSubmit={this.submit}>
                    <div className="inset">
                        <div>
                            <EmailInput id="id" name="id" type="text" labelText="ID" required onChangeEvent={this.idInput}/>
                        </div>
                        <div>
                            <PasswordInput id="password" name="password" type="password"  labelText="PASSWORD" required onChangeEvent={this.passwordInput}/>
                        </div>
                    </div>
                    <div className="p-container">
                        <input type="submit" id="login" value="Login" disabled={hasError}/>
                        {loginFail &&(<div id="login-error">{message}</div>)}
                    </div>
                </form>
            </div>
        );
    };
}

これでPASSです。

作成したテスト

Login.spec.js
import React from 'react';
import ReactDOM from 'react-dom';
import { configure, mount } from 'enzyme';
import Login from '../components/topdown/Login'
import Adapter from 'enzyme-adapter-react-16';

configure({ adapter: new Adapter() });

describe('Login', () => {
    it('renders without crashing', () => {
        console.error = jest.fn()
        const div = document.createElement('div');
        ReactDOM.render(<Login />, div);
        ReactDOM.unmountComponentAtNode(div);
        expect(console.error).not.toHaveBeenCalled();
    });

    it('should render ID text field', () => {
        const wrapper = mount(<Login/>);
        expect( wrapper.find('input#id').length ).toBe(1)
    });

    it('should render Password field', () => {
        const wrapper = mount(<Login/>);
        expect( wrapper.find('input#password').length ).toBe(1)
    });

    it('should render Loginb Button', () => {
        const wrapper = mount(<Login/>);
        expect( wrapper.find('input#login').length ).toBe(1)
    });

    it('unappropriate input to ID', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#id').first().simulate('change', { target: { value: 'Hello' } });
        expect( wrapper.find('div#id-error').text()).toBe("emailアドレスを入力してください。")
    });

    it('appropriate input to ID', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#id').first().simulate('change', { target: { value: 'hello@example.com' } });
        expect( wrapper.find('div#id-error').length ).toBe(0)
    });

    it('appropriate input to ID(2)', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#id').first().simulate('change', { target: { value: 'helloreact@hoge.com' } });
        expect( wrapper.find('div#id-error').length ).toBe(0)
    });

    it('ID message disappears', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#id').first().simulate('change', { target: { value: 'hello' } });
        wrapper.find('input#id').first().simulate('change', { target: { value: 'hello@example.com' } });
        expect( wrapper.find('div#id-error').length ).toBe(0)
    });

    it('unappropriate input to Password', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#password').first().simulate('change', { target: { value: 'pass' } });
        expect( wrapper.find('div#password-error').text()).toBe("パスワードは6文字以上入力してください。")
    });

    it('appropriate input to Password', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#password').first().simulate('change', { target: { value: 'password' } });
        expect( wrapper.find('div#password-error').length).toBe(0)
    });

    it('PASSWORD message disappears', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#password').first().simulate('change', { target: { value: 'pa' } });
        wrapper.find('input#password').first().simulate('change', { target: { value: 'password' } });
        expect( wrapper.find('div#password-error').length ).toBe(0)
    });

    it('required', () => {
        const wrapper = mount(<Login/>);
        expect( wrapper.find('input#id[required]').length ).toBe(1)
        expect( wrapper.find('input#password[required]').length ).toBe(1)
    });

    it('cannot  press button', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#password').first().simulate('change', { target: { value: 'password' } });
        wrapper.find('input#id').first().simulate('change', { target: { value: 'hello' } });
        expect( wrapper.find('input#login[disabled=true]').length ).toBe(1)
    });

    it('can press button', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#password').first().simulate('change', { target: { value: 'password' } });
        wrapper.find('input#id').first().simulate('change', { target: { value: 'hello@example.com' } });
        expect( wrapper.find('input#login').not('[disabled=true]').length ).toBe(1)
    });

    it('login fail', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#password').first().simulate('change', { target: { value: 'password' } });
        wrapper.find('input#id').first().simulate('change', { target: { value: 'hello@example.com' } });
        wrapper.find('input#login').first().simulate('submit');
        expect( wrapper.find('div#login-error').text()).toBe("IDまたはパスワードが間違っています。")
    });

    it('before pressing the button', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#password').first().simulate('change', { target: { value: 'password' } });
        wrapper.find('input#id').first().simulate('change', { target: { value: 'hello@example.com' } });
        expect( wrapper.find('div#login-error').length).toBe(0)
    });

    it('login success', () => {
        const wrapper = mount(<Login/>);
        wrapper.find('input#password').first().simulate('change', { target: { value: 'password123#' } });
        wrapper.find('input#id').first().simulate('change', { target: { value: 'test@example.com' } });
        wrapper.find('input#login').first().simulate('submit');
        expect( wrapper.find('div#login-error').text()).toBe("ログイン成功")
    });

});

最終的にできたテストは上記になります。

さいごに

テストを書いてから実装なので、リファクタリングをしてもデグレが起きにくくいい感じに思えます。
が、テストに求められる最短の実装でPASSに持っていくのがなにげに難しい。必要以上の実装や汎用性をもたせようとしたり創発的になり気味。
Reacもまだまだ、全然かけないみなので修行にはちょうどいい教材と思ってます。次は、Reduxを使ってもう少し複雑なものでTDDを修行してみたいところ。

16
22
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?