Jest-Expo でも Random と Crypto が動いてほしい
Expo プロジェクトを Jest でテストする方法については Expo の TypeScript プロジェクトで自動テストで紹介した。しかし、jest-expo を使うと Expo SDK の提供する API はダミーの値や undefined
を返すようになってしまう。[1]
まあ、Expo SDK の API はターゲット環境となる iOS/Android/Web のネイティブ API を呼び出しているわけで、Jest が実行される Node 環境でこれらが動かないのだから当然だ。
それでも動いてほしいときはある
今回、テストしたかったのは以下のような関数。
import * as Random from 'expo-random';
import * as Crypto from 'expo-crypto';
/**
* Returns a promise that generates cryptographic random hash digest.
*/
export function randomSHA256Async(): Promise<string> {
const bytes = Random.getRandomBytes(8);
const value = bytes.reduce((accumulator, value) => accumulator + String.fromCharCode(value), '');
return Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, value);
}
Expo の Random モジュールと Crypto モジュールを使って、ランダムな文字列を生成する関数である。[2]
単純に Expo SDK を呼び出すだけならテストする必要はないのだが、バイト列 (Uint8Array
) を文字列に変換する処理が入っているので、やはりテストはしておきたい。しかし、上記のとおり、Random.getRandomBytes()
が undefined
を返すので、このままではテストができない。
$ npm run test ./test/utils/Random.test.ts
FAIL test/utils/Random.test.ts
randomSHA256Async
✕ generate one (4 ms)
● randomSHA256Async › generate one
TypeError: Cannot read property 'length' of undefined
6 | */
7 | export function randomSHA256Async(): Promise<string> {
> 8 | const bytes = Random.getRandomBytes(8);
こちらの Issue では、それぞれのモジュールをモックする方法が勧められている。というわけで、jest.mock()
を使ってテストを書いてみよう。
import { randomSHA256Async } from 'utils/Random';
jest.mock('expo-random/build/ExpoRandom', () => ({
getRandomBytes: jest.fn(() => new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))
}));
jest.mock('expo-crypto/build/ExpoCrypto', () => ({
digestStringAsync: jest.fn(async () => 'e46bbb550892006aa4dfbc5813f1e0625b9ac4a76dab5c6632d154117a8707f7')
}));
describe('randomSHA256Async', () => {
test('generate one', async () => {
const digest = await randomSHA256Async();
expect(digest).toHaveLength(64);
expect(digest).toMatch(/^[0-9a-f]+$/);
});
});
これでテストが通る。
$ npm run test ./test/utils/Random.test.ts
PASS test/utils/Random.test.ts
randomSHA256Async
✓ generate one (3 ms)
やったー!😆
やっぱり、ちゃんと動いてほしい
テストが通って喜んだのも束の間「これってテストになってるのかな?🤔」と不安に襲われる。
というか Node 環境には乱数もハッシュ関数も crypto モジュールで用意されているので、それらで実装すればよさそうだ。
import crypto from 'crypto';
jest.mock('expo-random/build/ExpoRandom', () => ({
getRandomBytes: jest.fn((byteCount: number) => {
const buffer = crypto.randomBytes(byteCount);
return new Uint8Array(buffer.buffer);
})
}));
...
しかし、これは以下のエラーが出て動かない。
ReferenceError: /path/to/test/utils/Random.test.ts: The module factory of `jest.mock()` is not allowed to reference any out-of-scope variables.
Invalid variable access: crypto
Allowed objects: Array, ArrayBuffer, Atomics, BigInt, BigInt64Array, BigUint64Array, Boolean, Buffer, DataView, Date, Error, EvalError, Float32Array, Float64Array, Function, GLOBAL, Generator, GeneratorFunction, ...
Note: This is a precaution to guard against uninitialized mock variables. If it is ensured that the mock is required lazily, variable names prefixed with `mock` (case insensitive) are permitted.
エラーで書かれている通り、jest.mock()
では、外側のスコープにあるオブジェクトは一部以外はアクセスできない。このように変えてやる必要がある。
jest.mock('expo-random/build/ExpoRandom', () => ({
getRandomBytes: jest.fn((byteCount: number) => {
const crypto = require('crypto');
const buffer = crypto.randomBytes(byteCount);
return new Uint8Array(buffer.buffer);
})
}));
すべてのテストで動かす
Random にしろ Crypto にしろ、他のテストでも使う可能性があるので、せっかくならグローバルで有効になるようにしよう。まずはモックするファイルを用意する。
jest.mock('expo-random/build/ExpoRandom', () => ({
getRandomBytes: jest.fn((byteCount: number) => {
const crypto = require('crypto');
const buffer = crypto.randomBytes(byteCount);
return new Uint8Array(buffer.buffer);
}),
getRandomBytesAsync: jest.fn((byteCount: number) => {
const crypto = require('crypto');
const buffer = crypto.randomBytes(byteCount);
return Promise.resolve(new Uint8Array(buffer.buffer));
})
}));
jest.mock('expo-crypto/build/ExpoCrypto', () => ({
digestStringAsync: jest.fn((algorithm: string, data: string, options = { encoding: 'hex' }) => {
const crypto = require('crypto');
const hash = crypto.createHash(algorithm.toLowerCase().replace('-', ''));
hash.update(data);
return Promise.resolve(hash.digest(options.encoding ?? 'hex'));
})
}));
あとは、これらを Jest の setupFilesAfterEnv
で読み込むようにする。
const config: Config.InitialOptions = {
preset: 'jest-expo',
...
setupFilesAfterEnv: [
'./test/utils/expo-crypto/index.ts',
'./test/utils/expo-random/index.ts'
]
};
これで、すべてのテストで Expo の Random モジュールと Crypto モジュールが期待通りに動くようになる。
-
src/preset/expoModules.js で定義された API 一覧を、こんな感じでモックしているようだ。 ↩︎
-
ちなみに、OpenID Connect の nonce を生成するために使う。 ↩︎
Discussion