一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

Jotai を使った Dependency 管理とテスト技法

この記事は一休.com Advent Calendar 2024の23日目の記事です。

一休レストランのフロントエンドアーキテクトを担当してる恩田(@takashi_onda)です。

はじめに

先日の JSConf JP 2024 で「React への依存を最小にするフロントエンドの設計」という内容で登壇しました。

speakerdeck.com

発表では駆け足になってしまった、React への依存をしていない Vanilla JS 部分をどのように構成しているのかを、Dependency 管理とテストの文脈でご紹介したいと思います。

Dependency とは Dependency Injection の Dependency です。 タイトルも「Jotai を使った DI とテスト技法」とした方が伝わりやすいとは思います。 ですが、厳密には injection していないので、あえて Dependency という表現に留めています。

以下 Dependency や依存関係という言葉を使っているときは Dependency Injection の Dependency のことだとご認識ください。

アーキテクチャ

まずは、前提となるアーキテクチャの概観から説明します。

atom graph

ステート管理には Jotai を利用しており、primitive atom にはステートマシンの state だけを持つ、ステートマシンを中心に据えた設計1を採っています。

derived atom はステートマシンから導出しています。 図にあるように jotai-tanstack-query の queryOptions もステートマシンの derived atom です。 これにより、状態が遷移する度に必要に応じて fetch が走り、最新のデータが表示されます。

const isReservable$ = atom((get) => { /* snip */ })

export function useIsReservable() {
  return useAtomValue(isReservable$)
}

React コンポーネントは末端の derived atom を見ているだけなので、ロジックとは疎結合を保っています。

余談ですが、atom の命名として、かつての RxJS に倣い suffix として $ を利用しています。 以降のコード片でも同じ命名としているので $ は atom と思っていただければ。

const transition$ = atom(null, async (get, set, event: CalendarEvent) => {
  const current = get(calendarState$)
  const next = await transition(current, event)
  if (!isEqual(state, next)) {
    set(carendarState$, next)
  }
})

const selectDate$ = atom(null, (_get, set, date: string) => {
  set(transition$, calendarEvent('selectDate', { date: toDate(date) }))
})

export function useSelectDate() {
  return useSetAtom(selectDate$)
}

状態遷移は transition 関数を writable derived atom としていて、すべての変更・副作用は状態遷移を経由して実現しています。

Flux アーキテクチャではあるものの、React コンポーネントからはフックで得られた関数を呼ぶだけの独立した作りであり、表示側同様にロジックの構造とは疎結合になるように留意しています。

Dependency の管理

上述のアーキテクチャでは状態遷移を起点に、データの取得・更新など、外部とのやりとりが発生します。

テストが多くを占めますが、利用場面によって、その振る舞いを切り替えたいときがあります。

ここでは、 Jotai を Dependency の格納庫である Service Locator として活用する手法についてご紹介します。

Jotai で function を管理する

まずは軽く Jotai の TIPS 的なお話から。

Jotai では primitive atom, derived atom いずれも atom 関数で作成します。 その実装では typeof で第一引数が function かどうかを判定して、オーバーロードを行っています。

すなわち、そのままでは function を atom の値として扱えません。 derived atom とみなされてしまうためです。

そこで、以下のようなユーティリティを作成しました。

function functionAtom<F extends Function>(fn: F): WritableAtom<F, [F], void> {
  const wrapper$ = atom({ fn })
  return atom<F, [F], void>(
    (get) => get(wrapper$).fn,
    (_get, set, fn) => {
      set(wrapper$, { fn })
    }
  )
}

テスト時に function を test double に切り替える程度であれば、functionAtom ユーティリティだけで対応できます。 具体的には GraphQL クエリを実行する関数を管理しています。

export const callGraphql$ = functionAtom(callGraphql)

テストコードでは以下のように test double で置き換えています。

describe('queryRestaurants$', () => {
  test('pageCount$', async () => {
    // arrange
    const store = createStore()
    store.set(callGraphql$, vi.fn().mockResolvedValue(/* snip */))
    // act
    const page = await store.get(pageCount$) // drived from queryRestaurants$
    // assert
    expect(page).toEqual(7)
  })
})

Jotai Scope で Dependency を切り替える

次は、もう少し複雑なケースです。

コンポーネントの振る舞いを利用箇所によって切り替えたい、という場面を考えます。 カレンダーやモーダルダイアログで見られるような、複数の操作を持つ複雑なコンポーネントを想定してください。

React で素直に書くならコールバックを渡し、コンポーネント root で Context に保持して、コンポーネントの各所で使う形になるでしょう。

type Dependency = {
  onToggle: (facet: Facet) => boolean
  onCommit: (criteria: SearchCriteria) => void
}

const Context = createContext<Dependency>(defaultDependency)

export function Component(dependency: Dependency) {
  return (
    <Context value={dependency}>
      <ComponentBody />
    </Context>
  )
}

export function useOnToggle() {
  return use(Context).onToggle
}
export function useOnCommit() {
  return use(Context).onCommit
}

さて、そもそもの動機に戻ると、React に依存したコードを最小限にしたい、という背景がありました。 ロジック部分は Vanilla JS だけで完結させるのが理想的です。

言い換えれば、Jotai だけで Dependency を切り替える仕組みを作りたい、ということです。 そこで atoms in atomjotai-scope を利用することにしました。

コードを見ていただくのが早いと思います。

type Dependency = {
  toggle$: WritableAtom<null, [Facet], boolean>
  commit$: WritableAtom<null, [SearchCriteria], void>
}

const dependencyA: Dependency = {
  toggle$: atom(null, (get, set, facet) => true),
  commit$: atom(null, (get, set, criteria) => {}),
}

const dependencyB: Dependency = {
  toggle$: atom(null, (get, set, facet) => false),
  commit$: atom(null, (get, set, criteria) => {}),
}

type Mode = 'A' | 'B'
const mode$ = atom<Mode>('A')

// atom を返す atom
const dependency$ = atom((get) => {
  switch (get(mode$)) {
    case 'A': return dependencyA
    case 'B': return dependencyB
  }
})

if (import.meta.vitest) {
  const { describe, test, expect } = import.meta.vitest
  describe('dependency$', () => {
    test('mode A toggle', () => {
      // arrange
      const store = createStore()
      store.set(mode$, 'A')
      // act
      const { toggle$ } = store.get(dependency$)
      const result = store.set(toggle$, facetFixture())
      // assert
      expect(result).toBe(true)
    })
    test('mode B', () => {
      // snip
    })
  })
}

Jotai だけで Dependency の切り替えが完結しました。

あとは React とのグルーコードです。

ここで Jotai Scope が登場します。 React コンポーネントでは、振る舞いを切り替える区分値を指定するだけになりました。

export function useToggle() {
  return useSetAtom(useAtomValue(dependency$).toggle$)
}

export function useCommit() {
  return useSetAtom(useAtomValue(dependency$).commit$)
}

export function ModeProvider({ mode, children }: PropsWithChildren<{ mode: Mode }>) {
  return (
    <ScopeProvider atoms={[mode$]}>
      <Init mode={mode} />
      {children}
    </ScopeProvider>
  )
}

function Init({ mode }: { mode: Mode }) {
  const setMode = useSetAtom(mode$)
  useEffect(() => {
    setMode(mode)
  }, [mode, setMode])
  return null
}

テスト技法

一休レストランでは単体テストに Testing Library を利用していません。

React に依存するコードを最小化することで、Vanilla JS だけで単体テストやロジックレベルのシナリオテストを実現しています。

純粋関数で書く

基本的な方針として、derived atom とその計算ロジックは峻別しています。 言い換えれば Jotai の API を利用している部分とロジックの本体となる関数を分離するようにしています。

値を取得する derived atom の例です。

const c$ = atom((get) => {
  const a = get(a$)
  const b = get(b$)
  return calc(a, b)
})

function calc(a: number, b: number) {
  return a + b
}

if (import.meta.vitest) {
  const { describe, test, expect } = import.meta.vitest
  describe('calc', () => {
    test('1 + 2 = 3', () => {
      expect(calc(1, 2)).toEqual(3)
    })
  })
}

テストコードには Jotai への依存はなく、ただの純粋関数のテストになります。

writable derived atom も同様です。

const update$ = atom(null, (get, set) => {
  const a = get(a$)
  const b = get(b$)
  set(value$, (current) => calcNextValue(current, a, b))
})

function calcNextValue(value: Value, a: A, b: B): Value { /* snip */ }

更新処理の中で次の値の計算を純粋関数として分けておけば、引数を与えて返り値を確認するだけの、もっともシンプルな形のテストとして書けるようになります。

実際のコードでは、上述したように、ロジックの中核にステートマシンを据えているので、ステートマシンにイベントを送って次状態を確認するテストがそのほとんどを占めています。

describe('calendar state machine', () => {
  test('日付を変更すると、選択されている時間帯にもっとも近い予約可能な時間を設定する', async () => {
    const fetchTimes = vi.fn().mockResolvedValue({
      restaurant: {
        reservableTimes: ['11:30', '13:00', '18:30', '20:30', '21:00'],
      },
    })
    const { transition } = createStateMachine(fetchCalendar, fetchTimes)
    const current = createCurrent()
    const result = await transition(
      current,
      calendarEvent('selectVisitDate', { visitDate: asDate('2024-10-26') })
    )
    expect(result.value).toEqual('READY')
    expect(result.context.visitTime).toEqual({
      ...current.context,
      visitDate: '2024-10-26',
      selectedVisitDate: '2024-10-26',
      visitTime: '18:30',
    })
  })
})

シナリオテスト

最後に、ロジックレベルのシナリオテストについてご紹介します。

今まで見てきたように、画面上での操作は、ロジックレベルで見ると、ステートマシンの一連の状態遷移になります。 言い換えれば、ユーザーの操作に対応する状態遷移と、ステートマシンから派生する derived atom の値がどうなっているかを確認することで、ロジックレベルのシナリオテストが実現できます。

長くなるので一部だけ抜粋しますが、以下のような形でテストを書いています。

Jotai には依存していますが、一連のユーザー操作とそのときどんな値が得られるべきかのシナリオが Vanilla JS だけでテストできるのがポイントです。

test('人数・日時・時間未指定で、日付だけ選択して予約入力へ', async () => {
  const store = createStore()
  store.set(calendarQueryFn$, async () => reservableCalendar)
  store.set(timesQueryFn$, async () => reservableTimes)
  store.set(now$, '2023-10-25T00:00:00.000+09:00' as DateTime)

  // 初期表示
  await store.set(transition$, calendarInitEvent())
  expect(store.get(visitDate$)).toEqual('2023-10-26')
  expect(store.get(visitTime$)).toEqual('19:00')

  // 日付を選んだとき
  await store.set(selectDate$, toDate('2023-11-04'))
  expect(store.get(visitDate$)).toEqual('2023-11-04')
  expect(store.get(visitTime$)).toEqual('18:30')

  // ...
})

おわりに

ここまで読んでいただきありがとうございました。

本記事がフロントエンド設計を検討する際の一助となれば幸いです。


一休では、本記事でお伝えしたような課題をともに解決するエンジニアを募集しています。

www.ikyu.co.jp

まずはカジュアル面談からお気軽にご応募ください!

hrmos.co


  1. 記事では XState を紹介していますが、現在は独自のステートマシン実装への置き換えを進めています。軽量サブセットである @xstate/fsm がバージョン 5 から提供されなくなったこと、型定義や非同期処理の機能不足が理由です。