JavaScript
Node.js
非同期処理
jest

jest で非同期関数をテストするときの注意点

不適切な書き方をすると、落ちるべき(誤った)テストが通過する場合がある。

結論

コールバック

  • テスト関数の引数にdoneを入れる
  • コールバック関数内の最後でdone() する

Promise

  • Promise をreturnするか、async / await で扱う
  • 異常系のテストでは、catch 句の外側に expect.assertions(n) を書くか、expect(Promise).rejectsを使う

リンク


問題

適切な書き方をしなかった場合、非同期処理(内のexpect)が実行される前にテスト関数が終了してしまう。
Jest はテスト関数が(エラーなく)終了した時点で通過とみなすため、誤ったテストが通過とみなされる可能性がある。

どうするのか

(前提) 以下の例で扱うテスト対象の関数

いずれもフラグisSuccessに応じて'hoge'Error('error!')を返す。

コールバック

const someCallback = (isSuccess, cb) => {
  setTimeout(() => {
    const err = new Error('error!');
    const data = 'data';
    return isSuccess ? cb(null, data) : cb(err, null);
  }, 200);
};

Promise

const somePromise = isSuccess => {
  return new Promise((resolve, reject) => {
    const err = new Error('error!');
    const data = 'data';
    isSuccess ? resolve(data) : reject(err);
  });
};

❌ な書き方と ⭕ な書き方

コールバック

❌ テスト関数の引数にdoneを入れていない
❌ コールバック関数をdone()で終わらせていない

// pass!
test('no done ', () => {
  someCallback(false, (err, data) => {
    expect(data).toBe('fuga'); // data === hoge
  });
});

⭕ テスト関数の引数にdoneを入れている
⭕ コールバック関数をdone()で終わらせている

// fail!
test('done exists', done => {
  someCallback(false, (err, data) => {
    expect(data).toBe('fuga');
    done();
  });
});

Promise

Promise

❌ Promise をreturnしていない

// pass!
test('no return', () => {
  somePromise(false).then(() => {
    expect(data).toBe('fuga');
  });
});

⭕ Promise をreturnしている

// fail!
test('return exists', () => {
  // error!
  return somePromise(false).then(() => {
    expect(data).toBe('fuga');
  });
});

直接関係はないが、Promise はresolves/rejectsを使うとすっきり書ける

// fail!
test('resolves 1', () => {
  return expect(somePromise(false)).resolves.toBe('fuga');
});

// pass!
test('resolves 2', () => {
  return expect(somePromise(true)).resolves.toBe('hoge');
});

async / await

awaitしていない

  • というか素の Promise の書き方とごっちゃになっている
  • (実務で見た)
// pass!
test('no await', async () => {
  somePromise(false).then(result => {
    expect(result).toBe('fuga');
  });
});

awaitしている

// fail!
test('await exists', async () => {
  const result = await somePromise(false); // error!
  expect(result).toBe('fuga');
});

なお、awaitし忘れただけならexpectが実行されるので落ちる

// fail!
test('forgot await', async () => {
  const result = somePromise(false);
  expect(result).toBe('fuga'); // Comparing two different types of values. Expected string but received object.
});

異常系

expectが実行されない場合テストが pass してしまう

expect.assertions(n) していない

// pass!
test('no expect assertions', () => {
  return somePromise(true).catch(e => {
    expect(e.message).toBe('error!'); // resolveされたのでcatch句に入らない
  });
});

expect.assertions(n) している

  • いくつのexpectが実行されるべきかテストしてくれる
  • expectが条件分岐の中にあるならば、異常系に限らず書いておくと安心
// fail!
test('expect assertions exists', () => {
  expect.assertions(1); // Expected one assertion to be called but received zero assertion calls.
  return somePromise(true).catch(e => {
    expect(e.message).toBe('error!');
  });
});

rejectsを使っている

// fail!
test('using rejects', () => {
  // Expected received Promise to reject, instead it resolved to value "hoge"
  return expect(somePromise(true)).rejects.toThrow('error!');
});

// fail!
test('using rejects', async () => {
  // Expected received Promise to reject, instead it resolved to value "hoge"
  await expect(somePromise(true)).rejects.toThrow('error!');
});

どうしてこうなるの

  • Node.js は非同期処理に差し掛かると、その処理を一旦終了し、後続処理を続行する
    • 一旦終了した処理はキューにためられ、ほかの通常の同期処理が終わってから実行される
  • Jest は、テスト関数がエラーなく終了すると pass として扱う

非同期処理(というかイベントループ)についてはこのへんを読むと理解が深まる気がします。

おわり

みんなコールバックやめてPromise返してもらうようにして
async/awaitするなりresolves/rejectsするなりすると
expectが関数の外に出て
優しい世界が訪れる気がします。