見出し画像

【テスト駆動開発】Springboot & React - 第6回 : ユーザーページネーション


1. はじめに


こんにちは、今日はユーザーのページネーションを実装してみます。私にとっては、Webアプリを開発する度、一番ドキドキするのがページネーションです。なぜなら、ページネーションから、アプリケーションの仕組みがちゃんと揃えるイメージがあるからです。


今日の目標画面実装です!


「next」ボタン押下時、ユーザーリストが変わります。
ログインしたユーザー自身は表示されないです。
ログアウトすると、表示されます。


現在のプロジェクトの構造

2.ページネーションの基本概念


ページネーションの実装段階

1.データの総個数計算
データベースからデータの総個数を計算します。

2. ページあたりの表示データの個数設定
ページあたりの表示データの個数を設定します。

3.現在のページ番号設定
ユーザーが現在どのページを見ているかを示す現在のページ番号を設定します。

4.データをページ単位で分ける
データの合計数とページあたりの表示データの数に基づいて、データをページ単位で除きます。

5.ページ番号を作成
現在のページ番号に基づいて、前のページ番号、次のページ番号、最初のページ番号、最後のページ番号などを生成します

6.ページ番号を表示
 
ページ番号をユーザーに表示して、ユーザーが希望するページに移動できるようにします。


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


3.1. ユーザー照会

/UserControllerTest.java

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

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

	@Test
	public void getUsers_whenThereIsAUserInDB_receivePageWithUser() {
		userRepository.save(TestUtil.createValidUser());
		ResponseEntity<TestPage<Object>> response = getUsers(new ParameterizedTypeReference<TestPage<Object>>() {});
		assertThat(response.getBody().getNumberOfElements()).isEqualTo(1);
	}

	public <T> ResponseEntity<T> postSignup(Object request, Class<T> response){
		return testRestTemplate.postForEntity(API_1_0_USERS, request, response);
	}

	public <T> ResponseEntity<T> getUsers(ParameterizedTypeReference<T> responseType){
		return testRestTemplate.exchange(API_1_0_USERS, HttpMethod.GET, null, responseType);
	}

データベース内のユーザの有無に応じて適切な結果を返すことが確認します。

ユーザがデータベースに存在しない場合1 (getUsers_whenThereAreNoUsersInDB_receiveOK):

ユーザが存在しない状況において、HTTP GETリクエストがHTTPステータスコード200 (OK)を返し、エラーが発生しないことを確認しています。

ユーザがデータベースに存在しない場合2 (getUsers_whenThereAreNoUsersInDB_receivePageWithZeroItems):

ユーザが存在しない状況において、APIがページ内のアイテム数をゼロとして返すことを確認しています。

少なくとも1つのユーザがデータベースに存在する場合 (getUsers_whenThereIsAUserInDB_receivePageWithUser):

少なくとも1つのユーザが存在する場合に、APIがページ内のアイテム数を1として返すことを確認しています。

/TestPage.java

@Data
public class TestPage<T> implements Page<T> {

	long totalElements;
	int totalPages;
	int number;
	int numberOfElements;
	int size;
	boolean last;
	boolean first;
	boolean next;
	boolean previous;

	List<T> content;

	@Override
	public boolean hasContent() {
		// TODO Auto-generated method stub
		return false;
	}

	@Override
	public Sort getSort() {
		// TODO Auto-generated method stub
		return null;
	}

	@Override
	public boolean hasNext() {
		return next;
	}

	@Override
	public boolean hasPrevious() {
		return previous;
	}

	@Override
	public Pageable nextPageable() {
		// TODO Auto-generated method stub
		return null;
	}

	@Override
	public Pageable previousPageable() {
		// TODO Auto-generated method stub
		return null;
	}

	@Override
	public Iterator<T> iterator() {
		// TODO Auto-generated method stub
		return null;
	}

	@Override
	public <U> Page<U> map(Function<? super T, ? extends U> converter) {
		// TODO Auto-generated method stub
		return null;
	}

}

ジェネリック型を使用してページネーション情報を表す Java クラス TestPage を定義しています。

「TestPage<T>」はジェネリッククラスで、型 T に対応するページネーション情報を保持します。

ジェネリッククラスで型 T を使用することは、ジェネリクス(Generics)の概念を表します。ジェネリクスは、型パラメータを使用して、クラスやメソッドをより一般化し、異なる型のデータを操作できるようにするための機能です。

このコードでの TestPage<T> は、T が型パラメータとして宣言されている部分です。これは、TestPage クラスが異なる型のデータをページネーション情報として扱うための一般的な仕組みを提供しています。

具体的に、T はページ内の要素の型を指定します。例えば、T が String の場合、TestPage<String> は文字列要素を持つページネーション情報を表現します。同様に、T が他のクラスやデータ型の場合、それに対応する型のページネーション情報を表すことができます。


/user/UserController.java

	@GetMapping("/users")
	Page<?> getUsers() {
		return userService.getUsers();
	}


/user/UserService.java

	public Page<?> getUsers() {
		Pageable pageable = PageRequest.of(0, 10);
		return userRepository.findAll(pageable);
	}

public Page<?> getUsers(): このメソッドは、データベースからユーザーを取得し、Page オブジェクトを返すメソッドです。Page オブジェクトは、ページネーション情報とページ内のデータを含むオブジェクトです。Page<?> のようにワイルドカード型が使用されているため、返されるページ内の要素の具体的な型が不明確である点に注意。

Pageable pageable = PageRequest.of(0, 10);: ページネーション情報を構築しています。PageRequest.of(0, 10) は、最初のページ(0番目)から始まり、1ページあたりの要素数が10であることを示します。この情報を使用して、取得するページを指定できます。

return userRepository.findAll(pageable);: findAll(pageable) メソッドは、指定したページネーション情報に従ってユーザーを取得し、Page オブジェクトとして返します。


3.2. ユーザーモデリング


JsonView

/UserControllerTest.java

	@Test
	public void getUsers_whenThereIsAUserInDB_receiveUserWithoutPassword() {
		userRepository.save(TestUtil.createValidUser());
		ResponseEntity<TestPage<Map<String, Object>>> response = getUsers(new ParameterizedTypeReference<TestPage<Map<String, Object>>>() {});
		Map<String, Object> entity = response.getBody().getContent().get(0);
		assertThat(entity.containsKey("password")).isFalse();
	}


/user/UserController.java

	@GetMapping("/users")
	@JsonView(Views.Base.class)
	Page<?> getUsers() {
		return userService.getUsers();
	}

@JsonView アノテーションは、Jackson ライブラリを使用して JSON シリアライゼーション (JSON形式への変換) の際に特定のビューを適用するために使用されるアノテーションです。このアノテーションを使用することで、同じオブジェクトを異なるビューに対して異なるフィールドを表示させることが可能です。

/configuration/SerializationConfiguration.java

@Configuration
public class SerializationConfiguration {

	@Bean
	public Module springDataPageModule() {
		JsonSerializer<Page> pageSerializer = new JsonSerializer<Page>() {

			@Override
			public void serialize(Page value, JsonGenerator generator, SerializerProvider serializers) throws IOException {
				generator.writeStartObject();
				generator.writeNumberField("numberOfElements", value.getNumberOfElements());
				generator.writeNumberField("totalElements", value.getTotalElements());
				generator.writeNumberField("totalPages", value.getTotalPages());
				generator.writeNumberField("number", value.getNumber());
				generator.writeNumberField("size", value.getSize());
				generator.writeBooleanField("first", value.isFirst());
				generator.writeBooleanField("last", value.isLast());
				generator.writeBooleanField("next", value.hasNext());
				generator.writeBooleanField("previous", value.hasPrevious());

				generator.writeFieldName("content");
				serializers.defaultSerializeValue(value.getContent(), generator);
				generator.writeEndObject();
			}
		};
		return new SimpleModule().addSerializer(Page.class, pageSerializer);
	}

}

serialize メソッドは、Page オブジェクトを JSON 形式に変換するためのロジックを提供します。JsonGenerator を使用して JSON フィールドを生成し、serializers.defaultSerializeValue メソッドを呼び出して Page の content フィールドをデフォルトの方法でシリアライズします。

SimpleModule インスタンスは Jackson ライブラリのモジュールで、カスタムシリアライゼーションやデシリアライゼーションの設定を提供します。addSerializer メソッドを使用して、Page クラスに対するカスタムシリアライゼーションを登録します。

Projection

/user/UserProjection.java

public interface UserProjection {

	long getId();

	String getUsername();

	String getDisplayName();

	String getImage();

}

プロジェクションは、ユーザーエンティティの一部のフィールドまたは特定のデータだけを選択的に検索して返すために使用することができます。主にデータベース照会時に必要なフィールドだけを選択的に取得するために使用されます。

/user/UserRepository.java

	@Query("Select u from User u")
	Page<UserProjection> getAllUsersProjection(Pageable pageable);

/user/UserService.java

	public Page<?> getUsers() {
		Pageable pageable = PageRequest.of(0, 10);
		return userRepository.getAllUsersProjection(pageable);
	}

既存のクエリとサービス職を投影したものに合わせて修正してくれます。


UserVM
/user/vm/UserVM.java

@Data
@NoArgsConstructor
public class UserVM {

	private long id;

	private String username;

	private String displayName;

	private String image;

	public UserVM(User user) {
		this.setId(user.getId());
		this.setUsername(user.getUsername());
		this.setDisplayName(user.getDisplayName());
		this.setImage(user.getImage());
	}

}

このクラスは、User エンティティから必要なデータを取得し、APIの応答やビューで使用するためにデータをラップするのに使用される、一般的なデータ転送オブジェクト(DTO)です。


/user/LoginController.java

@PostMapping("/api/1.0/login")
	UserVM handleLogin(@CurrentUser User loggedInUser) {
		return new UserVM(loggedInUser);
	}

@JsonView(Views.Base.class)を全て削除します。


/user/UserController.java

@GetMapping("/users")
	Page<UserVM> getUsers() {
		return userService.getUsers().map(UserVM::new);
	}

UserVMでジェネリックタイプを設定します。

getUsers()の後に「.map(UserVM::new);」という記述が印象的です。

Page<User>オブジェクトに対して.mapメソッドを使って各UserオブジェクトをUserVMオブジェクトに変換します。これにより、各ページに含まれるUserエンティティがUserVMオブジェクトにマッピングされます。

/user/UserService.java

	public Page<User> getUsers() {
		Pageable pageable = PageRequest.of(0, 10);
		return userRepository.findAll(pageable);
	}

ジェネリックタイプをUserに設定し、リポジトリから呼び出すメソッドもfindAllに変更します。

なぜこのような作業をするかというと、DTO(UserVM)を使って、もうSerializationConfigurationを使う必要がなくなったからです。したがって、これからはUserVMがその役割を代行するので、SerializationConfigurationクラス、UserProjectionクラスを削除しても構いません。


3.3. ページネーション

/UserControllerTest.java

	@Test
	public void getUsers_whenPageIsRequestedFor3ItemsPerPageWhereTheDatabaseHas20Users_receive3Users() {
		IntStream.rangeClosed(1, 20).mapToObj(i -> "test-user-"+i)
			.map(TestUtil::createValidUser)
			.forEach(userRepository::save);
		String path = API_1_0_USERS + "?page=0&size=3";
		ResponseEntity<TestPage<Object>> response = getUsers(path, new ParameterizedTypeReference<TestPage<Object>>() {});
		assertThat(response.getBody().getContent().size()).isEqualTo(3);
	}

	@Test
	public void getUsers_whenPageSizeNotProvided_receivePageSizeAs10() {
		ResponseEntity<TestPage<Object>> response = getUsers(new ParameterizedTypeReference<TestPage<Object>>() {});
		assertThat(response.getBody().getSize()).isEqualTo(10);
	}	

	@Test
	public void getUsers_whenPageSizeIsGreaterThan100_receivePageSizeAs100() {
		String path = API_1_0_USERS + "?size=500";
		ResponseEntity<TestPage<Object>> response = getUsers(path, new ParameterizedTypeReference<TestPage<Object>>() {});
		assertThat(response.getBody().getSize()).isEqualTo(100);
	}	

	@Test
	public void getUsers_whenPageSizeIsNegative_receivePageSizeAs10() {
		String path = API_1_0_USERS + "?size=-5";
		ResponseEntity<TestPage<Object>> response = getUsers(path, new ParameterizedTypeReference<TestPage<Object>>() {});
		assertThat(response.getBody().getSize()).isEqualTo(10);
	}

	@Test
	public void getUsers_whenPageIsNegative_receiveFirstPage() {
		String path = API_1_0_USERS + "?page=-5";
		ResponseEntity<TestPage<Object>> response = getUsers(path, new ParameterizedTypeReference<TestPage<Object>>() {});
		assertThat(response.getBody().getNumber()).isEqualTo(0);
	}
...

	public <T> ResponseEntity<T> getUsers(String path, ParameterizedTypeReference<T> responseType){
		return testRestTemplate.exchange(path, HttpMethod.GET, null, responseType);
	}

getUsers_whenPageIsRequestedFor3ItemsPerPageWhereTheDatabaseHas20Users_receive3Users:

データベースに20人のユーザーがいる状況で、1ページあたり3つのアイテムがリクエストされた場合のテストです。

getUsers_whenPageSizeNotProvided_receivePageSizeAs10:
ページサイズが指定されていない場合、デフォルトでページサイズが10であることを検証するテストです。

getUsers_whenPageSizeIsGreaterThan100_receivePageSizeAs100:
ページサイズが100より大きい場合、最大ページサイズが100であることを検証するテストです。

getUsers_whenPageSizeIsNegative_receivePageSizeAs10:
ページサイズが負の値で指定された場合、デフォルトでページサイズが10であることを検証するテストです。

getUsers_whenPageIsNegative_receiveFirstPage:
ページ番号が負の値で指定された場合、最初のページが返されることを検証するテストです。

/TestUtil.java

	public static User createValidUser(String username) {
		User user = createValidUser();
		user.setUsername(username);
		return user;
	}


/user/UserController.java

	@GetMapping("/users")
	Page<UserVM> getUsers(Pageable page) {
		return userService.getUsers(page).map(UserVM::new);
	}


/user/UserService.java

	public Page<User> getUsers(Pageable pageable) {
		return userRepository.findAll(pageable);
	}


/resources/application.yml

spring:
  h2:
    console:
      enabled: true
      path: /h2-console  jpa:    properties:      javax:
        persistence:
          validation:
            mode: none  data:    web:      pageable:      
        default-page-size: 10
        max-page-size: 100

pageable:: ページネーション結果のための設定を構成します。

default-page-size:10:ページネーション結果の基本ページサイズを10に設定します。

max-page-size:100:ページネーション結果の最大ページサイズを100に設定します。

3.4. ログインユーザー除外

/UserControllerTest.java

	@Autowired
	UserService userService;
…
	@Test
	public void getUsers_whenUserLoggedIn_receivePageWithoutLoggedInUser() {
		userService.save(TestUtil.createValidUser("user1"));
		userService.save(TestUtil.createValidUser("user2"));
		userService.save(TestUtil.createValidUser("user3"));
		authenticate("user1");
		ResponseEntity<TestPage<Object>> response = getUsers(new ParameterizedTypeReference<TestPage<Object>>() {});
		assertThat(response.getBody().getTotalElements()).isEqualTo(2);
	}


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


/user/UserController.java

	@GetMapping("/users")
	Page<UserVM> getUsers(@CurrentUser User loggedInUser, Pageable page) {
		return userService.getUsers(loggedInUser, page).map(UserVM::new);
	}

@CurrentUser User loggedInUser

CurrentUserはユーザーを識別し、現在ログインしているユーザーを示すユーザー定義のアノテーションです。

/user/UserService.java

	public Page<User> getUsers(User loggedInUser, Pageable pageable) {
		if(loggedInUser != null) {
			return userRepository.findByUsernameNot(loggedInUser.getUsername(), pageable);
		}
		return userRepository.findAll(pageable);
	}


/user/UserRepository.java

	Page<User> findByUsernameNot(String username, Pageable page);


ランダムユーザー生成
/Application.java

	@Bean
	@Profile("!test")
	CommandLineRunner run(UserService userService) {
		return (args) -> {
			IntStream.rangeClosed(1,15)
				.mapToObj(i -> {
					User user = new User();
					user.setUsername("user"+i);
					user.setDisplayName("display"+i);
					user.setPassword("P4ssword");
					return user;
				})
				.forEach(userService::save);

		};
	}

Profileは、システムが特定の環境や条件で実行されるときに設定、動作、機能を変更したり、構成する方法です。"test" プロファイルを除外し、残りのプロファイルで CommandLineRunner を実行するように指示します。

IntStream.rangeClosed(1,15) ... forEach(userService::save): 1から15の範囲でユーザーを生成してuserService::saveを使って各ユーザーをデータベースに保存します。


4.フロントエンド実装過程


4.1. ユーザーリストのAPI

frontend/src/api/apiCalls.spec.js

  describe('listUser', () => {
    it('calls /api/1.0/users?page=0&size=3 when no param provided for listUsers', () => {
      const mockListUsers = jest.fn();
      axios.get = mockListUsers;
      apiCalls.listUsers();
      expect(mockListUsers).toBeCalledWith('/api/1.0/users?page=0&size=3');
    });
    it('calls /api/1.0/users?page=5&size=10 when corresponding params provided for listUsers', () => {
      const mockListUsers = jest.fn();
      axios.get = mockListUsers;
      apiCalls.listUsers({ page: 5, size: 10 });
      expect(mockListUsers).toBeCalledWith('/api/1.0/users?page=5&size=10');
    });
    it('calls /api/1.0/users?page=5&size=3 when only page param provided for listUsers', () => {
      const mockListUsers = jest.fn();
      axios.get = mockListUsers;
      apiCalls.listUsers({ page: 5 });
      expect(mockListUsers).toBeCalledWith('/api/1.0/users?page=5&size=3');
    });
    it('calls /api/1.0/users?page=0&size=5 when only size param provided for listUsers', () => {
      const mockListUsers = jest.fn();
      axios.get = mockListUsers;
      apiCalls.listUsers({ size: 5 });
      expect(mockListUsers).toBeCalledWith('/api/1.0/users?page=0&size=5');
    });
  });


frontend/src/api/apiCalls.js

export const listUsers = (param = { page: 0, size: 3 }) => {
  const path = `/api/1.0/users?page=${param.page || 0}&size=${param.size || 3}`;
  return axios.get(path);
};

バックエンドからユーザーのリストを取得するためのAPIリクエストを発行します。path変数が作成されます。この変数には、APIエンドポイントのURLが格納されます。param.pageとparam.sizeが提供されていない場合、デフォルト値としてそれぞれ0と3が使用されます。

4.2. ユーザーリスト

frontend/src/components/UserList.spec.js

import React from 'react';
import {
  render,
  waitForDomChange,
  waitForElement
} from '@testing-library/react';
import UserList from './UserList';
import * as apiCalls from '../api/apiCalls';

apiCalls.listUsers = jest.fn().mockResolvedValue({
  data: {
    content: [],
    number: 0,
    size: 3
  }
});

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

const mockedEmptySuccessResponse = {
  data: {
    content: [],
    number: 0,
    size: 3
  }
};

const mockSuccessGetSinglePage = {
  data: {
    content: [
      {
        username: 'user1',
        displayName: 'display1',
        image: ''
      },
      {
        username: 'user2',
        displayName: 'display2',
        image: ''
      },
      {
        username: 'user3',
        displayName: 'display3',
        image: ''
      }
    ],
    number: 0,
    first: true,
    last: true,
    size: 3,
    totalPages: 1
  }
};

describe('UserList', () => {
  describe('Layout', () => {
    it('has header of Users', () => {
      const { container } = setup();
      const header = container.querySelector('h3');
      expect(header).toHaveTextContent('Users');
    });
    it('displays three items when listUser api returns three users', async () => {
      apiCalls.listUsers = jest
        .fn()
        .mockResolvedValue(mockSuccessGetSinglePage);
      const { queryByTestId } = setup();
      await waitForDomChange();
      const userGroup = queryByTestId('usergroup');
      expect(userGroup.childElementCount).toBe(3);
    });
    it('displays the displayName@username when listUser api returns users', async () => {
      apiCalls.listUsers = jest
        .fn()
        .mockResolvedValue(mockSuccessGetSinglePage);
      const { queryByText } = setup();
      const firstUser = await waitForElement(() =>
        queryByText('display1@user1')
      );
      expect(firstUser).toBeInTheDocument();
    });
  });
  describe('Lifecycle', () => {
    it('calls listUsers api when it is rendered', () => {
      apiCalls.listUsers = jest
        .fn()
        .mockResolvedValue(mockedEmptySuccessResponse);
      setup();
      expect(apiCalls.listUsers).toHaveBeenCalledTimes(1);
    });
    it('calls listUsers method with page zero and size three', () => {
      apiCalls.listUsers = jest
        .fn()
        .mockResolvedValue(mockedEmptySuccessResponse);
      setup();
      expect(apiCalls.listUsers).toHaveBeenCalledWith({ page: 0, size: 3 });
    });
  });
});

console.error = () => {};

apiCalls モジュールからの関数 listUsers をモックして、テスト用のデータを返すように設定しています。このモック関数はAPIからユーザーリストを取得する役割を担います。

setup 関数は UserList コンポーネントをレンダリングするヘルパー関数です。

テストケースの中で、mockSuccessGetSinglePage と mockedEmptySuccessResponse といったモックデータを使用して、ユーザーリストの異なる状況を模擬しています。

テストスイートでは、UserListコンポーネントのレイアウトやライフサイクルに関するテストを行います。

レイアウトテストは、コンポーネントが正しい要素をレンダリングし、APIからデータを取得して表示することを確認します。特に、ヘッダーの表示とユーザーリストの表示が含まれます。

ライフサイクルテストでは、listUsers APIメソッドが呼び出されることと、パラメータで呼び出されることを確認します。


frontend/src/components/UserList.js

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

class UserList extends React.Component {
  state = {
    page: {
      content: [],
      number: 0,
      size: 3
    }
  };
  componentDidMount() {
    apiCalls
      .listUsers({ page: this.state.page.number, size: this.state.page.size })
      .then((response) => {
        this.setState({
          page: response.data
        });
      });
  }

  render() {
    return (
      <div className="card">
        <h3 className="card-title m-auto">Users</h3>
        <div className="list-group list-group-flush" data-testid="usergroup">
          {this.state.page.content.map((user) => {
            return (
              <div
                key={user.username}
                className="list-group-item list-group-item-action"
              >
                {`${user.displayName}@${user.username}`}
              </div>
            );
          })}
        </div>
      </div>
    );
  }
}

export default UserList;

コンポーネントは、ユーザーリストをカード内に表示し、各ユーザーの表示名とユーザー名を表示します。


frontend/src/containers/App.spec.js

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

apiCalls.listUsers = jest.fn().mockResolvedValue({
  data: {
    content: [],
    number: 0,
    size: 3
  }
});

テストのために模擬してAPIを送るようにしましょう。

frontend/src/pages/HomePage.spec.js

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

apiCalls.listUsers = jest.fn().mockResolvedValue({
  data: {
    content: [],
    number: 0,
    size: 3
  }
});

テストのために模擬してAPIを送るようにしましょう。

frontend/src/pages/HomePage.js

import UserList from '../components/UserList';

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

ユーザーリストを画面に表示します。

4.3. ユーザーリストの項目

frontend/src/components/UserListItem.spec.js

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

const user = {
  username: 'user1',
  displayName: 'display1',
  image: 'profile1.png'
};
describe('UserListItem', () => {
  it('has image', () => {
    const { container } = render(<UserListItem user={user} />);
    const image = container.querySelector('img');
    expect(image).toBeInTheDocument();
  });
  it('displays default image when user does not have one', () => {
    const userWithoutImage = {
      ...user,
      image: undefined
    };
    const { container } = render(<UserListItem user={userWithoutImage} />);
    const image = container.querySelector('img');
    expect(image.src).toContain('/profile.png');
  });
  it('displays users image when user have one', () => {
    const { container } = render(<UserListItem user={user} />);
    const image = container.querySelector('img');
    expect(image.src).toContain('/images/profile/' + user.image);
  });
});

「hasimage」テスト:
画像を持つユーザーをレンダリングするときに、画像が画面に表示されることを確認します。

'displays default image when user does not have one' テスト:
画像のないユーザーをレンダリングするときに、デフォルトの画像が画面に表示されることを確認します。

'displays users image when user have one' テスト:
画像を持つユーザーをレンダリングするときに、ユーザーの画像が画面に表示されることを確認します。

frontend/src/components/UserListItem.js

import React from 'react';
import defaultPicture from '../assets/profile.png';

const UserListItem = (props) => {
  let imageSource = defaultPicture;
  if (props.user.image) {
    imageSource = `/images/profile/${props.user.image}`;
  }
  return (
    <div className="list-group-item list-group-item-action">
      <img
        className="rounded-circle"
        alt="profile"
        width="32"
        height="32"
        src={imageSource}
      />
      <span className="pl-2">{`${props.user.displayName}@${
        props.user.username
      }`}</span>
    </div>
  );
};

export default UserListItem;

'UserListItem' を定義します。このコンポーネントは、ユーザーのプロフィール画像を表示するリストアイテムを生成します。ユーザー情報を表示する際にプロフィール画像を処理し、表示名やユーザー名と一緒にリストアイテムとして表示します。

  • 'defaultPicture' はデフォルトのプロフィール画像のファイルパスを指定します。

  • 'UserListItem' コンポーネントは、propsとして渡されたユーザーオブジェクトを受け取ります。ユーザーオブジェクトに画像が指定されている場合、その画像を表示し、指定されていない場合はデフォルトのプロフィール画像を表示します。

  • ユーザーリストアイテムは、プロフィール画像、ユーザーの表示名、ユーザー名から構成されています。



frontend/src/components/UserList.js

import UserListItem from './UserListItem';

class UserList extends React.Component {
  state = {
...
        <h3 className="card-title m-auto">Users</h3>
        <div className="list-group list-group-flush" data-testid="usergroup">
          {this.state.page.content.map((user) => {
            return <UserListItem key={user.username} user={user} />;
          })}
        </div>
      </div>

{this.state.page.content.map((user) => { return <UserListItem key={user.username} user={user} />; })}: this.state.page.contentに含まれるユーザーオブジェクトをマップし、各ユーザー情報を UserListItemコンポーネントに渡しています。keyプロパティはReactが各リストアイテムを一意に識別するのに使用されます。


4.4.ユーザーリストのページネーション

frontend/src/components/UserList.spec.js

const mockSuccessGetMultiPageFirst = {
  data: {
    content: [
      {
        username: 'user1',
        displayName: 'display1',
        image: ''
      },
      {
        username: 'user2',
        displayName: 'display2',
        image: ''
      },
      {
        username: 'user3',
        displayName: 'display3',
        image: ''
      }
    ],
    number: 0,
    first: true,
    last: false,
    size: 3,
    totalPages: 2
  }
};

const mockSuccessGetMultiPageLast = {
  data: {
    content: [
      {
        username: 'user4',
        displayName: 'display4',
        image: ''
      }
    ],
    number: 1,
    first: false,
    last: true,
    size: 3,
    totalPages: 2
  }
};
...
    it('displays the next button when response has last value as false', async () => {
      apiCalls.listUsers = jest
        .fn()
        .mockResolvedValue(mockSuccessGetMultiPageFirst);
      const { queryByText } = setup();
      const nextLink = await waitForElement(() => queryByText('next >'));
      expect(nextLink).toBeInTheDocument();
    });
    it('hides the next button when response has last value as true', async () => {
      apiCalls.listUsers = jest
        .fn()
        .mockResolvedValue(mockSuccessGetMultiPageLast);
      const { queryByText } = setup();
      const nextLink = await waitForElement(() => queryByText('next >'));
      expect(nextLink).not.toBeInTheDocument();
    });
    it('displays the previous button when response has first value as false', async () => {
      apiCalls.listUsers = jest
        .fn()
        .mockResolvedValue(mockSuccessGetMultiPageLast);
      const { queryByText } = setup();
      const previous = await waitForElement(() => queryByText('< previous'));
      expect(previous).toBeInTheDocument();
    });
    it('hides the previous button when response has first value as true', async () => {
      apiCalls.listUsers = jest
        .fn()
        .mockResolvedValue(mockSuccessGetMultiPageFirst);
      const { queryByText } = setup();
      const previous = await waitForElement(() => queryByText('< previous'));
      expect(previous).not.toBeInTheDocument();
    });
...
describe('Interactions', () => {
    it('loads next page when clicked to next button', async () => {
      apiCalls.listUsers = jest
        .fn()
        .mockResolvedValueOnce(mockSuccessGetMultiPageFirst)
        .mockResolvedValueOnce(mockSuccessGetMultiPageLast);
      const { queryByText } = setup();
      const nextLink = await waitForElement(() => queryByText('next >'));
      fireEvent.click(nextLink);

      const secondPageUser = await waitForElement(() =>
        queryByText('display4@user4')
      );
      expect(secondPageUser).toBeInTheDocument();
    });
    it('loads previous page when clicked to previous button', async () => {
      apiCalls.listUsers = jest
        .fn()
        .mockResolvedValueOnce(mockSuccessGetMultiPageLast)
        .mockResolvedValueOnce(mockSuccessGetMultiPageFirst);
      const { queryByText } = setup();
      const previousLink = await waitForElement(() =>
        queryByText('< previous')
      );
      fireEvent.click(previousLink);

      const firstPageUser = await waitForElement(() =>
        queryByText('display1@user1')
      );
      expect(firstPageUser).toBeInTheDocument();
    });
  });

ページネーションが含まれたユーザーリストを表示するコンポーネントのテストです。

mockSuccessGetMultiPageFirst と mockSuccessGetMultiPageLast は、異なるページのユーザーデータを模擬したモックデータです。

テストケースでは、APIからのレスポンスに基づいて、前後のページに移動するためのボタン(次へ、前へ)が正しく表示および非表示になることを確認します。

'Interactions' セクションでは、ユーザーが次へおよび前へボタンをクリックした際に、新しいページが読み込まれ、ユーザーが切り替わることをテストします。

frontend/src/components/UserList.js

class UserList extends React.Component {
  state = {
    page: {
      content: [],
      number: 0,
      size: 3
    }
  };
  componentDidMount() {
    this.loadData();
  }

  loadData = (requestedPage = 0) => {
    apiCalls
      .listUsers({ page: requestedPage, size: this.state.page.size })
      .then((response) => {
        this.setState({
          page: response.data
        });
      });
  };

  onClickNext = () => {
    this.loadData(this.state.page.number + 1);
  };

  onClickPrevious = () => {
    this.loadData(this.state.page.number - 1);
  };

  render() {
    return (
            return <UserListItem key={user.username} user={user} />;
          })}
        </div>
        <div className="clearfix">
          {!this.state.page.first && (
            <span
              className="badge badge-light float-left"
              style={{ cursor: 'pointer' }}
              onClick={this.onClickPrevious}
            >{`< previous`}</span>
          )}
          {!this.state.page.last && (
            <span
              className="badge badge-light float-right"
              style={{ cursor: 'pointer' }}
              onClick={this.onClickNext}
            >
              next >
            </span>
          )}
        </div>
      </div>
    );
  }

UserList コンポーネントは、ユーザー一覧を表示し、ページネーション(前へ、次へ)を提供します。

componentDidMount ライフサイクルメソッドは、コンポーネントが初期化された直後に実行され、初期データの読み込みに loadData メソッドを呼び出します。

loadData メソッドは、指定されたページのユーザーデータを API から読み込み、コンポーネントの状態を更新します。

onClickNext メソッドと onClickPrevious メソッドは、次へおよび前へボタンがクリックされたときに、それぞれ次のページと前のページのデータを読み込むために loadData メソッドを呼び出します。

render メソッドは、ユーザーリストの表示を担当し、各ユーザーのデータを UserListItem コンポーネントで表示します。また、ページネーションボタンも表示し、前へボタンと次へボタンが最初のページや最後のページの場合に非表示になるように制御します。


5. 最後に


今までユーザーのページネーションについて、勉強しました!ウェブ開発では、ページネーションは大規模なデータを効率的に管理するための不可欠な機能です。 ページネーションを使用すると、一度に大量のデータを表示することなく、ページ単位で分けて表示できます。 これにより、ユーザーは必要なデータをすばやく簡単に見つけることができます。

どんなデータでもページネーションを通じて簡単かつ迅速にユーザーアクセスが向上するように即戦力を備えるようにしなければなりません。



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


【参考】


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

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