見出し画像

【テスト駆動開発】Springboot & React - 第11回 : 記事登録


1. はじめに


こんにちは、前回はメニューバーのユーザー情報表示する機能を実装しました。

今回はログインしたユーザーが記事を登録し、データベースに格納する機能を実装してみます。今日の実装完成の画面です。

メイン画面にテキスト入力欄が表示されます。
カーソルを合わせてテキスト入力欄をクリックすると、「Submit」登録とキャンセルボタンが表示されます。
テキストを入力し、「Submit」ボタンを押すと
テキスト・作成時間・ユーザーの識別子がデータベースに格納されます。


2. 実装過程


2.1 記事登録API(バックエンド)

/Hoax.java

@Data
public class Hoax {

	private String content;
}

エンティティとして、記事の役割をする「Hoax」クラスを生成します。

/TestUtil.java

public static Hoax createValidHoax() {
		Hoax hoax = new Hoax();
		hoax.setContent("test content for the test hoax");
		return hoax;
	}

記事登録テストのための「TestUtil」を生成します。

/HoaxControllerTest.java

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public class HoaxControllerTest {

	private static final String API_1_0_HOAXES = "/api/1.0/hoaxes";

	@Autowired
	TestRestTemplate testRestTemplate;

	@Autowired
	UserService userService;

	@Autowired
	UserRepository userRepository;

	@Before
	public void cleanup() {
		userRepository.deleteAll();
		testRestTemplate.getRestTemplate().getInterceptors().clear();
	}

	@Test
	public void postHoax_whenHoaxIsValidAndUserIsAuthorized_receiveOk() {
		userService.save(TestUtil.createValidUser("user1"));
		authenticate("user1");
		Hoax hoax = TestUtil.createValidHoax();
		ResponseEntity<Object> response = postHoax(hoax, Object.class);
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
	}


	@Test
	public void postHoax_whenHoaxIsValidAndUserIsUnauthorized_receiveUnauthorized() {
		Hoax hoax = TestUtil.createValidHoax();
		ResponseEntity<Object> response = postHoax(hoax, Object.class);
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
	}

	@Test
	public void postHoax_whenHoaxIsValidAndUserIsUnauthorized_receiveApiError() {
		Hoax hoax = TestUtil.createValidHoax();
		ResponseEntity<ApiError> response = postHoax(hoax, ApiError.class);
		assertThat(response.getBody().getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value());
	}

	private <T> ResponseEntity<T> postHoax(Hoax hoax, Class<T> responseType) {
		return testRestTemplate.postForEntity(API_1_0_HOAXES, hoax, responseType);
	}


	private void authenticate(String username) {
		testRestTemplate.getRestTemplate()
			.getInterceptors().add(new BasicAuthenticationInterceptor(username, "P4ssword"));
	}
}

postHoax_whenHoaxIsValidAndUserIsAuthorized_receiveOk: ユーザーが有効な権限を持ち、有効なホークスを投稿した場合、HTTPステータスコードが200(OK)であることを検証しています。

postHoax_whenHoaxIsValidAndUserIsUnauthorized_receiveUnauthorized: ユーザーが認証されていない(権限がない)状態で有効なホークスを投稿した場合、HTTPステータスコードが401(Unauthorized)であることを検証しています。

postHoax_whenHoaxIsValidAndUserIsUnauthorized_receiveApiError: ユーザーが認証されていない状態で有効なホークスを投稿した場合、HTTPステータスコード401(Unauthorized)とともに、APIエラーが返されることを検証しています。APIエラーのステータスコードが正しいことを確認しています。


/HoaxController.java

@RestController
@RequestMapping("/api/1.0")
public class HoaxController {

	@PostMapping("/hoaxes")
	void createHoax() {

	}

}


これからは、データベースに格納しましょう。

	@Autowired
	HoaxRepository hoaxRepository;

	@Before
	public void cleanup() {
		hoaxRepository.deleteAll();
		userRepository.deleteAll();
		testRestTemplate.getRestTemplate().getInterceptors().clear();
	}

	@Test
	public void postHoax_whenHoaxIsValidAndUserIsAuthorized_hoaxSavedToDatabase() {
		userService.save(TestUtil.createValidUser("user1"));
		authenticate("user1");
		Hoax hoax = TestUtil.createValidHoax();
		postHoax(hoax, Object.class);

		assertThat(hoaxRepository.count()).isEqualTo(1);
	}

	@Test
	public void postHoax_whenHoaxIsValidAndUserIsAuthorized_hoaxSavedToDatabaseWithTimestamp() {
		userService.save(TestUtil.createValidUser("user1"));
		authenticate("user1");
		Hoax hoax = TestUtil.createValidHoax();
		postHoax(hoax, Object.class);

		Hoax inDB = hoaxRepository.findAll().get(0);

		assertThat(inDB.getTimestamp()).isNotNull();
	}

postHoax_whenHoaxIsValidAndUserIsAuthorized_hoaxSavedToDatabase: ユーザーが有効な権限を持ち、有効なホークスを投稿した場合、データベースにホークスが保存されることを検証しています。テストの最後で、hoaxRepository.count() を使用してデータベース内のホークスの数が1であることを確認しています。

postHoax_whenHoaxIsValidAndUserIsAuthorized_hoaxSavedToDatabaseWithTimestamp: ユーザーが有効な権限を持ち、有効なホークスを投稿した場合、データベースにホークスがタイムスタンプ付きで保存されることを検証しています。


Hoax.java

@Data
@Entity
public class Hoax {

	@Id
	@GeneratedValue
	private long id;

	private String content;

	@Temporal(TemporalType.TIMESTAMP)
	private Date timestamp;
}

Temporal(TemporalType.TIMESTAMP)のアノテーションは、日付や時間の情報を持つデータベースのカラムにマッピングされたJavaのフィールドやプロパティを指定します。TemporalType.TIMESTAMPは、日付と時刻の両方を含むタイムスタンプ型のデータを表します。

さあ、ControllerからRepositoryまで作成します!

HoaxController.java

@RestController
@RequestMapping("/api/1.0")
public class HoaxController {

	@Autowired
	HoaxService hoaxService;

	@PostMapping("/hoaxes")
	void createHoax(@RequestBody Hoax hoax) {
		hoaxService.save(hoax);
	}

}


HoaxService.java

@Service
public class HoaxService {

	HoaxRepository hoaxRepository;

	public HoaxService(HoaxRepository hoaxRepository) {
		super();
		this.hoaxRepository = hoaxRepository;
	}

	public void save(Hoax hoax) {
		hoax.setTimestamp(new Date());
		hoaxRepository.save(hoax);
	}

}

HoaxServiceクラスはHoaxRepositoryを依存関係として注入しています。コンストラクタを介して HoaxRepository を受け取り、その後、そのリポジトリを利用してデータベースにおけるHoax(偽情報)エンティティの保存を処理します。


HoaxRepository.java

public interface HoaxRepository extends JpaRepository<Hoax, Long>{

}



2.2 記事とユーザーの連結(バックエンド)

	//データベースに保存される記事のユーザー情報が、認証されたユーザー情報と一致することを検証
  @Test
	public void postHoax_whenHoaxIsValidAndUserIsAuthorized_hoaxSavedWithAuthenticatedUserInfo() {
		userService.save(TestUtil.createValidUser("user1"));
		authenticate("user1");
		Hoax hoax = TestUtil.createValidHoax();
		postHoax(hoax, Object.class);

		Hoax inDB = hoaxRepository.findAll().get(0);

		assertThat(inDB.getUser().getUsername()).isEqualTo("user1");
	}

  //ユーザーエンティティからそのユーザーが投稿したホークスにアクセスできることを検証
	@Test
	public void postHoax_whenHoaxIsValidAndUserIsAuthorized_hoaxCanBeAccessedFromUserEntity() {
		userService.save(TestUtil.createValidUser("user1"));
		authenticate("user1");
		Hoax hoax = TestUtil.createValidHoax();
		postHoax(hoax, Object.class);

		User inDBUser = userRepository.findByUsername("user1");
		assertThat(inDBUser.getHoaxes().size()).isEqualTo(1);

	}
…
	@After
	public void cleanupAfter() {
		hoaxRepository.deleteAll();
	}
}

@After アノテーションを使用したメソッド cleanupAfter は、各テストが実行された後にデータベース内のすべてのホークスを削除するために使用されます。これにより、各テストの影響を受けずに次のテストを実行できます。


/Hoax.java

	@ManyToOne
	private User user;

ユーザー(User)と記事(Hoax)は一対多の関係ですので、「@ManyToOne」アノテーションを付きます。

/HoaxController.java

@PostMapping("/hoaxes")
	void createHoax(@Valid @RequestBody Hoax hoax, @CurrentUser User user) {
		hoaxService.save(user, hoax);
	}


/HoaxService.java

public void save(User user, Hoax hoax) {
		hoax.setTimestamp(new Date());
		hoax.setUser(user);
		hoaxRepository.save(hoax);
	}


/User.java

@OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
	private List<Hoax> hoaxes;

Userエンティティ内のhoaxesフィールド(記事)が、1つのユーザーに関連付けられた複数の記事を表すことを示しています。そして、FetchType.EAGERによって、ユーザーエンティティを取得する際に、それに紐づくすべての記事も取得されるように指定されています。

EAGERフェッチ戦略は避けられるべきであり、代わりにLAZYフェッチ戦略が推奨されることが一般的です。LAZYフェッチ戦略では、関連エンティティは必要に応じて取得されるため、不要なデータの読み込みを避けることができ、パフォーマンスの向上につながります。(テストのため)

/SecurityConfiguration.java

@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.csrf().disable();

		http.headers().disable();


2.3 登録送信APIの作成(フロントエンド)

バックエンドが終わったので、フロントエンドを作成します。

frontend/src/api/apiCalls.spec.js

  describe('postHoax', () => {
    it('calls /api/1.0/hoaxes', () => {
      const mockPostHoax = jest.fn();
      axios.post = mockPostHoax;
      apiCalls.postHoax();
      const path = mockPostHoax.mock.calls[0][0];
      expect(path).toBe('/api/1.0/hoaxes');
    });
  });

const path = mockPostHoax.mock.calls[0][0];は、モックされたaxios.postが呼び出された際の引数を取得しています。具体的には、最初の呼び出しの第1引数(APIパス)を取得しています。

frontend/src/api/apiCalls.js

export const postHoax = (hoax) => {
  return axios.post('/api/1.0/hoaxes', hoax);
};


登録する画面を作成します。

frontend/src/components/HoaxSubmit.spec.js

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

describe('HoaxSubmit', () => {
  // HoaxSubmit コンポーネントのテストを行うためのテストスイート

  describe('Layout', () => {
    // HoaxSubmit コンポーネントのレイアウトに関するテストをグループ化

    it('has textarea', () => {
      // テキストエリアが存在することを確認するテスト
      const { container } = render(<HoaxSubmit />);
      const textArea = container.querySelector('textarea');
      expect(textArea).toBeInTheDocument();
    });

    it('has image', () => {
      // 画像が存在することを確認するテスト
      const { container } = render(<HoaxSubmit />);
      const image = container.querySelector('img');
      expect(image).toBeInTheDocument();
    });

    it('displays textarea 1 line', () => {
      // テキストエリアが1行で表示されることを確認するテスト
      const { container } = render(<HoaxSubmit />);
      const textArea = container.querySelector('textarea');
      expect(textArea.rows).toBe(1);
    });
  });
});


frontend/src/components/HoaxSubmit.js

import React, { Component } from 'react';
import ProfileImageWithDefault from './ProfileImageWithDefault';

class HoaxSubmit extends Component {
  render() {
    // HoaxSubmit コンポーネントは、ホークスを送信するためのフォームを提供します。

    return (
      <div className="card d-flex flex-row p-1">
        {/* プロフィール画像を表示するコンポーネント */}
        <ProfileImageWithDefault
          className="rounded-circle m-1"
          width="32"
          height="32"
        />
        <div className="flex-fill">
          {/* ユーザーが入力するテキストエリア */}
          <textarea className="form-control w-100" rows={1} />
        </div>
      </div>
    );
  }
}

export default HoaxSubmit;


frontend/src/pages/HomePage.js

import React from 'react';
import UserList from '../components/UserList';
import HoaxSubmit from '../components/HoaxSubmit';

class HomePage extends React.Component {
  render() {
    return (
      <div data-testid="homepage">
        <div className="row">
          <div className="col-8">
            <HoaxSubmit />
          </div>
          <div className="col-4">
            <UserList />
          </div>
        </div>
      </div>
    );
  }

<HoaxSubmit />を追加し、HoaxSubmit コンポーネントをメイン画面に表示します。


では、記事をReduxに連動しましょう!

frontend/src/components/HoaxSubmit.js

import React, { Component } from 'react';
import ProfileImageWithDefault from './ProfileImageWithDefault';
import { connect } from 'react-redux';

class HoaxSubmit extends Component {
  render() {
    // HoaxSubmit コンポーネントは、ログインしているユーザーのプロフィール画像とホークスの入力フィールドを提供します。
    
    return (
      <div className="card d-flex flex-row p-1">
        {/* プロフィール画像を表示するコンポーネント */}
        <ProfileImageWithDefault
          className="rounded-circle m-1"
          width="32"
          height="32"
          image={this.props.loggedInUser.image} // ログインしているユーザーの画像を表示
        />
        <div className="flex-fill">
          {/* ユーザーがホークスを入力するテキストエリア */}
          <textarea className="form-control w-100" rows={1} />
        </div>
      </div>
    );
  }
}

const mapStateToProps = (state) => {
  // Redux の state からログインしているユーザーの情報を取得して props にマッピング
  return {
    loggedInUser: state
  };
};

// Redux の store に接続して HoaxSubmit コンポーネントをエクスポート
export default connect(mapStateToProps)(HoaxSubmit);



2.4 登録とキャンセルボタン(フロントエンド)


まず、提出ボタンとキャンセルボタンを作成します。

frontend/src/components/HoaxSubmit.js

class HoaxSubmit extends Component {
  // HoaxSubmit コンポーネントの状態を定義
  state = {
    focused: false // フォーカスされていない状態を初期化
  };

  // テキストエリアがフォーカスされた時のイベントハンドラ
  onFocus = () => {
    this.setState({
      focused: true // フォーカスされた時に状態を更新
    });
  };

  // キャンセルボタンがクリックされた時のイベントハンドラ
  onClickCancel = () => {
    this.setState({
      focused: false // キャンセルされた時にフォーカスを外す
    });
  };

  render() {
    return (
      <div className="card d-flex flex-row p-1">
        {/* プロフィール画像を表示するコンポーネント */}
        <ProfileImageWithDefault
          className="rounded-circle m-1"
          width="32"
          height="32"
          image={this.props.loggedInUser.image}
        />
        <div className="flex-fill">
          {/* フォーカスされているかどうかによって行数を変えるテキストエリア */}
          <textarea
            className="form-control w-100"
            rows={this.state.focused ? 3 : 1}
            onFocus={this.onFocus} // テキストエリアにフォーカスがあたった時の処理
          />
          {/* フォーカスされている場合のみ表示されるボタン群 */}
          {this.state.focused && (
            <div className="text-right mt-1">
              {/* ホークスを送信するボタン */}
              <button className="btn btn-success">Submit</button>
              {/* キャンセルボタン */}
              <button
                className="btn btn-light ml-1"
                onClick={this.onClickCancel} // キャンセルボタンがクリックされた時の処理
              >
                <i className="fas fa-times"></i> Cancel
              </button>
            </div>
          )}
        </div>
      </div>
    );
  }
}

3 : 1 は、条件が真(focused が true)の場合は 3 を、偽(focused が false)の場合は 1 を返します。
このコードは、textarea の rows 属性において、focused の状態に応じて異なる値(3 または 1)を設定しています。focused 状態が true(フォーカスされている状態)の場合、rows 属性には 3 がセットされ、false(フォーカスされていない状態)の場合には 1 がセットされます。つまり、textarea の表示される行数がフォーカス状態によって変化する仕組みを表しています。


では、リクエストを送る「Submit」ボタンを作成します。

frontend/src/components/HoaxSubmit.js

import * as apiCalls from '../api/apiCalls';

class HoaxSubmit extends Component {
  // HoaxSubmit コンポーネントの状態を定義
  state = {
    focused: false, // フォーカスされていない状態を初期化
    content: undefined // 入力されたコンテンツを保持するための変数を初期化
  };

  // テキストエリアの内容が変更された時のイベントハンドラ
  onChangeContent = (event) => {
    const value = event.target.value;
    this.setState({ content: value }); // 入力された内容を状態にセット
  };

  // ボタンがクリックされた時のイベントハンドラ
  onClickHoaxify = () => {
    const body = {
      content: this.state.content // ホークスの内容を取得
    };
    apiCalls.postHoax(body).then((response) => {
      this.setState({
        focused: false, // フォーカスを外す
        content: '' // 入力内容をクリアする
      });
    });
  };

  // キャンセルボタンがクリックされた時のイベントハンドラ
  onClickCancel = () => {
    this.setState({
      focused: false, // フォーカスを外す
      content: '' // 入力内容をクリアする
    });
  };

  render() {
    return (
      <div className="card d-flex flex-row p-1">
        {/* プロフィール画像を表示するコンポーネント */}
        <ProfileImageWithDefault
          className="rounded-circle m-1"
          width="32"
          height="32"
          image={this.props.loggedInUser.image}
        />
        <div className="flex-fill">
          {/* フォーカスされているかどうかによって行数を変えるテキストエリア */}
          <textarea
            className="form-control w-100"
            rows={this.state.focused ? 3 : 1}
            onFocus={this.onFocus} // テキストエリアにフォーカスがあたった時の処理
            value={this.state.content} // テキストエリアの値を状態の内容にバインド
            onChange={this.onChangeContent} // テキストエリアの内容が変更された時の処理
          />
          {/* フォーカスされている場合のみ表示されるボタン群 */}
          {this.state.focused && (
            <div className="text-right mt-1">
              {/* ホークスを送信するボタン */}
              <button className="btn btn-success" onClick={this.onClickHoaxify}>
                Submit
              </button>
              {/* キャンセルボタン */}
              <button
                className="btn btn-light ml-1"
                onClick={this.onClickCancel} // キャンセルボタンがクリックされた時の処理
              >
                <i className="fas fa-times"></i> Cancel
              </button>
            </div>
          )}
        </div>
      </div>
    );
  }
}

....


最後にはログインしたユーザーの場合だけ、記事の登録欄が表示されるようにコードを作成します!要はReduxの状態を利用することです。

frontend/src/pages/HomePage.spec.js

// ユーザーがログインしている状態で HoaxSubmit コンポーネントが表示されることを確認
it('displays hoax submit when user logged in', () => {

  // setup 関数を使用してログイン状態のコンポーネントをセットアップ
  const { container } = setup();

  // テキストエリアが表示されているかどうかを検証
  const textArea = container.querySelector('textarea');
  expect(textArea).toBeInTheDocument();
});

// ユーザーがログインしていない状態で HoaxSubmit コンポーネントが表示されないことを確認
it('does not display hoax submit when user not logged in', () => {
 
  // ログインしていない状態のユーザーデータを定義
  const notLoggedInState = {
    id: 0,
    username: '',
    displayName: '',
    password: '',
    image: '',
    isLoggedIn: false
  };

  // setup 関数を使用してログインしていない状態のコンポーネントをセットアップ
  const { container } = setup(notLoggedInState);

  // テキストエリアが表示されていないかどうかを検証
  const textArea = container.querySelector('textarea');
  expect(textArea).not.toBeInTheDocument();
});


frontend/src/pages/HomePage.js

import React from 'react';
import UserList from '../components/UserList';
import HoaxSubmit from '../components/HoaxSubmit';
import { connect } from 'react-redux';

class HomePage extends React.Component {
  render() {
    // ホームページコンポーネントは、ユーザーリストとホークスの投稿フォームを表示する。

    return (
      <div data-testid="homepage">
        <div className="row">
          <div className="col-8">
            {/* ログインしている場合にのみ HoaxSubmit コンポーネントを表示 */}
            {this.props.loggedInUser.isLoggedIn && <HoaxSubmit />}
          </div>
          <div className="col-4">
            {/* ユーザーリストを表示 */}
            <UserList />
          </div>
        </div>
      </div>
    );
  }
}
const mapStateToProps = (state) => {
  // Redux の state からログインしているユーザーの情報を取得して props にマッピング
  return {
    loggedInUser: state
  };
};

// Redux の store に接続して HomePage コンポーネントをエクスポート
export default connect(mapStateToProps)(HomePage);


テキストを登録してみます。
うまく、データベースに保存されました。


3. 最後に


今まで記事登録登録機能をバックエンドからフロントエンドまで実装しました。今回も重要なのは、Reduxの状態を使って記事登録の画面が見えるかどうかを判断してくれる点です。次回は投稿した記事のフィードを作って複数の人が同時に見れるようにしてみます。



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


【参考】


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

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