ライティングテスト(フロントエンド)に関するいくつかのアイデア

作成者:カランカラン
💡

質問やフィードバックがありましたら、フォームからお願いします

本文は台湾華語で、ChatGPT で翻訳している記事なので、不確かな部分や間違いがあるかもしれません。ご了承ください

最近この1ヶ月のプロジェクト開発を通じて、テストを書くことに対する考え方がまったく変わりました。

フロントエンドの部分では、過去にもテストを書くことが好きでしたが、単にユニットテスト(Unit Test)を書くことにとどまらず、Reactを使って開発する関係上、コンポーネントの機能も一緒にテストしていました。例えば、クリックのシミュレーションやユーザーインタラクションなどです。Reduxを使って副作用(side-effect)を管理する場合も、Reduxロジックに関連するテストも一緒に書いていました。Reduxの世界では、pureを保つことができるため、テストを書くのが比較的簡単です。

例を挙げると、記事を読み込む機能があるとします。その場合、これを3つのアクションに分けることができます。それぞれ FETCH_ARTICLEFETCH_SUCCESSFETCH_FAILED で、ユーザーがボタンを押したときにトリガーされます。プログラムはおそらく以下のようになります:

const Article = ({
  fetchArticle,
  status,
  article,
}) => {
  if (status === 'idle') {
		return <button onClick={fetchArticle}>click me</button>
  } else if (status === 'loading') {
    return <Loading />
  }
  
  if (status === 'error') {
    return <Error />
  }
  return isLoading ? null : <article>{article}</article>
};

const mapStateToProps = state => ({
	article: state.article,  
  status: state.article.status,
});

const mapDispatchToProps = {
  fetchArticle,
}

export default connect(mapStateToProps, mapDispatchToProps)(Article);

テストの部分は、いくつかの観点から見ていくことができます:

describe('<Article/>', () => {
  it('should render button when isLoading', () => {
    expect(...); // button exists 
  });
  
  it('should call fetchArticle if button is clicked', () => {
    find(button).simulate('click');
    expect(...) // fetchArticle should be triggered
  })
  
  it('should render article isLoading == false', () => {
    expect(...); // button doesn't exists and article get rendered
  });
});

Reduxの部分はさらに簡単です:

describe('actions', () => {
  it('should return correct type', () => {
    expect(fetchArticle()).toBe({
	    type: 'FETCH_ARTICLE',
  	});
  });
	
  it('should return correct type and response', () => {
    expect(fetchSuccess(content)).toBe({
	    type: 'FETCH_ARTICLE_SUCCESS',
      payload: content,
  	});
  });
  
  it('should return correct type', () => {
    expect(fetchSuccess(err)).toBe({
	    type: 'FETCH_ARTICLE_FAILED',
      payload: err,
  	});
  });
});

describe('reducers', () => {
  it('should return correct state', () => {
    expect(reducer(fetchArticle(), initialState), {
      status: 'loading',
      article: null
    });
  });
  
  it('should return correct state', () => {
    expect(reducer(fetchArticleSuccess(content), initialState), {
      status: 'loaded',
      article: content
    });
  });
  
  it('should return correct state', () => {
    expect(reducer(fetchArticleFailed(err), initialState), {
      status: 'error',
			error: err,
    })
  });
});

これらのテストを見ると、かなり理想的に見えますが、最近気づいたことがあります。どんなにテストをしっかり書いても、考慮していないケースが常に現れ、それが非常に頻繁に起こるため、テストについて考え直すようになりました。

上記のシンプルなシナリオから考えると、いくつかの小さな問題を考慮することができます:

  • もし記事の返り値が正しくない場合、記事の特定のフィールドにアクセスする際にどうなるのか?
  • ローディング状態のときに、ネットワークの不安定さやタイムアウト、パラメータエラー、サーバーエラー、ブラウザのマウントエラーなどの予期しないエラーが発生する可能性があり、これらをすべて同じ error 処理にまとめることは適切なのか?
  • ボタンの重複クリックの問題はどうか?ユーザーがボタンを連続してクリックするのを防ぐために disabled を使うべきか?
  • Articleの折り返しは必要か?必要ならどのように折り返すべきか?ユーザーに横スクロールさせるか縦スクロールさせるか?
  • ボタンや各部分の文言は正しいか?

これらは上記のテストではカバーされていない部分です。「それならば徐々に追加していけばいいじゃないか」と思うかもしれませんが、テストを追加する一方で、コンポーネントも変更しています。変更を行うことで安心感はありますが、最初の私もそう考えていましたが、変更後にはまた予期しないシナリオが現れ、QAが終わらず、デプロイが遅れ、最終的には皆が困惑する状況に陥ります。

フロントエンドにはUIと相互作用するシーンが非常に多く、一般的なテストでその行動を実現するのは難しいです。

その後、私はあることに気づきました。単なるテストケースを書くことでは、考慮していなかったシナリオ(状態)が突然出現する魔法はかかりません。そして、たとえそうであっても、フロントエンドでは実際にユーザーの行動をシミュレートしない限り、さまざまなブラウザやデバイス、実際の世界で発生する出来事(例えば、ネットワークの不安定さ)に直面するのが容易です。

最近のプロジェクトで私の考えに最も影響を与えたのは、テストを書くことに対する考え方です。誤解しないでください、私は今でもできるだけテストを書くようにしていますが、無関係に見えるテストケースを書くのではなく、もっと意味のあるテストに焦点を当てたいのです。

したがって、テストを書くことは今の私にとって必要ですが、最初からテストを書くことに没頭する必要はありません。

最も重要なステップは、さまざまな要件を徹底的に明確にすることです。本当にその通りで、他に方法はありません。

QAの問題が多く発生するのは、初めに要件を徹底的に理解していなかったためであり、双方の要件の理解に乖離があったからです。

例えば、先ほどのArticle取得に関しては、APIを呼び出すタイミング、APIの可能なレスポンス、さまざまなAPIの状況に対する追加処理の必要性、UI上の内容の特別な処理の必要性(先ほどの折り返しや他のUIの考慮)や重複クリックなど、これらをすべて確認した後でテストを書き始めても遅くはありません。

理由は簡単です。ユーザーが見るUIは、アプリケーションとのインタラクションの媒介ですが、実際にはE2Eテストがあまり行われていないため(ただし、この概念は徐々に修正されつつあり、幸いなことです)、テストをいくら書いてもUIで失敗することが多いです。完全なE2Eテストを行うのは難しく、時にはバックエンドの協力が必要です(例えば、シミュレーション用のデータベースや環境の準備など)ので、テスト環境をできるだけ現実に近づける必要があります。

もう一つのポイントは、テストを書く際に、ユニットテストにあまり時間をかけないことです。もちろん、測定すべきものは測定すべきですが、expect(1+1).toBe(2) のような明らかに明白なものにこだわる必要はありません。

この点で、私はCypressとPuppeteerという2つの統合テストツールを強くお勧めします(Puppeteerは他のことにも使えますが)。機能が非常に充実しており、必要なものがすべて揃っています。これらのツールがあれば、面倒なことはもはや逃げる言い訳にはなりません。

useEffectでAPIを呼び出すコードに注意

useEffectは副作用を実行するためのものであるため、うっかりすると悪夢になりかねません。もしAPIの返り値がコンポーネント内部の状態を変更する場合、unmount時にAPIをキャンセルすることを忘れないでください。そうしないと、潜在的なメモリリークを引き起こす可能性があります。

なぜでしょうか?APIのリクエストが完了する前にコンポーネントをunmountした場合、APIが返答した後にコールバックを実行する際に警告が表示されることになります。なぜなら、そのコールバックがすでにunmountされたコンポーネントの状態を変更しようとしているからです。

さらに、useEffect内にあるAPI(直接fetch()する場合)は、本当にテストが難しいです... 一つのコンポーネントのテストを書くには、モックを駆使しなければならず、APIのレスポンスと一致するかどうかを慎重に確認する必要があります。一時的な便利さを求めて害をもたらさないようにしましょう。

状態が爆発する問題

また、多くのQAバグの原因は、状態管理の複雑さに起因します。イテレーションの初期段階では、状態がそれほど多くないため、単純な useState で全てをうまく管理できることがありますが、状態が増えるにつれてif文があちらこちらに散らばることになります。この時点で新しい状態を追加するだけで、人生に疑問を抱く瞬間が訪れます。特に以下のようなコードを見たときには:

if (isLoading && !isEditing && profile && isNotEmpty && isLoggedIn) {
  // fuck my logic
}

もし一つの場所だけならまだしも、コンポーネント全体にこのようなコードが散見される場合、ほぼ確実に複数のQAが発生します。テストがいくら多くても、状態管理の複雑さを隠すことはできず、悪いロジックを良いロジックに変えることもできません。たとえ現在のテストが全ての状態をカバーしていても、新しい状態を追加するとエラーが発生する可能性があるのです。

したがって、状態をどのように管理するか(エレガントな方法)は、フロントエンドの大きな課題と言えます。Reduxを書くことは常に面倒ですが、この手法は人々が維持しやすいコードを書くのを助け、重要なのは複雑な状態をreducerを通じて一元管理することです。

Reactでは、useReducerを使用することで状態の爆発問題を回避できます。なぜなら、reducerは本質的に状態マシンのようなもので、reducer関数を通じて現在の状態変化を確認できます。しかし、これだけでは不十分です。状態はenumのようには振る舞わず、むしろ木やグラフのような存在で、状態の変化には依存関係があります。例えば、idle状態から直接success状態に移行することはなく、必ずloadingを経由してからsuccessに進む必要があります。また、failed状態からidle状態に戻ることはできず、loading状態に戻ってから返却結果に基づいてどの状態に移行するかを決定します。

一般的なreducerではこのようなことはできません。依存関係のある状態を検出できないため、エンジニアの規律と誠実さに頼るしかありませんが、私たちはエンジニアの誠実さが最も信頼できないものであることを知っていますので、必ずミスが発生します。

より良い方法は、状態をより表現できる言語を使ってモデリングすることです。最近注目を集めている xstate は非常に興味深いもので、状態管理のシーンをほぼすべてカバーしています。興味があれば、ぜひ調べてみてください。

この記事が役に立ったと思ったら、下のリンクからコーヒーを奢ってくれると嬉しいです ☕ 私の普通の一日が輝かしいものになります ✨

Buy me a coffee