JavaScriptの単体テストで大切にしていること
この記事はairCloset Advent Calendarの記事です。
airCloset Advent Calendar 2022 - 12月15日
12月20日にも書く予定です。
エンジニアの他にも、CTO/CPO/PdM/デザイナーなど、様々な社内メンバーが書いた記事がありますので、ぜひご覧になってください。
はじめに
株式会社エアークローゼットでエンジニアをしている三好 @miyomiyo344_ です。
弊社では9割以上の領域でJavaScript(TypeScript)を採用していて、WebのフロントエンドはReactで、バックエンドはNode.jsを用いています。
また、モバイルアプリの開発はiOS、Androidアプリ共に、React Native、Flutterで開発しています。
開発では単体テストの作成が基本マストの運用になっているのですが、個人的にTDDが好きなのと、単体テストの作成にあたって工夫していることがいくつか溜まってきたので、一度まとめておきたいと思い、本記事を書いています。
目的
「なぜ工夫しているのか?」と聞かれると、
メンテナンスされやすく、仕様書としても見ることができる単体テストを目指しているためです。
例えば、以下のような単体テストはその後の開発効率を落とすと考えています。
- 書いた本人にしか分からないテストケース
 - テストの事前準備や後処理が不十分
 - 仕様が変わったときに修正しにくい
 
このような状態を避けるためのTipsをつらつらと書いてみたいと思います。
Before処理
テスト結果抽出にはWhere句を入れよう
asis
  describe('Where句は入れようテスト asis', () => {
    let expectedData;
    before(async () => {
      expectedData = await db.users.findOne(() => {}); // ここ
    });
    it('test case', () => {
      expect(expectedData.status).toEqual('OK');
    });
  });
tobe
  describe('Where句は入れようテスト tobe', () => {
    let expectedData;
    const userId = 1;
    before(async () => {
      expectedData = await db.users.findOne(() => { where: { id: userId } }); // where句指定する
    });
    it('test case', () => {
      expect(expectedData.status).toEqual('OK');
    });
  });
スプレッド構文を使ってテストデータの可読性を上げよう
asis
  describe('スプレッド構文を使おうテスト asis', () => {
    before(async () => {
      // テスト用データをDBにInsertする関数
      await testdataGenerator.createUsers(
        {
          id: 1,
          email: 'test1@gmail.com',
	  password: 'hoge',
          tel: '08012345678',
          prefecture: 1,
          step: 'STEP1',
        },
        {
          id: 2,
          email: 'test2@gmail.com',
	  password: 'hoge',
          tel: '08012345678',
          prefecture: 1,
          step: 'STEP2',
        },
      );
    });
  });
tobe
  describe('スプレッド構文を使おうテスト tobe', () => {
    before(async () => {
      const USER_TEST_DATA = {
          email: 'test@gmail.com',
        password: 'hoge',
        tel: '08012345678',
        prefecture: 1,
      };
      // テスト用データをDBにInsertする関数
      await testdataGenerator.createUsers(
        {
          ...USER_TEST_DATA,
          id: 1,
          step: 'STEP1',
        },
        {
          ...USER_TEST_DATA,
          id: 2,
          step: 'STEP2',
        },
      );
    });
  });
BeforeEachは安易に使わないようにしよう
BeforeEachとは
JestやMochaなどで使用できる、describe()内の全てのit()の前に1回ずつ起動するFunction。
事前に同じテストデータが必要なテストや、同じAPI/Functionを叩く必要があるテストが複数ある場合に便利。
安易に使わないほうがいい理由
新規で単体テストのファイルを作成する場合、BeforeEachは便利なので使いたくなりがちです。
ただ、将来的に機能の追加や修正が行われ、単体テストの修正が必要になったときに、テストデータなどの前提条件が一時的に崩れます。
Beforeの処理が多ければ多いほど、Debugに時間がかかるケースが多いです。
BeforeEachを使いたいという理由で消化するにはもったいない時間になることが多いので、安易に使わないようにしています。
BeforeEachを使わなくても、Beforeの中の処理を共通化することは難しくないし代替案はあるので、困ることはないかなと思っています。
階層間の通信はモックして責任を分離しよう
これは開発環境によって方法が変わりますが、例えば設計手法としてDDDを採用していた場合、Domain層やRepository層ごとで単体テストを分けて書くと思います。
そのときに、例えばDomain層のテストをするときにはRepository層のFunctionはモックして、責任を分離することで後々のメンテナンスコストを下げたいと思っています。
After処理
後片付けはちゃんとしよう
単体テストが増えてきて、突如開発したものとは絶対に関係なさそうなテストが落ちた場合、大体の原因は、
- Before処理で作ったデータがClearされていない
 - MockがRestoreされていない
 
など、Beforeで準備したもののリセット処理が漏れていること。だと思います。
解決策としては、全ての単体テストの共通Functionとして、AfterFunctionを作っておいて、After処理のなかでその関数を絶対に通るようにルール化するのもいいかもしれないですね。
Before処理で作成したデータのテーブル一覧を必ずAfter処理に渡すようにするとか。
工夫はいろいろとできそうです。
テストディレクトリ管理
ネストはなるべく避けよう
asis
  describe('条件A', () => {
    describe('条件B', () => {
      describe('条件C', () => {
        // case
      });
      describe('条件D', () => {
        // case
      });
      describe('条件E', () => {
        // case
      });
    });
  });
tobe
  describe('条件A 且つ B 且つ C', () => {
    // case
  });
  describe('条件A 且つ B 且つ D', () => {
    // case
  });
  describe('条件A 且つ B 且つ E', () => {
    // case
  });
テストディレクトリのタイトルには条件と期待する結果を書こう
asis
  describe('処理が成功した場合', () => {
    it('', () => {});
    it('', () => {});
  });
  
  describe('処理が失敗した場合', () => {
    it('', () => {});
    it('', () => {});
  });
tobe
  describe('処理が成功した場合、期待するデータが生成されている', () => {
    it('', () => {});
    it('', () => {});
  });
  describe('処理が失敗した場合、エラーメッセージを返す', () => {
    it('', () => {});
    it('', () => {});
  });
describe.each(it.each)を活用しよう
asis
  describe('statusが1の場合、responseが200として返ってくる', () => {
    before(async () => {
      // テスト用データをDBにInsertする関数
      await testdataGenerator.createUsers({
        id: 1,
        status: 1,
      });
    });
    it('responseが200である', () => {});
  });
  describe('statusが2の場合、処理が成功する', () => {
    before(async () => {
      await testdataGenerator.createUsers({
        id: 1,
        status: 2,
      });
    });
    it('responseが200である', () => {});
  });
  describe('statusが3の場合、処理が成功する', () => {
    before(async () => {
      await testdataGenerator.createUsers({
        id: 1,
        status: 3,
      });
    });
    it('responseが200である', () => {});
  });
tobe
  const successStatusList = [1, 2, 3];
  describe.each(successStatusList)('statusが1,2,3の場合、処理が成功する', (testStatus) => {
    before(async () => {
      await testdataGenerator.createUsers({
        id: 1,
        status: testStatus,
      });
    });
    it('responseが200である', () => {});
  });
テストケース
テストケースタイトルには具体的な内容を書こう
asis
  describe('statusが1の場合、responseが200である', () => {
    it('verify result', () => {});
  });
tobe
  describe('statusが1の場合、responseが200である', () => {
    it('responseが200である', () => {});
  });
itの中で業務処理を行うのはやめよう
asis
  describe('statusが1の場合、responseが200である', () => {
    it('responseが200である', async () => {
      let response;
      response = await getResponse();
      expect(response).to.equal(200);
    });
  });
tobe
  describe('statusが1の場合、responseが200である', () => {
    let response;
    before(async () => {
      response = await getResponse();
    });
    it('responseが200である', async () => {
      expect(response).to.equal(200);
    });
  });
1つのitに対して、1つの検証を行おう
asis
  describe('statusが1の場合、処理が成功する', () => {
    let response;
    it('処理が成功する', () => {
      expect(response.length).to.equal(1);
      expect(response.status).to.equal(2);
      expect(response.date).to.equal(null);
    });
  });
tobe
  describe('statusが1の場合、処理が成功する', () => {
    let response;
    it('lengthは1である', () => {
      expect(response.length).to.equal(1);
    });
    it('statusは2である', () => {
      expect(response.status).to.equal(2);
    });
    it('dateはnullである', () => {
      expect(response.date).to.equal(null);
    });
  });
さいごに
以上になります。
随時更新していく予定です。
再掲にはなりますが、もっといいTips等ありましたら、ご教示いただけますと幸いです。
Discussion