見出し画像

【テスト駆動開発】Springboot & React - 第2回 : バリデーション


1. はじめに


こんにちは、今日はバリデーションについて勉強します!Web開発において、データ入力と処理は常に主要な課題の一つです。ユーザーがフォームを作成したり、データを送信する際、データの整合性、セキュリティの確保、ユーザーエクスペリエンスの向上という点で、検証することは非常に重要です。

今日の目標画面1です。
パスワードが合わない場合
今日の目標画面2です。
パスワードが合う場合


2. バリデーションとは?


バリデーション(Validation)は、データや入力値が特定の条件や基準を満たしているかどうかを確認し、データの正当性や整合性を「検証」するプロセスです。

バリデーションは、Webアプリケーションのフォーム入力、データベース操作、APIリクエストなど、さまざまなコンテキストで使用されます。

ユーザー登録フォームのバリデーションにはパスワードの長さや文字パターン、パスワードの一致確認などがあります。バリデーションは、データの信頼性を確保し、システムの適切な動作を保証するために非常に重要な要素です。


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


3.1. ユーザー検証

/user/User.java

public class User {
	@GeneratedValue
	private long id;

	@NotNull
	@Size(min = 4, max=255)
	private String username;

	@NotNull
	@Size(min = 4, max=255)
	private String displayName;

	@NotNull
	@Size(min = 8, max=255)
	@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).*$")
	private String password;

}

ユーザー属性(username, displayName, password

これらの属性はユーザーオブジェクトの特性を定義しています。@NotNullアノテーションは、これらの属性がnullでないことを要求し、@Sizeアノテーションは属性の文字列長に制約を設けています。

@Pattern

password属性に対して正規表現を使用しています。この正規表現は、少なくとも1つの小文字、1つの大文字、1つの数字を含むパスワードを要求します。つまり、セキュアなパスワードの要件を指定しています。


/user/UserController.java

@PostMapping("/api/1.0/users")

GenericResponse createUser(@Valid @RequestBody User user) {  
    userService.save(user);
		return new GenericResponse("User saved");
	}


/UserControllerTest.java

	@Test
	public void postUser_whenUserHasNullUsername_receiveBadRequest() {
		User user = createValidUser();
		user.setUsername(null);
		ResponseEntity<Object> response = postSignup(user, Object.class);
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
	}

	@Test
	public void postUser_whenUserHasNullDisplayName_receiveBadRequest() {
		User user = createValidUser();
		user.setDisplayName(null);
		ResponseEntity<Object> response = postSignup(user, Object.class);
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
	}

	@Test
	public void postUser_whenUserHasNullPassword_receiveBadRequest() {
		User user = createValidUser();
		user.setPassword(null);
		ResponseEntity<Object> response = postSignup(user, Object.class);
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
	}

	@Test
	public void postUser_whenUserHasUsernameWithLessThanRequired_receiveBadRequest() {
		User user = createValidUser();
		user.setUsername("abc");
		ResponseEntity<Object> response = postSignup(user, Object.class);
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
	}

	@Test
	public void postUser_whenUserHasDisplayNameWithLessThanRequired_receiveBadRequest() {
		User user = createValidUser();
		user.setDisplayName("abc");
		ResponseEntity<Object> response = postSignup(user, Object.class);
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
	}

	@Test
	public void postUser_whenUserHasPasswordWithLessThanRequired_receiveBadRequest() {
		User user = createValidUser();
		user.setPassword("P4sswd");
		ResponseEntity<Object> response = postSignup(user, Object.class);
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
	}

	@Test
	public void postUser_whenUserHasUsernameExceedsTheLengthLimit_receiveBadRequest() {
		User user = createValidUser();
		String valueOf256Chars = IntStream.rangeClosed(1,256).mapToObj(x -> "a").collect(Collectors.joining());
		user.setUsername(valueOf256Chars);
		ResponseEntity<Object> response = postSignup(user, Object.class);
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
	}

	@Test
	public void postUser_whenUserHasDisplayNameExceedsTheLengthLimit_receiveBadRequest() {
		User user = createValidUser();
		String valueOf256Chars = IntStream.rangeClosed(1,256).mapToObj(x -> "a").collect(Collectors.joining());
		user.setDisplayName(valueOf256Chars);
		ResponseEntity<Object> response = postSignup(user, Object.class);
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
	}

	@Test
	public void postUser_whenUserHasPasswordExceedsTheLengthLimit_receiveBadRequest() {
		User user = createValidUser();
		String valueOf256Chars = IntStream.rangeClosed(1,256).mapToObj(x -> "a").collect(Collectors.joining());
		user.setPassword(valueOf256Chars + "A1");
		ResponseEntity<Object> response = postSignup(user, Object.class);
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
	}

	@Test
	public void postUser_whenUserHasPasswordWithAllLowercase_receiveBadRequest() {
		User user = createValidUser();
		user.setPassword("alllowercase");
		ResponseEntity<Object> response = postSignup(user, Object.class);
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
	}

	@Test
	public void postUser_whenUserHasPasswordWithAllUppercase_receiveBadRequest() {
		User user = createValidUser();
		user.setPassword("ALLUPPERCASE");
		ResponseEntity<Object> response = postSignup(user, Object.class);
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
	}

	@Test
	public void postUser_whenUserHasPasswordWithAllNumber_receiveBadRequest() {
		User user = createValidUser();
		user.setPassword("123456789");
		ResponseEntity<Object> response = postSignup(user, Object.class);
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
	}

	public <T> ResponseEntity<T> postSignup(Object request, Class<T> response){
		return testRestTemplate.postForEntity(API_1_0_USERS, request, response);
	}
  1. ユーザー属性とバリデーションテスト

    • ユーザー属性(username, displayName, password)に対するバリデーションテストを実施しています。それぞれ、属性がnullであるか、指定された文字数の制約を満たさない場合にはHttpStatus.BAD_REQUEST(HTTP 400)の応答を期待しています。

  2. パスワードの強度テスト

    • パスワード属性に対して、長さとパターン(大文字、小文字、数字の組み合わせ)の制約をテストしています。これにより、弱いパスワードが受け入れられないことを確認しています。

  3. postSignup メソッド

    • postSignup メソッドは、テスト対象のエンドポイントにリクエストを送信し、レスポンスを取得するために使用されます。このメソッドは、リクエストとレスポンスの型を指定して呼び出されます。


3.2. エラーモデリング

/UserControllerTest.java

@Test
	public void postUser_whenUserIsInvalid_receiveApiError() {
		User user = new User();
		ResponseEntity<ApiError> response = postSignup(user, ApiError.class);
		assertThat(response.getBody().getUrl()).isEqualTo(API_1_0_USERS);
	}

	@Test
	public void postUser_whenUserIsInvalid_receiveApiErrorWithValidationErrors() {
		User user = new User();
		ResponseEntity<ApiError> response = postSignup(user, ApiError.class);
		assertThat(response.getBody().getValidationErrors().size()).isEqualTo(3);
	}

無効なユーザーが登録された場合のAPIエラーレスポンスを確認するためのものです。

最初のテストはAPIエラーレスポンスのURLが期待通りであることを確認です。

2番目のテストはAPIエラーレスポンスにバリデーションエラーが含まれていることを確認しています。


/e@rror/ApiError.java

@Data
@NoArgsConstructor
public class ApiError {

	private long timestamp = new Date().getTime();

	private int status;

	private String message;

	private String url;

	private Map<String, String> validationErrors;

	public ApiError(int status, String message, String url) {
		super();
		this.status = status;
		this.message = message;
		this.url = url;
	}

}

ApiError クラスには、タイムスタンプ、ステータスコード、メッセージ、URL、およびバリデーションエラーのマップが含まれています。このクラスは、APIのエラー情報を表現するために使用されます。

/user/UserController.java

@ExceptionHandler({MethodArgumentNotValidException.class})
	@ResponseStatus(HttpStatus.BAD_REQUEST)
	ApiError handleValidationException(MethodArgumentNotValidException exception, HttpServletRequest request) {
		ApiError apiError = new ApiError(400, "Validation error", request.getServletPath());

		BindingResult result = exception.getBindingResult();

		Map<String, String> validationErrors = new HashMap<>();

		for(FieldError fieldError: result.getFieldErrors()) {
			validationErrors.put(fieldError.getField(), fieldError.getDefaultMessage());
		}
		apiError.setValidationErrors(validationErrors);

		return apiError;
	}

APIエラーレスポンスを生成するためのエクセプションハンドラメソッドです。

バリデーションエラーの処理
メソッドは、バリデーションエラー情報を取得し、それをAPIエラーオブジェクトにマッピングします。バリデーションエラーの詳細は、result.getFieldErrors()を介して取得され、フィールド名とエラーメッセージの対応関係を持つマップに格納されます。

APIエラーレスポンスの生成
バリデーションエラー情報を含むApiErrorオブジェクトが生成され、ステータスコード400(BAD_REQUEST)と適切なメッセージ、URLが設定されます。バリデーションエラー情報は、APIエラーオブジェクトに格納され、クライアントに返されます。



3.3. エラーメッセージの外部化

/resources/ValidationMessages.properties

javax.validation.constraints.NotNull.message = Cannot be null
hoaxify.constraints.username.NotNull.message = Username cannot be null
javax.validation.constraints.Size.message    = It must have minimum {min} and maximum {max} characters
hoaxify.constraints.password.Pattern.message = Password must have at least one uppercase, one lowercase letter and one number

/UserControllerTest.java

	@Test
	public void postUser_whenUserHasNullUsername_receiveMessageOfNullErrorForUsername() {
		User user = createValidUser();
		user.setUsername(null);
		ResponseEntity<ApiError> response = postSignup(user, ApiError.class);
		Map<String, String> validationErrors = response.getBody().getValidationErrors();
		assertThat(validationErrors.get("username")).isEqualTo("Username cannot be null");
	}

	@Test
	public void postUser_whenUserHasNullPassword_receiveGenericMessageOfNullError() {
		User user = createValidUser();
		user.setPassword(null);
		ResponseEntity<ApiError> response = postSignup(user, ApiError.class);
		Map<String, String> validationErrors = response.getBody().getValidationErrors();
		assertThat(validationErrors.get("password")).isEqualTo("Cannot be null");
	}

	@Test
	public void postUser_whenUserHasInvalidLengthUsername_receiveGenericMessageOfSizeError() {
		User user = createValidUser();
		user.setUsername("abc");
		ResponseEntity<ApiError> response = postSignup(user, ApiError.class);
		Map<String, String> validationErrors = response.getBody().getValidationErrors();
		assertThat(validationErrors.get("username")).isEqualTo("It must have minimum 4 and maximum 255 characters");
	}

	@Test
	public void postUser_whenUserHasInvalidPasswordPattern_receiveMessageOfPasswordPatternError() {
		User user = createValidUser();
		user.setPassword("alllowercase");
		ResponseEntity<ApiError> response = postSignup(user, ApiError.class);
		Map<String, String> validationErrors = response.getBody().getValidationErrors();
		assertThat(validationErrors.get("password")).isEqualTo("Password must have at least one uppercase, one lowercase letter and one number");
	}

これの4つのテストケースは、バリデーションエラーシナリオに対応したAPIエラーレスポンスの検証を行っています。

postUser_whenUserHasNullUsername_receiveMessageOfNullErrorForUsername

ユーザーのユーザー名がnullの場合、バリデーションエラーメッセージが「Username cannot be null」であることを検証しています。

postUser_whenUserHasNullPassword_receiveGenericMessageOfNullError

ユーザーのパスワードがnullの場合、バリデーションエラーメッセージが「Cannot be null」であることを検証しています。

postUser_whenUserHasInvalidLengthUsername_receiveGenericMessageOfSizeError

ユーザーのユーザー名が指定された文字数の範囲外にある場合、バリデーションエラーメッセージが「It must have minimum 4 and maximum 255 characters」であることを検証しています。

postUser_whenUserHasInvalidPasswordPattern_receiveMessageOfPasswordPatternError

ユーザーのパスワードが要求されるパターンに合致しない場合、バリデーションエラーメッセージが「Password must have at least one uppercase, one lowercase letter and one number」であることを検証しています。

3.4. 制約

/UserControllerTest.java

	@Test
	public void postUser_whenAnotherUserHasSameUsername_receiveBadRequest() {
		userRepository.save(createValidUser());

		User user = createValidUser();
		ResponseEntity<Object> response = postSignup(user, Object.class);
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
	}

	@Test
	public void postUser_whenAnotherUserHasSameUsername_receiveMessageOfDuplicateUsernamet() {
		userRepository.save(createValidUser());

		User user = createValidUser();
		ResponseEntity<ApiError> response = postSignup(user, ApiError.class);
		Map<String, String> validationErrors = response.getBody().getValidationErrors();
		assertThat(validationErrors.get("username")).isEqualTo("This name is in use");
	}

postUser_whenAnotherUserHasSameUsername_receiveBadRequest:

重複するユーザー名を許可しないことを確認しています。

postUser_whenAnotherUserHasSameUsername_receiveMessageOfDuplicateUsername:

重複したユーザー名のエラーが適切にハンドリングされていることを確認しています。

/UserRepositoryTest.java

@RunWith(SpringRunner.class)
@DataJpaTest
@ActiveProfiles("test")
public class UserRepositoryTest {

	@Autowired
	TestEntityManager testEntityManager;

	@Autowired
	UserRepository userRepository;

	@Test
	public void findByUsername_whenUserExists_returnsUser() {
		User user = new User();

		user.setUsername("test-user");
		user.setDisplayName("test-display");
		user.setPassword("P4ssword");

		testEntityManager.persist(user);

		User inDB = userRepository.findByUsername("test-user");
		assertThat(inDB).isNotNull();

	}

	@Test
	public void findByUsername_whenUserDoesNotExist_returnsNull() {
		User inDB = userRepository.findByUsername("nonexistinguser");
		assertThat(inDB).isNull();
	}

}

findByUsername_whenUserExists_returnsUser:

存在するユーザーを検索して取得するテストです。

findByUsername_whenUserDoesNotExist_returnsNull:

存在しないユーザーを検索したときに正しく null が返されることを確認するテストです。

/user/UniqueUsername.java

@Constraint(validatedBy = UniqueUsernameValidator.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueUsername {

	String message() default "{hoaxify.constraints.username.UniqueUsername.message}";

	Class<?>[] groups() default { };

	Class<? extends Payload>[] payload() default { };

}


/user/UniqueUsernameValidator.java

public class UniqueUsernameValidator implements ConstraintValidator<UniqueUsername, String>{

	@Autowired
	UserRepository userRepository;

	@Override
	public boolean isValid(String value, ConstraintValidatorContext context) {

		User inDB = userRepository.findByUsername(value);
		if(inDB == null) {
			return true;
		}

		return false;
	}

}

/user/User.java

	@NotNull(message = "{hoaxify.constraints.username.NotNull.message}")
	@Size(min = 4, max=255)
	@UniqueUsername // Add the annotation 
	private String username;


/user/UserRepository.java

public interface UserRepository extends JpaRepository<User, Long>{

	User findByUsername(String username);

}


/resources/ValidationMessages.properties

hoaxify.constraints.password.Pattern.message = Password must have at least one uppercase, one lowercase letter and one number
hoaxify.constraints.username.UniqueUsername.message = This name is in use


/resources/application.yml

  h2:
    console:
      enabled: true
     path: /h2-console  jpa:    properties:      javax:
        persistence:
          validation:
            mode: none



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


4.1. バリデーション・エラーの表示

/pages/UserSignupPage.spec.js

    it('displays validation error for displayName when error is received for the field', async () => {
      const actions = {
        postSignup: jest.fn().mockRejectedValue({
          response: {
            data: {
              validationErrors: {
                displayName: 'Cannot be null'
              }
            }
          }
        })
      }
      const { queryByText } = setupForSubmit({ actions });
      fireEvent.click(button);

      const errorMessage = await waitForElement(() => queryByText('Cannot be null'));
      expect(errorMessage).toBeInTheDocument();

    })

エラーメッセージが画面に表示されることを検証し、エラーメッセージが「Cannot be null」という内容であることを確認することです。

/pages/UserSignupPage.js


export class UserSignupPage extends React.Component {
  state = {
    displayName: '',
    username: '',
    password: '',
    passwordRepeat: '',   
    pendingApiCall: false,

    // Add Errors Field
    errors: {}
  };
...
...
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 });
      })

     // Modify ApiError
       .catch((apiError) => {
        let errors = { ...this.state.errors };
        if (apiError.response.data && apiError.response.data.validationErrors) {
          errors = { ...apiError.response.data.validationErrors };
        }
        this.setState({ pendingApiCall: false, errors });
      }); 
  };
...
...
  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}
          />

          // Display Error
          <div className="invalid-feedback">
            {this.state.errors.displayName}
          </div>
        </div>
        <div className="col-12 mb-3">
          <label>Username</label>


4.2. フォーム入力コンポーネント


/components/Input.spec.js

import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import Input from './Input';

describe('Layout', () => {
  it('has input item', () => {
    const { container } = render(<Input />);
    const input = container.querySelector('input');
    expect(input).toBeInTheDocument();
  });

  it('displays the label provided in props', () => {
    const { queryByText } = render(<Input label="Test label" />);
    const label = queryByText('Test label');
    expect(label).toBeInTheDocument();
  });

  it('does not displays the label when no label provided in props', () => {
    const { container } = render(<Input />);
    const label = container.querySelector('label');
    expect(label).not.toBeInTheDocument();
  });

  it('has text type for input when type is not provided as prop', () => {
    const { container } = render(<Input />);
    const input = container.querySelector('input');
    expect(input.type).toBe('text');
  });

  it('has password type for input password type is provided as prop', () => {
    const { container } = render(<Input type="password" />);
    const input = container.querySelector('input');
    expect(input.type).toBe('password');
  });

  it('displays placeholder when it is provided as prop', () => {
    const { container } = render(<Input placeholder="Test placeholder" />);
    const input = container.querySelector('input');
    expect(input.placeholder).toBe('Test placeholder');
  });

  it('has value for input when it is provided as prop', () => {
    const { container } = render(<Input value="Test value" />);
    const input = container.querySelector('input');
    expect(input.value).toBe('Test value');
  });

  it('has onChange callback when it is provided as prop', () => {
    const onChange = jest.fn();
    const { container } = render(<Input onChange={onChange} />);
    const input = container.querySelector('input');
    fireEvent.change(input, { target: { value: 'new-input' } });
    expect(onChange).toHaveBeenCalledTimes(1);
  });

  it('has default style when there is no validation error or success', () => {
    const { container } = render(<Input />);
    const input = container.querySelector('input');
    expect(input.className).toBe('form-control');
  });

  it('has success style when hasError property is false', () => {
    const { container } = render(<Input hasError={false} />);
    const input = container.querySelector('input');
    expect(input.className).toBe('form-control is-valid');
  });

  it('has style for error case when there is error', () => {
    const { container } = render(<Input hasError={true} />);
    const input = container.querySelector('input');
    expect(input.className).toBe('form-control is-invalid');
  });

  it('displays the error text when it is provided', () => {
    const { queryByText } = render(
      <Input hasError={true} error="Cannot be null" />
    );
    expect(queryByText('Cannot be null')).toBeInTheDocument();
  });

  it('does not display the error text when hasError not provided', () => {
    const { queryByText } = render(<Input error="Cannot be null" />);
    expect(queryByText('Cannot be null')).not.toBeInTheDocument();
  });
});

テストケースは以下を確認します。
1.コンポーネントが適切な要素を含むかどうか。
2.ラベル、プレースホルダ、値、タイプなどのプロ3.パティが適切に表示されるかどうか。

/components/Input.js

import React from 'react';

const Input = (props) => {
  let inputClassName = 'form-control';
  if (props.hasError !== undefined) {
    inputClassName += props.hasError ? ' is-invalid' : ' is-valid';
  }

  return (
    <div>
      {props.label && <label>{props.label}</label>}
      <input
        className={inputClassName}
        type={props.type || 'text'}
        placeholder={props.placeholder}
        value={props.value}
        onChange={props.onChange}
      />
      {props.hasError && (
        <span className="invalid-feedback">{props.error}</span>
      )}
    </div>
  );
};

Input.defaultProps = {
  onChange: () => {}
};

export default Input;

プロパティに基づいてスタイルや表示を調整できる。
ラベル、入力フィールド、エラーメッセージを含むフォーム要素を生成する。
デフォルトのプロパティ(onChange)を持っており、エラーがある場合には適切なスタイルとエラーメッセージを表示する。

/pages/UserSignupPage.js

import React from 'react';
import Input from '../components/Input';

export class UserSignupPage extends React.Component {
  state = {
    ...
      <div className="container">
        <h1 className="text-center">Sign Up</h1>
        <div className="col-12 mb-3">
          <Input
       // Add 'label', 'hasError', 'error'
            label="Display Name"
            placeholder="Your display name"
            value={this.state.displayName}
            onChange={this.onChangeDisplayName}

            // Add 'label', 'hasError', 'error'
            hasError={this.state.errors.displayName && true}
            error={this.state.errors.displayName}
          />

        </div>
        <div className="col-12 mb-3">
         <Input
       // Add 'label', 'hasError', 'error'
            label="Username"
            placeholder="Your username"
            value={this.state.username}
            onChange={this.onChangeUsername}
            
            // Add 'label', 'hasError', 'error'
            hasError={this.state.errors.username && true}
            error={this.state.errors.username}
          />
... // Modify Same Part In Password, Password Repeat


4.3. クライアントサイドのバリデーション

/pages/UserSignupPage.spec.js



    it('enables the signup button when password and repeat password have same value', () => {
      setupForSubmit();
      expect(button).not.toBeDisabled();
    });

    it('disables the signup button when password repeat does not match to password', () => {
      setupForSubmit();
      fireEvent.change(passwordRepeat, changeEvent('new-pass'));
      expect(button).toBeDisabled();
    });

    it('disables the signup button when password does not match to password repeat', () => {
      setupForSubmit();
      fireEvent.change(passwordInput, changeEvent('new-pass'));
      expect(button).toBeDisabled();
    });

    it('displays error style for password repeat input when password repeat mismatch', () => {
      const { queryByText } = setupForSubmit();
      fireEvent.change(passwordRepeat, changeEvent('new-pass'));
      const mismatchWarning = queryByText('Does not match to password');
      expect(mismatchWarning).toBeInTheDocument();
    });

    it('displays error style for password repeat input when password input mismatch', () => {
      const { queryByText } = setupForSubmit();
      fireEvent.change(passwordInput, changeEvent('new-pass'));
      const mismatchWarning = queryByText('Does not match to password');
      expect(mismatchWarning).toBeInTheDocument();
    });

    it('hides the validation error when user changes the content of displayName', async () => {
      const actions = {
        postSignup: jest.fn().mockRejectedValue({
          response: {
            data: {
              validationErrors: {
                displayName: 'Cannot be null'
              }
            }
          }
        })
      };
      const { queryByText } = setupForSubmit({ actions });
      fireEvent.click(button);

      await waitForElement(() => queryByText('Cannot be null'));
      fireEvent.change(displayNameInput, changeEvent('name updated'));

      const errorMessage = queryByText('Cannot be null');
      expect(errorMessage).not.toBeInTheDocument();
    });

    it('hides the validation error when user changes the content of username', async () => {
      const actions = {
        postSignup: jest.fn().mockRejectedValue({
          response: {
            data: {
              validationErrors: {
                username: 'Username cannot be null'
              }
            }
          }
        })
      };
      const { queryByText } = setupForSubmit({ actions });
      fireEvent.click(button);

      await waitForElement(() => queryByText('Username cannot be null'));
      fireEvent.change(usernameInput, changeEvent('name updated'));

      const errorMessage = queryByText('Username cannot be null');
      expect(errorMessage).not.toBeInTheDocument();
    });
    it('hides the validation error when user changes the content of password', async () => {
      const actions = {
        postSignup: jest.fn().mockRejectedValue({
          response: {
            data: {
              validationErrors: {
                password: 'Cannot be null'
              }
            }
          }
        })
      };
      const { queryByText } = setupForSubmit({ actions });
      fireEvent.click(button);

      await waitForElement(() => queryByText('Cannot be null'));
      fireEvent.change(passwordInput, changeEvent('updated-password'));

      const errorMessage = queryByText('Cannot be null');
      expect(errorMessage).not.toBeInTheDocument();
    });

これらのテストケースは、ユーザー登録フォームのさまざまな振る舞いをテストしています。

1. パスワードと繰り返しパスワードが一致する場合、登録ボタンが有効になることを確認。
2. パスワードと繰り返しパスワードが一致しない場合、登録ボタンが無効になることを確認。
3. パスワードと繰り返しパスワードが不一致の場合、エラースタイルとエラーメッセージが表示されることを確認。
4. フォームの他のフィールドに入力が行われた場合、バリデーションエラーメッセージが隠れることを確認。

/pages/UserSignupPage.js

export class UserSignupPage extends React.Component {
    password: '',
    passwordRepeat: '',
    pendingApiCall: false,
    errors: {},

    // Add !!
    passwordRepeatConfirmed: true
  };

  onChangeDisplayName = (event) => {
    const value = event.target.value;

    // Add !!
    const errors = { ...this.state.errors };
    delete errors.displayName;
    this.setState({ displayName: value, errors });
  };

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

    // Add !!
    const errors = { ...this.state.errors };
    delete errors.username;
    this.setState({ username: value, errors });
  };

  onChangePassword = (event) => {
    const value = event.target.value;

    // Add !!
    const passwordRepeatConfirmed = this.state.passwordRepeat === value;
    const errors = { ...this.state.errors };
    delete errors.password;
    errors.passwordRepeat = passwordRepeatConfirmed
      ? ''
      : 'Does not match to password';
    this.setState({ password: value, passwordRepeatConfirmed, errors });
  };

  onChangePasswordRepeat = (event) => {
    const value = event.target.value;

    // Add !!
    const passwordRepeatConfirmed = this.state.password === value;
    const errors = { ...this.state.errors };
    errors.passwordRepeat = passwordRepeatConfirmed
      ? ''
      : 'Does not match to password';
    this.setState({ passwordRepeat: value, passwordRepeatConfirmed, errors });
  };
...
          <button
            className="btn btn-primary"
            onClick={this.onClickSignup}
            
            // Add !!
            disabled={
              this.state.pendingApiCall || !this.state.passwordRepeatConfirmed
            }
          >{this.state.pendingApiCall && (
              <div className="spinner-border text-light spinner-border-sm mr-1">
                <span className="sr-only">Loading...</span>
              </div>
            )}
            Sign Up
          </button>

コンポーネント「UserSignupPage」にいくつかの新しい機能を追加しています。

パスワードと繰り返しパスワードの一致確認: パスワードと繰り返しパスワードが一致するかどうかを確認するための passwordRepeatConfirmed ステートが追加されました。

入力フィールドのエラーメッセージの削除: 各入力フィールドで値が変更された際に、該当するエラーメッセージが削除される仕組みが追加されました。これにより、入力値が変更されると、エラーメッセージが適切に更新されます。

登録ボタンの無効化: パスワードと繰り返しパスワードが一致しない場合、またはAPI呼び出しが進行中の場合、登録ボタンが無効になります。これは、フォームの一貫性を保つための重要な変更です。

登録ボタン内のスピナーアイコン: API呼び出しが進行中である場合、登録ボタン内にスピナーアイコンが表示されます。これにより、ユーザーに進行中のプロセスを示す視覚的なフィードバックが提供されます。

これらの変更は、ユーザー体験を向上させ、フォームの一貫性とエラーハンドリングを強化するのに役立ちます。


5. 最後に


今までWebアプリケーション開発でデータバリデーションをどのように行うか見てみました。 このような検査は、入力フィールドの長さ、パターン、値の一致有無などを確認してデータの整合性を確保します。コード内では「Validation Errors」というオブジェクトを使ってエラーメッセージを管理し、入力フィールドの変更によって動的に更新されるようにしました。次回にはWebアプリにおいて非常に重要な機能である「ログイン」について勉強します!



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


【参考】


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


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