見出し画像

【テスト駆動開発】Springboot & React - 第4回 : Reactのルーティング


1. はじめに


こんにちは、今日はReactのRoutingを通じて、ユーザー加入とログインを分岐してみます!Reactのルーティングは、アプリケーション内で異なるページやビューを表示するための重要な機能です。完成された画面は以下の画像です。

三つのペ―ジでナビゲーションバーが共通コンポーネントになります。コンポーネントをすべてのページで使用することで、一貫性を維持し、コードの再利用を通じて効率的なUI開発を実現し、ユーザーエクスペリエンスを向上させることができます。

メイン画面
ユーザー加入
ログイン


2.バックエンド実装過程


2.1. 新しいページ

このようにテストを通過させて、機能をひとつづつ実装します。
ここには、UserPage,HomePageを作成します。

frontend/src/pages/UserPage.spec.js

import React from 'react';
import {render} from '@testing-library/react';
import UserPage from './UserPage';

describe('UserPage',  () => {
    
    describe('Layout', () => {
     it('has root page div', () => {
        const { queryByTestId } = render(<UserPage />);
        const userPageDiv = queryByTestId('userpage');
        expect(userPageDiv).toBeInTheDocument();
     })   
    })
})

ユーザーページのレイアウトをテストします。ユーザーページコンポーネントをrender関数を使ってレンダリングしてページのルートdivを検索してるか確認します。

frontend/src/pages/UserPage.js

import React from 'react';

class UserPage extends React.Component{
    render() {
        return (
            <div data-testid="userpage" />
        )
    }
}

export default UserPage;


frontend/src/pages/HomePage.spec.js

import React from 'react';
import {render} from '@testing-library/react';
import HomePage from './HomePage';

describe('HomePage',  () => {
    
    describe('Layout', () => {
     it('has root page div', () => {
        const { queryByTestId } = render(<HomePage />);
        const homePageDiv = queryByTestId('homepage');
        expect(homePageDiv).toBeInTheDocument();
     })   
    })
})

ホームページコンポーネントをrender関数を使ってレンダリングしてページのルートdivを検索してるか確認します。

frontend/src/pages/HomePage.js

import React from 'react';

class HomePage extends React.Component{
    render() {
        return (
            <div data-testid="homepage" />
        )
    }
}

export default HomePage;


2.2. ルーター(Router)

frontend/src/containers/App.spec.js

import React from 'react';
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import App from './App';

const setup = (path) => {
  return render(
    <MemoryRouter initialEntries={[path]}>
      <App />
    </MemoryRouter>
  );
};

describe('App', () => {
  it('displays homepage when url is /', () => {
    const { queryByTestId } = setup('/');
    expect(queryByTestId('homepage')).toBeInTheDocument();
  });

  it('displays LoginPage when url is /login', () => {
    const { container } = setup('/login');
    const header = container.querySelector('h1');
    expect(header).toHaveTextContent('Login');
  });

  it('displays only LoginPage when url is /login', () => {
    const { queryByTestId } = setup('/login');
    expect(queryByTestId('homepage')).not.toBeInTheDocument();
  });
  it('displays UserSignupPage when url is /signup', () => {
    const { container } = setup('/signup');
    const header = container.querySelector('h1');
    expect(header).toHaveTextContent('Sign Up');
  });
  it('displays userpage when url is other than /, /login or /signup', () => {
    const { queryByTestId } = setup('/user1');
    expect(queryByTestId('userpage')).toBeInTheDocument();
  });
});

MemoryRouterを使って仮想パスでアプリをレンダリングします。様々なURLに対するテストを実行します。MemoryRouterはURLルーティングをテストしたり、仮想パスでアプリをレンダリングするために使われるコンポーネントです。 このコンポーネントは実際のブラウザのURLを使わず、メモリ内でURLパスを管理します。これは主にテスト目的で使われます。

MemoryRouterを使うと、テスト中に実際のブラウザのURLを変更することなく、Reactアプリのルーティング動作をシミュレーションすることができます。


1.URL が '/' の場合、ホームページが表示されます。
2.URL が '/login' の場合、ログインページが表示され、ページのタイトルが 'Login' と表示されます。
3.URL が '/login' の場合、ホームページは表示されません。
4.URL が '/signup' の場合、会員登録ページが表示され、ページのタイトルが 'Sign Up' と表示されます。
5.URL が '/login' または '/signup' 以外の URL の場合、ユーザーページが表示されます。

frontend/src/containers/App.js

import React from 'react';
import { Route, Switch } from 'react-router-dom';
import HomePage from '../pages/HomePage';
import LoginPage from '../pages/LoginPage';
import UserSignupPage from '../pages/UserSignupPage';
import UserPage from '../pages/UserPage';

function App() {
  return (
    <div>
      <div className="container">
        <Switch>
          <Route exact path="/" component={HomePage} />
          <Route path="/login" component={LoginPage} />
          <Route path="/signup" component={UserSignupPage} />
          <Route path="/:username" component={UserPage} />
        </Switch>
      </div>
    </div>
  );
}

export default App;

RouteとSwitchコンポーネントを使用して、URLパスとコンポーネント間の対応関係を設定します。

/path へのリクエストは HomePage コンポーネントにルーティングされます。
/login 経路へのリクエストは LoginPage コンポーネントにルーティングされます。
/signup パスへのリクエストは UserSignupPage コンポーネントにルーティングされます。
/:username パスへのリクエストは UserPage コンポーネントにルーティングされ、:username の部分は動的にユーザー名に置き換えられます。

frontend/src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import { HashRouter } from 'react-router-dom';
import * as serviceWorker from './serviceWorker';
import App from './containers/App';
import * as apiCalls from './api/apiCalls';

const actions = {
  postLogin: apiCalls.login
};

ReactDOM.render(
  <HashRouter>
    <App />
  </HashRouter>,
  document.getElementById('root')
);

HashRouterを使ってReact Routerのルーティングを設定します。ブラウザのURLパスの代わりにハッシュ(#)を使ってルーティングを処理します。

HashRouterは、URLのハッシュ(またはフラグメント識別子)を使用してルーティングを処理する方法を意味します。例えば、URLが http://example.com/#/productsのようにハッシュ記号(#)の後にパスを含む場合、これはHashRouterを使用してルーティングされたものです。

HashRouterを使うメリットは?

サーバー側の設定は必要ありません。 ブラウザのURLにハッシュを使用するため、サーバー側でルーティング設定を行う必要はありません。

静的ホスティングに最適です。 静的ファイルホスティングサービス(例えば、GitHub Pages)でReactアプリをホスティングするときに便利です。

ブラウザの互換性がいいです。 古いブラウザでも動作するので、互換性の問題を減らすことができます。



2.3. ルーティング:ログインの成功

frontend/src/containers/App.js

import LoginPage from '../pages/LoginPage';
import UserSignupPage from '../pages/UserSignupPage';
import UserPage from '../pages/UserPage';
import * as apiCalls from '../api/apiCalls';

const actions = {
  postLogin: apiCalls.login
};

function App() {
  return (
    <div>
      <div className="container">
        <Switch>
          <Route exact path="/" component={HomePage} />
          <Route
            path="/login"
            component={(props) => <LoginPage {...props} actions={actions} />}
          />
          <Route path="/signup" component={UserSignupPage} />
          <Route path="/:username" component={UserPage} />
        </Switch>

ログインページ(/login)に対するRouteはactionsプロパティと一緒にLoginPageコンポーネントをレンダリングします。これによってLoginPageコンポーネントはactionsプロパティを使ってログインアクションを実行することができます。

矢印関数のパラメータとしてpropsが渡されます。これはRouteコンポーネントによって自動的に提供され、その経路に関する情報を含みます。

<LoginPage {...props} actions={actions}>
この部分はLoginPageコンポーネントをレンダリングします。props={...props} は props オブジェクトの内容を全て渡します。 actions={actions} は actions オブジェクトを LoginPage コンポーネントに actions プロパティとして渡します。

結果的に、LoginPageコンポーネントはpropsとactionsを受け取り、ログインアクションを実行したり、必要な情報を表示することができます。


frontend/src/pages/LoginPage.spec.js

    it('redirects to homePage after successful login', async () => {
      const actions = {
        postLogin: jest.fn().mockResolvedValue({})
      };
      const history = {
        push: jest.fn()
      };
      setupForSubmit({ actions, history });
      fireEvent.click(button);

      await waitForDomChange();

      expect(history.push).toHaveBeenCalledWith('/');
    });

ログイン後にホームページにリダイレクトされるかをテストするコードです。

actions: テストで使うpostLoginアクションを持つフェイクオブジェクトです。jest.fn().mockResolvedValue({})を使ってpostLoginアクションを模擬(mocks)して、空のオブジェクト({})を返すように設定します。

history: テストで使うhistoryオブジェクトを持つ偽のオブジェクトです。 push関数を持ちます。

setupForSubmit({ actions, history }): ログインフォームを設定し、この関数にactionsとhistoryを渡します。この関数はログインフォームをレンダリングしてユーザーが入力した情報を渡してログインの試みをシミュレーションします。

await waitForDomChange(): DOMが変更されるまで待ちます。ログインが完了してページリダイレクトが行われるまで待ちます。

expect(history.push).toHaveBeenCalledWith('/'): リダイレクトされたURLが/(ホームページ)に変更されたか確認します。


frontend/src/pages/LoginPage.js

onClickLogin = () => {
    const body = {
      username: this.state.username,
      password: this.state.password
    };
    this.setState({ pendingApiCall: true });
    this.props.actions
      .postLogin(body)
      .then((response) => {
        this.setState({ pendingApiCall: false }, () => {
          this.props.history.push('/');
        });
      })

ログインボタンをクリックすると、ユーザーが入力した情報をサーバーに送信し、ログイン成功時にホームページにリダイレクトします。

pendingApiCallステータスの更新:pendingApiCallステータスをtrueに設定し、API呼び出しが進行中であることを示します。これにより、ローディングスピナーや他のローディング表示要素を画面に表示することができます。

postLoginアクションの呼び出し:this.props.actions.postLogin(body)を呼び出してログインAPIを実行します。このアクションはユーザーが提出した情報でサーバーにログインリクエストを送信し、ログインが成功すると約束(Promise)を返します。


2.4. ルーティング:ユーザー加入の成功

frontend/src/pages/UserSignupPage.spec.js

    it('redirects to homePage after successful signup', async () => {
      const actions = {
        postSignup: jest.fn().mockResolvedValue({})
      };
      const history = {
        push: jest.fn()
      };
      setupForSubmit({ actions, history });
      fireEvent.click(button);

      await waitForDomChange();

      expect(history.push).toHaveBeenCalledWith('/');
    });

会員登録が成功したときにホームページにリダイレクトするかを確認するテストです。

actionsとhistoryオブジェクトの設定:postSignupアクションをモック(mock)に設定し、historyオブジェクトをモックに設定します。これによってアクションの戻り値とhistory.pushの呼び出しを追跡することができます。

setupForSubmitメソッド呼び出し:setupForSubmitメソッドを呼び出して会員登録フォームをレンダリングし、イベントハンドラを設定します。

ボタンクリック及び待機:会員登録ボタン(button)をクリックして、waitForDomChange関数を使用してDOMの変更を待機します。これによって非同期動作の完了を待ちます。

history.pushの呼び出し確認: waitForDomChangeの後、expect(history.push).toHaveBeenCalledWith('/')を使ってhistory.pushメソッドが'/'(ホームページ)で正しく呼び出されたか確認します。


frontend/src/pages/UserSignupPage.js

   onClickSignup = () => {
    const user = {
      username: this.state.username,
      displayName: this.state.displayName,
      password: this.state.password
    };
    this.setState({ pendingApiCall: true });
    this.props.actions
      .postSignup(user)
      .then((response) => {
        this.setState({ pendingApiCall: false }, () =>
          this.props.history.push('/')
        );
      })
...

UserSignupPage.defaultProps = {
  actions: {
    postSignup: () =>
      new Promise((resolve, reject) => {
        resolve({});
      })
  },
  history: {
    push: () => {}
  }

actionsプロパティのデフォルト値:actionsプロパティは会員登録に関連するアクションを定義します。 postSignupアクションは会員登録APIを呼び出し、デフォルトでは空のPromiseオブジェクトを返します。これにより、postSignupアクションが正しく実装されていないか、使用しない場合、空のアクションに置き換えられます。

historyプロパティのデフォルト値:historyプロパティはページのルーティングを管理するためのオブジェクトです。 pushメソッドは指定された経路でページを移動させる役割をします。 この部分では、pushメソッドを空の関数に設定してルーティングアクションを実行せずに進む場合、デフォルト値として使用します。

このようなdefaultPropsを設定することで、UserSignupPageコンポーネントが必要なプロパティが正しく提供されなかった場合にもエラーを防止してアプリケーションの安定性を維持することができます。同時に、テスト作成や開発初期段階で特定の動作を定義していない場合にも基本動作を設定することができ、開発過程をより便利にすることができます。

frontend/src/containers/App.js

function App() {
  return (
    <div>
      <div className="container">
        <Switch>
          <Route exact path="/" component={HomePage} />
          <Route
            path="/login"
            component={(props) => <LoginPage {...props} actions={actions} />}
          />
          <Route
            path="/signup"
            component={(props) => (
              <UserSignupPage {...props} actions={actions} />
            )}
          />
          <Route path="/:username" component={UserPage} />
        </Switch>
      </div>


2.5. ナビゲーションバー

frontend/src/components/TopBar.spec.js

import React from 'react';
import { render } from '@testing-library/react';
import TopBar from './TopBar';
import { MemoryRouter } from 'react-router-dom';

const setup = () => {
  return render(
    <MemoryRouter>
      <TopBar />
    </MemoryRouter>
  );
};

describe('TopBar', () => {
  describe('Layout', () => {
    it('has application logo', () => {
      const { container } = setup();
      const image = container.querySelector('img');
      expect(image.src).toContain('hoaxify-logo.png');
    });

    it('has link to home from logo', () => {
      const { container } = setup();
      const image = container.querySelector('img');
      expect(image.parentElement.getAttribute('href')).toBe('/');
    });

    it('has link to signup', () => {
      const { queryByText } = setup();
      const signupLink = queryByText('Sign Up');
      expect(signupLink.getAttribute('href')).toBe('/signup');
    });
    it('has link to login', () => {
      const { queryByText } = setup();
      const loginLink = queryByText('Login');
      expect(loginLink.getAttribute('href')).toBe('/login');
    });
  });
});

TopBarコンポーネントのレイアウトとUI要素をテストします。
has application logoテスト: TopBarコンポーネントにロゴイメージが存在するか確認します。
has link to home from logoテスト: ロゴ画像にホームページへ移動できるリンク(href)があるか確認します。
has link to signupテスト: 会員登録ページに移動できるリンク(Sign Up)があるか確認します。
has link to loginテスト: ログインページに移動できるリンク(Login)があるか確認します。


frontend/src/components/TopBar.js

import React from 'react';
import logo from '../assets/hoaxify-logo.png';
import { Link } from 'react-router-dom';

class TopBar extends React.Component {
  render() {
    return (
      <div className="bg-white shadow-sm mb-2">
        <div className="container">
          <nav className="navbar navbar-light navbar-expand">
            <Link to="/" className="navbar-brand">
              <img src={logo} width="60" alt="Hoaxify" /> Hoaxify
            </Link>
            <ul className="nav navbar-nav ml-auto">
              <li className="nav-item">
                <Link to="/signup" className="nav-link">
                  Sign Up
                </Link>
              </li>
              <li className="nav-item">
                <Link to="/login" className="nav-link">
                  Login
                </Link>
              </li>
            </ul>
          </nav>
        </div>
      </div>
    );
  }
}

export default TopBar;

TopBarコンポーネントのレイアウトとUI要素をテストします。
has application logoテスト: TopBarコンポーネントにロゴイメージが存在するか確認します。
has link to home from logoテスト: ロゴ画像にホームページへ移動できるリンク(href)があるか確認します。
has link to signupテスト: 会員登録ページに移動できるリンク(Sign Up)があるか確認します。
has link to loginテスト: ログインページに移動できるリンク(Login)があるか確認します。

frontend/src/containers/App.spec.js

 it('displays topBar when url is /', () => {
    const { container } = setup('/');
    const navigation = container.querySelector('nav');
    expect(navigation).toBeInTheDocument();
  });
  it('displays topBar when url is /login', () => {
    const { container } = setup('/login');
    const navigation = container.querySelector('nav');
    expect(navigation).toBeInTheDocument();
  });
  it('displays topBar when url is /signup', () => {
    const { container } = setup('/signup');
    const navigation = container.querySelector('nav');
    expect(navigation).toBeInTheDocument();
  });
  it('displays topBar when url is /user1', () => {
    const { container } = setup('/user1');
    const navigation = container.querySelector('nav');
    expect(navigation).toBeInTheDocument();
  });

  it('shows the UserSignupPage when clicking signup', () => {
    const { queryByText, container } = setup('/');
    const signupLink = queryByText('Sign Up');
    fireEvent.click(signupLink);
    const header = container.querySelector('h1');
    expect(header).toHaveTextContent('Sign Up');
  });
  it('shows the LoginPage when clicking login', () => {
    const { queryByText, container } = setup('/');
    const loginLink = queryByText('Login');
    fireEvent.click(loginLink);
    const header = container.querySelector('h1');
    expect(header).toHaveTextContent('Login');
  });

  it('shows the HomePage when clicking the logo', () => {
    const { queryByTestId, container } = setup('/login');
    const logo = container.querySelector('img');
    fireEvent.click(logo);
    expect(queryByTestId('homepage')).toBeInTheDocument();
  });

ルートによるナビゲーションバー(TopBar)が画面に表示されるかテストします。

URLが/, 「/login」, 「/signup」, 「/user1」 の時、ナビゲーションバーが表示されるか確認します。

「Sign Up」と「Login」リンクをクリックすると、それぞれ「Sign Up」と「Login」ページが表示されるか確認します。

ロゴ画像をクリックするとホームページに移動されることを確認します。


frontend/src/containers/App.js

import TopBar from '../components/TopBar';

const actions = {
...
function App() {
  return (
    <div>
      <TopBar />
      <div className="container">
        <Switch>
          <Route exact path="/" component={HomePage} />
	

AppコンポーネントがTopBarコンポーネントをレンダリングして、続いてSwitchコンポーネントを使ってURLのパスによって他のコンポーネントをレンダリングします。Routeコンポーネントを使ってパスとコンポーネントを連結して、/パスにはHomePageコンポーネントをレンダリングします。



3. 最後に


今までReactのルーター(Router)について勉強しました!URLによって違うページを表示し、TopBarコンポーネントを全てのページに共通で表示するように設計しました。 これにより、ナビゲーションバーを共通要素として全てのページで簡単に使うことができます!



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


【参考】


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

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