TL;DR
- カルーセルに、数秒ごとに
slideIndexを自動で進めるタイマー(自動送り機能)を足す。 - テストでは Vitest の
vi.useFakeTimersでタイマーをモックし、実装ではカスタムフックuseTimeoutを作る。
内容
カスタムフックを用いたリファクタリングで作ったカルーセルに、数秒ごとにスライドを自動で進める機能を足す。
フェイクタイマーの準備
TDD で進めるので、先にテストを書く。Vitest では、vi.useFakeTimers を呼ぶと setTimeout や setInterval などのタイマー関数が制御できる関数に置き換わり、タイマーをモックできる。ここではすべてのテストでモックしたタイマーを使いたいので、セットアップファイルで呼んでおく。
// test-setup.ts
vi.useFakeTimers()ただし、2024 年 9 月時点では、これだけだと userEvent でユーザ操作をシミュレートしているテストがすべて失敗する。Vitest が、タイマーが置き換えられたことを認識できないのが原因である(testing-library/react-testing-library#1197)。回避策として、vite.config.ts に shouldAdvanceTime を設定する。
// vite.config.ts
export default defineConfig({
// ...
test: {
globals: true,
environment: "happy-dom",
setupFiles: ["test-setup.ts"],
fakeTimers: { shouldAdvanceTime: true },
},
})テスト
vi.advanceTimersByTime で時間を進め、スライドが切り替わることを確かめる。
// Carousel.test.tsx
describe("with auto-advance", () => {
it("advances the slide according to autoAdvanceInterval", () => {
const autoAdvanceInterval = 5_000
render(<Carousel slides={slides} autoAdvanceInterval={autoAdvanceInterval} />)
const img = screen.getByRole("img")
expect(img).toHaveAttribute("src", slides[0].imgUrl)
act(() => {
vi.advanceTimersByTime(autoAdvanceInterval)
})
expect(img).toHaveAttribute("src", slides[1].imgUrl)
act(() => {
vi.advanceTimersByTime(autoAdvanceInterval)
})
expect(img).toHaveAttribute("src", slides[2].imgUrl)
})
})実装
この実装には問題があり、useCallback と useRef を使用して不要な更新を防ぐで直す。
まず、タイマーを作るカスタムフック useTimeout を書く。useEffect のコールバックが返した関数は、再描画やアンマウントのときに呼ばれる。ここではそれを使ってタイマーを解除する。
// useTimeout.tsx
import { useEffect } from "react"
export const useTimeout = (delay: number | undefined, callback: () => void) => {
useEffect(() => {
if (!delay) return
const timeout = setTimeout(callback, delay)
return () => clearTimeout(timeout)
}, [delay, callback])
}useSlideIndex から useTimeout を呼ぶ。スライドが変わるたびに incrementSlideIndex が作り直されるので、そのたびに新しいタイマーがセットされる。
// useSlideIndex.tsx
export const useSlideIndex = (
slides?: unknown[],
slideIndexProp?: number,
onSlideIndexChange?: (newSlideIndex: number) => void,
autoAdvanceInterval?: number,
) => {
// ...
useTimeout(autoAdvanceInterval, incrementSlideIndex)
return [slideIndex, decrementSlideIndex, incrementSlideIndex] as const
}最後に、コンポーネントで autoAdvanceInterval を受け取ってフックに渡す。
// Carousel.tsx
export type CarouselProps = {
slides?: Slide[]
slideIndex?: number
onSlideIndexChange?: (newSlideIndex: number) => void
autoAdvanceInterval?: number
defaultImgHeight?: CarouselSlideProps["imgHeight"]
DefaultImgComponent?: CarouselSlideProps["ImgComponent"]
}
const Carousel = ({
slides,
slideIndex: slideIndexProp,
onSlideIndexChange,
autoAdvanceInterval,
defaultImgHeight,
DefaultImgComponent,
}: CarouselProps) => {
const [slideIndex, decrementSlideIndex, incrementSlideIndex] = useSlideIndex(
slides,
slideIndexProp,
onSlideIndexChange,
autoAdvanceInterval,
)
return (
<div data-testid="carousel">
<CarouselSlide
imgHeight={defaultImgHeight}
ImgComponent={DefaultImgComponent}
{...slides?.[slideIndex]}
/>
<CarouselButton data-testid="prev-button" onClick={decrementSlideIndex}>
Prev
</CarouselButton>
<CarouselButton data-testid="next-button" onClick={incrementSlideIndex}>
Next
</CarouselButton>
</div>
)
}