TL;DR
- 「どのスライドを表示しているか」を状態として持つカルーセルコンポーネントを、TDD で実装する。
- Testing Library は内部状態に直接触れないので、ユーザの操作をシミュレートし、DOM の出力が期待どおりに変わるかを確かめる。
内容
第3章の 3 つ目の実践例である。カルーセルは、どのスライドを表示しているかを状態として持つ必要がある。
Step 1: 定義
// Carousel.test.tsx
describe("Carousel", () => {
it("renders a <div>", () => {
render(<Carousel />)
expect(screen.getByTestId("carousel")).toBeInTheDocument()
})
})// Carousel.tsx
const Carousel = () => <div data-testid="carousel" />
export default CarouselStep 2: スライドのリストを受け取り、1 枚を表示する
コンポーネントを実装するときは、コアの機能から始めるとよい。まず、受け取ったスライドのうち最初の 1 枚を表示することをテストする。
// Carousel.test.tsx
const slides = [
{
imgUrl: "https://example.com/slide1.png",
description: "Slide 1",
attribution: "Uno Pizzeria",
},
{
imgUrl: "https://example.com/slide2.png",
description: "Slide 2",
attribution: "Dos Equis",
},
{
imgUrl: "https://example.com/slide3.png",
description: "Slide 3",
attribution: "Three Amigos",
},
]
it("renders the first slide by default", () => {
render(<Carousel slides={slides} />)
const img = screen.getByRole("img")
expect(img).toHaveAttribute("src", slides[0].imgUrl)
})CarouselSlide は入れ子になった React Component を TDD で実装するで作ったものを使う。
// Carousel.tsx
type Slide = {
imgUrl?: string
description?: ReactNode
attribution?: ReactNode
}
const Carousel = ({ slides }: { slides: Slide[] }) => {
return (
<div data-testid="carousel">
<CarouselSlide {...slides?.[0]} />
</div>
)
}Step 3: Next ボタンでスライドを進める
ここから、状態とユーザ操作の部分を実装する。Enzyme と違い、Testing Library ではコンポーネントの内部状態に直接アクセスできない。コンポーネントの内部はブラックボックスとして扱われる。そのため、状態のテストは次の流れになる。
- コンポーネントを描画する(
render)。 - ユーザの操作をシミュレートする。
- コンポーネントの DOM 出力が期待どおりに変わるかを確かめる。
// Carousel.test.tsx
it("renders the slide when the Next button is clicked", async () => {
render(<Carousel slides={slides} />)
const img = screen.getByRole("img")
const nextButton = screen.getByTestId("next-button")
const user = userEvent.setup()
await user.click(nextButton)
expect(img).toHaveAttribute("src", slides[1].imgUrl)
await user.click(nextButton)
expect(img).toHaveAttribute("src", slides[2].imgUrl)
await user.click(nextButton)
expect(img).toHaveAttribute("src", slides[0].imgUrl)
})Testing Library でシミュレートしたユーザイベントは、すべて非同期関数である。await で待つと、イベントが完全に処理され、DOM が更新されてから次の行に進む。await の付け忘れは、typescript-eslint の no-floating-promises ルールで防げる。
// Carousel.tsx
const Carousel = ({ slides }: { slides?: Slide[] }) => {
const [slideIndex, setSlideIndex] = useState(0)
return (
<div data-testid="carousel">
<CarouselSlide {...slides?.[slideIndex]} />
<CarouselButton
data-testid="next-button"
onClick={() => {
if (!slides) return
setSlideIndex((i) => (i + 1) % slides.length)
}}
>
Next
</CarouselButton>
</div>
)
}Step 4: Prev ボタンでスライドを戻す
// Carousel.test.tsx
it("reverses the slide when the Prev button is clicked", async () => {
render(<Carousel slides={slides} />)
const img = screen.getByRole("img")
const prevButton = screen.getByTestId("prev-button")
const user = userEvent.setup()
await user.click(prevButton)
expect(img).toHaveAttribute("src", slides[2].imgUrl)
await user.click(prevButton)
expect(img).toHaveAttribute("src", slides[1].imgUrl)
await user.click(prevButton)
expect(img).toHaveAttribute("src", slides[0].imgUrl)
})// Carousel.tsx
const Carousel = ({ slides }: { slides?: Slide[] }) => {
const [slideIndex, setSlideIndex] = useState(0)
return (
<div data-testid="carousel">
<CarouselSlide {...slides?.[slideIndex]} />
<CarouselButton
data-testid="prev-button"
onClick={() => {
if (!slides) return
setSlideIndex((i) => (i + slides.length - 1) % slides.length)
}}
>
Prev
</CarouselButton>
<CarouselButton
data-testid="next-button"
onClick={() => {
if (!slides) return
setSlideIndex((i) => (i + 1) % slides.length)
}}
>
Next
</CarouselButton>
</div>
)
}