Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

引数は固定だけど返り値でジェネリクスを使いたいケースがある

In short:

  • 関数が reference を返す場合(useRef の返り値など)は返り値のみにジェネリクスを使っても問題ない
  • fetch のような返り値が引数の内容によって決まるケースでも(型安全性は犠牲になるが)使うことがある。

ジェネリクスの一般的な用途

そもそもジェネリクスの目的は 「引数の型だけちょびっと異なる型を上手く扱いたい」 というのが原点です

const id = <T>(x: T) => x;
const toString = <T>(x: T) => `${x}`;

id 関数も toString 関数も、引数の型として number も string も受け付けています。

このように、ジェネリクスは引数の型を可変にするために使われるのが一般的 です。

引数にジェネリクスを使わないパターン

引数にジェネリクスを使わないパターンは ごく一部の例外を除いて存在しません (その例外についてが本題なので後述します)。

例えば常に例外を投げて失敗する async 関数を考えます

const throwError = <T>(): Promise<T> => {
    throw new Error();
};

型検査も通りますし、使うこともできます。ただ、Promise<T> の代わりに Promise<never> と書いても同じです。

const throwError = (): Promise<never> => {
    throw new Error();
};

このように、返り値にジェネリクスを使わない形に書き換えられるケースがほとんどです。

返り値にのみジェネリクスを使うケース: 返り値が reference のとき

見てきたように基本的に返り値にだけジェネリクスを使うケースはありませんが、レアな例外の一つが reference を返すケースです。React ユーザーであればすぐピンとくると思います。

たとえば const divRef = useRef<HTMLDivElement>(null) のようなコードを書いたことがありませんか? この場合、引数は null ですが、返り値が RefObject<HTMLDivElement> です。まさに引数にはジェネリクスを使わないのに返り値にジェネリクスを使うケースです。

このように、状態が変化しうるものが返り値のときは、返り値にだけジェネリクスが現れます。

返り値にのみジェネリクスを使うケース: fetch 関数

fetch 関数も、返り値にのみジェネリクスを使う例外の一つです。

const fetchAPI = <T>(endpoint: string): Promise<T> =>
  fetch(endpoint).then((res) => res.json());

ただし、この場合は実はジェネリクスを使わなくても書けます。

const fetchAPI = (endpoint: string) =>
  fetch(endpoint).then((res) => res.json());

この場合、Promise<any> が返ってきます。つまり、any を T に変更しているのです。実際には any なので当然、型安全性を損ないます。ただ、「API は滅多に変わらないから type guard なしでキャストしてしまおう」という判断は実際のプロダクトでも十分考えられます(type guard を手動で管理しようとすると結構な労力になるので…)

このように型安全性を多少犠牲にしつつ利便性をとる場合にも使われることがあります。当然ですが多用すべきではありません。

まとめ

ジェネリクスが返り値に現れた場合、それらはほとんどの場合 unknown あるいは never で置き換えることができます。reference を返すケースを除けば、型安全性のためにも返り値だけにジェネリクスを使うのはお勧めしません。

git でブランチを切り替えたとき、node_modules を更新する (キャッシュ機構付き)

結論: 以下を .git/hooks/post-checkout に追加

#!/bin/sh
PREV_BRANCH=`git reflog show -q | head -n1 | awk '{print $6}'`
CACHE_DIR=~/.cache/my_project_node_modules
mkdir -p $CACHE_DIR
prev_node_modules_cache="$CACHE_DIR/$(git show $PREV_BRANCH:package.json | shasum | awk '{print $1}')-$(git show $PREV_BRANCH:package-lock.json | shasum | awk '{print $1}')"
cur_node_modules="$CACHE_DIR/$(shasum package.json | awk '{print $1}')-$(shasum package-lock.json |awk '{print $1}')"

if [ "$prev_node_modules_cache" = "$cur_node_modules" ]; then
  exit
fi

mv node_modules $prev_node_modules_cache
if [ -d "$cur_node_modules" ]; then
  mv $cur_node_modules node_modules
else
  npm ci
fi

解説

package.json と package-lock.json をキーに、node_modules を ~/.cache/my_project_node_modules へ保存している。

  • 切り替え前のブランチと package.json も package-lock.json も同一の場合: 特に何もしない (同じ node_modules を使う)
  • 切り替え前のブランチと package.json か package-lock.json が異なる場合:
    • まず、今の node_modules をキャッシュディレクトリに移動する
    • 既にキャッシュが存在する場合: キャッシュディレクトリから適切な node_modules を持ってくる
    • キャッシュが存在しない場合: npm ci を行う

node_modules をコピーではなく移動させているだけなので、かなり軽快に動作する。

(ざっとしか検証してないので、動かなかったらコメントお願いします)

Jest モック方法まとめ

毎回どれを使えばいいのかわからなくなるのでまとめました。

(今回、純粋に jest について書きたいので TS ではなく JS で記事を書きました。ts-jest が絡んでも基本的には同じです)

今回紹介したサンプルはこちらのリポジトリにまとめてあります。 jest-mock-demo

jest のモックは3種類ある

モックの対象によって3パターンあります。

  • モックされた関数(jest.fn())を使う
  • オブジェクトのメソッドをモックする
  • モジュールをモックする

以下ではそれぞれ解説します。特にモジュールのモックは複雑なので長めです。

1. モック関数を使う

まずは一番カンタンなモック関数についてです。

モック関数は jest.fn() で生成できます。引数として、あるいはコンポーネントへの prop として渡す場合などに使えます。

const f = jest.fn();
[1, 2, 3].map(f);
expect(f).toBeCalledTimes(3);

呼び出した時に何かして欲しい場合は、 jest.fn().mockImplementation(() => {}) という形で実装します。

const f = jest.fn().mockImplementation((n) => n * 2);
expect([1, 2, 3].map(f)).toEqual([2, 4, 6])
expect(f).toBeCalledTimes(3);

呼び出し回数をリセットするには

モック関数の呼び出し回数は自動でリセットされません。つまり何もしないとテストケースごとに共有されます。

呼び出し回数をリセットするのは簡単です。モック関数の mockClear メソッドを呼び出せば OKです。jest.clearAllMocks() を使えば全てのモック関数を一括で mockClear できます。

const f = jest.fn();
f();
f.mockClear();
expect(f).not.toBeCalled(); // 呼び出し回数がリセットされて0になっている
afterEach(() => {
  jest.clearAllMocks(); // 全てのモック関数の呼び出し回数をリセットする
});

2. オブジェクトのメソッドをモックする

次はオブジェクトのメソッドをモックするケースです。やり方が2 通りあります。

  • jest.spyOn(object, 'method')
  • object.method = jest.fn()

前者はメソッドの上書きが起きません。つまり、以下のコードで obj.method() を呼んだ場合、ログに called と表示されます。メソッドが呼ばれたかをテストしたいときに使えます。

const obj = {
  method: () => {
    console.log('called');
  }
};
const spy = jest.spyOn(obj, 'method');
obj.method(); // ちゃんとログに called が表示される
expect(spy).toBeCalled();

spyOn した上で実装を変えたい場合、mockImplementation を使うか jest.fn() を代入すれば良いです。

const spy = jest.spyOn(obj, 'method').mockImplementation(() => {}); // 実装を上書きする
obj.method(); // ログに called と表示されない
obj.method = jest.fn();
obj.method(); // ログに called と表示されない

3. モジュールをモックする

コレが一番複雑です。jest のランタイムの仕組みと密接に関わっています。

モジュールのモックは、import 先を丸ごとモックしたい時に使います。axios などの外部ライブラリをモックする際に便利です。

// fetchArticle.js
import axios from 'axios';

export const fetchArticle = (id) => {
  return axios.get(`/api/article/${id}`)
}
// fetchArticle.test.js
jest.mock('axios', () => {
  return {
    default: {
      get: jest.fn().mockImplementation(() => Promise.resolve({body: {result: {title: 'test article', content: 'test article'}}}));
    }
  }
});
import { fetchArticle } fom './fetchArticle';
test('fetchArticle', async () => {
  expect((await fetchArticle()).body.result).toEqual({title:'test article', content: 'test article'}); // axios をモックできている
});

モジュールのモックとモジュールキャッシュについて

モジュールのモックの仕組みは非常にややこしいので、一から説明していきます。

まず、jest の require は返す内容をキャッシュします。つまり、require(module) で帰ってくるものは毎回同一です。

expect(require('axios')).toBe(require('axios')); // コレが成り立つ

孫モジュールもキャッシュされます。

// A.js
export { B } from './B';

// B.js
export const B = () => {};

// A.test.js
expect(require('./A').B).toBe(require('./A').B); // A が依存している B に対してもキャッシュが存在する

このキャッシュのせいでモックが複雑になっています。なんと、モジュールキャッシュがあるとモックが効きません

const oldB = require('./A').B;
jest.mock('./B', () => ({ B: jest.fn() }));
expect(require('./A').B).toBe(oldB); // キャッシュがあるので require しても oldB が返ってくる = モックできていない

つまり、モックする場合はモジュールのキャッシュを一度リセットする必要があります。

const oldB = require('./A').B;
jest.resetModules(); // モジュールキャッシュを全てクリアする
jest.mock('./B', () => ({ B: jest.fn() }));

expect(require('./A').B).not.toBe(oldB); // キャッシュが消えているのでモックできている

ただ、ややこしいことにキャッシュを消さなくても良いケースがあります。

  • babel-jest を使っていて jest.mock した場合
  • まだ require しておらずキャッシュが存在しない場合

babel-jest を使っていて jest.mock した場合

babel-jest を使っている場合、jest.mock の呼び出しは自動的にコードブロックの先頭で行われます。つまり、 import や require より先に jest.mock されます

import { B } from './B';
jest.mock('./B', () => ({ B: 42 })); // import より前(=キャッシュがない状態)で実行される

expect(B).toBe(42); // ちゃんとモックされている

この場合はキャッシュがない状態で jest.mock しているため、require するとモックが返ってきます。

まだ require しておらずキャッシュが存在しない場合

mock 実行時点でそのモジュールを一度も require していなければ、mock したものが得られます。

jest.mock('./B', () => ({ B: 42 }));
expect(require('./B').B).toBe(42); // モックされている

ただし、間接的にでも require されてる場合はダメです。

require('./A'); // 中で ./B を require している
jest.mock('./B', () => ({ B: 42 }));
expect(require('./B').B).not.toBe(42); // キャッシュがあるのでモックされていない

モジュールが依存しているモジュールをモックしたい

以上を踏まえて、テストしたいモジュールAがモジュールBを使っていて B をモックする場合を考えます。例えば、React コンポーネントが axios に依存していて、axios をモックしたいケースが該当します。

// Article.jsx
import axios from 'axios'; // Article が依存している axios をモックしたい
import React, { useState, useEffect } from 'react';
export const Article = ({ id }) => {
  const [article, setArticle] = useState(null);
  useEffect(() => {
    axios.get(id).then((res) => setArticle(res.body.result));
  }, []);
  return (
    article && (
      <main>
        <h1>{article.title}</h1>
        <p>{article.content}</p>
      </main>
    )
  );
};

すべてのテストケースで同じモックを使う場合、jest.mock で OK です(babel-jest を使っていて jest.mock した場合に該当)。

import { Article } from './Article';
import { render } from '@testing-library/react';
import React from 'react';
jest.mock('axios', () => ({
  get: jest.fn().mockImplementation(() =>
    Promise.resolve({
      body: { result: { title: 'test article', content: 'test article' } }
    })
  )
}));
test('記事が表示できるか', async () => {
  const { findByRole } = render(<Article id="test" />); // モック結果が使われる
  await findByRole('main');
});

テストケースごとに切り替えたい場合、jest.isolateModules か jest.resetModules を使います。

// テストケースごとにモックを切り替えたい
jest.isolateModules(() => {
  const { render } = require('@testing-library/react'); // 再度 import する必要がある
  jest.doMock('axios', () => ({
    get: jest.fn().mockResolvedValue({
      body: { result: { title: 'test article1', content: 'test article' } }
    })
  }));
  const { Article } = require('./Article');
  test('記事A', async () => {
    const { findByRole } = render(<Article id="test" />); // モック結果が使われる
    await findByRole('main');
  });
});
jest.isolateModules(() => {
  const { render } = require('@testing-library/react'); // 再度 import する必要がある
  jest.doMock('axios', () => ({
    get: jest.fn().mockResolvedValue({
      body: { result: { title: 'test article2', content: 'test article' } }
    })
  }));
  const { Article } = require('./Article');
  test('記事B', async () => {
    const { findByRole } = render(<Article id="test" />); // モック結果が使われる
    await findByRole('main');
  });
});

isolateModules の中で再度 React や react-testing-library を import する必要があります。これは、キャッシュが消えているため、Article.jsx のなかで import している React とテストファイルの React が異なる実体を参照しているためです。再 import しないと複数の React インスタンスがあるというエラーが出ます。

まとめ

これまで見てきたように、jest のモックには3種類あります。特にモジュールのモックが非常に複雑で厄介です。

  • モックされた関数(jest.fn())を使う
  • オブジェクトのメソッドをモックする
  • モジュールをモックする

モジュールのモックは jest のモジュールキャッシュの仕組みと、babel-jest の仕組みを理解できればそこまで難しくありません。

今回紹介したサンプルはすべてこちらのリポジトリにまとめてあります。 jest-mock-demo

componentDidMount と componentDidUpdate は同期的に layout と paint の間に実行される (useLayoutEffect と同じ)

(めっちゃ短いけど、ちょいちょい調べるので)

useLayoutEffect は componentDidMount や componentDidUpdate と同じフェーズで実行されるということに注意してください

引用元: フック API リファレンス useLayoutEffect

逆に言えば、componentDidMount と componentDidUpdate は layout と paint の間に同期的に実行されるという意味

YouTube のスワイプで消せるフロートカードを実装する

↑これを CSS + JS で作りました。フロートカードと呼んでますが、正式名称は分かりません。半モーダルとか bottom drawer とか slidable drawer と呼ぶらしい?

動かせるデモはこちら

実装のポイント: scroll-snap

JS をほぼ使わずに CSS の scroll-snap を使って実装しました。scroll-snap を使うと次のような実装ができます。

  • スワイプが中途半端だったら元の位置に戻す
  • 一定量を超えたら画面外に移動させて消す

便利ですね。touchstart や touchmove のイベント量を監視してアレコレするよりよほどシンプルです。

scroll-snap はかなり便利

scroll-snap を使うと、カルーセルのようにスクロールした際に特定の位置でピタッと止まるように吸着させられます。

scroll-snap は応用の幅があります。例えば、「スワイプで LIKE」「スワイプで削除」のような機能もカンタンに作れます。ほかの人が作ったデモ。便利ですね。

今回のフロートカードも scroll-snap 応用編に近いですよね。

苦労したポイント

カードの外の部分をスワイプしたときにカードが移動しないようにするのが難しかった

これは、カード外がタップされたら overflow-y: hidden をつけるようにして解決しました。 overflow-y: scroll を消すというやり方だと iPhone の safari でうまく動作しなかったので…

カードを画面外にスワイプさせるのが難しかった

これは、カードの上下にスナップポイントを用意してやることで解消しました。今回初めて scroll-snap 使ったので、思いつくまでに時間がかかりました。

未実装の箇所

実はいくつか未実装の箇所がある。

  • 画面の高さが十分でない時(横画面の時など)に上にスワイプすることでカードを上に伸ばす
  • リストアイテムが多い時のスクロールとの共存

この辺りは素直に scroll-snap を使うだけだと難しい気がします。どうしたらできるんだろ。

まとめ

初めにも張りましたが、動かせるデモはこちらです。ぜひ触ってみてください。結構わけわからないコードになってるので。

scroll-snap について知らなかったのでだいぶ勉強になりました。仕事でカルーセル実装するときに JS で頑張っていたけど、scroll-snap を使っていればもっと楽できたのでは…?とムチャクチャ思いました。

あと、StackBlitz でゴリゴリ初めて書きましたが、微妙にかゆいところに手が届かない感じですね…例えばこのあたり。

  • useState を補完で出すと React.useState になる
  • remove braces from arrow function ができない
  • デフォルトの React テンプレートから作ると import * as React from 'react' が消せない

今回みたいにパッと動くものを作る用途では便利ですが、1時間以上書く場合は少しストレスですね。

みんなもフロートカードを作ってみよう!意外と苦戦するよ!