見出し画像

【テスト駆動開発】Springboot & React - 第一回 : 会員登録ページ



1. はじめに


こんにちは、今日からはTDD(テスト駆動開発)にSpringとReactを結合したWebアプリケーションを作ります!これまでは主に伝統的なウォーターフォール開発方式を行ってきましたが、ウォーターフォール方式は硬直性とドキュメントの依存性という問題点を抱えています。さらに、テスト時期が遅れるという問題も抱えています。このような様々な問題を克服するための方案の一つがテスト駆動開発方式(TDD)です。


TDDの三段階
RED:テスト失敗
GREEN:テスト通過
REFACTOR:コード修正


2. テスト駆動開発(TDD)の概念


TDDとは?
TDDは「テスト駆動開発」の略で、ソフトウェア開発方法論の一つです。

TDDの基本原則
TDDは次のような3つの段階で構成されます。

テスト作成(Test Phase)-RED
まず、開発する機能に対するテストケースを作成します。開発者はまだ実装されていない機能やモジュールのテストケースを作成します。このテストケースは実際のコードがまだ作成されていないため、失敗します。失敗するテストを通じて、どのような機能を実装すべきかを明確に理解し、開始点を決めます。

コード作成(Code Phase)-GREEN
 
テストケースを作成した後、そのテストを通過できるコードを作成します。この時、目標はテストを通過するコードを作ることです。

リファクタリング(Refactor Phase)-REFACTOR
コード作成後は、コードの品質を改善して最適化する作業を行います。リファクタリングはコードの可読性、性能、保守性を向上させる作業を意味します。


TDDのメリット

より良いコード品質
TDDを使用すると、コードの品質が向上します。バグを早期に発見して修正できるため、プロジェクトの品質が向上します。

迅速なフィードバック
TDDを使用すると、コード作成とテスト実行の間に迅速なフィードバックループが形成されます。

設計改善
TDDにより、コードの設計が徐々に改善されます。コードがモジュール化され、拡張可能になります。


3. 実装過程



3.1 プロジェクトの生成

Spring InitializでWeb、JPA, security, h2, 依存性を追加します。
npx create-react-app frontend
reactプロジェクトを生成します。
npm start
アプリ起動。
npm install --save -dev@testing-library
npm install --save-dev@test-library/jest-dom
テストライブラリをインストールします。
	<dependencies>
<!--		<dependency>-->
<!--			<groupId>org.springframework.boot</groupId>-->
<!--			<artifactId>spring-boot-starter-security</artifactId>-->
<!--		</dependency>-->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>

		<dependency>
			<groupId>com.h2database</groupId>
			<artifactId>h2</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
<!--		<dependency>-->
<!--			<groupId>org.springframework.security</groupId>-->
<!--			<artifactId>spring-security-test</artifactId>-->
<!--			<scope>test</scope>-->
<!--		</dependency>-->
		<dependency>
			<groupId>org.junit.vintage</groupId>
			<artifactId>junit-vintage-engine</artifactId>
			<scope>test</scope>
			<exclusions>
				<exclusion>
					<groupId>org.hamcrest</groupId>
					<artifactId>hamcrest-core</artifactId>
				</exclusion>
			</exclusions>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-validation</artifactId>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
		</dependency>
	</dependencies>

バックエンドのプロジェクトの依存性を以上のように作成します。

UserControllerTestから作成します。
ない部分をひとつづ、作っていきます。
Userクラス作成します。
UserControllerも作成。
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public class UserControllerTest {

    @Autowired
    TestRestTemplate testRestTemplate;

    @Test
    public void postUser_whenUserIsValid_receiveOk() {
        User user = new User();
        user.setUsername("test-user");
        user.setDisplayName("test-display");
        user.setPassword("P4ssword");

        ResponseEntity<Object> response = testRestTemplate.postForEntity("/api/1.0/users", user, Object.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }
}

@RunWith(SpringRunner.class): このアノテーションはJUnitテストクラスを実行するのに使われるテストランチャーを指定します。SpringRunnerはスプリングブートアプリケーションをロードしてテストするため使われます。

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT): このアノテーションはスプリングブートアプリケーションをテストするのに使われ、内蔵ウェブサーバーをランダムなポートで実行して実際のHTTPリクエストを処理します。これは実際にアプリケーションを起動してランダムなポートを使ってテストHTTPリクエストを処理します。

@ActiveProfiles("test"): このアノテーションはtestプロファイルを有効にします。プロファイルはスプリングブートアプリケーションの構成を制御するために使います。ここではtestプロファイルを有効にしてテストに必要な設定を使います。

TestRestTemplate testRestTemplate: TestRestTemplateはスプリングブートアプリケーションをテストするためのRestTemplateの特別なバージョンです。 これを使ってHTTPリクエストを送って応答を確認することができます。

Test: このアノテーションはテストメソッドを表します。 postUser_whenUserIsValid_receiveOk メソッドは特定のテストケースを実行するのに使います。

テストメソッド(postUser_whenUserIsValid_receiveOk):このメソッドは、特定のシナリオをテストするために使用されます。 ここでは、ユーザー情報をPOST要請でサーバーに送り、サーバーが正しい応答(200OK)を返すか確認します。

ResponseEntity<Object>response=testRestTemplate.postForEntity("/api/1.0/users",user,Object.class):このコードは/api/1.0/usersエンドポイントにPOSTリクエストを送信し、応答をResponseEntityオブジェクトとして受信します。 テストRestTemplateを使用してHTTP要請を行い、応答を受けることがテストの核心です。

assertThat(response.getStatusCode()).isEqualTo(HttpStatus。OK): このコードは、サーバから受信した応答のHTTPステータスコードを確認し、このステータスコードがHttpStatus.OK (200) であるかを検証します。

@Data
public class User {

    private String username;
    private String displayName;
    private String password;


}
@RestController
public class UserController {

    @PostMapping("/api/1.0/users")
    void createUser() {

    }

}
テスト成功。


3.2 データベースへのユーザー保存

ユーザー保存するテストメソッドの作成。
Extract Methodでリファクタリング
ハードコーディングされた部分も外部化。
UserとUserRepository作成します。
@Data
@Entity
@Table(name="users")
public class User {

    @Id
    @GeneratedValue
    private long id;
    private String username;
    private String displayName;
    private String password;


}
public interface UserRepository extends JpaRepository<User, Long>{

}


テスト失敗
UserServiceを通じて、ユーザーを保存するビジネスロジック作成
@Service
public class UserService {

    UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        super();
        this.userRepository = userRepository;
    }

    public User save(User user) {
        return userRepository.save(user);
    }
}
 UserControllerにも保存ロジックを追加。
@RestController
public class UserController {

    @Autowired
    UserService userService;
    @PostMapping("/api/1.0/users")
    void createUser(@RequestBody User user) {
        userService.save(user);
    }

}
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class UserControllerTest {

    private static final String API_1_0_USERS = "/api/1.0/users";
    @Autowired
    TestRestTemplate testRestTemplate;

    @Autowired
    UserRepository userRepository;

    @Before
    public void cleanup() {
        userRepository.deleteAll();
    }

    @Test
    public void postUser_whenUserIsValid_receiveOk() {
        User user = createValidUser();
        ResponseEntity<Object> response = testRestTemplate.postForEntity(API_1_0_USERS, user, Object.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }

    @Test
    public void postUser_whenUserIsValid_userSavedToDatabase() {
        User user = createValidUser();
        testRestTemplate.postForEntity(API_1_0_USERS, user, Object.class);
        assertThat(userRepository.count()).isEqualTo(1);


    }

    private User createValidUser() {
        User user = new User();
        user.setUsername("test-user");
        user.setDisplayName("test-display");
        user.setPassword("P4ssword");
        return user;
    }


}

FixMethodOrderアノテーションはJUnitテストメソッドの実行順序を指定するために使用します。 MethodSorters列挙型を使って実行順序を定義します。 通常、JUnitはテストメソッドを実行する順序を保証しません。 テストメソッド間の依存関係がなければ順序は関係ありませんが、時には一部のテストは他のテストの結果に依存することがあるので、実行順序を制御したい場合があります。注意すべき点は、テストメソッド間の依存関係を最小限に抑え、可能であれば、各テストメソッドが独立して実行されるようにすることをお勧めします。


3.3 パスワードハッシュ

application.ymlでh2データベースの設定をします。
Postmanでリクエストを送信してみます。
データベースにうまく格納されました。
assert文で格納されたパスワードとユーザが入力したパスワードが違うことを検証しています。
<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		...
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</dependency>
		...
	</dependencies>

SpringSecurityの依存関係を有効にします。

パスワードエンコーダー依存性を注入し、
コンストラクタと、保存ロジックにパスワードエンコーダーを追加します。
SpringBootApplication(exclude = SecurityAutoConfiguration.class)はスプリングブートアプリケーションを起動する時、スプリングセキュリティの自動設定を無効にして、その設定を無効にすることを意味します。
セキュリティ設定を自動的に有効にしないため、ユーザーは自分でセキュリティ設定を実装し、必要なセキュリティ設定を制御することができます。
データベースにユーザー保存、
パスワードハッシュのテストコードが成功しました。
ポストマンでテストしてみると、
実際にデータベースにハッシュされて格納されました。


3.4 ユーザー登録ページ

次はフロントエンドのためReactアプリに移りましょう。

npm start
npm test

npm startでアプリを実行し、npm testでテストを実行します。

テストコードのファイル名は末尾にspecや、testが付きます。
フロントエンドのTDDも、テストコードを中心に失敗させた後、テストを通過させるために実際のロジックを補完していく方法で作成します。
import React from 'react';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { UserSignupPage } from './UserSignupPage';

describe('UserSignupPage', ()=> {
    describe('Layout', () => {
        it('has header of Sign up', () => {
            const { container } = render(<UserSignupPage />)
            const header = container.querySelector('h1');
            expect(header).toHaveTextContent('Sign Up');
        });
    });
})

このコードの主な目的はUserSignupPageコンポーネントが "Sign Up" ヘッダーをレンダリングするか確認することです。 これはコンポーネントのレイアウトと初期レンダリングを検証するための基本的なテストです。

レンダリングと要素アクセス
render(<UserSignupPage />): UserSignupPageコンポーネントをレンダリングします。
const header = container.querySelector('h1');: レンダリングされたコンポーネントでh1タグエレメントを探してheader変数に保存します。

テストアサーション
expect(header).toHaveTextContent('Sign Up');: header要素が'Sign Up'テキストを含んでいるか検査します。この部分は実際にコンポーネントが予想通り'Sign Up'ヘッダーをレンダリングするか確認する部分です。

import React from 'react';

export class UserSignupPage extends React.Component {

    render() {
        return (
        <div>
            <h1>Sign Up</h1>
        </div>

    )
}

}


3.5 フォームレイアウト

import React from 'react';
import { render, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { UserSignupPage } from './UserSignupPage';

beforeEach(cleanup);

describe('UserSignupPage', ()=> {
    describe('Layout', () => {
        it('has header of Sign up', () => {
            const { container } = render(<UserSignupPage />)
            const header = container.querySelector('h1');
            expect(header).toHaveTextContent('Sign Up');
        });
        it('has input for display name', () => {
            const { queryByPlaceholderText } = render(<UserSignupPage />);
            const displayNameInput = queryByPlaceholderText('Your display name');
            expect(displayNameInput).toBeInTheDocument();
          });
          it('has input for username', () => {
            const { queryByPlaceholderText } = render(<UserSignupPage />);
            const usernameInput = queryByPlaceholderText('Your username');
            expect(usernameInput).toBeInTheDocument();
          });
          it('has input for password', () => {
            const { queryByPlaceholderText } = render(<UserSignupPage />);
            const passwordInput = queryByPlaceholderText('Your password');
            expect(passwordInput).toBeInTheDocument();
          });
          it('has password type for password input', () => {
            const { queryByPlaceholderText } = render(<UserSignupPage />);
            const passwordInput = queryByPlaceholderText('Your password');
            expect(passwordInput.type).toBe('password');
          });
          it('has input for password repeat', () => {
            const { queryByPlaceholderText } = render(<UserSignupPage />);
            const passwordRepeat = queryByPlaceholderText('Repeat your password');
            expect(passwordRepeat).toBeInTheDocument();
          });
          it('has submit button', () => {
            const { container } = render(<UserSignupPage />);
            const button = container.querySelector('button');
            expect(button).toBeInTheDocument();
          });
    });
});

各テストは別のUI要素を確認します。例えば、it('has input for display name', ...) テストケースは、'Your display name' プレースホルダープロパティを持つ入力フィールドがレンダリングされるかどうかを確認し、expect(displayNameInput).toBeInTheDocument();を使ってその要素が存在するかどうかを検証します。

注目すべきテストコード!!!
beforeEach(cleanup)は
テスト実行前によく実行する作業の一つで、テストケース間でテスト環境を初期化したり、クリーンアップするために使います。


import React from 'react';

export class UserSignupPage extends React.Component {

    render() {
        return (
        <div>
            <h1>Sign Up</h1>
            <div>
                <input placeholder="Your display name" />            
            </div>
            <div>
                <input placeholder="Your username" />
            </div>
            <div>
                <input placeholder="Your password" type="password"/>
            </div>
            <div>
                <input placeholder="Repeat your password" type="password"/>
            </div>
            <div>
                <button>Sign Up</button>
            </div>
        </div>

    )
}

}

各要素に合うテストコードが通るようにUserSignupPageを作成します。


3.6 入力変更の処理


describe('Interactions', () => {
    const changeEvent = (content) => {
      return {
        target: {
          value: content
        }
      };
    };

    it('sets the displayName value into state', () => {
      const { queryByPlaceholderText } = render(<UserSignupPage />);
      const displayNameInput = queryByPlaceholderText('Your display name');

      fireEvent.change(displayNameInput, changeEvent('my-display-name'));

      expect(displayNameInput).toHaveValue('my-display-name');
    });

    it('sets the username value into state', () => {
      const { queryByPlaceholderText } = render(<UserSignupPage />);
      const usernameInput = queryByPlaceholderText('Your username');

      fireEvent.change(usernameInput, changeEvent('my-user-name'));

      expect(usernameInput).toHaveValue('my-user-name');
    });

    it('sets the password value into state', () => {
      const { queryByPlaceholderText } = render(<UserSignupPage />);
      const passwordInput = queryByPlaceholderText('Your password');

      fireEvent.change(passwordInput, changeEvent('P4ssword'));

      expect(passwordInput).toHaveValue('P4ssword');
    });

    it('sets the password repeat value into state', () => {
      const { queryByPlaceholderText } = render(<UserSignupPage />);
      const passwordRepeat = queryByPlaceholderText('Repeat your password');

      fireEvent.change(passwordRepeat, changeEvent('P4ssword'));

      expect(passwordRepeat).toHaveValue('P4ssword');
    });
  });

changeEvent関数

changeEvent 関数はユーザーの入力を模倣するイベントオブジェクトを生成します。このイベントオブジェクトはtarget属性を持ち、value属性を設定してユーザー入力値を表します。

sets the displayName value into state

このテストケースは displayName 入力フィールドにユーザー名を入力して、この入力値がコンポーネントの状態に正しく反映されるか確認します。
fireEvent.change(displayNameInput, changeEvent('my-display-name')) を使って displayNameInput 入力フィールドに 'my-display-name' 値を入力します。
expect(displayNameInput).toHaveValue('my-display-name') を使って displayNameInput 入力フィールドの値を検証します。

sets the username value into state

このテストケースはusername入力フィールドにユーザー名を入力して、この入力値がコンポーネントの状態に正しく反映されるか確認します。同様にfireEvent.change()を使って入力を変更し、expect()を使って値を検証します。
sets the password value into state:

このテストケースはpassword入力フィールドにパスワードを入力して、この入力値がコンポーネントの状態に正しく反映されるか確認します。fireEvent.change()とexpect()を使って検証します。

sets the password repeat value into state

このテストケースはpasswordRepeat入力フィールドにパスワードを入力して、この入力値がコンポーネントの状態に正しく反映されるか確認します。もう一度、fireEvent.change()とexpect()を使って検証します。
各テストケースは他の入力フィールドを検証し、ユーザーの入力が状態に正しく反映されるかを確認します。

import React from 'react';

export class UserSignupPage extends React.Component {

    state = {
        displayName: '',
        username: '',
        password: '',
        passwordRepeat: ''
    };

    onChangeDisplayName = (event) => {
        const value = event.target.value;
        this.setState({ displayName: value })
    };

    onChangeUserName = (event) => {
        const value = event.target.value;
        this.setState({ username: value });
    };

    onChangePassword = (event) => {
        const value = event.target.value;
        this.setState({ password: value });
    };

    onChangePasswordRepeat = (event) => {
        const value = event.target.value;
        this.setState({ passwordRepeat: value });
    };


    render() {
        return (
            <div>
                <h1>Sign Up</h1>
                <div>
                    <input
                        placeholder="Your display name"
                        value={this.state.displayName}
                        onChange={this.onChangeDisplayName}
                    />
                </div>
                <div>
                    <input
                        placeholder="Your username"
                        value={this.state.username}
                        onChange={this.onChangeUserName}
                    />
                </div>
                <div>
                    <input placeholder="Your password"
                        type="password"
                        value={this.state.password}
                        onChange={this.onChangePassword}
                    />
                </div>
                <div>
                    <input placeholder="Repeat your password"
                        type="password"
                        value={this.state.passwordRepeat}
                        onChange={this.onChangePasswordRepeat}
                    />
                </div>
                <div>
                    <button>Sign Up</button>
                </div>
            </div>

        )
    }

}

ユーザーが入力した情報はコンポーネントの状態に反映され、onChangeイベントハンドラを使用して入力フィールドの内容が変更されるたびに状態が更新されます。

stateオブジェクト
UserSignupPageコンポーネントの状態を定義します。この状態オブジェクトはユーザーが入力した値を保存するために使われます。初期状態では、各入力フィールドの値は空の文字列で初期化されます。

onChangeDisplayName、onChangeUserName、onChangePassword、onChangePasswordRepeatメソッド
各入力フィールドで発生したonChangeイベントに対するハンドラメソッドです。 これらのメソッドは、イベントオブジェクトを受け取り、その入力フィールドに入力された値を抽出し、setStateメソッドを使用してコンポーネントの状態を更新します。例えば、onChangeDisplayNameメソッドはdisplayNameの状態を更新します。

renderメソッド
コンポーネントのレンダリングを定義します。ページには「Sign Up」ヘッダーと4つの入力フィールド(名前、ユーザー名、パスワード、パスワード再入力)が含まれており、各入力フィールドの値はstateの該当プロパティと連結されています。 また、各入力フィールドにはonChangeハンドラが連結されており、ユーザーの入力がステータスに反映されるようにします。

入力値が変わるたびに開発者モードのcomponentsでstate値が変わることが確認できます。


3.7 クリック・ハンドリング

/pages/UserSignupPage.spec.js

    let button, displayNameInput, usernameInput, passwordInput, passwordRepeat;

    const setupForSubmit = (props) => {
      const rendered = render(<UserSignupPage {...props} />);

      const { container, queryByPlaceholderText } = rendered;

      displayNameInput = queryByPlaceholderText('Your display name');
      usernameInput = queryByPlaceholderText('Your username');
      passwordInput = queryByPlaceholderText('Your password');
      passwordRepeat = queryByPlaceholderText('Repeat your password');

      fireEvent.change(displayNameInput, changeEvent('my-display-name'));
      fireEvent.change(usernameInput, changeEvent('my-user-name'));
      fireEvent.change(passwordInput, changeEvent('P4ssword'));
      fireEvent.change(passwordRepeat, changeEvent('P4ssword'));

      button = container.querySelector('button');
      return rendered;
    };

ユーザーの入力をシミュレーションするために使われるヘルパー関数であるsetupForSubmitを定義します。

let
テストで使用する複数の変数を宣言し、初期化します。これらの変数は、テストでユーザー入力を模倣し、テスト結果を検証するために使用されます。

setupForSubmit

UserSignupPageコンポーネントをレンダリングします。この関数はpropsを渡すことができるので、テスト時にコンポーネントに必要なプロパティを設定することができます。propsは、Reactコンポーネント間でデータを配信し、コンポーネントの動作を制御するために使用されるオブジェクトです。rendered オブジェクトからコンテナと入力フィールドを取得します。

queryByPlaceholderTextを使って入力フィールドを取得します。 この時、queryByPlaceholderTextは入力フィールドのプレースホルダー(placeholder)プロパティを使って該当要素を選択します。

fireEvent.changeを使って入力フィールドの値を変更し、changeEvent関数はユーザー入力を模倣するイベントオブジェクトを生成します。

最後に「Sign Up」ボタンを取得します。


    it('calls postSignup when the fields are valid and the actions are provided in props', () => {
      const actions = {
        postSignup: jest.fn().mockResolvedValueOnce({})
      };
      setupForSubmit({ actions });
      fireEvent.click(button);
      expect(actions.postSignup).toHaveBeenCalledTimes(1);
    });


    it('does not throw exception when clicking the button when actions not provided in props', () => {
      setupForSubmit();
      expect(() => fireEvent.click(button)).not.toThrow();
    });


    it('calls post with user body when the fields are valid', () => {
      const actions = {
        postSignup: jest.fn().mockResolvedValueOnce({})
      };
      setupForSubmit({ actions });
      fireEvent.click(button);
      const expectedUserObject = {
        username: 'my-user-name',
        displayName: 'my-display-name',
        password: 'P4ssword'
      };
      expect(actions.postSignup).toHaveBeenCalledWith(expectedUserObject);
    });

calls postSignup when the fields are valid and the actions are provided in props
このテストはユーザーが有効な情報を入力して「Sign Up」ボタンをクリックした時、postSignupアクション関数が呼び出されるか確認します。
actionsオブジェクトはpostSignup関数を持ち、jest.fn()を使ってモック(mock)関数を生成します。
setupForSubmit関数を使ってコンポーネントをレンダリングしてユーザー入力をシミュレーションします。
fireEvent.click(button)を使って「Sign Up」ボタンをクリックします。

最後に、expect(actions.postSignup).toHaveBeenCalledTimes(1)を使ってpostSignup関数が一度呼び出されたか確認します。

does not throw exception when clicking the button when actions not provided in props
このテストはactionsがpropsに提供されなかった時、"Sign Up"ボタンをクリックしても例外が発生しないか確認します。
setupForSubmit関数を使ってコンポーネントをレンダリングします。
fireEvent.click(button)を使って「Sign Up」ボタンをクリックして例外が発生しないか確認します。


calls post with user body when the fields are valid
このテストはユーザーが有効な情報を入力して「Sign Up」ボタンをクリックした時、postSignupアクション関数がユーザー情報と一緒に呼び出されるか確認します。
actionsオブジェクトはpostSignup関数を持ち、jest.fn()を使ってモック(mock)関数を生成します。
setupForSubmit関数を使ってコンポーネントをレンダリングしてユーザー入力をシミュレーションします。
fireEvent.click(button)を使って「Sign Up」ボタンをクリックします。
expectedUserObject オブジェクトを生成してユーザー情報を表示します。
最後に、expect(actions.postSignup).toHaveBeenCalledWith(expectedUserObject)を使ってpostSignup関数が予想されたユーザー情報と一緒に呼び出されたか確認します。

/pages/UserSignupPage.js

  onClickSignup = () => {
    const user = {
      username: this.state.username,
      displayName: this.state.displayName,
      password: this.state.password
    };
    this.props.actions.postSignup(user);
  };
<button onClick={this.onClickSignup}>Sign Up</button>
UserSignupPage.defaultProps = {
  actions: {
    postSignup: () =>
      new Promise((resolve, reject) => {
        resolve({});
      })
  }
};

onClickSignupメソッド

onClickSignupメソッドは"Sign Up"ボタンがクリックされた時に実行されるコールバック関数です。
このメソッドはコンポーネントの状態(this.state)からユーザー情報(ユーザー名、表示名、パスワード)を抽出してuserオブジェクトを生成します。
その後、this.props.actions.postSignup(user)を呼び出してユーザー情報を引数で渡し、会員登録(postSignup)アクションを実行します。

"Sign Up"ボタン

ボタン要素はonClickイベントハンドラでonClickSignupメソッドを設定します。 つまり、ボタンをクリックするとonClickSignupメソッドが実行されます。

UserSignupPage.defaultProps

UserSignupPageコンポーネントの基本propsを定義する部分です。
ここでactionsプロパティはオブジェクトで構成され、その中のpostSignup関数は空の状態のPromiseを返します。これはコンポーネントがactionsプロパティを提供されない場合、デフォルトで使われます。



3.8 スタイリング

Bootstrapをpublic/index.htmlにCDNで追加します。

/public/index.html

    <link
      rel="stylesheet"
      href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
      integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
      crossorigin="anonymous"
    />


/pages/UserSignupPage.js

render() {
        return (
            <div className="container">
            <h1 className="text-center">Sign Up</h1>
            <div className="col-12 mb-3">
              <label>Display Name</label>
                    <input
                        className="form-control"
                        placeholder="Your display name"
                        value={this.state.displayName}
                        onChange={this.onChangeDisplayName}
                    />
                </div>
<div className="text-center">
                    <button className="btn btn-primary" 
                    onClick={this.onClickSignup}>Sign Up</button>
                </div>

ブートストラップ構文を利用して会員登録フォームを作成します。 container, text-center, col-12, mb-3, btn btn-primaryなどがその要素です。

会員登録フォームがとてもすっきりしました。



3.9 バックエンドへのリクエスト送信

フロントエンドからバックエンドへリクエストを飛ばすライブラリであるaxiosをインストールします。

npm install --save axios
apiフォルダを一つ作って、その中にapiCalls.jsとそのテストファイルを作ります。
ブラウザ-Reactのフロントエンド-スプリングブーツのバックエンドの送受信構造を示すシーケンス図です。 ここでリアクトサーバーのwebpackはproxyの役割をします。proxyはインスタンス間の一種の仲介者だと考えてください。


frontend/package.json

  "jest": {
    "transformIgnorePatterns": [
      "node_modules/(?!(axios))"
    ]

フロントエンドでjestのバージョンによってモジュールをインポートできないエラーが発生することもあります。フロントエンドの固有問題の一つです。 だから依存関係の部分で上のようにjestの部分を追加します。

frontend/src/api/apiCalls.spec.js

import axios from 'axios'
import * as apiCalls from './apiCalls';
describe('apiCalls', () => {
  describe('signup', () => {
    it('calls /api/1.0/users', () => {
      const mockSignup = jest.fn();
      axios.post = mockSignup;
      apiCalls.signup();

      const path = mockSignup.mock.calls[0][0];
      expect(path).toBe('/api/1.0/users');
    });
  });
});

このテストはapiCallsモジュールのsignup関数が正しいパス(/api/1.0/users)にPOSTリクエストを送るか確認します。

jest.fn(): mockSignupという偽(mock)関数を生成します。この関数は実際にサーバーへリクエストを送らず、代わりにリクエストが実行されたかどうかを追跡します。

axios.post = mockSignup;: axiosのpostメソッドをmockSignup関数に置き換えます。これにより、axiosのpostメソッドが呼び出された時、mockSignup関数が呼び出されます。

apiCalls.signup();: signup関数を呼び出します。この関数はaxiosのpostメソッドを使ってサーバーへPOSTリクエストを送ります。

mockSignup.mock.calls[0][0]: mockSignup関数が呼び出された回数と各呼び出しの最初の引数を取得します。 この場合、最初の呼び出しの最初の引数はリクエストパスを表します。

expect(path).toBe('/api/1.0/users');: pathが /api/1.0/users と一致するか確認します。これにより、signup関数が正しいパスでPOSTリクエストを送るかどうかを確認します。

frontend/src/api/apiCalls.js

import axios from 'axios';

export const signup = (user) => {
    return axios.post('/api/1.0/users', user);
}

この関数はユーザーが会員登録をしようとする時signup関数を呼び出してユーザー情報をサーバーへ送って、サーバーの応答を受け取る役割をします。

import axios from 'axios';: Axiosライブラリを取得します。AxiosはHTTPクライアントライブラリで、サーバーとのHTTP通信を簡素化し、便利に行うことができます。

signup関数: signup関数はユーザー情報(userオブジェクト)を引数で受け取ります。 このユーザー情報は新しいユーザーを登録するためサーバーへ送られます。

return axios.post('/api/1.0/users', user);: axios.post メソッドを使ってPOSTリクエストを送ります。このリクエストは '/api/1.0/users' パスに行き、ユーザー情報(userオブジェクト)がリクエストの本文(body)に含まれます。 axios.postメソッドはPromiseを返し、このPromiseはリクエストが成功したら完了した状態の応答を持ってきたり、失敗したらエラーを返します。

frontend/src/index.js

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { UserSignupPage } from './pages/UserSignupPage';
import * as apiCalls from './api/apiCalls';

const actions = {
    postSignup: apiCalls.signup
};

const root = ReactDOM.createRoot(
    document.getElementById('root')
);

root.render(
    <UserSignupPage actions={actions} />,
    document.getElementById('root')

);

const actions = { postSignup: apiCalls.signup };はコンポーネントにAPI呼び出しを行う関数を提供することです。

クロム開発者モードのネットワーク部分を確認するとsignupリクエストが確認出来ます。


3.10 プログレスインジケータ

/pages/UserSignupPage.spec.js

import {
  render,
  cleanup,
  fireEvent,
  waitForElementToBeRemoved
} from '@testing-library/react';
...
  const mockAsyncDelayed = () => {
      return jest.fn().mockImplementation(() => {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            resolve({});
          }, 300);
        });
      });
    };

...
    it('does not allow user to click the Sign Up button when there is an ongoing api call', () => {
      const actions = {
        postSignup: mockAsyncDelayed()
      };
      setupForSubmit({ actions });
      fireEvent.click(button);

      fireEvent.click(button);
      expect(actions.postSignup).toHaveBeenCalledTimes(1);
    });

    it('displays spinner when there is an ongoing api call', () => {
      const actions = {
        postSignup: mockAsyncDelayed()
      };
      const { queryByText } = setupForSubmit({ actions });
      fireEvent.click(button);

      const spinner = queryByText('Loading...');
      expect(spinner).toBeInTheDocument();
    });

    it('hides spinner after api call finishes successfully', async () => {
      const actions = {
        postSignup: mockAsyncDelayed()
      };
      const { queryByText } = setupForSubmit({ actions });
      fireEvent.click(button);

      const spinner = queryByText('Loading...');
      await waitForElementToBeRemoved(spinner);

      expect(spinner).not.toBeInTheDocument();
    });

    it('hides spinner after api call finishes with error', async () => {
      const actions = {
        postSignup: jest.fn().mockImplementation(() => {
          return new Promise((resolve, reject) => {
            setTimeout(() => {
              reject({
                response: { data: {} }
              });
            }, 300);
          });
        })
      };
      const { queryByText } = setupForSubmit({ actions });
      fireEvent.click(button);

      const spinner = queryByText('Loading...');
      await waitForElementToBeRemoved(spinner);

      expect(spinner).not.toBeInTheDocument();
    });
});

console.error = () => {};

does not allow user to click the Sign Up button when there is an ongoing api call:

このテストはAPI呼び出しが進行中の時、"Sign Up"ボタンをクリックしても何も動作しないことを確認します。
actionsオブジェクトはmockAsyncDelayed関数を持ち、この関数は300ms後に空のPromiseを返します。
setupForSubmit関数を使ってコンポーネントをレンダリングして「Sign Up」ボタンをクリックします。
ボタンをダブルクリックしてactions.postSignup関数が1回だけ呼び出されたことを確認します。

displays spinner when there is an ongoing api call:

このテストはAPI呼び出しが進行中である時、「Loading...」というテキストが画面に表示されることを確認します。
actionsオブジェクトはmockAsyncDelayed関数を持ち、この関数は300ms後に空のPromiseを返します。
setupForSubmit関数を使ってコンポーネントをレンダリングして「Sign Up」ボタンをクリックします。
"Loading..." テキストが画面に表示されたことを確認します。

hides spinner after api call finishes successfully:

このテストはAPI呼び出しが正常に完了した後、"Loading..."テキストが画面から消えることを確認します。
actionsオブジェクトはmockAsyncDelayed関数を持ち、この関数は300ms後に空のPromiseを返します。
setupForSubmit関数を使ってコンポーネントをレンダリングして「Sign Up」ボタンをクリックします。
"Loading..." テキストが消えるまで待ってから確認します。

hides spinner after api call finishes with error:

このテストは、API呼び出しがエラーで終了した後、"Loading..."テキストが画面から消えることを確認します。
actionsオブジェクトはエラーを返す非同期タスクを実行するように設定されています。
setupForSubmit関数を使用してコンポーネントをレンダリングし、「Sign Up」ボタンをクリックします。
「Loading..." テキストが消えるまで待ってから確認します。

console.error = () => {};:

テスト実行中にコンソールエラーメッセージを出力しないように設定します。これはテスト中に不要なコンソール出力を防止するために使います。


hoaxify-frontend/src/pages/UserSignupPage.js

export class UserSignupPage extends React.Component {
    state = {
        displayName: '',
        username: '',
        password: '',
        passwordRepeat: ''
        passwordRepeat: '',
        pendingApiCall: false
    };
...
        this.setState({ pendingApiCall: true });
        this.props.actions
            .postSignup(user)
            .then((response) => {
                this.setState({ pendingApiCall: false });
            })
            .catch((error) => {
                this.setState({ pendingApiCall: false });
            });
    };
...
                    <button
                        className="btn btn-primary"
                        onClick={this.onClickSignup}
                        disabled={this.state.pendingApiCall}
                    >
                        {this.state.pendingApiCall && (
                            <div className="spinner-border text-light spinner-border-sm mr-1">
                                <span className="sr-only">Loading...</span>
                            </div>
                        )}
                        Sign Up
                    </button>

コードのコアアイデアは、ユーザーが「Sign Up」ボタンをクリックすると、ローディング状態(pendingApiCall)が有効になり、API呼び出し中であることを示し、API呼び出しが完了するとローディング状態が無効になります。

ローディング状態を視覚的に示すために、ローディングスピナーが表示されます。このような実装により、ユーザーエクスペリエンスを向上させ、ユーザーが会員登録ボタンを何度も押すなどのエラーを防ぐことができます。

stateオブジェクト
UserSignupPageコンポーネントの状態を表します。この状態にはユーザーが入力した情報とローディング状態を表すpendingApiCall値が含まれています。

onClickSignupメソッド
「Sign Up」ボタンがクリックされると呼び出されるメソッドで、ユーザー情報をサーバーに送る役割をします。 また、API呼び出しが進行中であることを示すpendingApiCallの値を変更します。

"Sign Up"ボタン
ユーザーがクリックできるボタンで、クリックするとonClickSignupメソッドが呼び出されます。ボタンはAPI呼び出しが進行中である場合、無効化されます(disabled={this.state.pendingApiCall})。

ローディングスピナー
this.state.pendingApiCallがtrueの場合、「Sign Up」ボタンの横にローディングスピナーが表示されます。これにより、ユーザーはAPI呼び出しが進行中であることを視覚的に認識することができます。




4. 最後に


今までテスト駆動開発で会員登録ページを作成しました。 確かにウォーターフォール方式と大きく違って、まだなかなか慣れませんね。 テストコードを作成しながら間違ったことを修正するため、実際のロジックを実装する必要があるなんて。 でも、この方式に慣れれば、確実に早くプロトタイプをリリースすることができそうです。 もちろん、慣れればの話ですが。

最後にはJestにもっと早く慣れるためよく使う5つのメソッドだけまとめてお開きしたいです。

describe(name, fn)
テストスイートを定義する関数で、特定のテストグループを作る時使います。 nameはグループの名前で、fnはそのグループのテストケースを定義する関数を受け取ります。

it(name, fn)
個別テストケースを定義する関数で、nameはテストケースの説明で、fnは実際のテストロジックを入れた関数です。

expect(value)
テストでどんな値を期待するかを定義する関数で、Jestのマッチャー(matcher)関数と一緒に使います。例えば、expect(someValue).toBe(expectedValue)はsomeValueがexpectedValueと一致するかテストします。

beforeEach(fn)
各テストケースを実行する前に実行する関数を定義するメソッドです。主にテストを実行する前に初期化や設定作業をする時に使います。

afterEach(fn)
各テストケースの実行後に実行する関数を定義するメソッドです。主にテスト実行後、クリーンアップ(clean-up)作業をする時使います。



エンジニアファーストの会社 株式会社CRE-CO
ソンさん


【参考】


  • [Udemy] Spring Boot and React with Test Driven Development


この記事が気に入ったらサポートをしてみませんか?