Jestjs - Test bị FAIL ngẫu nhiên

17/4/2023 nodejsjesttesting

#Mô tả lỗi

Đợt vừa rồi project mình làm việc có xảy ra lỗi test file bị FAIL ngẫu nhiên. Lúc lỗi test file này, lúc thì test file khác, retry lại đôi lúc lại thành công. Lỗi cụ thể mình gặp là:

ReferenceError: You are trying to `import` a file after the Jest environment has been torn down.

      at BufferList.Readable (node_modules/readable-stream/lib/_stream_readable.js:179:22)
      at BufferList.Duplex (node_modules/readable-stream/lib/_stream_duplex.js:67:12)
      at new BufferList (node_modules/bl/bl.js:33:16)
      at new MessageStream (node_modules/mongodb/lib/cmap/message_stream.js:35:21)
      at new Connection (node_modules/mongodb/lib/cmap/connection.js:52:28)
/home/the_path/node_modules/readable-stream/lib/_stream_readable.js:111
  var isDuplex = stream instanceof Duplex;
                        ^

TypeError: Right-hand side of 'instanceof' is not callable

Trace lỗi thì mình nhận thấy một lib có sử dụng dynamic import ở trong callback của một setImmediate. Và callback đó chạy sau khi Jest đã teardown, nên kết quả dynamic import trả về undefined, dẫn đến một lỗi ném ra khi undefined không phải là một callable.

Tuy nhiên khi chạy riêng test file đã bị Jest đánh FAIL thì không có lỗi. Cộng với việc Jest không báo lỗi một file cố định nên mình phán đoán đoạn code gây ra lỗi là của test file trước. Do đó mình thử tái hiện lỗi bằng cách tạo một project mới có 2 test file. Test file 1 sẽ ném lỗi trong một callback của setTimeout, với thời gian timeout sao cho callback được thực thi khi mà Test file 2 đang chạy. Test file 2 viết test đơn giản cho PASS. Kết quả đúng như dự đoán: Test file 1 PASS nhưng Test file 2 FAIL. Đây là kết quả test.

#Nguyên nhân

Kết luận lỗi xảy ra khi một test file sau khi đã teardown, ném ra một lỗi trong callback của một Asynchronous resource (Promise, Timer…), trong khi đó một test file khác đang được thực thi thì Jest sẽ mark test file đang chạy đó là FAIL. Về bản chất đây không phải lỗi của Jest. Lỗi là do code (của mình hoặc dependency) đã không đóng hết resource khi chạy xong. Việc này không chỉ gây ra lỗi như trên mà còn gây ra Memory leaks.

#Xử lý tạm thời

Mình cũng đã feedback với Jest, tuy nhiên một Contributor của Jest cho rằng khá khó để giải quyết vấn đề này. Cách giải quyết tạm thời của mình là lưu tất cả Timer vào một biến global sau đó khi teardown sẽ clear hết timer.

// timers.ts

const _setInterval = global.setInterval;
const _setTimeout = global.setTimeout;
const _setImmediate = global.setImmediate;

const INTERVALS: (NodeJS.Timeout & number)[] = [];
const TIMEOUTS: (NodeJS.Timeout & number)[] = [];
const IMMEDIATES: (NodeJS.Timeout & number)[] = [];

global.setInterval = function (): NodeJS.Timeout {
  const interval = _setInterval(...arguments);
  INTERVALS.push(interval);
  return interval;
};

/**
 * didn't work with promisify(setTimeout)
 * @returns 
 */
global.setTimeout = function (): NodeJS.Timeout {
  const timeout = _setTimeout(...arguments);
  TIMEOUTS.push(timeout);
  return timeout;
};

global.setImmediate = function (): NodeJS.Immediate {
  const immediate = _setImmediate(...arguments);
  IMMEDIATES.push(immediate);
  return immediate;
};

export const clearAllTimers = () => {
  INTERVALS.forEach((i) => clearInterval(i));
  TIMEOUTS.forEach((i) => clearTimeout(i));
  IMMEDIATES.forEach((i) => clearImmediate(i));
};
// setup-after-env.ts

import { clearAllTimers } from './timers';

afterAll(() => {
  clearAllTimers();
});
// package.json

...
  "jest": {
    ...
    "setupFilesAfterEnv": ["/pathof/setup-after-env.ts"],
    ...
  }
...

Với cách này tạm thời chỉ giải quyết cho các Timer. Còn các loại resource khác như Promise, File system, Database driver… thì chưa giải quyết được. Cũng may là project mình chỉ bị dính lỗi với mấy cái Timer.

Update: Từ v28.0.0 thì có thể dùng jest.useFakeTimers kèm với option advanceTimers: true. Chi tiết

Nếu bạn gặp lỗi này thì kiểm tra xem nếu do code của project thì đơn giản là hãy close resource khi test xong. Còn nếu là code của dependency thì phải request author sửa vậy 😓.