👋

【1日1zenn - day20】vitestでuseStateやuseRefをmockする方法

に公開

最近UXデザイン側のタスクが多くて中々コードを書けていなかったのですが、
ちょっと落ち着いてコードを書いたら案の定詰まったのでサクッと振り返ります。

状況

  • 言語
    • React
    • TypeScript
    • Vitest
  • 実装内容
    • モーダルを下スワイプすることで閉じるhooks
      • スワイプした距離がモーダルの高さの一定以上だったらhandleCloseを作動させる
    • 元々あるモーダルに適用させていたが、ほかのモーダルにも実装することになったため、src/hooks配下の共通hooksとして切り出した
  • 事象
    • 以下のエラーが出るようになった
      • TypeError: Cannot redefine property: useState
      • TypeError: mockUseRef.mockReturnValue is not a function

Cannot redefine property: useState について

実装として、スワイプしたときに最初に触れた座標をuseStateで保持しており、そのSetterが正しく呼び出されているかテストする際に発生しました。

import * as react from 'react'

describe('useSwipeDownToClose', () => {
  describe('handleHoge', () => {
    context('~~~のとき', () => {
      it('期待するアクションがよばれること', () => {
        vi.spyOn(react, 'useState').mockImplementation(() => [ //ここで上記エラー発生
          0,
          mockSetTouchStartY
        ])
      })
    })
  })
})

やりたいことはuseStateの実装を保持しつつ、呼び出されたときの戻り値としてmock化したsetterを渡すことです。
よってuseStateをspyし、mockImplementationで戻り値を指定すればいいはずだと思っていました。

exportされたファイルをspyするためには名前空間でimportしてspyOnに渡す必要があるので上記のような書き方にしてます。

ちなみにspyとmockの差分は以下の認識なので、今回はspyを選んでます。

  • 元の実装の保持とか関係なく、ただ呼び出しなどの監視さえできればいい場合: mock
    • 引数で渡す関数などであればこれで良かったりする
  • 元の実装を保持したい場合: spy
    • 引数で渡す関数ではなく、テストしたいhooks内で生成される関数の場合はspyが必要
    • useStateを引数で渡すよう実装を変えればmockにして解決できたと思うが、共通メソッドかつ今回のuseStateがこのhooks内でのみ使われるものなので外に出したくなかった

しかしこの書き方だと、Cannot redefine property : useStateというように、useStateを再定義できないエラーが出ました。

要因と解決策

結論から言うと以下のようにすることで直りました。

import * as react from 'react'

// 以下でreactの実装を保持したままmockする
vi.mock('react', async () => {
  const actual = await vi.importActual<typeof import('react')>('react')
  return {
    ...actual,
  }
})

describe('useSwipeDownToClose', () => {
  describe('handleHoge', () => {
    context('~~~のとき', () => {
      it('期待するアクションがよばれること', () => {
        vi.spyOn(react, 'useState').mockImplementation(() => [
          0,
          mockSetTouchStartY
        ])
      })
    })
  })
})

まだざっくりの理解なのですが、useStateをmockできていないせいで、生のuseState自体の実装を上書きしようとしていてこのエラーが出ていたっぽいです。

関連しそう?なことメモ

  • mock化はimportより先に実行される
  • 多分この記事を自分でも実装してみたら理解できる気がする(週末やる)

mockUseRef.mockReturnValue is not a function

これはモーダルの高さとスワイプした距離を比べてcloseするか判定するために、それぞれのモーダルの高さをrefから取得するuseRefです。

元の実装は以下でした。

const mockUseRef = vi.mocked(useRef)

describe('useHandleClose', () => {
  beforeEach(() => {
    const mockModalRef = {
      current: { offsetheight: 600, },
    } as RefObject<HTMLDivElement>
    mockUseRef.mockReturnValue(mockModalRef)
  })
})

これも要因としては同様にmock化できていなくて、2つやったら直りました。

解法①

mockUseRefの宣言を以下にしました。

const mockUseRef = vi.spyOn(react, 'useRef')

これはuseState同様に、mockUseRefをspyする形です。

元の実装でconsoleを仕込むと以下のようになりました。

const mockUseRef = vi.mocked(useRef)
console.log('mockUseRefは?', mockUseRef)

//出力結果
mockUseRefは? [Function: useRef]

mockUseRefの中に、mock用のメソッドが追加されていないことがわかります。
これだとmockUseRef.mockReturnValue(mockModalRef)みたいに呼び出しても、mockUseRefの中にそんなメソッドねえよ、とエラーが返されるわけです。

一方spyすると以下のような出力になりました

const mockUseRef = vi.spyOn(react, 'useRef')
console.log('mockUseRefは?', mockUseRef)

//出力結果
mockUseRefは? [Function: useRef] {
  getMockName: [Function (anonymous)],
  mockName: [Function (anonymous)],
  mockClear: [Function (anonymous)],
  mockReset: [Function (anonymous)],
  mockRestore: [Function (anonymous)],
  getMockImplementation: [Function (anonymous)],
  mockImplementation: [Function (anonymous)],
  mockImplementationOnce: [Function (anonymous)],
  withImplementation: [Function: withImplementation],
  mockReturnThis: [Function (anonymous)],
  mockReturnValue: [Function (anonymous)],
  mockReturnValueOnce: [Function (anonymous)],
  mockResolvedValue: [Function (anonymous)],
  mockResolvedValueOnce: [Function (anonymous)],
  mockRejectedValue: [Function (anonymous)],
  mockRejectedValueOnce: [Function (anonymous)]
}

ちゃんとmock系のメソッドも生えてますね。

解法②

こっちは正直理解できていないのですが、とりあえず最終系は以下です。


describe('useHandleClose', () => {
  beforeEach(() => {
    const mockUseRef = vi.spyOn(react, 'useRef') //ここに書いた
    const mockModalRef = {
      current: { offsetheight: 600, },
    } as RefObject<HTMLDivElement>
    mockUseRef.mockReturnValue(mockModalRef)
  })
})

なんか一生テスト実行時にuseRefの戻り値がmockModalRefにならなくて、nullチェックに引っかかって普通にテスト落ちていたのですが、上記のようにグローバル変数ではなくdescribe内でbeforeEachしたら通るようになりました。またcontext内で書いても直ったのですが、逆にdescribeでbeforeEachせずに書いた場合は設定できなかったです。
これもきっと何か要因があるはずなのですが、そこまで辿り着けず。。

TODOメモ

全然理解できてなくて、このままだとまた沼ると思うので、土日とかで理解しに行きます。
一旦詰まった時の引き出しとしてちゃんと保存しておきます。

Discussion