9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

ViewModel / FormModelを用いたテスタブルなコンポーネント設計

Last updated at Posted at 2019-05-27

AngularでもReactでも共通する話題ですが、フロントエンドでのコンポーネントをテストしやすい設計としてViewModelとFormModelを取り入れたらうまくいった話です。

コンポーネントテストの難しさ

そもそもフロントエンドでのコンポーネントの単体テストする難しさ(面倒さ)は何故なのか。それはinputの作りにくさとoutputの扱いづらさが起因してると考えられます。
・ inputとして値を与えるために、Serviceなどの外部モックを作る必要がある。(AngularであればDIの部分)
・ outputがDOMなので、DOM操作をしての検証が必要となる。
スクリーンショット 2019-05-24 11.33.49.png

責務を分割したコンポーネント設計

テストがしやすいコンポーネントにするために、コンポーネントを責務ごとに分割しよう、というのが今回の趣旨です。

まず、単純にコンポーネントを作った際にコンポーネントがもつ責務の範囲は、
・ Service層またはStoreとのデータのやりとり
・ 自身のStateの管理
・ Viewの構築
が挙げられます。

これを
・ Service層とのデータのやりとり & Stateの管理を行うコンポーネント(Container Component)
・ Viewのテンプレートとしてのコンポーネント(Presentation Component)
・ Viewの構築ロジックをもつ値オブジェクト(ViewModel/FormModel)
に分割します。

商品情報を表示するコンポーネントを例にして、Reactで実装したケースで説明します。

スクリーンショット 2019-05-24 12.19.51.png

以下は、データの取得、State管理、View構築を含んだComponentです。

product/product.component.tsx
interface Props {
  productId: number;
}
interface State {
  product: Product | null;
}

export class ProductComponent extends Component<Props, State> {
  constructor(props) {
    super(props);
    this.state = {
      product: null
    };
    this.getProduct();
  }

  async getProduct(){
    const product: Product = await new ProductApiService().getProduct(this.props.productId);
    this.setState({...this.state, product})
  }

  formatCurrency(price: number): string{
    return ${(price || 0).toLocaleString()}`;
  }

  render() {
    return (
      <div>        
        {this.state.product ? (
          <div>
            <h1>{this.state.product.name}</h1>
            <div>
              {this.formatCurrency(this.state.product.price)}
            </div>
          </div>
        ) : null}
      </div>
    );
  }
}

1. Stateを持たないコンポーネントへ分離

Stateを持つContainerComponentと、Stateを持たないPresentationComponentへ分離します。Redux関連でも割とよく使われるコンポーネントパターンです。
https://redux.js.org/basics/usage-with-react#presentational-and-container-components
スクリーンショット 2019-05-24 11.33.57.png

ContainerComponent

ContainerComponentはView部分を子コンポーネントに移し、View部分を薄くします。

product/productContainer.component.tsx
interface ContainerProps {
  productId: number;
}
interface ContainerState {
  product: Product | null;
}

export class ProductContainerComponent extends Component<ContainerProps, ContainerState> {
  constructor(props) {
    super(props);
    this.state = {
      product: null
    };
    this.getProduct();
  }

  async getProduct(){
    const product: Product = await new ProductApiService().getProduct(this.props.productId);
    this.setState({...this.state, product})
  }

  render() {
    return (
      <div>        
        {this.state.product ? (
          <ProductComponent product={this.state.product}></ProductComponent>
        ) : null}
      </div>
    );
  }
}

PresentaionComponent

PresentationComponentはStateを持たないので、渡された値を元にViewを構築します。
ReactであればFunctionalComponentとしてViewのみを返す関数にします。

product/components/product.component.tsx
interface Props {
  product: Product;
}

export const ProductComponent: React.FC<Props> = props => {
  const formatCurrency = (price: number): string => {
    return ${(price || 0).toLocaleString()}`;
  }

  return (
    <div>
      <h1>{props.product.name}</h1>
      <div>
        {formatCurrency(props.product.price)}
      </div>
    </div>
  );
}

2. ViewModelへのロジックの分離

本題です。
PresentationComponentでのModelからViewを構築するロジックをViewModelへ移します。
ViewModelは表示に特化したモデルクラスで、モデルをViewに合わせて整形します。値の変換、ifでの表示分岐ロジックなどを担います。
ViewModelはContainerComponent内で作成し、PresentationComponentにViewModelを渡すようにします。
PresentationComponentは受け取ったViewModelを表示するのみにします。

スクリーンショット 2019-05-24 11.34.07.png

ViewModel

この例では、モデルの「名前」と「金額」を整形済みの形で返します。

product/components/productComponent.viewmodel.tsx
export class ProductComponentViewModel{
  constructor(private product: Product){}

  get name(){
    return this.product.name;
  }

  get price(){
    return ${(this.product.price || 0).toLocaleString()}`;
  }
}

PresentationComponent

関数を持たず、受け取ったViewModelのプロパティを表示するのみになります。

product/components/product.component.tsx
interface Props {
  product: ProductComponentViewModel;
}

export const ProductComponent: React.FC<Props> = props => {
  return (
    <div>
      <h1>{props.product.name}</h1>
      <div>
        {props.product.price}
      </div>
    </div>
  );
}

ContainerComponent

ViewModelを作成し、子コンポーネントに渡します。

product/productContainer.component.tsx
// ... (省略)
render() {
    return (
      <div>        
        {this.state.product ? (
          <ProductComponent product={new ProductComponentViewModel(this.state.product)}></ProductComponent>
        ) : null}
      </div>
    );
  }
// ... (省略)

ViewModelの共通化

ViewModelは原則として1つのコンポーネントに対して作り、他のコンポーネントへの流用は行いません。
今回のようなProductの値を整形したい場合に、同じ整形ロジックを別のViewModelでも使いたいシチュエーションがあります。 その場合はmodelに対応した共通ViewModelをつくり、各ViewModel側で集約(aggregate)する形をとるとよいです。

viewmodel/product.viewmodel.ts
export class ProductViewModel{
  constructor(private product: Product){}

  get name(){
    return this.product.name;
  }

  get price(){
    return ${(this.product.price || 0).toLocaleString()}`;
  }
}
product/components/productComponent.viewmodel.tsx
export class ProductComponentViewModel{
  product: ProductViewModel;

  constructor(private _product: Product){
    this.product = new ProductViewModel(_product);
  }

  get name(){
    return this.product.name;
  }

  get price(){
    return this.product.price;
  }
}

FormModel

FormModelとは、Formに特化したViewModelです。Formに表示する値の整形や、Formのバリデーションを行います。

下の例は、フィールドとサブミットボタンだけの簡単なフォームで、フィールドに値がない場合はボタンを非活性にするバリデーションを行っています。
スクリーンショット 2019-05-24 20.23.18.png

ContainerComponent

FormModelを作成して子コンポーネントに渡し、子コンポーネントからの変更のコールバックもFormModelで受け取ります。

form/formContainer.component.tsx
export class FormContainerComponent extends Component<ContainerProps, ContainerState> {
  constructor(props) {
    super(props);
    this.state = {
      name: ''
    };
  }
  handleChange(form: FormModel){
    const name = form.name;
    this.setState({...this.state, name: name});
  }

  render() {
    const form = new FormModel({
          name: this.state.name, 
          onChange: this.handleChange.bind(this)
          })
    return (
      <FormComponent form={form}/>
    );
  }
}

FormModel

フォームのバリデーションロジックを持ちます。また、フォームに変更があった場合に新しいFormModelを作成しコールバックを呼びます。

form/components/form.formmodel.ts
interface FormModelProps{
  name: string;
  onChange: (form: FormModel) => void;
}

class FormModel implements FormModelProps{
  readonly name: string;
  readonly onChange: (form: FormModel) => void;
  readonly isValid: boolean;

  constructor(private props: FormModelProps){
    this.name = props.name;
    this.onChange = props.onChange;
    this.isValid = this.validate();
  }

  validate(){
    return this.name !== '';
  }

  handleNameChange(name: string){
    this.onChange(new FormModel({...this.props, name}));
  }
}

PresentationComponent

ViewModelと同様に、FormModelの値を表示するのみにします。
onChangeのイベントハンドリングは、値をとる部分がViewに依存するのでfunctionを持ちますが、取得した値をFormModelに渡して後の処理は移譲します。

form/components/form.component.ts
interface Props{
  form: FormModel
}

export const FormComponent: React.FC<Props> = props => {
  const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const name = event.currentTarget.value;
    props.form.handleNameChange(name);
  }

  return (
    <fieldset>
        <input value={props.form.name} onChange={handleNameChange.bind(props)}/>
        <button disabled={!props.form.isValid}>submit</button>
      </fieldset>
  )
}

まとめ

・ Viewの構築ロジックをViewModelとしてピュアなJSクラスになるのでテストがしやすくなる
・ Formも同様に、FormModelにロジックを移すことでテストがしやすくなる
・ PresentationComponentはViewModelの値を表示をするのり、Snaoshotテストを行えば良い状態になる
(SnapshotテストはJestの機能なので、Jasmineを使用している場合はJestへの切り替えをおすすめします)

9
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?