見出し画像

【テスト駆動開発】Springboot & React - 第12回 : 記事一覧表示


1. はじめに


こんにちは、前回は記事を登録する機能を実装しました。

今回は登録された記事一覧をメイン画面やプロフィール画面に表示する機能を実装します。今日の実装完成の画面です。

メイン画面でテキストを入力し、「Submit」ボタンを押します。
登録された記事が一覧と共に画面に表示されます。
(Feed)機能。
プロフィールに入ります。
プロフィールの画面に自分の登録した記事の一覧が表示されます。


2. 実装過程


2.1 全記事照会(バックエンド)


/HoaxControllerTest.java

	@Autowired
	HoaxService hoaxService;

	@Test
	public void getHoaxes_whenThereAreNoHoaxes_receiveOk() {
		ResponseEntity<Object> response = getHoaxes(new ParameterizedTypeReference<Object>() {});
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
	}

	@Test
	public void getHoaxes_whenThereAreNoHoaxes_receivePageWithZeroItems() {
		ResponseEntity<TestPage<Object>> response = getHoaxes(new ParameterizedTypeReference<TestPage<Object>>() {});
		assertThat(response.getBody().getTotalElements()).isEqualTo(0);
	}

	@Test
	public void getHoaxes_whenThereAreHoaxes_receivePageWithItems() {
		User user = userService.save(TestUtil.createValidUser("user1"));
		hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());

		ResponseEntity<TestPage<Object>> response = getHoaxes(new ParameterizedTypeReference<TestPage<Object>>() {});
		assertThat(response.getBody().getTotalElements()).isEqualTo(3);
	}

	public <T> ResponseEntity<T> getHoaxes(ParameterizedTypeReference<T> responseType){
		return testRestTemplate.exchange(API_1_0_HOAXES, HttpMethod.GET, null, responseType);
	}
  
	@Test
	public void postHoax_whenHoaxIsValidAndUserIsAuthorized_receiveHoaxVM() {
		userService.save(TestUtil.createValidUser("user1"));
		authenticate("user1");
		Hoax hoax = TestUtil.createValidHoax();
		ResponseEntity<HoaxVM> response = postHoax(hoax, HoaxVM.class);
		assertThat(response.getBody().getUser().getUsername()).isEqualTo("user1");
	}

	@Test
	public void getHoaxes_whenThereAreHoaxes_receivePageWithHoaxVM() {
		User user = userService.save(TestUtil.createValidUser("user1"));
		hoaxService.save(user, TestUtil.createValidHoax());

		ResponseEntity<TestPage<HoaxVM>> response = getHoaxes(new ParameterizedTypeReference<TestPage<HoaxVM>>() {});
		HoaxVM storedHoax = response.getBody().getContent().get(0);
		assertThat(storedHoax.getUser().getUsername()).isEqualTo("user1");
	}

APIエンドポイントの振る舞いをテストしています。

  • 記事が存在しない場合、APIからはOKのステータスコードが返されることを確認。

  • 記事が存在しない場合、ページ内にアイテムがゼロであることを確認。

  • 記事が存在する場合、ページ内にアイテムが正しく取得されることを確認。

  • 記事が認証された状態で正しいホークスを投稿すると、正しいホークVMが返されることを確認。

  • 記事が存在する場合、APIから正しいホークVMが返されることを確認。

    /vm/HoaxVM.java

@Data
@NoArgsConstructor
public class HoaxVM {

	private long id;

	private String content;

	private long date;

	private UserVM user;

	public HoaxVM(Hoax hoax) {
		this.setId(hoax.getId());
		this.setContent(hoax.getContent());
		this.setDate(hoax.getTimestamp().getTime());
		this.setUser(new UserVM(hoax.getUser()));
	}

}

VM(DTO)を作成し、必要なフィールドだけを取得します。

/HoaxController.java

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

	@GetMapping("/hoaxes")
	Page<HoaxVM> getAllHoaxes(Pageable pageable) {
		return hoaxService.getAllHoaxes(pageable).map(HoaxVM::new);
	}

.map(HoaxVM::new)は、Page<Hoax>(ホークス情報を含むページ)をPage<HoaxVM>(ホークビューモデルを含むページ)に変換するために使用されています。このコードにより、各 Hoax オブジェクトが新しい HoaxVM オブジェクトに変換され、ページ全体が HoaxVM オブジェクトのページに変換されます。

HoaxVM::new は、Javaのコンストラクタ参照 であり、HoaxVM クラスのコンストラクタを指します。この場合、HoaxVM クラスは、与えられた Hoax オブジェクトから HoaxVM オブジェクトを作成するためのものです。

:: は、Java 8から導入されたメソッド参照 (Method Reference) 演算子です。メソッド参照は、ラムダ式をより簡潔に表現するための仕組みです。

クラス名::メソッド名 : 特定のクラスの静的メソッドを参照します。

インスタンス名::メソッド名 : 特定のインスタンスのインスタンスメソッドを参照します。

クラス名::new : コンストラクタを参照します。

例えば、HoaxVM::new は HoaxVM クラスのコンストラクタを指します。これは、新しい HoaxVM オブジェクトを生成するためのコンストラクタを参照しています。このようなメソッド参照は、関数型インタフェース内でラムダ式の代わりに使用されることがあります。


/HoaxService.java

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

	public Page<Hoax> getAllHoaxes(Pageable pageable) {
		return hoaxRepository.findAll(pageable);
	}

全記事を照会するgetAllHoaxesメソッドを作成します。


2.2 ユーザーの記事照会(バックエンド)

	@Test
	public void getHoaxesOfUser_whenUserExists_receiveOk() {
		userService.save(TestUtil.createValidUser("user1"));
		ResponseEntity<Object> response = getHoaxesOfUser("user1", new ParameterizedTypeReference<Object>() {});
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
	}

	@Test
	public void getHoaxesOfUser_whenUserDoesNotExist_receiveNotFound() {
		ResponseEntity<Object> response = getHoaxesOfUser("unknown-user", new ParameterizedTypeReference<Object>() {});
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
	}
	@Test
	public void getHoaxesOfUser_whenUserExists_receivePageWithZeroHoaxes() {
		userService.save(TestUtil.createValidUser("user1"));
		ResponseEntity<TestPage<Object>> response = getHoaxesOfUser("user1", new ParameterizedTypeReference<TestPage<Object>>() {});
		assertThat(response.getBody().getTotalElements()).isEqualTo(0);
	}

	@Test
	public void getHoaxesOfUser_whenUserExistWithHoax_receivePageWithHoaxVM() {
		User user = userService.save(TestUtil.createValidUser("user1"));
		hoaxService.save(user, TestUtil.createValidHoax());

		ResponseEntity<TestPage<HoaxVM>> response = getHoaxesOfUser("user1", new ParameterizedTypeReference<TestPage<HoaxVM>>() {});
		HoaxVM storedHoax = response.getBody().getContent().get(0);
		assertThat(storedHoax.getUser().getUsername()).isEqualTo("user1");
	}

	@Test
	public void getHoaxesOfUser_whenUserExistWithMultipleHoaxes_receivePageWithMatchingHoaxesCount() {
		User user = userService.save(TestUtil.createValidUser("user1"));
		hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());

		ResponseEntity<TestPage<HoaxVM>> response = getHoaxesOfUser("user1", new ParameterizedTypeReference<TestPage<HoaxVM>>() {});
		assertThat(response.getBody().getTotalElements()).isEqualTo(3);
	}

	@Test
	public void getHoaxesOfUser_whenMultipleUserExistWithMultipleHoaxes_receivePageWithMatchingHoaxesCount() {
		User userWithThreeHoaxes = userService.save(TestUtil.createValidUser("user1"));
		IntStream.rangeClosed(1, 3).forEach(i -> {
			hoaxService.save(userWithThreeHoaxes, TestUtil.createValidHoax());	
		});

		User userWithFiveHoaxes = userService.save(TestUtil.createValidUser("user2"));
		IntStream.rangeClosed(1, 5).forEach(i -> {
			hoaxService.save(userWithFiveHoaxes, TestUtil.createValidHoax());	
		});


		ResponseEntity<TestPage<HoaxVM>> response = getHoaxesOfUser(userWithFiveHoaxes.getUsername(), new ParameterizedTypeReference<TestPage<HoaxVM>>() {});
		assertThat(response.getBody().getTotalElements()).isEqualTo(5);
	}


	public <T> ResponseEntity<T> getHoaxesOfUser(String username, ParameterizedTypeReference<T> responseType){
		String path = "/api/1.0/users/" + username + "/hoaxes";
		return testRestTemplate.exchange(path, HttpMethod.GET, null, responseType);
	}
  1. getHoaxesOfUser_whenUserExists_receiveOk(): 特定のユーザーが存在する場合、APIエンドポイントが正常に動作し、HTTPステータスコードが OK (200) を返すことを確認しています。

  2. getHoaxesOfUser_whenUserDoesNotExist_receiveNotFound(): 存在しないユーザーを指定した場合、APIエンドポイントが NOT_FOUND (404) のHTTPステータスコードを返すことを確認しています。

  3. getHoaxesOfUser_whenUserExists_receivePageWithZeroHoaxes(): 特定のユーザーが存在するがそのユーザーに関連するホークスがない場合、ページ内にホークスがゼロであることを確認しています。

  4. getHoaxesOfUser_whenUserExistWithHoax_receivePageWithHoaxVM(): 特定のユーザーが存在し、そのユーザーに関連するホークスがある場合、ページ内に正しい HoaxVM オブジェクトが存在することを確認しています。

  5. getHoaxesOfUser_whenUserExistWithMultipleHoaxes_receivePageWithMatchingHoaxesCount(): 特定のユーザーが存在し、そのユーザーに複数のホークスが関連付けられている場合、ページ内に正しいホークスの数が返されることを確認しています。

  6. getHoaxesOfUser_whenMultipleUserExistWithMultipleHoaxes_receivePageWithMatchingHoaxesCount(): 複数のユーザーが存在し、それぞれのユーザーが複数のホークスを持っている場合、特定のユーザーのホークス数が正しく返されることを確認しています。


/HoaxController.java

	@GetMapping("/users/{username}/hoaxes")
	Page<HoaxVM> getHoaxesOfUser(@PathVariable String username, Pageable pageable) {
		return hoaxService.getHoaxesOfUser(username, pageable).map(HoaxVM::new);

	}

ユーザー名で記事を取得するControllerメソッドです。パラメータで、「username」を@PathVariableで取得し、これに該当する記事を取得します。

/HoaxService.java

@Service
public class HoaxService {

	HoaxRepository hoaxRepository;

	UserService userService;

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

…
	public Page<Hoax> getHoaxesOfUser(String username, Pageable pageable) {
		User inDB = userService.getByUsername(username);
		return hoaxRepository.findByUser(inDB, pageable);
	}

UserServiceを注入します。

/HoaxRepository.java

public interface HoaxRepository extends JpaRepository<Hoax, Long>{

	Page<Hoax> findByUser(User user, Pageable pageable);

}



2.3 記事一覧の表示(フロントエンド)

フロントエンド側で、リクエストを送るAPIを作成します。

frontend/src/api/apiCalls.spec.js

  describe('loadHoaxes', () => {
    it('calls /api/1.0/hoaxes?page=0&size=5&sort=id,desc when no param provided', () => {
      const mockGetHoaxes = jest.fn();
      axios.get = mockGetHoaxes;
      apiCalls.loadHoaxes();
      expect(mockGetHoaxes).toBeCalledWith(
        '/api/1.0/hoaxes?page=0&size=5&sort=id,desc'
      );
    });
    it('calls /api/1.0/users/user1/hoaxes?page=0&size=5&sort=id,desc when user param provided', () => {
      const mockGetHoaxes = jest.fn();
      axios.get = mockGetHoaxes;
      apiCalls.loadHoaxes('user1');
      expect(mockGetHoaxes).toBeCalledWith(
        '/api/1.0/users/user1/hoaxes?page=0&size=5&sort=id,desc'
      );
    });
  });


frontend/src/api/apiCalls.js

export const loadHoaxes = (username) => {
  const basePath = username
    ? `/api/1.0/users/${username}/hoaxes`
    : '/api/1.0/hoaxes';
  return axios.get(basePath + '?page=0&size=5&sort=id,desc');
};

「username」があると、「/api/1.0/users/${username}/hoaxes」、ないと、「/api/1.0/hoaxes」をbasePath変数に保存します。

frontend/src/components/HoaxFeed.spec.js

import React from 'react';
import { render } from '@testing-library/react';
import HoaxFeed from './HoaxFeed';
import * as apiCalls from '../api/apiCalls';

const setup = (props) => {
  return render(<HoaxFeed {...props} />);
};

const mockEmptyResponse = {
  data: {
    content: []
  }
};

describe('HoaxFeed', () => {
  describe('Lifecycle', () => {
    it('calls loadHoaxes when it is rendered', () => {
      apiCalls.loadHoaxes = jest.fn().mockResolvedValue(mockEmptyResponse);
      setup();
      expect(apiCalls.loadHoaxes).toHaveBeenCalled();
    });
    it('calls loadHoaxes with user parameter when it is rendered with user property', () => {
      apiCalls.loadHoaxes = jest.fn().mockResolvedValue(mockEmptyResponse);
      setup({ user: 'user1' });
      expect(apiCalls.loadHoaxes).toHaveBeenCalledWith('user1');
    });
    it('calls loadHoaxes without user parameter when it is rendered without user property', () => {
      apiCalls.loadHoaxes = jest.fn().mockResolvedValue(mockEmptyResponse);
      setup();
      const parameter = apiCalls.loadHoaxes.mock.calls[0][0];
      expect(parameter).toBeUndefined();
    });
  });
  describe('Layout', () => {
    it('displays no hoax message when the response has empty page', () => {
      apiCalls.loadHoaxes = jest.fn().mockResolvedValue(mockEmptyResponse);
      const { queryByText } = setup();
      expect(queryByText('There are no hoaxes')).toBeInTheDocument();
    });
  });
});

テストケースは、2つのブロックに分かれています:
Lifecycle ブロックでは、コンポーネントのライフサイクルイベントと関連するテストを行っています。loadHoaxes 関数がコンポーネントのレンダリング時に適切に呼び出されることを確認しています。また、user プロパティが指定された場合にはその値が loadHoaxes 関数に渡されることを確認しています。

Layout ブロックでは、取得した記事がない場合、「There are no hoaxed」が画面に表示されることをテストします。


frontend/src/components/HoaxFeed.js

import React, { Component } from 'react';
import * as apiCalls from '../api/apiCalls';

class HoaxFeed extends Component {
  componentDidMount() {
    // コンポーネントがマウントされた直後に呼び出されるライフサイクルメソッド
    // this.props.user には親コンポーネントから渡された user プロパティが含まれている。
    // loadHoaxes 関数は、apiCalls モジュールからインポートされたAPI呼び出しの一部であり、
    // このコンポーネントがマウントされた直後に hoaxes をロードするために呼び出されます
    apiCalls.loadHoaxes(this.props.user);
  }

  render() {
    / レンダリング関数で
    // コンポーネントが表示される内容を定義
    return (
      <div className="card card-header text-center">
        There are no hoaxes
      </div>
    );
  }
}

export default HoaxFeed;

componentDidMount メソッドは、Reactコンポーネントのライフサイクルメソッドの1つであり、コンポーネントがマウントされた直後に呼び出されます。ここでは、apiCalls.loadHoaxes(this.props.user); を呼び出して、loadHoaxes 関数を実行しています。this.props.user は親コンポーネントから受け取ったユーザー情報です。

frontend/src/pages/HomePage.js

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

<HoaxFeed />を追加し、コンポーネントをレンダリングします。


frontend/src/pages/UserPage.js

    return (
      <div data-testid="userpage">
        <div className="row">
          <div className="col">{pageContent}</div>
          <div className="col">
            <HoaxFeed user={this.props.match.params.username} />
          </div>
        </div>
      </div>
    );

<HoaxFeed user={this.props.match.params.username} />を追加し、ユーザー名に該当する記事コンポーネントをレンダリングします。


スピナーを作ります。

frontend/src/components/Spinner.js

import React from 'react';

const Spinner = () => {
  return (
    <div className="d-flex">
      <div className="spinner-border text-black-50 m-auto">
        <span className="sr-only">Loading...</span>
      </div>
    </div>
  );
};

export default Spinner;


frontend/src/components/HoaxFeed.js

import Spinner from './Spinner';

class HoaxFeed extends Component {
  state = {
    page: {
      content: []
    },
    isLoadingHoaxes: false
  };

  componentDidMount() {
    this.setState({ isLoadingHoaxes: true });
    apiCalls.loadHoaxes(this.props.user).then((response) => {
      this.setState({ page: response.data, isLoadingHoaxes: false });
    });
  }
  render() {
    if (this.state.isLoadingHoaxes) {
      return <Spinner />;
    }
    if (this.state.page.content.length === 0) {
      return (
        <div className="card card-header text-center">There are no hoaxes</div>
      );
    }

    return (
      <div>
        {this.state.page.content.map((hoax) => {
          return <span key={hoax.id}>{hoax.content}</span>;
        })}
      </div>
    );
  }
}
 render() {
    if (this.state.isLoadingHoaxes) {
      return <Spinner />;
    }

初めて記事をレンダリングする際、最初にスピナーをレンダリングします。


メイン画面が表示される時、
スピナーが回ります。
ユーザー画面も同じ作業。


2.4「timeago.js」・ページネーション準備(フロントエンド)

/package.json

"timeago.js": "^4.0.0"

「npm insall -save timeago.js@4.0.0」コマンドを入力し、「timeago.js」モジュールをインストールします。

timeago.js は、過去の日時から現在までの経過時間を自動的にフォーマットして表示するためのJavaScriptのライブラリです。時間の経過を簡潔な表現で表示できます。

例えば、投稿されたメッセージやコメントのタイムスタンプなどを「数分前」「1時間前」といった形式で表示するのに役立ちます。

frontend/src/components/HoaxView.js

class HoaxView extends Component {
  render() {
    const { hoax } = this.props;
    const { user, date } = hoax;
    const { username, displayName, image } = user;
    const relativeDate = format(date);
    return (
      <div className="card p-1">
        <div className="d-flex">
          <ProfileImageWithDefault
            className="rounded-circle m-1"
            width="32"
            height="32"
            image={image}
          />
          <div className="flex-fill m-auto pl-2">
            <h6 className="d-inline">
              {displayName}@{username}
            </h6>
            <span className="text-black-50"> - </span>
            <span className="text-black-50">{relativeDate}</span>
          </div>
        </div>
        <div className="pl-5">{hoax.content}</div>
      </div>
    );
  }
}

export default HoaxView;

hoax から user と date の情報を抽出しています。

user には、投稿者の情報が含まれています。date は投稿された日時です。

user オブジェクトから username, displayName, image の情報を抽出しています。

format 関数を使用して date を相対的な日時表記に変換しています。この関数は、投稿された日時を「数時間前」「1日前」といった相対的な表現に変換するものと考えられます。

{relativeDate}、つまりtimeago.jsにより表示される相対時間。


frontend/src/components/HoaxFeed.js

import HoaxView from './HoaxView';
...
return (
      <div>
        {this.state.page.content.map((hoax) => {
          return <HoaxView key={hoax.id} hoax={hoax} />;
        })}
    {/* ページングの最後でない場合は「Load More」を表示 */}
        {this.state.page.last === false && (
          <div className="card card-header text-center">Load More</div>
        )}
      </div>
    );

ページングが最後でない場合は「Load More」を表示します。

ページングの最後でない場合は「Load More」を表示 



3. 最後に


今まで記事一覧を表示する機能を実装しました。メイン画面が呼び出された時、ライフサイクルメソッドで優先的にスピナーが回って記事リストを表示するのが最初の印象的な部分でした。 二番目はtimeago.jsというJavaScriptライブラリですが、相対的な時間を表示する機能を提供することも分かりました。 これで、そろそろWebアプリケーションの形が整ってきましたね。 次回は本格的に記事のページネーション、余裕があれば記事の削除まで実装してみます。


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


【参考】


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

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