From 9a7cf980dd6e3a0526422daf7da2db1dd806b260 Mon Sep 17 00:00:00 2001 From: MrSmart00 Date: Sat, 18 Apr 2026 06:22:37 +0900 Subject: [PATCH 1/2] test: migrate all tests to jest-cucumber BDD format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jest-cucumberを導入し、全31テストファイルをGherkin(.feature) + ステップ定義(.steps.ts)形式に移行。 英語キーワード(Feature/Scenario/Given/When/Then)+ 日本語説明文のスタイルで統一。 CLAUDE.mdにBDDテストガイドラインを追記。 Co-Authored-By: Claude --- CLAUDE.md | 54 ++- .../components/PokemonAbilities.test.tsx | 31 -- .../detail/components/PokemonDetail.test.tsx | 116 ------ .../components/PokemonFlavorText.test.tsx | 19 - .../components/PokemonPhysicalInfo.test.tsx | 30 -- .../detail/components/PokemonStats.test.tsx | 36 -- __tests__/detail/components/StatBar.test.tsx | 34 -- .../detail/features/detailScreen.feature | 47 +++ .../detail/features/pokemonAbilities.feature | 22 ++ .../detail/features/pokemonDetail.feature | 57 +++ .../detail/features/pokemonDetailApi.feature | 37 ++ .../detail/features/pokemonFlavorText.feature | 16 + .../features/pokemonPhysicalInfo.feature | 27 ++ .../detail/features/pokemonSpeciesApi.feature | 44 +++ .../detail/features/pokemonStats.feature | 23 ++ __tests__/detail/features/statBar.feature | 21 + .../detail/features/usePokemonDetail.feature | 38 ++ .../features/usePokemonFlavorText.feature | 25 ++ .../features/usePokemonSpeciesInfo.feature | 29 ++ .../detail/hooks/usePokemonDetail.test.ts | 116 ------ .../detail/hooks/usePokemonFlavorText.test.ts | 57 --- .../hooks/usePokemonSpeciesInfo.test.ts | 64 ---- .../repository/pokemonDetailApi.test.ts | 125 ------ .../repository/pokemonSpeciesApi.test.ts | 126 ------ .../detail/screens/DetailScreen.test.tsx | 107 ------ __tests__/detail/steps/detailScreen.steps.tsx | 193 ++++++++++ .../detail/steps/pokemonAbilities.steps.tsx | 75 ++++ .../detail/steps/pokemonDetail.steps.tsx | 224 +++++++++++ .../detail/steps/pokemonDetailApi.steps.ts | 183 +++++++++ .../detail/steps/pokemonFlavorText.steps.tsx | 58 +++ .../steps/pokemonPhysicalInfo.steps.tsx | 91 +++++ .../detail/steps/pokemonSpeciesApi.steps.ts | 199 ++++++++++ __tests__/detail/steps/pokemonStats.steps.tsx | 89 +++++ __tests__/detail/steps/statBar.steps.tsx | 87 +++++ .../detail/steps/usePokemonDetail.steps.ts | 185 +++++++++ .../steps/usePokemonFlavorText.steps.ts | 102 +++++ .../steps/usePokemonSpeciesInfo.steps.ts | 121 ++++++ .../features/favoritesScreen.feature | 16 + .../features/usePokemonByIds.feature | 50 +++ .../favorites/hooks/usePokemonByIds.test.ts | 167 -------- .../screens/FavoritesScreen.test.tsx | 58 --- .../favorites/steps/favoritesScreen.steps.tsx | 84 ++++ .../favorites/steps/usePokemonByIds.steps.ts | 261 +++++++++++++ .../components/FloatingSearchButton.test.tsx | 88 ----- __tests__/home/domain/pokemonListItem.test.ts | 59 --- .../features/floatingSearchButton.feature | 34 ++ __tests__/home/features/homeScreen.feature | 47 +++ __tests__/home/features/pokemonApi.feature | 25 ++ .../home/features/pokemonGraphqlApi.feature | 34 ++ .../home/features/pokemonListItem.feature | 30 ++ .../home/features/useFloatingSearch.feature | 29 ++ .../home/features/usePokemonList.feature | 72 ++++ __tests__/home/features/useSearch.feature | 32 ++ .../home/hooks/useFloatingSearch.test.ts | 53 --- __tests__/home/hooks/usePokemonList.test.ts | 167 -------- __tests__/home/hooks/useSearch.test.ts | 57 --- __tests__/home/repository/pokemonApi.test.ts | 67 ---- .../home/repository/pokemonGraphqlApi.test.ts | 141 ------- __tests__/home/screens/HomeScreen.test.tsx | 108 ------ .../home/steps/floatingSearchButton.steps.tsx | 176 +++++++++ __tests__/home/steps/homeScreen.steps.tsx | 215 +++++++++++ __tests__/home/steps/pokemonApi.steps.ts | 137 +++++++ .../home/steps/pokemonGraphqlApi.steps.ts | 268 +++++++++++++ __tests__/home/steps/pokemonListItem.steps.ts | 78 ++++ .../home/steps/useFloatingSearch.steps.ts | 108 ++++++ __tests__/home/steps/usePokemonList.steps.ts | 361 ++++++++++++++++++ __tests__/home/steps/useSearch.steps.ts | 150 ++++++++ .../components/LanguagePicker.test.tsx | 41 -- .../settings/features/languagePicker.feature | 18 + .../settings/steps/languagePicker.steps.tsx | 75 ++++ .../shared/components/FavoriteButton.test.tsx | 61 --- .../shared/components/PokemonCard.test.tsx | 95 ----- __tests__/shared/domain/typeColors.test.ts | 39 -- .../shared/features/favoriteButton.feature | 40 ++ __tests__/shared/features/i18n.feature | 36 ++ __tests__/shared/features/pokemonApi.feature | 26 ++ __tests__/shared/features/pokemonCard.feature | 54 +++ __tests__/shared/features/typeColors.feature | 12 + .../shared/features/useFavoritesStore.feature | 54 +++ __tests__/shared/features/useLanguage.feature | 18 + __tests__/shared/i18n/i18n.test.ts | 61 --- __tests__/shared/i18n/useLanguage.test.ts | 39 -- .../shared/repository/pokemonApi.test.ts | 95 ----- .../shared/steps/favoriteButton.steps.tsx | 157 ++++++++ __tests__/shared/steps/i18n.steps.ts | 156 ++++++++ __tests__/shared/steps/pokemonApi.steps.ts | 149 ++++++++ __tests__/shared/steps/pokemonCard.steps.tsx | 229 +++++++++++ __tests__/shared/steps/typeColors.steps.ts | 67 ++++ .../shared/steps/useFavoritesStore.steps.tsx | 265 +++++++++++++ __tests__/shared/steps/useLanguage.steps.ts | 87 +++++ .../shared/stores/useFavoritesStore.test.tsx | 114 ------ .../splash/components/AnimatedSplash.test.tsx | 64 ---- .../splash/features/animatedSplash.feature | 21 + .../splash/features/useAnimatedSplash.feature | 27 ++ .../splash/hooks/useAnimatedSplash.test.ts | 67 ---- .../splash/steps/animatedSplash.steps.tsx | 113 ++++++ .../splash/steps/useAnimatedSplash.steps.ts | 118 ++++++ jest.config.js | 6 +- package.json | 1 + pnpm-lock.yaml | 195 ++++++++++ 100 files changed, 6170 insertions(+), 2510 deletions(-) delete mode 100644 __tests__/detail/components/PokemonAbilities.test.tsx delete mode 100644 __tests__/detail/components/PokemonDetail.test.tsx delete mode 100644 __tests__/detail/components/PokemonFlavorText.test.tsx delete mode 100644 __tests__/detail/components/PokemonPhysicalInfo.test.tsx delete mode 100644 __tests__/detail/components/PokemonStats.test.tsx delete mode 100644 __tests__/detail/components/StatBar.test.tsx create mode 100644 __tests__/detail/features/detailScreen.feature create mode 100644 __tests__/detail/features/pokemonAbilities.feature create mode 100644 __tests__/detail/features/pokemonDetail.feature create mode 100644 __tests__/detail/features/pokemonDetailApi.feature create mode 100644 __tests__/detail/features/pokemonFlavorText.feature create mode 100644 __tests__/detail/features/pokemonPhysicalInfo.feature create mode 100644 __tests__/detail/features/pokemonSpeciesApi.feature create mode 100644 __tests__/detail/features/pokemonStats.feature create mode 100644 __tests__/detail/features/statBar.feature create mode 100644 __tests__/detail/features/usePokemonDetail.feature create mode 100644 __tests__/detail/features/usePokemonFlavorText.feature create mode 100644 __tests__/detail/features/usePokemonSpeciesInfo.feature delete mode 100644 __tests__/detail/hooks/usePokemonDetail.test.ts delete mode 100644 __tests__/detail/hooks/usePokemonFlavorText.test.ts delete mode 100644 __tests__/detail/hooks/usePokemonSpeciesInfo.test.ts delete mode 100644 __tests__/detail/repository/pokemonDetailApi.test.ts delete mode 100644 __tests__/detail/repository/pokemonSpeciesApi.test.ts delete mode 100644 __tests__/detail/screens/DetailScreen.test.tsx create mode 100644 __tests__/detail/steps/detailScreen.steps.tsx create mode 100644 __tests__/detail/steps/pokemonAbilities.steps.tsx create mode 100644 __tests__/detail/steps/pokemonDetail.steps.tsx create mode 100644 __tests__/detail/steps/pokemonDetailApi.steps.ts create mode 100644 __tests__/detail/steps/pokemonFlavorText.steps.tsx create mode 100644 __tests__/detail/steps/pokemonPhysicalInfo.steps.tsx create mode 100644 __tests__/detail/steps/pokemonSpeciesApi.steps.ts create mode 100644 __tests__/detail/steps/pokemonStats.steps.tsx create mode 100644 __tests__/detail/steps/statBar.steps.tsx create mode 100644 __tests__/detail/steps/usePokemonDetail.steps.ts create mode 100644 __tests__/detail/steps/usePokemonFlavorText.steps.ts create mode 100644 __tests__/detail/steps/usePokemonSpeciesInfo.steps.ts create mode 100644 __tests__/favorites/features/favoritesScreen.feature create mode 100644 __tests__/favorites/features/usePokemonByIds.feature delete mode 100644 __tests__/favorites/hooks/usePokemonByIds.test.ts delete mode 100644 __tests__/favorites/screens/FavoritesScreen.test.tsx create mode 100644 __tests__/favorites/steps/favoritesScreen.steps.tsx create mode 100644 __tests__/favorites/steps/usePokemonByIds.steps.ts delete mode 100644 __tests__/home/components/FloatingSearchButton.test.tsx delete mode 100644 __tests__/home/domain/pokemonListItem.test.ts create mode 100644 __tests__/home/features/floatingSearchButton.feature create mode 100644 __tests__/home/features/homeScreen.feature create mode 100644 __tests__/home/features/pokemonApi.feature create mode 100644 __tests__/home/features/pokemonGraphqlApi.feature create mode 100644 __tests__/home/features/pokemonListItem.feature create mode 100644 __tests__/home/features/useFloatingSearch.feature create mode 100644 __tests__/home/features/usePokemonList.feature create mode 100644 __tests__/home/features/useSearch.feature delete mode 100644 __tests__/home/hooks/useFloatingSearch.test.ts delete mode 100644 __tests__/home/hooks/usePokemonList.test.ts delete mode 100644 __tests__/home/hooks/useSearch.test.ts delete mode 100644 __tests__/home/repository/pokemonApi.test.ts delete mode 100644 __tests__/home/repository/pokemonGraphqlApi.test.ts delete mode 100644 __tests__/home/screens/HomeScreen.test.tsx create mode 100644 __tests__/home/steps/floatingSearchButton.steps.tsx create mode 100644 __tests__/home/steps/homeScreen.steps.tsx create mode 100644 __tests__/home/steps/pokemonApi.steps.ts create mode 100644 __tests__/home/steps/pokemonGraphqlApi.steps.ts create mode 100644 __tests__/home/steps/pokemonListItem.steps.ts create mode 100644 __tests__/home/steps/useFloatingSearch.steps.ts create mode 100644 __tests__/home/steps/usePokemonList.steps.ts create mode 100644 __tests__/home/steps/useSearch.steps.ts delete mode 100644 __tests__/settings/components/LanguagePicker.test.tsx create mode 100644 __tests__/settings/features/languagePicker.feature create mode 100644 __tests__/settings/steps/languagePicker.steps.tsx delete mode 100644 __tests__/shared/components/FavoriteButton.test.tsx delete mode 100644 __tests__/shared/components/PokemonCard.test.tsx delete mode 100644 __tests__/shared/domain/typeColors.test.ts create mode 100644 __tests__/shared/features/favoriteButton.feature create mode 100644 __tests__/shared/features/i18n.feature create mode 100644 __tests__/shared/features/pokemonApi.feature create mode 100644 __tests__/shared/features/pokemonCard.feature create mode 100644 __tests__/shared/features/typeColors.feature create mode 100644 __tests__/shared/features/useFavoritesStore.feature create mode 100644 __tests__/shared/features/useLanguage.feature delete mode 100644 __tests__/shared/i18n/i18n.test.ts delete mode 100644 __tests__/shared/i18n/useLanguage.test.ts delete mode 100644 __tests__/shared/repository/pokemonApi.test.ts create mode 100644 __tests__/shared/steps/favoriteButton.steps.tsx create mode 100644 __tests__/shared/steps/i18n.steps.ts create mode 100644 __tests__/shared/steps/pokemonApi.steps.ts create mode 100644 __tests__/shared/steps/pokemonCard.steps.tsx create mode 100644 __tests__/shared/steps/typeColors.steps.ts create mode 100644 __tests__/shared/steps/useFavoritesStore.steps.tsx create mode 100644 __tests__/shared/steps/useLanguage.steps.ts delete mode 100644 __tests__/shared/stores/useFavoritesStore.test.tsx delete mode 100644 __tests__/splash/components/AnimatedSplash.test.tsx create mode 100644 __tests__/splash/features/animatedSplash.feature create mode 100644 __tests__/splash/features/useAnimatedSplash.feature delete mode 100644 __tests__/splash/hooks/useAnimatedSplash.test.ts create mode 100644 __tests__/splash/steps/animatedSplash.steps.tsx create mode 100644 __tests__/splash/steps/useAnimatedSplash.steps.ts diff --git a/CLAUDE.md b/CLAUDE.md index e4b036d..6f9e1cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,17 +20,57 @@ GitHub Issues #1〜#5に各Stepの仕様が定義されている。 2. **Green** — テストが通る最小限の実装を書く 3. **Refactor** — コードを整理する(テストが通ることを確認しながら) -### テストの書き方(BDDスタイル) +### テストの書き方(Cucumber BDDスタイル) + +新規テストは **jest-cucumber** を使い、Gherkin(.feature)+ ステップ定義で記述する。 +既存の `describe/it` 形式テストは段階的に移行する。 + +#### ファイル配置 + +``` +__tests__/ + / + features/ # .feature ファイル(Gherkin仕様) + .feature + steps/ # ステップ定義 + .steps.ts(x) +``` -- `describe` で機能・コンポーネント名を記述 -- `it` で振る舞いを日本語で記述 -- テストは `__tests__/` ディレクトリに配置 - **`app/` ディレクトリにテストを置かないこと**(Expo Routerがルートとして認識するため) +#### .feature ファイルの書き方 + +- キーワードは英語(Feature/Scenario/Given/When/Then/And)、説明文は日本語で記述 +- 同じロジックに複数の入力パターンがある場合は `Scenario Outline:` + `Examples:` テーブルを使う + +```gherkin +Feature: ポケモンリストアイテムの変換 + + Scenario Outline: URLからポケモンIDを抽出する + Given PokeAPIのURL "" が与えられている + When URLからIDを抽出する + Then IDは である + + Examples: + | url | id | + | https://pokeapi.co/api/v2/pokemon/25/ | 25 | +``` + +#### ステップ定義の書き方 + +- `loadFeature()` で .feature を読み込み、`defineFeature()` でステップを定義 +- インポートは `@/src/` 経由(バレルファイル経由のルール維持) + ```typescript -describe('PokemonCard', () => { - it('ポケモンの名前が表示される', () => { ... }); - it('タイプバッジが色分けされて表示される', () => { ... }); +import { defineFeature, loadFeature } from "jest-cucumber"; +import { extractPokemonId } from "@/src/home"; + +const feature = loadFeature("__tests__/home/features/pokemonListItem.feature"); + +defineFeature(feature, (test) => { + test("URLからポケモンIDを抽出する", ({ given, when, then }) => { + // given/when/then でステップを実装 + }); }); ``` diff --git a/__tests__/detail/components/PokemonAbilities.test.tsx b/__tests__/detail/components/PokemonAbilities.test.tsx deleted file mode 100644 index c67ab66..0000000 --- a/__tests__/detail/components/PokemonAbilities.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { render, screen } from "@testing-library/react-native"; -import { PokemonAbilities } from "@/src/detail/components/PokemonAbilities"; -import type { PokemonAbility } from "@/src/shared"; - -const mockAbilities: PokemonAbility[] = [ - { name: "overgrow", isHidden: false }, - { name: "chlorophyll", isHidden: true }, -]; - -describe("PokemonAbilities", () => { - it("セクションタイトルが表示される", () => { - render(); - expect(screen.getByText("detail.abilities")).toBeTruthy(); - }); - - it("とくせい名がキャピタライズされて表示される", () => { - render(); - expect(screen.getByText("Overgrow")).toBeTruthy(); - }); - - it("隠れとくせいにラベルが付与される", () => { - render(); - expect(screen.getByText("Chlorophyll detail.hiddenAbility")).toBeTruthy(); - }); - - it("複数のとくせいが全て表示される", () => { - render(); - expect(screen.getByText("Overgrow")).toBeTruthy(); - expect(screen.getByText("Chlorophyll detail.hiddenAbility")).toBeTruthy(); - }); -}); diff --git a/__tests__/detail/components/PokemonDetail.test.tsx b/__tests__/detail/components/PokemonDetail.test.tsx deleted file mode 100644 index db3f9c2..0000000 --- a/__tests__/detail/components/PokemonDetail.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react-native"; -import { PokemonDetail } from "@/src/detail/components/PokemonDetail"; -import type { Pokemon } from "@/src/shared"; - -const mockPokemon: Pokemon = { - id: 25, - name: "pikachu", - types: ["electric"], - stats: [ - { name: "hp", baseStat: 35 }, - { name: "attack", baseStat: 55 }, - { name: "defense", baseStat: 40 }, - { name: "special-attack", baseStat: 50 }, - { name: "special-defense", baseStat: 50 }, - { name: "speed", baseStat: 90 }, - ], - height: 4, - weight: 60, - abilities: [ - { name: "static", isHidden: false }, - { name: "lightning-rod", isHidden: true }, - ], -}; - -const multiTypePokemon: Pokemon = { - id: 6, - name: "charizard", - types: ["fire", "flying"], - stats: [ - { name: "hp", baseStat: 78 }, - { name: "attack", baseStat: 84 }, - { name: "defense", baseStat: 78 }, - { name: "special-attack", baseStat: 109 }, - { name: "special-defense", baseStat: 85 }, - { name: "speed", baseStat: 100 }, - ], - height: 17, - weight: 905, - abilities: [ - { name: "blaze", isHidden: false }, - { name: "solar-power", isHidden: true }, - ], -}; - -describe("PokemonDetail", () => { - it("ローカライズ名が渡された場合に表示される", () => { - render(); - expect(screen.getByText("ピカチュウ")).toBeTruthy(); - }); - - it("ローカライズ名がnullの場合はAPI名が表示される", () => { - render(); - expect(screen.getByText("pikachu")).toBeTruthy(); - }); - - it("ポケモンのIDが3桁ゼロ埋めで表示される", () => { - render(); - expect(screen.getByText("#025")).toBeTruthy(); - }); - - it("ポケモンの画像が表示される", () => { - render(); - const image = screen.getByTestId("pokemon-detail-image"); - expect(image.props.source.uri).toContain("25.png"); - }); - - it("タイプバッジが翻訳されて表示される", () => { - render(); - expect(screen.getByText("types.electric")).toBeTruthy(); - }); - - it("複数タイプが翻訳されて全て表示される", () => { - render(); - expect(screen.getByText("types.fire")).toBeTruthy(); - expect(screen.getByText("types.flying")).toBeTruthy(); - }); - - it("isFavoriteとonToggleFavoriteが渡された場合、お気に入りボタンが表示される", () => { - render( - , - ); - expect(screen.getByTestId("favorite-button")).toBeTruthy(); - }); - - it("お気に入りボタン押下後アニメーション完了でonToggleFavoriteが呼ばれる", () => { - const onToggleFavorite = jest.fn(); - render( - , - ); - fireEvent.press(screen.getByTestId("favorite-button")); - expect(onToggleFavorite).toHaveBeenCalledTimes(1); - }); - - it("isFavoriteが未指定の場合、お気に入りボタンが表示されない", () => { - render(); - expect(screen.queryByTestId("favorite-button")).toBeNull(); - }); - - it("フレーバーテキストが渡された場合に表示される", () => { - render(); - expect(screen.getByText("でんきを ためこむ せいしつ。")).toBeTruthy(); - }); - - it("フレーバーテキストが未指定の場合は表示されない", () => { - render(); - expect(screen.queryByTestId("flavor-text-loading")).toBeNull(); - }); -}); diff --git a/__tests__/detail/components/PokemonFlavorText.test.tsx b/__tests__/detail/components/PokemonFlavorText.test.tsx deleted file mode 100644 index e4f4005..0000000 --- a/__tests__/detail/components/PokemonFlavorText.test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { render, screen } from "@testing-library/react-native"; -import { PokemonFlavorText } from "@/src/detail/components/PokemonFlavorText"; - -describe("PokemonFlavorText", () => { - it("フレーバーテキストが表示される", () => { - render(); - expect(screen.getByText("でんきを ためこむ せいしつ。")).toBeTruthy(); - }); - - it("ローディング中にインジケータが表示される", () => { - render(); - expect(screen.getByTestId("flavor-text-loading")).toBeTruthy(); - }); - - it("テキストがnullでローディングでない場合は何も表示されない", () => { - const { toJSON } = render(); - expect(toJSON()).toBeNull(); - }); -}); diff --git a/__tests__/detail/components/PokemonPhysicalInfo.test.tsx b/__tests__/detail/components/PokemonPhysicalInfo.test.tsx deleted file mode 100644 index 23ffcbe..0000000 --- a/__tests__/detail/components/PokemonPhysicalInfo.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { render, screen } from "@testing-library/react-native"; -import { PokemonPhysicalInfo } from "@/src/detail/components/PokemonPhysicalInfo"; - -describe("PokemonPhysicalInfo", () => { - it("身長の値が表示される", () => { - render(); - expect(screen.getByText("0.7detail.heightUnit")).toBeTruthy(); - }); - - it("体重の値が表示される", () => { - render(); - expect(screen.getByText("6.9detail.weightUnit")).toBeTruthy(); - }); - - it("身長ラベルが表示される", () => { - render(); - expect(screen.getByText("detail.height")).toBeTruthy(); - }); - - it("体重ラベルが表示される", () => { - render(); - expect(screen.getByText("detail.weight")).toBeTruthy(); - }); - - it("整数の身長が正しくフォーマットされる", () => { - render(); - expect(screen.getByText("2.0detail.heightUnit")).toBeTruthy(); - expect(screen.getByText("100.0detail.weightUnit")).toBeTruthy(); - }); -}); diff --git a/__tests__/detail/components/PokemonStats.test.tsx b/__tests__/detail/components/PokemonStats.test.tsx deleted file mode 100644 index 3ab587c..0000000 --- a/__tests__/detail/components/PokemonStats.test.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { render, screen } from "@testing-library/react-native"; -import { PokemonStats } from "@/src/detail/components/PokemonStats"; -import type { PokemonStat } from "@/src/shared"; - -const mockStats: PokemonStat[] = [ - { name: "hp", baseStat: 45 }, - { name: "attack", baseStat: 49 }, - { name: "defense", baseStat: 49 }, - { name: "special-attack", baseStat: 65 }, - { name: "special-defense", baseStat: 65 }, - { name: "speed", baseStat: 45 }, -]; - -describe("PokemonStats", () => { - it("セクションタイトルが表示される", () => { - render(); - expect(screen.getByText("detail.baseStats")).toBeTruthy(); - }); - - it("6つのステータスバーが表示される", () => { - render(); - expect(screen.getByText("detail.stats.hp")).toBeTruthy(); - expect(screen.getByText("detail.stats.attack")).toBeTruthy(); - expect(screen.getByText("detail.stats.defense")).toBeTruthy(); - expect(screen.getByText("detail.stats.special-attack")).toBeTruthy(); - expect(screen.getByText("detail.stats.special-defense")).toBeTruthy(); - expect(screen.getByText("detail.stats.speed")).toBeTruthy(); - }); - - it("各ステータスの値が正しく表示される", () => { - render(); - expect(screen.getAllByText("45")).toHaveLength(2); - expect(screen.getAllByText("49")).toHaveLength(2); - expect(screen.getAllByText("65")).toHaveLength(2); - }); -}); diff --git a/__tests__/detail/components/StatBar.test.tsx b/__tests__/detail/components/StatBar.test.tsx deleted file mode 100644 index ca168fc..0000000 --- a/__tests__/detail/components/StatBar.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { render, screen } from "@testing-library/react-native"; -import { StatBar } from "@/src/detail/components/StatBar"; - -describe("StatBar", () => { - it("ステータス名が表示される", () => { - render(); - expect(screen.getByText("HP")).toBeTruthy(); - }); - - it("ステータス値が表示される", () => { - render(); - expect(screen.getByText("45")).toBeTruthy(); - }); - - it("バーの幅がステータス値に比例する", () => { - render(); - const bar = screen.getByTestId("stat-bar-fill"); - expect(bar.props.style).toEqual( - expect.arrayContaining([ - expect.objectContaining({ width: "50%" }), - ]) - ); - }); - - it("maxValueのデフォルトは180", () => { - render(); - const bar = screen.getByTestId("stat-bar-fill"); - expect(bar.props.style).toEqual( - expect.arrayContaining([ - expect.objectContaining({ width: "50%" }), - ]) - ); - }); -}); diff --git a/__tests__/detail/features/detailScreen.feature b/__tests__/detail/features/detailScreen.feature new file mode 100644 index 0000000..f45d027 --- /dev/null +++ b/__tests__/detail/features/detailScreen.feature @@ -0,0 +1,47 @@ +Feature: 詳細画面 + + Scenario: ローカライズされたポケモン名が表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then 「ピカチュウ」が表示される + + Scenario: ポケモンのIDが表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then 「#025」が表示される + + Scenario: ローディング中にActivityIndicatorが表示される + Given ローディング中のモック状態が設定されている + When 詳細画面をID「25」でレンダリングする + Then ローディングインジケータが表示される + + Scenario: エラー時にエラーメッセージが表示される + Given エラー状態のモックが設定されている + When 詳細画面をID「999」でレンダリングする + Then 「detail.notFound」が表示される + + Scenario: お気に入りボタンが表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then お気に入りボタンが表示される + + Scenario: ステータスが詳細画面に表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then 「detail.baseStats」が表示される + + Scenario: 身長と体重が詳細画面に表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then 「detail.height」が表示される + And 「detail.weight」が表示される + + Scenario: フレーバーテキストが表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then 「It keeps its tail raised.」が表示される + + Scenario: ローカライズ名がnullの場合はAPI名が表示される + Given ローカライズ名がnullのモック状態が設定されている + When 詳細画面をID「25」でレンダリングする + Then 「Pikachu」が表示される diff --git a/__tests__/detail/features/pokemonAbilities.feature b/__tests__/detail/features/pokemonAbilities.feature new file mode 100644 index 0000000..50ed6a2 --- /dev/null +++ b/__tests__/detail/features/pokemonAbilities.feature @@ -0,0 +1,22 @@ +Feature: ポケモンとくせい表示 + + Scenario: セクションタイトルが表示される + Given とくせいリストが与えられている + When PokemonAbilitiesをレンダリングする + Then セクションタイトル「detail.abilities」が表示される + + Scenario: とくせい名がキャピタライズされて表示される + Given とくせいリストが与えられている + When PokemonAbilitiesをレンダリングする + Then とくせい名「Overgrow」が表示される + + Scenario: 隠れとくせいにラベルが付与される + Given とくせいリストが与えられている + When PokemonAbilitiesをレンダリングする + Then 隠れとくせい「Chlorophyll detail.hiddenAbility」が表示される + + Scenario: 複数のとくせいが全て表示される + Given とくせいリストが与えられている + When PokemonAbilitiesをレンダリングする + Then とくせい名「Overgrow」が表示される + And 隠れとくせい「Chlorophyll detail.hiddenAbility」が表示される diff --git a/__tests__/detail/features/pokemonDetail.feature b/__tests__/detail/features/pokemonDetail.feature new file mode 100644 index 0000000..ff7acb6 --- /dev/null +++ b/__tests__/detail/features/pokemonDetail.feature @@ -0,0 +1,57 @@ +Feature: ポケモン詳細コンポーネント + + Scenario: ローカライズ名が渡された場合に表示される + Given ピカチュウのデータが用意されている + When ローカライズ名「ピカチュウ」を指定してPokemonDetailをレンダリングする + Then 「ピカチュウ」が表示される + + Scenario: ローカライズ名がnullの場合はAPI名が表示される + Given ピカチュウのデータが用意されている + When ローカライズ名をnullにしてPokemonDetailをレンダリングする + Then 「pikachu」が表示される + + Scenario: ポケモンのIDが3桁ゼロ埋めで表示される + Given ピカチュウのデータが用意されている + When PokemonDetailをレンダリングする + Then 「#025」が表示される + + Scenario: ポケモンの画像が表示される + Given ピカチュウのデータが用意されている + When PokemonDetailをレンダリングする + Then ポケモン画像のURIに「25.png」が含まれる + + Scenario: タイプバッジが翻訳されて表示される + Given ピカチュウのデータが用意されている + When PokemonDetailをレンダリングする + Then 「types.electric」が表示される + + Scenario: 複数タイプが翻訳されて全て表示される + Given リザードンのデータが用意されている + When PokemonDetailをレンダリングする + Then 「types.fire」が表示される + And 「types.flying」が表示される + + Scenario: お気に入りボタンが表示される + Given ピカチュウのデータが用意されている + When お気に入り機能付きでPokemonDetailをレンダリングする + Then お気に入りボタンが表示される + + Scenario: お気に入りボタン押下後にonToggleFavoriteが呼ばれる + Given ピカチュウのデータが用意されている + When お気に入り機能付きでPokemonDetailをレンダリングしてボタンを押す + Then onToggleFavoriteが1回呼ばれる + + Scenario: お気に入りが未指定の場合ボタンが表示されない + Given ピカチュウのデータが用意されている + When PokemonDetailをレンダリングする + Then お気に入りボタンが表示されない + + Scenario: フレーバーテキストが渡された場合に表示される + Given ピカチュウのデータが用意されている + When フレーバーテキスト付きでPokemonDetailをレンダリングする + Then フレーバーテキストが表示される + + Scenario: フレーバーテキストが未指定の場合は表示されない + Given ピカチュウのデータが用意されている + When PokemonDetailをレンダリングする + Then フレーバーテキストのローディングが表示されない diff --git a/__tests__/detail/features/pokemonDetailApi.feature b/__tests__/detail/features/pokemonDetailApi.feature new file mode 100644 index 0000000..2ecca81 --- /dev/null +++ b/__tests__/detail/features/pokemonDetailApi.feature @@ -0,0 +1,37 @@ +Feature: ポケモン詳細API + + Scenario: 正しいURLでfetchを呼び出す + Given PokeAPIがピカチュウのレスポンスを返す + When fetchPokemonDetailをID25で呼び出す + Then fetchが「https://pokeapi.co/api/v2/pokemon/25」で呼ばれる + + Scenario: レスポンスからステータスを正しく変換する + Given PokeAPIがピカチュウのレスポンスを返す + When fetchPokemonDetailをID25で呼び出す + Then ステータスが正しく変換される + + Scenario: 身長と体重を正しく変換する + Given PokeAPIがピカチュウのレスポンスを返す + When fetchPokemonDetailをID25で呼び出す + Then 身長が4である + And 体重が60である + + Scenario: とくせいを正しく変換する + Given PokeAPIがピカチュウのレスポンスを返す + When fetchPokemonDetailをID25で呼び出す + Then とくせいが正しく変換される + + Scenario: 名前がキャピタライズされる + Given PokeAPIがピカチュウのレスポンスを返す + When fetchPokemonDetailをID25で呼び出す + Then 名前が「Pikachu」である + + Scenario: タイプが正しく変換される + Given PokeAPIがピカチュウのレスポンスを返す + When fetchPokemonDetailをID25で呼び出す + Then タイプが「electric」である + + Scenario: HTTPエラー時にエラーをスローする + Given PokeAPIが404エラーを返す + When fetchPokemonDetailをID99999で呼び出す + Then 「Failed to fetch pokemon detail: 404」エラーがスローされる diff --git a/__tests__/detail/features/pokemonFlavorText.feature b/__tests__/detail/features/pokemonFlavorText.feature new file mode 100644 index 0000000..d21b1d8 --- /dev/null +++ b/__tests__/detail/features/pokemonFlavorText.feature @@ -0,0 +1,16 @@ +Feature: ポケモンフレーバーテキスト表示 + + Scenario: フレーバーテキストが表示される + Given フレーバーテキストが与えられている + When PokemonFlavorTextをレンダリングする + Then テキスト「でんきを ためこむ せいしつ。」が表示される + + Scenario: ローディング中にインジケータが表示される + Given テキストがnullでローディング中である + When PokemonFlavorTextをレンダリングする + Then ローディングインジケータが表示される + + Scenario: テキストがnullでローディングでない場合は何も表示されない + Given テキストがnullでローディングでない + When PokemonFlavorTextをレンダリングする + Then 何も表示されない diff --git a/__tests__/detail/features/pokemonPhysicalInfo.feature b/__tests__/detail/features/pokemonPhysicalInfo.feature new file mode 100644 index 0000000..f064374 --- /dev/null +++ b/__tests__/detail/features/pokemonPhysicalInfo.feature @@ -0,0 +1,27 @@ +Feature: ポケモン体格情報の表示 + + Scenario: 身長の値が表示される + Given 身長7、体重69のポケモンデータが与えられている + When PokemonPhysicalInfoをレンダリングする + Then 「0.7detail.heightUnit」が表示される + + Scenario: 体重の値が表示される + Given 身長7、体重69のポケモンデータが与えられている + When PokemonPhysicalInfoをレンダリングする + Then 「6.9detail.weightUnit」が表示される + + Scenario: 身長ラベルが表示される + Given 身長7、体重69のポケモンデータが与えられている + When PokemonPhysicalInfoをレンダリングする + Then 「detail.height」が表示される + + Scenario: 体重ラベルが表示される + Given 身長7、体重69のポケモンデータが与えられている + When PokemonPhysicalInfoをレンダリングする + Then 「detail.weight」が表示される + + Scenario: 整数の身長が正しくフォーマットされる + Given 身長20、体重1000のポケモンデータが与えられている + When PokemonPhysicalInfoをレンダリングする + Then 「2.0detail.heightUnit」が表示される + And 「100.0detail.weightUnit」が表示される diff --git a/__tests__/detail/features/pokemonSpeciesApi.feature b/__tests__/detail/features/pokemonSpeciesApi.feature new file mode 100644 index 0000000..85b7eaf --- /dev/null +++ b/__tests__/detail/features/pokemonSpeciesApi.feature @@ -0,0 +1,44 @@ +Feature: ポケモン種族API + + Scenario: fetchPokemonFlavorTextが正しいURLでfetchを呼び出す + Given PokeAPIが種族レスポンスを返す + When fetchPokemonFlavorTextをID25で呼び出す + Then fetchが「https://pokeapi.co/api/v2/pokemon-species/25」で呼ばれる + + Scenario: 英語のフレーバーテキストを返す + Given PokeAPIが種族レスポンスを返す + When fetchPokemonFlavorTextをID25で呼び出す + Then 英語のフレーバーテキストが返される + + Scenario: フレーバーテキストがない場合はnullを返す + Given PokeAPIが空の種族レスポンスを返す + When fetchPokemonFlavorTextをID25で呼び出す + Then nullが返される + + Scenario: fetchPokemonFlavorTextでHTTPエラー時にエラーをスローする + Given PokeAPIが404エラーを返す + When fetchPokemonFlavorTextをID99999で呼び出す + Then 「Failed to fetch pokemon species: 404」エラーがスローされる + + Scenario: 指定した言語のポケモン名とフレーバーテキストを返す + Given PokeAPIが種族レスポンスを返す + When fetchPokemonSpeciesInfoをID25と言語「ja」で呼び出す + Then localizedNameが「ピカチュウ」である + And flavorTextが「でんきを ためこむ せいしつ。」である + + Scenario: 英語を指定した場合に英語のデータを返す + Given PokeAPIが種族レスポンスを返す + When fetchPokemonSpeciesInfoをID25と言語「en」で呼び出す + Then localizedNameが「Pikachu」である + And flavorTextが英語である + + Scenario: 該当する言語がない場合はnullを返す + Given PokeAPIが空の種族レスポンスを返す + When fetchPokemonSpeciesInfoをID25と言語「ja」で呼び出す + Then localizedNameがnullである + And flavorTextがnullである + + Scenario: fetchPokemonSpeciesInfoでHTTPエラー時にエラーをスローする + Given PokeAPIが404エラーを返す + When fetchPokemonSpeciesInfoをID99999と言語「ja」で呼び出す + Then 「Failed to fetch pokemon species: 404」エラーがスローされる diff --git a/__tests__/detail/features/pokemonStats.feature b/__tests__/detail/features/pokemonStats.feature new file mode 100644 index 0000000..7ac8238 --- /dev/null +++ b/__tests__/detail/features/pokemonStats.feature @@ -0,0 +1,23 @@ +Feature: ポケモンステータス表示 + + Scenario: セクションタイトルが表示される + Given ステータスデータが与えられている + When PokemonStatsをレンダリングする + Then 「detail.baseStats」が表示される + + Scenario: 6つのステータスバーが表示される + Given ステータスデータが与えられている + When PokemonStatsをレンダリングする + Then 「detail.stats.hp」が表示される + And 「detail.stats.attack」が表示される + And 「detail.stats.defense」が表示される + And 「detail.stats.special-attack」が表示される + And 「detail.stats.special-defense」が表示される + And 「detail.stats.speed」が表示される + + Scenario: 各ステータスの値が正しく表示される + Given ステータスデータが与えられている + When PokemonStatsをレンダリングする + Then 値「45」が2つ表示される + And 値「49」が2つ表示される + And 値「65」が2つ表示される diff --git a/__tests__/detail/features/statBar.feature b/__tests__/detail/features/statBar.feature new file mode 100644 index 0000000..02670c8 --- /dev/null +++ b/__tests__/detail/features/statBar.feature @@ -0,0 +1,21 @@ +Feature: ステータスバー表示 + + Scenario: ステータス名が表示される + Given ラベル「HP」、値45のStatBarが与えられている + When StatBarをレンダリングする + Then 「HP」が表示される + + Scenario: ステータス値が表示される + Given ラベル「HP」、値45のStatBarが与えられている + When StatBarをレンダリングする + Then 「45」が表示される + + Scenario: バーの幅がステータス値に比例する + Given ラベル「HP」、値128、最大値256のStatBarが与えられている + When StatBarをレンダリングする + Then バーの幅が「50%」である + + Scenario: maxValueのデフォルトは180 + Given ラベル「Speed」、値90のStatBarが与えられている + When StatBarをレンダリングする + Then バーの幅が「50%」である diff --git a/__tests__/detail/features/usePokemonDetail.feature b/__tests__/detail/features/usePokemonDetail.feature new file mode 100644 index 0000000..8e3d34e --- /dev/null +++ b/__tests__/detail/features/usePokemonDetail.feature @@ -0,0 +1,38 @@ +Feature: usePokemonDetailフック + + Scenario: 初期ロード時にisLoadingがtrueになる + Given fetchPokemonDetailが未解決のPromiseを返す + When usePokemonDetailをID25で呼び出す + Then isLoadingがtrueである + And pokemonがnullである + + Scenario: データ取得後にポケモン詳細が設定される + Given fetchPokemonDetailがピカチュウのデータを返す + When usePokemonDetailをID25で呼び出して完了を待つ + Then isLoadingがfalseである + And ポケモン詳細データが設定される + And fetchPokemonDetailがID25で呼ばれる + + Scenario: エラー時にerror状態が設定される + Given fetchPokemonDetailがエラー「Not found」を返す + When usePokemonDetailをID25で呼び出して完了を待つ + Then isLoadingがfalseである + And errorが「Not found」である + And pokemonがnullである + + Scenario: Error以外のエラーでもerror状態が設定される + Given fetchPokemonDetailが文字列エラーを返す + When usePokemonDetailをID25で呼び出して完了を待つ + Then isLoadingがfalseである + And errorが「Unknown error」である + + Scenario: アンマウント後にデータ取得が完了しても状態が更新されない + Given fetchPokemonDetailが遅延Promiseを返す + When usePokemonDetailをID25で呼び出してアンマウントしてからPromiseを解決する + Then pokemonがnullである + And isLoadingがtrueである + + Scenario: IDが変わると再取得する + Given fetchPokemonDetailがピカチュウのデータを返す + When usePokemonDetailをID25で呼び出して完了後にID1に変更する + Then 新しいポケモンデータが設定される diff --git a/__tests__/detail/features/usePokemonFlavorText.feature b/__tests__/detail/features/usePokemonFlavorText.feature new file mode 100644 index 0000000..2923013 --- /dev/null +++ b/__tests__/detail/features/usePokemonFlavorText.feature @@ -0,0 +1,25 @@ +Feature: usePokemonFlavorTextフック + + Scenario: 初期ロード時にisLoadingがtrueになる + Given fetchPokemonFlavorTextが未解決のPromiseを返す + When usePokemonFlavorTextをID25で呼び出す + Then isLoadingがtrueである + And flavorTextがnullである + + Scenario: データ取得後にフレーバーテキストが設定される + Given fetchPokemonFlavorTextがテキストを返す + When usePokemonFlavorTextをID25で呼び出して完了を待つ + Then isLoadingがfalseである + And flavorTextが「でんきを ためこむ せいしつ。」である + + Scenario: エラー時はflavorTextがnullのままになる + Given fetchPokemonFlavorTextがエラーを返す + When usePokemonFlavorTextをID25で呼び出して完了を待つ + Then isLoadingがfalseである + And flavorTextがnullである + + Scenario: アンマウント後にデータ取得が完了しても状態が更新されない + Given fetchPokemonFlavorTextが遅延Promiseを返す + When usePokemonFlavorTextをID25で呼び出してアンマウントしてからPromiseを解決する + Then flavorTextがnullである + And isLoadingがtrueである diff --git a/__tests__/detail/features/usePokemonSpeciesInfo.feature b/__tests__/detail/features/usePokemonSpeciesInfo.feature new file mode 100644 index 0000000..f63d7b1 --- /dev/null +++ b/__tests__/detail/features/usePokemonSpeciesInfo.feature @@ -0,0 +1,29 @@ +Feature: usePokemonSpeciesInfoフック + + Scenario: 初期ロード時にisLoadingがtrueになる + Given fetchPokemonSpeciesInfoが未解決のPromiseを返す + When usePokemonSpeciesInfoをID25で呼び出す + Then isLoadingがtrueである + And localizedNameがnullである + And flavorTextがnullである + + Scenario: ローカライズされたポケモン名とフレーバーテキストを返す + Given fetchPokemonSpeciesInfoが日本語データを返す + When usePokemonSpeciesInfoをID25で呼び出して完了を待つ + Then isLoadingがfalseである + And localizedNameが「ピカチュウ」である + And flavorTextが「でんきを ためこむ せいしつ。」である + And fetchPokemonSpeciesInfoがID25と言語「ja」で呼ばれる + + Scenario: エラー時にnullを返す + Given fetchPokemonSpeciesInfoがエラーを返す + When usePokemonSpeciesInfoをID25で呼び出して完了を待つ + Then isLoadingがfalseである + And localizedNameがnullである + And flavorTextがnullである + + Scenario: アンマウント後にデータ取得が完了しても状態が更新されない + Given fetchPokemonSpeciesInfoが遅延Promiseを返す + When usePokemonSpeciesInfoをID25で呼び出してアンマウントしてからPromiseを解決する + Then localizedNameがnullである + And isLoadingがtrueである diff --git a/__tests__/detail/hooks/usePokemonDetail.test.ts b/__tests__/detail/hooks/usePokemonDetail.test.ts deleted file mode 100644 index bece55d..0000000 --- a/__tests__/detail/hooks/usePokemonDetail.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { renderHook, waitFor, act } from "@testing-library/react-native"; -import { usePokemonDetail } from "@/src/detail/hooks/usePokemonDetail"; -import { fetchPokemonDetail } from "@/src/detail/repository/pokemonDetailApi"; -import type { Pokemon } from "@/src/shared"; - -jest.mock("@/src/detail/repository/pokemonDetailApi"); - -const mockFetch = fetchPokemonDetail as jest.MockedFunction; - -const mockPokemon: Pokemon = { - id: 25, - name: "Pikachu", - types: ["electric"], - stats: [ - { name: "hp", baseStat: 35 }, - { name: "attack", baseStat: 55 }, - { name: "defense", baseStat: 40 }, - { name: "special-attack", baseStat: 50 }, - { name: "special-defense", baseStat: 50 }, - { name: "speed", baseStat: 90 }, - ], - height: 4, - weight: 60, - abilities: [ - { name: "static", isHidden: false }, - { name: "lightning-rod", isHidden: true }, - ], -}; - -describe("usePokemonDetail", () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - it("初期ロード時にisLoadingがtrueになる", () => { - mockFetch.mockReturnValue(new Promise(() => {})); - const { result } = renderHook(() => usePokemonDetail(25)); - - expect(result.current.isLoading).toBe(true); - expect(result.current.pokemon).toBeNull(); - }); - - it("データ取得後にポケモン詳細が設定される", async () => { - mockFetch.mockResolvedValueOnce(mockPokemon); - const { result } = renderHook(() => usePokemonDetail(25)); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.pokemon).toEqual(mockPokemon); - expect(mockFetch).toHaveBeenCalledWith(25); - }); - - it("エラー時にerror状態が設定される", async () => { - mockFetch.mockRejectedValueOnce(new Error("Not found")); - const { result } = renderHook(() => usePokemonDetail(25)); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.error).toBe("Not found"); - expect(result.current.pokemon).toBeNull(); - }); - - it("Error以外のエラーでもerror状態が設定される", async () => { - mockFetch.mockRejectedValueOnce("string error"); - const { result } = renderHook(() => usePokemonDetail(25)); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.error).toBe("Unknown error"); - }); - - it("アンマウント後にデータ取得が完了しても状態が更新されない", async () => { - let resolve!: (value: Pokemon) => void; - mockFetch.mockReturnValue(new Promise((r) => { resolve = r; })); - const { result, unmount } = renderHook(() => usePokemonDetail(25)); - - expect(result.current.isLoading).toBe(true); - unmount(); - - await act(async () => { resolve(mockPokemon); }); - - expect(result.current.pokemon).toBeNull(); - expect(result.current.isLoading).toBe(true); - }); - - it("IDが変わると再取得する", async () => { - mockFetch.mockResolvedValueOnce(mockPokemon); - const { result, rerender } = renderHook( - (props: { id: number }) => usePokemonDetail(props.id), - { initialProps: { id: 25 } } - ); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - const newPokemon: Pokemon = { - ...mockPokemon, - id: 1, - name: "Bulbasaur", - types: ["grass", "poison"], - }; - mockFetch.mockResolvedValueOnce(newPokemon); - rerender({ id: 1 }); - - await waitFor(() => { - expect(result.current.pokemon).toEqual(newPokemon); - }); - }); -}); diff --git a/__tests__/detail/hooks/usePokemonFlavorText.test.ts b/__tests__/detail/hooks/usePokemonFlavorText.test.ts deleted file mode 100644 index 0998514..0000000 --- a/__tests__/detail/hooks/usePokemonFlavorText.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { renderHook, waitFor, act } from "@testing-library/react-native"; -import { usePokemonFlavorText } from "@/src/detail/hooks/usePokemonFlavorText"; -import { fetchPokemonFlavorText } from "@/src/detail/repository/pokemonSpeciesApi"; - -jest.mock("@/src/detail/repository/pokemonSpeciesApi"); - -const mockFetch = fetchPokemonFlavorText as jest.MockedFunction; - -describe("usePokemonFlavorText", () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - it("初期ロード時にisLoadingがtrueになる", () => { - mockFetch.mockReturnValue(new Promise(() => {})); - const { result } = renderHook(() => usePokemonFlavorText(25)); - - expect(result.current.isLoading).toBe(true); - expect(result.current.flavorText).toBeNull(); - }); - - it("データ取得後にフレーバーテキストが設定される", async () => { - mockFetch.mockResolvedValueOnce("でんきを ためこむ せいしつ。"); - const { result } = renderHook(() => usePokemonFlavorText(25)); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.flavorText).toBe("でんきを ためこむ せいしつ。"); - }); - - it("エラー時はflavorTextがnullのままになる", async () => { - mockFetch.mockRejectedValueOnce(new Error("Not found")); - const { result } = renderHook(() => usePokemonFlavorText(25)); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.flavorText).toBeNull(); - }); - - it("アンマウント後にデータ取得が完了しても状態が更新されない", async () => { - let resolve!: (value: string | null) => void; - mockFetch.mockReturnValue(new Promise((r) => { resolve = r; })); - const { result, unmount } = renderHook(() => usePokemonFlavorText(25)); - - expect(result.current.isLoading).toBe(true); - unmount(); - - await act(async () => { resolve("テスト"); }); - - expect(result.current.flavorText).toBeNull(); - expect(result.current.isLoading).toBe(true); - }); -}); diff --git a/__tests__/detail/hooks/usePokemonSpeciesInfo.test.ts b/__tests__/detail/hooks/usePokemonSpeciesInfo.test.ts deleted file mode 100644 index 6c94a4c..0000000 --- a/__tests__/detail/hooks/usePokemonSpeciesInfo.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { renderHook, waitFor, act } from "@testing-library/react-native"; -import { usePokemonSpeciesInfo } from "@/src/detail/hooks/usePokemonSpeciesInfo"; -import { fetchPokemonSpeciesInfo } from "@/src/detail/repository/pokemonSpeciesApi"; - -jest.mock("@/src/detail/repository/pokemonSpeciesApi"); - -const mockFetch = fetchPokemonSpeciesInfo as jest.MockedFunction; - -describe("usePokemonSpeciesInfo", () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - it("初期ロード時にisLoadingがtrueになる", () => { - mockFetch.mockReturnValue(new Promise(() => {})); - const { result } = renderHook(() => usePokemonSpeciesInfo(25)); - - expect(result.current.isLoading).toBe(true); - expect(result.current.localizedName).toBeNull(); - expect(result.current.flavorText).toBeNull(); - }); - - it("ローカライズされたポケモン名とフレーバーテキストを返す", async () => { - mockFetch.mockResolvedValueOnce({ - localizedName: "ピカチュウ", - flavorText: "でんきを ためこむ せいしつ。", - }); - const { result } = renderHook(() => usePokemonSpeciesInfo(25)); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.localizedName).toBe("ピカチュウ"); - expect(result.current.flavorText).toBe("でんきを ためこむ せいしつ。"); - expect(mockFetch).toHaveBeenCalledWith(25, "ja"); - }); - - it("エラー時にnullを返す", async () => { - mockFetch.mockRejectedValueOnce(new Error("Not found")); - const { result } = renderHook(() => usePokemonSpeciesInfo(25)); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.localizedName).toBeNull(); - expect(result.current.flavorText).toBeNull(); - }); - - it("アンマウント後にデータ取得が完了しても状態が更新されない", async () => { - let resolve!: (value: { localizedName: string | null; flavorText: string | null }) => void; - mockFetch.mockReturnValue(new Promise((r) => { resolve = r; })); - const { result, unmount } = renderHook(() => usePokemonSpeciesInfo(25)); - - expect(result.current.isLoading).toBe(true); - unmount(); - - await act(async () => { resolve({ localizedName: "ピカチュウ", flavorText: "テスト" }); }); - - expect(result.current.localizedName).toBeNull(); - expect(result.current.isLoading).toBe(true); - }); -}); diff --git a/__tests__/detail/repository/pokemonDetailApi.test.ts b/__tests__/detail/repository/pokemonDetailApi.test.ts deleted file mode 100644 index 4a9f310..0000000 --- a/__tests__/detail/repository/pokemonDetailApi.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { fetchPokemonDetail } from "@/src/detail/repository/pokemonDetailApi"; - -const mockApiResponse = { - id: 25, - name: "pikachu", - types: [ - { slot: 1, type: { name: "electric", url: "https://pokeapi.co/api/v2/type/13/" } }, - ], - stats: [ - { base_stat: 35, effort: 0, stat: { name: "hp", url: "" } }, - { base_stat: 55, effort: 0, stat: { name: "attack", url: "" } }, - { base_stat: 40, effort: 0, stat: { name: "defense", url: "" } }, - { base_stat: 50, effort: 0, stat: { name: "special-attack", url: "" } }, - { base_stat: 50, effort: 0, stat: { name: "special-defense", url: "" } }, - { base_stat: 90, effort: 0, stat: { name: "speed", url: "" } }, - ], - height: 4, - weight: 60, - abilities: [ - { ability: { name: "static", url: "" }, is_hidden: false, slot: 1 }, - { ability: { name: "lightning-rod", url: "" }, is_hidden: true, slot: 3 }, - ], -}; - -const originalFetch = globalThis.fetch; - -beforeEach(() => { - globalThis.fetch = jest.fn(); -}); - -afterEach(() => { - globalThis.fetch = originalFetch; -}); - -describe("fetchPokemonDetail", () => { - it("正しいURLでfetchを呼び出す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockApiResponse), - }); - - await fetchPokemonDetail(25); - - expect(globalThis.fetch).toHaveBeenCalledWith( - "https://pokeapi.co/api/v2/pokemon/25" - ); - }); - - it("レスポンスからステータスを正しく変換する", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockApiResponse), - }); - - const result = await fetchPokemonDetail(25); - - expect(result.stats).toEqual([ - { name: "hp", baseStat: 35 }, - { name: "attack", baseStat: 55 }, - { name: "defense", baseStat: 40 }, - { name: "special-attack", baseStat: 50 }, - { name: "special-defense", baseStat: 50 }, - { name: "speed", baseStat: 90 }, - ]); - }); - - it("身長と体重を正しく変換する", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockApiResponse), - }); - - const result = await fetchPokemonDetail(25); - - expect(result.height).toBe(4); - expect(result.weight).toBe(60); - }); - - it("とくせいを正しく変換する", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockApiResponse), - }); - - const result = await fetchPokemonDetail(25); - - expect(result.abilities).toEqual([ - { name: "static", isHidden: false }, - { name: "lightning-rod", isHidden: true }, - ]); - }); - - it("名前がキャピタライズされる", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockApiResponse), - }); - - const result = await fetchPokemonDetail(25); - - expect(result.name).toBe("Pikachu"); - }); - - it("タイプが正しく変換される", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockApiResponse), - }); - - const result = await fetchPokemonDetail(25); - - expect(result.types).toEqual(["electric"]); - }); - - it("HTTPエラー時にエラーをスローする", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 404, - }); - - await expect(fetchPokemonDetail(99999)).rejects.toThrow( - "Failed to fetch pokemon detail: 404" - ); - }); -}); diff --git a/__tests__/detail/repository/pokemonSpeciesApi.test.ts b/__tests__/detail/repository/pokemonSpeciesApi.test.ts deleted file mode 100644 index 6faf083..0000000 --- a/__tests__/detail/repository/pokemonSpeciesApi.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { fetchPokemonFlavorText, fetchPokemonSpeciesInfo } from "@/src/detail/repository/pokemonSpeciesApi"; - -const mockSpeciesResponse = { - flavor_text_entries: [ - { flavor_text: "When several of these POKéMON gather, their electricity could build and cause lightning storms.", language: { name: "en", url: "" }, version: { name: "red", url: "" } }, - { flavor_text: "でんきを ためこむ せいしつ。", language: { name: "ja", url: "" }, version: { name: "red", url: "" } }, - ], - names: [ - { name: "Pikachu", language: { name: "en", url: "" } }, - { name: "ピカチュウ", language: { name: "ja", url: "" } }, - ], -}; - -const mockEmptyResponse = { - flavor_text_entries: [], - names: [], -}; - -const originalFetch = globalThis.fetch; - -beforeEach(() => { - globalThis.fetch = jest.fn(); -}); - -afterEach(() => { - globalThis.fetch = originalFetch; -}); - -describe("fetchPokemonFlavorText", () => { - it("正しいURLでfetchを呼び出す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockSpeciesResponse), - }); - - await fetchPokemonFlavorText(25); - - expect(globalThis.fetch).toHaveBeenCalledWith( - "https://pokeapi.co/api/v2/pokemon-species/25" - ); - }); - - it("英語のフレーバーテキストを返す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockSpeciesResponse), - }); - - const result = await fetchPokemonFlavorText(25); - - expect(result).toBe("When several of these POKéMON gather, their electricity could build and cause lightning storms."); - }); - - it("フレーバーテキストがない場合はnullを返す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockEmptyResponse), - }); - - const result = await fetchPokemonFlavorText(25); - - expect(result).toBeNull(); - }); - - it("HTTPエラー時にエラーをスローする", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 404, - }); - - await expect(fetchPokemonFlavorText(99999)).rejects.toThrow( - "Failed to fetch pokemon species: 404" - ); - }); -}); - -describe("fetchPokemonSpeciesInfo", () => { - it("指定した言語のポケモン名とフレーバーテキストを返す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockSpeciesResponse), - }); - - const result = await fetchPokemonSpeciesInfo(25, "ja"); - - expect(result.localizedName).toBe("ピカチュウ"); - expect(result.flavorText).toBe("でんきを ためこむ せいしつ。"); - }); - - it("英語を指定した場合に英語のデータを返す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockSpeciesResponse), - }); - - const result = await fetchPokemonSpeciesInfo(25, "en"); - - expect(result.localizedName).toBe("Pikachu"); - expect(result.flavorText).toBe( - "When several of these POKéMON gather, their electricity could build and cause lightning storms." - ); - }); - - it("該当する言語がない場合はnullを返す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockEmptyResponse), - }); - - const result = await fetchPokemonSpeciesInfo(25, "ja"); - - expect(result.localizedName).toBeNull(); - expect(result.flavorText).toBeNull(); - }); - - it("HTTPエラー時にエラーをスローする", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 404, - }); - - await expect(fetchPokemonSpeciesInfo(99999, "ja")).rejects.toThrow( - "Failed to fetch pokemon species: 404" - ); - }); -}); diff --git a/__tests__/detail/screens/DetailScreen.test.tsx b/__tests__/detail/screens/DetailScreen.test.tsx deleted file mode 100644 index a0ff14d..0000000 --- a/__tests__/detail/screens/DetailScreen.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { render, screen } from "@testing-library/react-native"; -import { DetailScreen } from "@/src/detail"; -import type { Pokemon } from "@/src/shared"; - -const mockPokemon: Pokemon = { - id: 25, - name: "Pikachu", - types: ["electric"], - stats: [ - { name: "hp", baseStat: 35 }, - { name: "attack", baseStat: 55 }, - { name: "defense", baseStat: 40 }, - { name: "special-attack", baseStat: 50 }, - { name: "special-defense", baseStat: 50 }, - { name: "speed", baseStat: 90 }, - ], - height: 4, - weight: 60, - abilities: [ - { name: "static", isHidden: false }, - { name: "lightning-rod", isHidden: true }, - ], -}; - -const mockUsePokemonDetail = { - pokemon: mockPokemon as Pokemon | null, - isLoading: false, - error: null as string | null, -}; - -const mockUsePokemonSpeciesInfo = { - flavorText: "It keeps its tail raised." as string | null, - localizedName: "ピカチュウ" as string | null, - isLoading: false, -}; - -jest.mock("@/src/detail/hooks/usePokemonDetail", () => ({ - usePokemonDetail: () => mockUsePokemonDetail, -})); - -jest.mock("@/src/detail/hooks/usePokemonSpeciesInfo", () => ({ - usePokemonSpeciesInfo: () => mockUsePokemonSpeciesInfo, -})); - -const renderWithProvider = (id: string) => render(); - -describe("DetailScreen", () => { - beforeEach(() => { - mockUsePokemonDetail.pokemon = mockPokemon; - mockUsePokemonDetail.isLoading = false; - mockUsePokemonDetail.error = null; - mockUsePokemonSpeciesInfo.flavorText = "It keeps its tail raised."; - mockUsePokemonSpeciesInfo.localizedName = "ピカチュウ"; - mockUsePokemonSpeciesInfo.isLoading = false; - }); - - it("ローカライズされたポケモン名が表示される", () => { - renderWithProvider("25"); - expect(screen.getByText("ピカチュウ")).toBeTruthy(); - }); - - it("ポケモンのIDが表示される", () => { - renderWithProvider("25"); - expect(screen.getByText("#025")).toBeTruthy(); - }); - - it("ローディング中にActivityIndicatorが表示される", () => { - mockUsePokemonDetail.isLoading = true; - mockUsePokemonDetail.pokemon = null; - renderWithProvider("25"); - expect(screen.getByTestId("loading-indicator")).toBeTruthy(); - }); - - it("エラー時にエラーメッセージが表示される", () => { - mockUsePokemonDetail.pokemon = null; - mockUsePokemonDetail.error = "Not found"; - renderWithProvider("999"); - expect(screen.getByText("detail.notFound")).toBeTruthy(); - }); - - it("お気に入りボタンが表示される", () => { - renderWithProvider("25"); - expect(screen.getByTestId("favorite-button")).toBeTruthy(); - }); - - it("ステータスが詳細画面に表示される", () => { - renderWithProvider("25"); - expect(screen.getByText("detail.baseStats")).toBeTruthy(); - }); - - it("身長と体重が詳細画面に表示される", () => { - renderWithProvider("25"); - expect(screen.getByText("detail.height")).toBeTruthy(); - expect(screen.getByText("detail.weight")).toBeTruthy(); - }); - - it("フレーバーテキストが表示される", () => { - renderWithProvider("25"); - expect(screen.getByText("It keeps its tail raised.")).toBeTruthy(); - }); - - it("ローカライズ名がnullの場合はAPI名が表示される", () => { - mockUsePokemonSpeciesInfo.localizedName = null; - renderWithProvider("25"); - expect(screen.getByText("Pikachu")).toBeTruthy(); - }); -}); diff --git a/__tests__/detail/steps/detailScreen.steps.tsx b/__tests__/detail/steps/detailScreen.steps.tsx new file mode 100644 index 0000000..3072950 --- /dev/null +++ b/__tests__/detail/steps/detailScreen.steps.tsx @@ -0,0 +1,193 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { render, screen } from "@testing-library/react-native"; +import { DetailScreen } from "@/src/detail"; +import type { Pokemon } from "@/src/shared"; + +const feature = loadFeature( + "__tests__/detail/features/detailScreen.feature" +); + +const mockPokemon: Pokemon = { + id: 25, + name: "Pikachu", + types: ["electric"], + stats: [ + { name: "hp", baseStat: 35 }, + { name: "attack", baseStat: 55 }, + { name: "defense", baseStat: 40 }, + { name: "special-attack", baseStat: 50 }, + { name: "special-defense", baseStat: 50 }, + { name: "speed", baseStat: 90 }, + ], + height: 4, + weight: 60, + abilities: [ + { name: "static", isHidden: false }, + { name: "lightning-rod", isHidden: true }, + ], +}; + +const mockUsePokemonDetail = { + pokemon: mockPokemon as Pokemon | null, + isLoading: false, + error: null as string | null, +}; + +const mockUsePokemonSpeciesInfo = { + flavorText: "It keeps its tail raised." as string | null, + localizedName: "ピカチュウ" as string | null, + isLoading: false, +}; + +jest.mock("@/src/detail/hooks/usePokemonDetail", () => ({ + usePokemonDetail: () => mockUsePokemonDetail, +})); + +jest.mock("@/src/detail/hooks/usePokemonSpeciesInfo", () => ({ + usePokemonSpeciesInfo: () => mockUsePokemonSpeciesInfo, +})); + +const renderWithProvider = (id: string) => render(); + +defineFeature(feature, (test) => { + beforeEach(() => { + mockUsePokemonDetail.pokemon = mockPokemon; + mockUsePokemonDetail.isLoading = false; + mockUsePokemonDetail.error = null; + mockUsePokemonSpeciesInfo.flavorText = "It keeps its tail raised."; + mockUsePokemonSpeciesInfo.localizedName = "ピカチュウ"; + mockUsePokemonSpeciesInfo.isLoading = false; + }); + + test("ローカライズされたポケモン名が表示される", ({ given, when, then }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ポケモンのIDが表示される", ({ given, when, then }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ローディング中にActivityIndicatorが表示される", ({ given, when, then }) => { + given("ローディング中のモック状態が設定されている", () => { + mockUsePokemonDetail.isLoading = true; + mockUsePokemonDetail.pokemon = null; + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then("ローディングインジケータが表示される", () => { + expect(screen.getByTestId("loading-indicator")).toBeTruthy(); + }); + }); + + test("エラー時にエラーメッセージが表示される", ({ given, when, then }) => { + given("エラー状態のモックが設定されている", () => { + mockUsePokemonDetail.pokemon = null; + mockUsePokemonDetail.error = "Not found"; + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("お気に入りボタンが表示される", ({ given, when, then }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then("お気に入りボタンが表示される", () => { + expect(screen.getByTestId("favorite-button")).toBeTruthy(); + }); + }); + + test("ステータスが詳細画面に表示される", ({ given, when, then }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("身長と体重が詳細画面に表示される", ({ given, when, then, and }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("フレーバーテキストが表示される", ({ given, when, then }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ローカライズ名がnullの場合はAPI名が表示される", ({ given, when, then }) => { + given("ローカライズ名がnullのモック状態が設定されている", () => { + mockUsePokemonSpeciesInfo.localizedName = null; + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); +}); diff --git a/__tests__/detail/steps/pokemonAbilities.steps.tsx b/__tests__/detail/steps/pokemonAbilities.steps.tsx new file mode 100644 index 0000000..ab9b418 --- /dev/null +++ b/__tests__/detail/steps/pokemonAbilities.steps.tsx @@ -0,0 +1,75 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { render, screen } from "@testing-library/react-native"; +import { PokemonAbilities } from "@/src/detail/components/PokemonAbilities"; +import type { PokemonAbility } from "@/src/shared"; + +const feature = loadFeature( + "__tests__/detail/features/pokemonAbilities.feature" +); + +const mockAbilities: PokemonAbility[] = [ + { name: "overgrow", isHidden: false }, + { name: "chlorophyll", isHidden: true }, +]; + +defineFeature(feature, (test) => { + test("セクションタイトルが表示される", ({ given, when, then }) => { + given("とくせいリストが与えられている", () => { + // mockAbilities is predefined + }); + + when("PokemonAbilitiesをレンダリングする", () => { + render(); + }); + + then(/^セクションタイトル「(.*)」が表示される$/, (title: string) => { + expect(screen.getByText(title)).toBeTruthy(); + }); + }); + + test("とくせい名がキャピタライズされて表示される", ({ given, when, then }) => { + given("とくせいリストが与えられている", () => { + // mockAbilities is predefined + }); + + when("PokemonAbilitiesをレンダリングする", () => { + render(); + }); + + then(/^とくせい名「(.*)」が表示される$/, (name: string) => { + expect(screen.getByText(name)).toBeTruthy(); + }); + }); + + test("隠れとくせいにラベルが付与される", ({ given, when, then }) => { + given("とくせいリストが与えられている", () => { + // mockAbilities is predefined + }); + + when("PokemonAbilitiesをレンダリングする", () => { + render(); + }); + + then(/^隠れとくせい「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("複数のとくせいが全て表示される", ({ given, when, then, and }) => { + given("とくせいリストが与えられている", () => { + // mockAbilities is predefined + }); + + when("PokemonAbilitiesをレンダリングする", () => { + render(); + }); + + then(/^とくせい名「(.*)」が表示される$/, (name: string) => { + expect(screen.getByText(name)).toBeTruthy(); + }); + + and(/^隠れとくせい「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); +}); diff --git a/__tests__/detail/steps/pokemonDetail.steps.tsx b/__tests__/detail/steps/pokemonDetail.steps.tsx new file mode 100644 index 0000000..a3d51b2 --- /dev/null +++ b/__tests__/detail/steps/pokemonDetail.steps.tsx @@ -0,0 +1,224 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { render, screen, fireEvent } from "@testing-library/react-native"; +import { PokemonDetail } from "@/src/detail/components/PokemonDetail"; +import type { Pokemon } from "@/src/shared"; + +const feature = loadFeature( + "__tests__/detail/features/pokemonDetail.feature" +); + +const mockPokemon: Pokemon = { + id: 25, + name: "pikachu", + types: ["electric"], + stats: [ + { name: "hp", baseStat: 35 }, + { name: "attack", baseStat: 55 }, + { name: "defense", baseStat: 40 }, + { name: "special-attack", baseStat: 50 }, + { name: "special-defense", baseStat: 50 }, + { name: "speed", baseStat: 90 }, + ], + height: 4, + weight: 60, + abilities: [ + { name: "static", isHidden: false }, + { name: "lightning-rod", isHidden: true }, + ], +}; + +const multiTypePokemon: Pokemon = { + id: 6, + name: "charizard", + types: ["fire", "flying"], + stats: [ + { name: "hp", baseStat: 78 }, + { name: "attack", baseStat: 84 }, + { name: "defense", baseStat: 78 }, + { name: "special-attack", baseStat: 109 }, + { name: "special-defense", baseStat: 85 }, + { name: "speed", baseStat: 100 }, + ], + height: 17, + weight: 905, + abilities: [ + { name: "blaze", isHidden: false }, + { name: "solar-power", isHidden: true }, + ], +}; + +defineFeature(feature, (test) => { + let onToggleFavorite: jest.Mock; + + test("ローカライズ名が渡された場合に表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemon is predefined + }); + + when(/^ローカライズ名「(.*)」を指定してPokemonDetailをレンダリングする$/, (name: string) => { + render(); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ローカライズ名がnullの場合はAPI名が表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemon is predefined + }); + + when("ローカライズ名をnullにしてPokemonDetailをレンダリングする", () => { + render(); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ポケモンのIDが3桁ゼロ埋めで表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemon is predefined + }); + + when("PokemonDetailをレンダリングする", () => { + render(); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ポケモンの画像が表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemon is predefined + }); + + when("PokemonDetailをレンダリングする", () => { + render(); + }); + + then(/^ポケモン画像のURIに「(.*)」が含まれる$/, (fragment: string) => { + const image = screen.getByTestId("pokemon-detail-image"); + expect(image.props.source.uri).toContain(fragment); + }); + }); + + test("タイプバッジが翻訳されて表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemon is predefined + }); + + when("PokemonDetailをレンダリングする", () => { + render(); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("複数タイプが翻訳されて全て表示される", ({ given, when, then, and }) => { + given("リザードンのデータが用意されている", () => { + // multiTypePokemon is predefined + }); + + when("PokemonDetailをレンダリングする", () => { + render(); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("お気に入りボタンが表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemon is predefined + }); + + when("お気に入り機能付きでPokemonDetailをレンダリングする", () => { + render( + , + ); + }); + + then("お気に入りボタンが表示される", () => { + expect(screen.getByTestId("favorite-button")).toBeTruthy(); + }); + }); + + test("お気に入りボタン押下後にonToggleFavoriteが呼ばれる", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + onToggleFavorite = jest.fn(); + }); + + when("お気に入り機能付きでPokemonDetailをレンダリングしてボタンを押す", () => { + render( + , + ); + fireEvent.press(screen.getByTestId("favorite-button")); + }); + + then("onToggleFavoriteが1回呼ばれる", () => { + expect(onToggleFavorite).toHaveBeenCalledTimes(1); + }); + }); + + test("お気に入りが未指定の場合ボタンが表示されない", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemon is predefined + }); + + when("PokemonDetailをレンダリングする", () => { + render(); + }); + + then("お気に入りボタンが表示されない", () => { + expect(screen.queryByTestId("favorite-button")).toBeNull(); + }); + }); + + test("フレーバーテキストが渡された場合に表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemon is predefined + }); + + when("フレーバーテキスト付きでPokemonDetailをレンダリングする", () => { + render(); + }); + + then("フレーバーテキストが表示される", () => { + expect(screen.getByText("でんきを ためこむ せいしつ。")).toBeTruthy(); + }); + }); + + test("フレーバーテキストが未指定の場合は表示されない", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemon is predefined + }); + + when("PokemonDetailをレンダリングする", () => { + render(); + }); + + then("フレーバーテキストのローディングが表示されない", () => { + expect(screen.queryByTestId("flavor-text-loading")).toBeNull(); + }); + }); +}); diff --git a/__tests__/detail/steps/pokemonDetailApi.steps.ts b/__tests__/detail/steps/pokemonDetailApi.steps.ts new file mode 100644 index 0000000..e1dee72 --- /dev/null +++ b/__tests__/detail/steps/pokemonDetailApi.steps.ts @@ -0,0 +1,183 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { fetchPokemonDetail } from "@/src/detail/repository/pokemonDetailApi"; +import type { Pokemon } from "@/src/shared"; + +const feature = loadFeature( + "__tests__/detail/features/pokemonDetailApi.feature" +); + +const mockApiResponse = { + id: 25, + name: "pikachu", + types: [ + { slot: 1, type: { name: "electric", url: "https://pokeapi.co/api/v2/type/13/" } }, + ], + stats: [ + { base_stat: 35, effort: 0, stat: { name: "hp", url: "" } }, + { base_stat: 55, effort: 0, stat: { name: "attack", url: "" } }, + { base_stat: 40, effort: 0, stat: { name: "defense", url: "" } }, + { base_stat: 50, effort: 0, stat: { name: "special-attack", url: "" } }, + { base_stat: 50, effort: 0, stat: { name: "special-defense", url: "" } }, + { base_stat: 90, effort: 0, stat: { name: "speed", url: "" } }, + ], + height: 4, + weight: 60, + abilities: [ + { ability: { name: "static", url: "" }, is_hidden: false, slot: 1 }, + { ability: { name: "lightning-rod", url: "" }, is_hidden: true, slot: 3 }, + ], +}; + +const originalFetch = globalThis.fetch; + +defineFeature(feature, (test) => { + let result: Pokemon; + let fetchError: Error | null; + + beforeEach(() => { + globalThis.fetch = jest.fn(); + fetchError = null; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("正しいURLでfetchを呼び出す", ({ given, when, then }) => { + given("PokeAPIがピカチュウのレスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockApiResponse), + }); + }); + + when("fetchPokemonDetailをID25で呼び出す", async () => { + result = await fetchPokemonDetail(25); + }); + + then(/^fetchが「(.*)」で呼ばれる$/, (url: string) => { + expect(globalThis.fetch).toHaveBeenCalledWith(url); + }); + }); + + test("レスポンスからステータスを正しく変換する", ({ given, when, then }) => { + given("PokeAPIがピカチュウのレスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockApiResponse), + }); + }); + + when("fetchPokemonDetailをID25で呼び出す", async () => { + result = await fetchPokemonDetail(25); + }); + + then("ステータスが正しく変換される", () => { + expect(result.stats).toEqual([ + { name: "hp", baseStat: 35 }, + { name: "attack", baseStat: 55 }, + { name: "defense", baseStat: 40 }, + { name: "special-attack", baseStat: 50 }, + { name: "special-defense", baseStat: 50 }, + { name: "speed", baseStat: 90 }, + ]); + }); + }); + + test("身長と体重を正しく変換する", ({ given, when, then, and }) => { + given("PokeAPIがピカチュウのレスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockApiResponse), + }); + }); + + when("fetchPokemonDetailをID25で呼び出す", async () => { + result = await fetchPokemonDetail(25); + }); + + then(/^身長が(\d+)である$/, (height: string) => { + expect(result.height).toBe(Number(height)); + }); + + and(/^体重が(\d+)である$/, (weight: string) => { + expect(result.weight).toBe(Number(weight)); + }); + }); + + test("とくせいを正しく変換する", ({ given, when, then }) => { + given("PokeAPIがピカチュウのレスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockApiResponse), + }); + }); + + when("fetchPokemonDetailをID25で呼び出す", async () => { + result = await fetchPokemonDetail(25); + }); + + then("とくせいが正しく変換される", () => { + expect(result.abilities).toEqual([ + { name: "static", isHidden: false }, + { name: "lightning-rod", isHidden: true }, + ]); + }); + }); + + test("名前がキャピタライズされる", ({ given, when, then }) => { + given("PokeAPIがピカチュウのレスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockApiResponse), + }); + }); + + when("fetchPokemonDetailをID25で呼び出す", async () => { + result = await fetchPokemonDetail(25); + }); + + then(/^名前が「(.*)」である$/, (name: string) => { + expect(result.name).toBe(name); + }); + }); + + test("タイプが正しく変換される", ({ given, when, then }) => { + given("PokeAPIがピカチュウのレスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockApiResponse), + }); + }); + + when("fetchPokemonDetailをID25で呼び出す", async () => { + result = await fetchPokemonDetail(25); + }); + + then(/^タイプが「(.*)」である$/, (type: string) => { + expect(result.types).toEqual([type]); + }); + }); + + test("HTTPエラー時にエラーをスローする", ({ given, when, then }) => { + given("PokeAPIが404エラーを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + }); + }); + + when("fetchPokemonDetailをID99999で呼び出す", async () => { + try { + result = await fetchPokemonDetail(99999); + } catch (e) { + fetchError = e as Error; + } + }); + + then(/^「(.*)」エラーがスローされる$/, (message: string) => { + expect(fetchError).not.toBeNull(); + expect(fetchError!.message).toBe(message); + }); + }); +}); diff --git a/__tests__/detail/steps/pokemonFlavorText.steps.tsx b/__tests__/detail/steps/pokemonFlavorText.steps.tsx new file mode 100644 index 0000000..3a16630 --- /dev/null +++ b/__tests__/detail/steps/pokemonFlavorText.steps.tsx @@ -0,0 +1,58 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { render, screen } from "@testing-library/react-native"; +import { PokemonFlavorText } from "@/src/detail/components/PokemonFlavorText"; + +const feature = loadFeature( + "__tests__/detail/features/pokemonFlavorText.feature" +); + +defineFeature(feature, (test) => { + let text: string | null; + let isLoading: boolean; + let renderResult: ReturnType | null; + + test("フレーバーテキストが表示される", ({ given, when, then }) => { + given("フレーバーテキストが与えられている", () => { + text = "でんきを ためこむ せいしつ。"; + isLoading = false; + }); + + when("PokemonFlavorTextをレンダリングする", () => { + render(); + }); + + then(/^テキスト「(.*)」が表示される$/, (expected: string) => { + expect(screen.getByText(expected)).toBeTruthy(); + }); + }); + + test("ローディング中にインジケータが表示される", ({ given, when, then }) => { + given("テキストがnullでローディング中である", () => { + text = null; + isLoading = true; + }); + + when("PokemonFlavorTextをレンダリングする", () => { + render(); + }); + + then("ローディングインジケータが表示される", () => { + expect(screen.getByTestId("flavor-text-loading")).toBeTruthy(); + }); + }); + + test("テキストがnullでローディングでない場合は何も表示されない", ({ given, when, then }) => { + given("テキストがnullでローディングでない", () => { + text = null; + isLoading = false; + }); + + when("PokemonFlavorTextをレンダリングする", () => { + renderResult = render(); + }); + + then("何も表示されない", () => { + expect(renderResult!.toJSON()).toBeNull(); + }); + }); +}); diff --git a/__tests__/detail/steps/pokemonPhysicalInfo.steps.tsx b/__tests__/detail/steps/pokemonPhysicalInfo.steps.tsx new file mode 100644 index 0000000..aea7ec8 --- /dev/null +++ b/__tests__/detail/steps/pokemonPhysicalInfo.steps.tsx @@ -0,0 +1,91 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { render, screen } from "@testing-library/react-native"; +import { PokemonPhysicalInfo } from "@/src/detail/components/PokemonPhysicalInfo"; + +const feature = loadFeature( + "__tests__/detail/features/pokemonPhysicalInfo.feature" +); + +defineFeature(feature, (test) => { + let height: number; + let weight: number; + + test("身長の値が表示される", ({ given, when, then }) => { + given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { + height = Number(h); + weight = Number(w); + }); + + when("PokemonPhysicalInfoをレンダリングする", () => { + render(); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("体重の値が表示される", ({ given, when, then }) => { + given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { + height = Number(h); + weight = Number(w); + }); + + when("PokemonPhysicalInfoをレンダリングする", () => { + render(); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("身長ラベルが表示される", ({ given, when, then }) => { + given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { + height = Number(h); + weight = Number(w); + }); + + when("PokemonPhysicalInfoをレンダリングする", () => { + render(); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("体重ラベルが表示される", ({ given, when, then }) => { + given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { + height = Number(h); + weight = Number(w); + }); + + when("PokemonPhysicalInfoをレンダリングする", () => { + render(); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("整数の身長が正しくフォーマットされる", ({ given, when, then, and }) => { + given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { + height = Number(h); + weight = Number(w); + }); + + when("PokemonPhysicalInfoをレンダリングする", () => { + render(); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); +}); diff --git a/__tests__/detail/steps/pokemonSpeciesApi.steps.ts b/__tests__/detail/steps/pokemonSpeciesApi.steps.ts new file mode 100644 index 0000000..dedce8e --- /dev/null +++ b/__tests__/detail/steps/pokemonSpeciesApi.steps.ts @@ -0,0 +1,199 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { fetchPokemonFlavorText, fetchPokemonSpeciesInfo } from "@/src/detail/repository/pokemonSpeciesApi"; + +const feature = loadFeature( + "__tests__/detail/features/pokemonSpeciesApi.feature" +); + +const mockSpeciesResponse = { + flavor_text_entries: [ + { flavor_text: "When several of these POKéMON gather, their electricity could build and cause lightning storms.", language: { name: "en", url: "" }, version: { name: "red", url: "" } }, + { flavor_text: "でんきを ためこむ せいしつ。", language: { name: "ja", url: "" }, version: { name: "red", url: "" } }, + ], + names: [ + { name: "Pikachu", language: { name: "en", url: "" } }, + { name: "ピカチュウ", language: { name: "ja", url: "" } }, + ], +}; + +const mockEmptyResponse = { + flavor_text_entries: [], + names: [], +}; + +const originalFetch = globalThis.fetch; + +defineFeature(feature, (test) => { + let flavorTextResult: string | null; + let speciesInfoResult: { localizedName: string | null; flavorText: string | null }; + let fetchError: Error | null; + + beforeEach(() => { + globalThis.fetch = jest.fn(); + fetchError = null; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("fetchPokemonFlavorTextが正しいURLでfetchを呼び出す", ({ given, when, then }) => { + given("PokeAPIが種族レスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSpeciesResponse), + }); + }); + + when("fetchPokemonFlavorTextをID25で呼び出す", async () => { + flavorTextResult = await fetchPokemonFlavorText(25); + }); + + then(/^fetchが「(.*)」で呼ばれる$/, (url: string) => { + expect(globalThis.fetch).toHaveBeenCalledWith(url); + }); + }); + + test("英語のフレーバーテキストを返す", ({ given, when, then }) => { + given("PokeAPIが種族レスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSpeciesResponse), + }); + }); + + when("fetchPokemonFlavorTextをID25で呼び出す", async () => { + flavorTextResult = await fetchPokemonFlavorText(25); + }); + + then("英語のフレーバーテキストが返される", () => { + expect(flavorTextResult).toBe("When several of these POKéMON gather, their electricity could build and cause lightning storms."); + }); + }); + + test("フレーバーテキストがない場合はnullを返す", ({ given, when, then }) => { + given("PokeAPIが空の種族レスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockEmptyResponse), + }); + }); + + when("fetchPokemonFlavorTextをID25で呼び出す", async () => { + flavorTextResult = await fetchPokemonFlavorText(25); + }); + + then("nullが返される", () => { + expect(flavorTextResult).toBeNull(); + }); + }); + + test("fetchPokemonFlavorTextでHTTPエラー時にエラーをスローする", ({ given, when, then }) => { + given("PokeAPIが404エラーを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + }); + }); + + when("fetchPokemonFlavorTextをID99999で呼び出す", async () => { + try { + flavorTextResult = await fetchPokemonFlavorText(99999); + } catch (e) { + fetchError = e as Error; + } + }); + + then(/^「(.*)」エラーがスローされる$/, (message: string) => { + expect(fetchError).not.toBeNull(); + expect(fetchError!.message).toBe(message); + }); + }); + + test("指定した言語のポケモン名とフレーバーテキストを返す", ({ given, when, then, and }) => { + given("PokeAPIが種族レスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSpeciesResponse), + }); + }); + + when(/^fetchPokemonSpeciesInfoをID(\d+)と言語「(.*)」で呼び出す$/, async (id: string, lang: string) => { + speciesInfoResult = await fetchPokemonSpeciesInfo(Number(id), lang); + }); + + then(/^localizedNameが「(.*)」である$/, (name: string) => { + expect(speciesInfoResult.localizedName).toBe(name); + }); + + and(/^flavorTextが「(.*)」である$/, (text: string) => { + expect(speciesInfoResult.flavorText).toBe(text); + }); + }); + + test("英語を指定した場合に英語のデータを返す", ({ given, when, then, and }) => { + given("PokeAPIが種族レスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSpeciesResponse), + }); + }); + + when(/^fetchPokemonSpeciesInfoをID(\d+)と言語「(.*)」で呼び出す$/, async (id: string, lang: string) => { + speciesInfoResult = await fetchPokemonSpeciesInfo(Number(id), lang); + }); + + then(/^localizedNameが「(.*)」である$/, (name: string) => { + expect(speciesInfoResult.localizedName).toBe(name); + }); + + and("flavorTextが英語である", () => { + expect(speciesInfoResult.flavorText).toBe( + "When several of these POKéMON gather, their electricity could build and cause lightning storms." + ); + }); + }); + + test("該当する言語がない場合はnullを返す", ({ given, when, then, and }) => { + given("PokeAPIが空の種族レスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockEmptyResponse), + }); + }); + + when(/^fetchPokemonSpeciesInfoをID(\d+)と言語「(.*)」で呼び出す$/, async (id: string, lang: string) => { + speciesInfoResult = await fetchPokemonSpeciesInfo(Number(id), lang); + }); + + then("localizedNameがnullである", () => { + expect(speciesInfoResult.localizedName).toBeNull(); + }); + + and("flavorTextがnullである", () => { + expect(speciesInfoResult.flavorText).toBeNull(); + }); + }); + + test("fetchPokemonSpeciesInfoでHTTPエラー時にエラーをスローする", ({ given, when, then }) => { + given("PokeAPIが404エラーを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + }); + }); + + when(/^fetchPokemonSpeciesInfoをID(\d+)と言語「(.*)」で呼び出す$/, async (id: string, lang: string) => { + try { + speciesInfoResult = await fetchPokemonSpeciesInfo(Number(id), lang); + } catch (e) { + fetchError = e as Error; + } + }); + + then(/^「(.*)」エラーがスローされる$/, (message: string) => { + expect(fetchError).not.toBeNull(); + expect(fetchError!.message).toBe(message); + }); + }); +}); diff --git a/__tests__/detail/steps/pokemonStats.steps.tsx b/__tests__/detail/steps/pokemonStats.steps.tsx new file mode 100644 index 0000000..755e018 --- /dev/null +++ b/__tests__/detail/steps/pokemonStats.steps.tsx @@ -0,0 +1,89 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { render, screen } from "@testing-library/react-native"; +import { PokemonStats } from "@/src/detail/components/PokemonStats"; +import type { PokemonStat } from "@/src/shared"; + +const feature = loadFeature( + "__tests__/detail/features/pokemonStats.feature" +); + +const mockStats: PokemonStat[] = [ + { name: "hp", baseStat: 45 }, + { name: "attack", baseStat: 49 }, + { name: "defense", baseStat: 49 }, + { name: "special-attack", baseStat: 65 }, + { name: "special-defense", baseStat: 65 }, + { name: "speed", baseStat: 45 }, +]; + +defineFeature(feature, (test) => { + test("セクションタイトルが表示される", ({ given, when, then }) => { + given("ステータスデータが与えられている", () => { + // mockStats is predefined + }); + + when("PokemonStatsをレンダリングする", () => { + render(); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("6つのステータスバーが表示される", ({ given, when, then, and }) => { + given("ステータスデータが与えられている", () => { + // mockStats is predefined + }); + + when("PokemonStatsをレンダリングする", () => { + render(); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("各ステータスの値が正しく表示される", ({ given, when, then, and }) => { + given("ステータスデータが与えられている", () => { + // mockStats is predefined + }); + + when("PokemonStatsをレンダリングする", () => { + render(); + }); + + then(/^値「(\d+)」が(\d+)つ表示される$/, (value: string, count: string) => { + expect(screen.getAllByText(value)).toHaveLength(Number(count)); + }); + + and(/^値「(\d+)」が(\d+)つ表示される$/, (value: string, count: string) => { + expect(screen.getAllByText(value)).toHaveLength(Number(count)); + }); + + and(/^値「(\d+)」が(\d+)つ表示される$/, (value: string, count: string) => { + expect(screen.getAllByText(value)).toHaveLength(Number(count)); + }); + }); +}); diff --git a/__tests__/detail/steps/statBar.steps.tsx b/__tests__/detail/steps/statBar.steps.tsx new file mode 100644 index 0000000..e43a83c --- /dev/null +++ b/__tests__/detail/steps/statBar.steps.tsx @@ -0,0 +1,87 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { render, screen } from "@testing-library/react-native"; +import { StatBar } from "@/src/detail/components/StatBar"; + +const feature = loadFeature( + "__tests__/detail/features/statBar.feature" +); + +defineFeature(feature, (test) => { + let label: string; + let value: number; + let maxValue: number | undefined; + + test("ステータス名が表示される", ({ given, when, then }) => { + given(/^ラベル「(.*)」、値(\d+)のStatBarが与えられている$/, (l: string, v: string) => { + label = l; + value = Number(v); + maxValue = undefined; + }); + + when("StatBarをレンダリングする", () => { + render(); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ステータス値が表示される", ({ given, when, then }) => { + given(/^ラベル「(.*)」、値(\d+)のStatBarが与えられている$/, (l: string, v: string) => { + label = l; + value = Number(v); + maxValue = undefined; + }); + + when("StatBarをレンダリングする", () => { + render(); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("バーの幅がステータス値に比例する", ({ given, when, then }) => { + given(/^ラベル「(.*)」、値(\d+)、最大値(\d+)のStatBarが与えられている$/, (l: string, v: string, m: string) => { + label = l; + value = Number(v); + maxValue = Number(m); + }); + + when("StatBarをレンダリングする", () => { + render(); + }); + + then(/^バーの幅が「(.*)」である$/, (width: string) => { + const bar = screen.getByTestId("stat-bar-fill"); + expect(bar.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ width }), + ]) + ); + }); + }); + + test("maxValueのデフォルトは180", ({ given, when, then }) => { + given(/^ラベル「(.*)」、値(\d+)のStatBarが与えられている$/, (l: string, v: string) => { + label = l; + value = Number(v); + maxValue = undefined; + }); + + when("StatBarをレンダリングする", () => { + render(); + }); + + then(/^バーの幅が「(.*)」である$/, (width: string) => { + const bar = screen.getByTestId("stat-bar-fill"); + expect(bar.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ width }), + ]) + ); + }); + }); +}); diff --git a/__tests__/detail/steps/usePokemonDetail.steps.ts b/__tests__/detail/steps/usePokemonDetail.steps.ts new file mode 100644 index 0000000..a951a8f --- /dev/null +++ b/__tests__/detail/steps/usePokemonDetail.steps.ts @@ -0,0 +1,185 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { renderHook, waitFor, act } from "@testing-library/react-native"; +import { usePokemonDetail } from "@/src/detail/hooks/usePokemonDetail"; +import { fetchPokemonDetail } from "@/src/detail/repository/pokemonDetailApi"; +import type { Pokemon } from "@/src/shared"; + +jest.mock("@/src/detail/repository/pokemonDetailApi"); + +const mockFetch = fetchPokemonDetail as jest.MockedFunction; + +const feature = loadFeature( + "__tests__/detail/features/usePokemonDetail.feature" +); + +const mockPokemon: Pokemon = { + id: 25, + name: "Pikachu", + types: ["electric"], + stats: [ + { name: "hp", baseStat: 35 }, + { name: "attack", baseStat: 55 }, + { name: "defense", baseStat: 40 }, + { name: "special-attack", baseStat: 50 }, + { name: "special-defense", baseStat: 50 }, + { name: "speed", baseStat: 90 }, + ], + height: 4, + weight: 60, + abilities: [ + { name: "static", isHidden: false }, + { name: "lightning-rod", isHidden: true }, + ], +}; + +defineFeature(feature, (test) => { + let hookResult: ReturnType, { id: number }>>; + let resolve: (value: Pokemon) => void; + + beforeEach(() => { + mockFetch.mockReset(); + }); + + test("初期ロード時にisLoadingがtrueになる", ({ given, when, then, and }) => { + given("fetchPokemonDetailが未解決のPromiseを返す", () => { + mockFetch.mockReturnValue(new Promise(() => {})); + }); + + when("usePokemonDetailをID25で呼び出す", () => { + hookResult = renderHook(() => usePokemonDetail(25)); + }); + + then("isLoadingがtrueである", () => { + expect(hookResult.result.current.isLoading).toBe(true); + }); + + and("pokemonがnullである", () => { + expect(hookResult.result.current.pokemon).toBeNull(); + }); + }); + + test("データ取得後にポケモン詳細が設定される", ({ given, when, then, and }) => { + given("fetchPokemonDetailがピカチュウのデータを返す", () => { + mockFetch.mockResolvedValueOnce(mockPokemon); + }); + + when("usePokemonDetailをID25で呼び出して完了を待つ", async () => { + hookResult = renderHook(() => usePokemonDetail(25)); + await waitFor(() => { + expect(hookResult.result.current.isLoading).toBe(false); + }); + }); + + then("isLoadingがfalseである", () => { + expect(hookResult.result.current.isLoading).toBe(false); + }); + + and("ポケモン詳細データが設定される", () => { + expect(hookResult.result.current.pokemon).toEqual(mockPokemon); + }); + + and("fetchPokemonDetailがID25で呼ばれる", () => { + expect(mockFetch).toHaveBeenCalledWith(25); + }); + }); + + test("エラー時にerror状態が設定される", ({ given, when, then, and }) => { + given(/^fetchPokemonDetailがエラー「(.*)」を返す$/, (message: string) => { + mockFetch.mockRejectedValueOnce(new Error(message)); + }); + + when("usePokemonDetailをID25で呼び出して完了を待つ", async () => { + hookResult = renderHook(() => usePokemonDetail(25)); + await waitFor(() => { + expect(hookResult.result.current.isLoading).toBe(false); + }); + }); + + then("isLoadingがfalseである", () => { + expect(hookResult.result.current.isLoading).toBe(false); + }); + + and(/^errorが「(.*)」である$/, (message: string) => { + expect(hookResult.result.current.error).toBe(message); + }); + + and("pokemonがnullである", () => { + expect(hookResult.result.current.pokemon).toBeNull(); + }); + }); + + test("Error以外のエラーでもerror状態が設定される", ({ given, when, then, and }) => { + given("fetchPokemonDetailが文字列エラーを返す", () => { + mockFetch.mockRejectedValueOnce("string error"); + }); + + when("usePokemonDetailをID25で呼び出して完了を待つ", async () => { + hookResult = renderHook(() => usePokemonDetail(25)); + await waitFor(() => { + expect(hookResult.result.current.isLoading).toBe(false); + }); + }); + + then("isLoadingがfalseである", () => { + expect(hookResult.result.current.isLoading).toBe(false); + }); + + and(/^errorが「(.*)」である$/, (message: string) => { + expect(hookResult.result.current.error).toBe(message); + }); + }); + + test("アンマウント後にデータ取得が完了しても状態が更新されない", ({ given, when, then, and }) => { + given("fetchPokemonDetailが遅延Promiseを返す", () => { + mockFetch.mockReturnValue(new Promise((r) => { resolve = r; })); + }); + + when("usePokemonDetailをID25で呼び出してアンマウントしてからPromiseを解決する", async () => { + hookResult = renderHook(() => usePokemonDetail(25)); + expect(hookResult.result.current.isLoading).toBe(true); + hookResult.unmount(); + await act(async () => { resolve(mockPokemon); }); + }); + + then("pokemonがnullである", () => { + expect(hookResult.result.current.pokemon).toBeNull(); + }); + + and("isLoadingがtrueである", () => { + expect(hookResult.result.current.isLoading).toBe(true); + }); + }); + + test("IDが変わると再取得する", ({ given, when, then }) => { + given("fetchPokemonDetailがピカチュウのデータを返す", () => { + mockFetch.mockResolvedValueOnce(mockPokemon); + }); + + when("usePokemonDetailをID25で呼び出して完了後にID1に変更する", async () => { + hookResult = renderHook( + (props: { id: number }) => usePokemonDetail(props.id), + { initialProps: { id: 25 } } + ); + await waitFor(() => { + expect(hookResult.result.current.isLoading).toBe(false); + }); + + const newPokemon: Pokemon = { + ...mockPokemon, + id: 1, + name: "Bulbasaur", + types: ["grass", "poison"], + }; + mockFetch.mockResolvedValueOnce(newPokemon); + hookResult.rerender({ id: 1 }); + + await waitFor(() => { + expect(hookResult.result.current.pokemon).toEqual(newPokemon); + }); + }); + + then("新しいポケモンデータが設定される", () => { + expect(hookResult.result.current.pokemon?.id).toBe(1); + }); + }); +}); diff --git a/__tests__/detail/steps/usePokemonFlavorText.steps.ts b/__tests__/detail/steps/usePokemonFlavorText.steps.ts new file mode 100644 index 0000000..0afc345 --- /dev/null +++ b/__tests__/detail/steps/usePokemonFlavorText.steps.ts @@ -0,0 +1,102 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { renderHook, waitFor, act } from "@testing-library/react-native"; +import { usePokemonFlavorText } from "@/src/detail/hooks/usePokemonFlavorText"; +import { fetchPokemonFlavorText } from "@/src/detail/repository/pokemonSpeciesApi"; + +jest.mock("@/src/detail/repository/pokemonSpeciesApi"); + +const mockFetch = fetchPokemonFlavorText as jest.MockedFunction; + +const feature = loadFeature( + "__tests__/detail/features/usePokemonFlavorText.feature" +); + +defineFeature(feature, (test) => { + let hookResult: ReturnType, unknown>>; + let resolve: (value: string | null) => void; + + beforeEach(() => { + mockFetch.mockReset(); + }); + + test("初期ロード時にisLoadingがtrueになる", ({ given, when, then, and }) => { + given("fetchPokemonFlavorTextが未解決のPromiseを返す", () => { + mockFetch.mockReturnValue(new Promise(() => {})); + }); + + when("usePokemonFlavorTextをID25で呼び出す", () => { + hookResult = renderHook(() => usePokemonFlavorText(25)); + }); + + then("isLoadingがtrueである", () => { + expect(hookResult.result.current.isLoading).toBe(true); + }); + + and("flavorTextがnullである", () => { + expect(hookResult.result.current.flavorText).toBeNull(); + }); + }); + + test("データ取得後にフレーバーテキストが設定される", ({ given, when, then, and }) => { + given("fetchPokemonFlavorTextがテキストを返す", () => { + mockFetch.mockResolvedValueOnce("でんきを ためこむ せいしつ。"); + }); + + when("usePokemonFlavorTextをID25で呼び出して完了を待つ", async () => { + hookResult = renderHook(() => usePokemonFlavorText(25)); + await waitFor(() => { + expect(hookResult.result.current.isLoading).toBe(false); + }); + }); + + then("isLoadingがfalseである", () => { + expect(hookResult.result.current.isLoading).toBe(false); + }); + + and(/^flavorTextが「(.*)」である$/, (text: string) => { + expect(hookResult.result.current.flavorText).toBe(text); + }); + }); + + test("エラー時はflavorTextがnullのままになる", ({ given, when, then, and }) => { + given("fetchPokemonFlavorTextがエラーを返す", () => { + mockFetch.mockRejectedValueOnce(new Error("Not found")); + }); + + when("usePokemonFlavorTextをID25で呼び出して完了を待つ", async () => { + hookResult = renderHook(() => usePokemonFlavorText(25)); + await waitFor(() => { + expect(hookResult.result.current.isLoading).toBe(false); + }); + }); + + then("isLoadingがfalseである", () => { + expect(hookResult.result.current.isLoading).toBe(false); + }); + + and("flavorTextがnullである", () => { + expect(hookResult.result.current.flavorText).toBeNull(); + }); + }); + + test("アンマウント後にデータ取得が完了しても状態が更新されない", ({ given, when, then, and }) => { + given("fetchPokemonFlavorTextが遅延Promiseを返す", () => { + mockFetch.mockReturnValue(new Promise((r) => { resolve = r; })); + }); + + when("usePokemonFlavorTextをID25で呼び出してアンマウントしてからPromiseを解決する", async () => { + hookResult = renderHook(() => usePokemonFlavorText(25)); + expect(hookResult.result.current.isLoading).toBe(true); + hookResult.unmount(); + await act(async () => { resolve("テスト"); }); + }); + + then("flavorTextがnullである", () => { + expect(hookResult.result.current.flavorText).toBeNull(); + }); + + and("isLoadingがtrueである", () => { + expect(hookResult.result.current.isLoading).toBe(true); + }); + }); +}); diff --git a/__tests__/detail/steps/usePokemonSpeciesInfo.steps.ts b/__tests__/detail/steps/usePokemonSpeciesInfo.steps.ts new file mode 100644 index 0000000..22c3504 --- /dev/null +++ b/__tests__/detail/steps/usePokemonSpeciesInfo.steps.ts @@ -0,0 +1,121 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { renderHook, waitFor, act } from "@testing-library/react-native"; +import { usePokemonSpeciesInfo } from "@/src/detail/hooks/usePokemonSpeciesInfo"; +import { fetchPokemonSpeciesInfo } from "@/src/detail/repository/pokemonSpeciesApi"; + +jest.mock("@/src/detail/repository/pokemonSpeciesApi"); + +const mockFetch = fetchPokemonSpeciesInfo as jest.MockedFunction; + +const feature = loadFeature( + "__tests__/detail/features/usePokemonSpeciesInfo.feature" +); + +defineFeature(feature, (test) => { + let hookResult: ReturnType, unknown>>; + let resolve: (value: { localizedName: string | null; flavorText: string | null }) => void; + + beforeEach(() => { + mockFetch.mockReset(); + }); + + test("初期ロード時にisLoadingがtrueになる", ({ given, when, then, and }) => { + given("fetchPokemonSpeciesInfoが未解決のPromiseを返す", () => { + mockFetch.mockReturnValue(new Promise(() => {})); + }); + + when("usePokemonSpeciesInfoをID25で呼び出す", () => { + hookResult = renderHook(() => usePokemonSpeciesInfo(25)); + }); + + then("isLoadingがtrueである", () => { + expect(hookResult.result.current.isLoading).toBe(true); + }); + + and("localizedNameがnullである", () => { + expect(hookResult.result.current.localizedName).toBeNull(); + }); + + and("flavorTextがnullである", () => { + expect(hookResult.result.current.flavorText).toBeNull(); + }); + }); + + test("ローカライズされたポケモン名とフレーバーテキストを返す", ({ given, when, then, and }) => { + given("fetchPokemonSpeciesInfoが日本語データを返す", () => { + mockFetch.mockResolvedValueOnce({ + localizedName: "ピカチュウ", + flavorText: "でんきを ためこむ せいしつ。", + }); + }); + + when("usePokemonSpeciesInfoをID25で呼び出して完了を待つ", async () => { + hookResult = renderHook(() => usePokemonSpeciesInfo(25)); + await waitFor(() => { + expect(hookResult.result.current.isLoading).toBe(false); + }); + }); + + then("isLoadingがfalseである", () => { + expect(hookResult.result.current.isLoading).toBe(false); + }); + + and(/^localizedNameが「(.*)」である$/, (name: string) => { + expect(hookResult.result.current.localizedName).toBe(name); + }); + + and(/^flavorTextが「(.*)」である$/, (text: string) => { + expect(hookResult.result.current.flavorText).toBe(text); + }); + + and(/^fetchPokemonSpeciesInfoがID(\d+)と言語「(.*)」で呼ばれる$/, (id: string, lang: string) => { + expect(mockFetch).toHaveBeenCalledWith(Number(id), lang); + }); + }); + + test("エラー時にnullを返す", ({ given, when, then, and }) => { + given("fetchPokemonSpeciesInfoがエラーを返す", () => { + mockFetch.mockRejectedValueOnce(new Error("Not found")); + }); + + when("usePokemonSpeciesInfoをID25で呼び出して完了を待つ", async () => { + hookResult = renderHook(() => usePokemonSpeciesInfo(25)); + await waitFor(() => { + expect(hookResult.result.current.isLoading).toBe(false); + }); + }); + + then("isLoadingがfalseである", () => { + expect(hookResult.result.current.isLoading).toBe(false); + }); + + and("localizedNameがnullである", () => { + expect(hookResult.result.current.localizedName).toBeNull(); + }); + + and("flavorTextがnullである", () => { + expect(hookResult.result.current.flavorText).toBeNull(); + }); + }); + + test("アンマウント後にデータ取得が完了しても状態が更新されない", ({ given, when, then, and }) => { + given("fetchPokemonSpeciesInfoが遅延Promiseを返す", () => { + mockFetch.mockReturnValue(new Promise((r) => { resolve = r; })); + }); + + when("usePokemonSpeciesInfoをID25で呼び出してアンマウントしてからPromiseを解決する", async () => { + hookResult = renderHook(() => usePokemonSpeciesInfo(25)); + expect(hookResult.result.current.isLoading).toBe(true); + hookResult.unmount(); + await act(async () => { resolve({ localizedName: "ピカチュウ", flavorText: "テスト" }); }); + }); + + then("localizedNameがnullである", () => { + expect(hookResult.result.current.localizedName).toBeNull(); + }); + + and("isLoadingがtrueである", () => { + expect(hookResult.result.current.isLoading).toBe(true); + }); + }); +}); diff --git a/__tests__/favorites/features/favoritesScreen.feature b/__tests__/favorites/features/favoritesScreen.feature new file mode 100644 index 0000000..c55bdcb --- /dev/null +++ b/__tests__/favorites/features/favoritesScreen.feature @@ -0,0 +1,16 @@ +Feature: お気に入り画面 + + Scenario: お気に入りが空の場合、プレースホルダーが表示される + Given お気に入りポケモンがない + When お気に入り画面を表示する + Then プレースホルダーテキストが表示される + + Scenario: ローディング中にActivityIndicatorが表示される + Given データをローディング中である + When お気に入り画面を表示する + Then ローディングインジケーターが表示される + + Scenario: お気に入りのポケモンがカードとして表示される + Given お気に入りにピカチュウが登録されている + When お気に入り画面を表示する + Then "Pikachu" のカードが表示される diff --git a/__tests__/favorites/features/usePokemonByIds.feature b/__tests__/favorites/features/usePokemonByIds.feature new file mode 100644 index 0000000..49d42b8 --- /dev/null +++ b/__tests__/favorites/features/usePokemonByIds.feature @@ -0,0 +1,50 @@ +Feature: IDリストによるポケモン取得フック + + Scenario: 空配列の場合はローディングせず空配列を返す + Given IDリストが空配列である + When フックをレンダーする + Then isLoadingはfalseである + And pokemonは空配列である + + Scenario: 複数IDのポケモンを並列取得する + Given ID 25 と 1 のポケモンデータが存在する + When IDリスト [25, 1] でフックをレンダーする + Then ローディング完了後にポケモンが2件取得される + And fetchPokemonByIdが2回呼ばれる + And fetchPokemonSpeciesInfoが2回呼ばれる + + Scenario: ローカライズ名がある場合はローカライズ名を使用する + Given ID 25 のポケモンにローカライズ名 "ピカチュウ" が存在する + When IDリスト [25] でフックをレンダーする + Then ポケモンの名前は "ピカチュウ" である + + Scenario: ローカライズ名がnullの場合は英語名にフォールバックする + Given ID 25 のポケモンにローカライズ名がnullである + When IDリスト [25] でフックをレンダーする + Then ポケモンの名前は "Pikachu" である + + Scenario: 現在の言語をfetchPokemonSpeciesInfoに渡す + Given ID 25 のポケモンデータが存在する + When IDリスト [25] でフックをレンダーする + Then fetchPokemonSpeciesInfoにID 25 と言語 "ja" が渡される + + Scenario: 一部取得に失敗してもエラーが設定される + Given ID 25 のポケモンは取得成功しID 999 は取得失敗する + When IDリスト [25, 999] でフックをレンダーする + Then エラーメッセージは "Not found" である + + Scenario: Error以外のエラーでもerror状態が設定される + Given ID 25 のポケモン取得が文字列エラーで失敗する + When IDリスト [25] でフックをレンダーする + Then エラーメッセージは "Unknown error" である + + Scenario: アンマウント後にデータ取得が完了しても状態が更新されない + Given ID 25 のポケモン取得が未解決のPromiseを返す + When IDリスト [25] でフックをレンダーしてアンマウントする + Then pokemonは空配列のままである + And isLoadingはtrueのままである + + Scenario: IDリストが変わると再取得する + Given ID 25 と 1 のポケモンデータが存在する + When IDリスト [25] でフックをレンダーし、その後 [25, 1] に変更する + Then ポケモンが2件取得される diff --git a/__tests__/favorites/hooks/usePokemonByIds.test.ts b/__tests__/favorites/hooks/usePokemonByIds.test.ts deleted file mode 100644 index 1aac339..0000000 --- a/__tests__/favorites/hooks/usePokemonByIds.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { renderHook, waitFor, act } from "@testing-library/react-native"; -import { usePokemonByIds } from "@/src/favorites/hooks/usePokemonByIds"; -import { fetchPokemonById } from "@/src/shared/repository/pokemonApi"; -import { fetchPokemonSpeciesInfo } from "@/src/shared/repository/pokemonSpeciesApi"; -import type { PokemonSummary, PokemonSpeciesInfo } from "@/src/shared"; - -jest.mock("@/src/shared/repository/pokemonApi"); -jest.mock("@/src/shared/repository/pokemonSpeciesApi"); - -const mockFetchById = fetchPokemonById as jest.MockedFunction; -const mockFetchSpecies = fetchPokemonSpeciesInfo as jest.MockedFunction; - -const mockPokemon: PokemonSummary[] = [ - { id: 25, name: "Pikachu", types: ["electric"] }, - { id: 1, name: "Bulbasaur", types: ["grass", "poison"] }, -]; - -const mockSpeciesJa: PokemonSpeciesInfo[] = [ - { localizedName: "ピカチュウ", flavorText: null }, - { localizedName: "フシギダネ", flavorText: null }, -]; - -describe("usePokemonByIds", () => { - beforeEach(() => { - mockFetchById.mockReset(); - mockFetchSpecies.mockReset(); - }); - - it("空配列の場合はローディングせず空配列を返す", () => { - const { result } = renderHook(() => usePokemonByIds([])); - - expect(result.current.isLoading).toBe(false); - expect(result.current.pokemon).toEqual([]); - }); - - it("複数IDのポケモンを並列取得する", async () => { - mockFetchById - .mockResolvedValueOnce(mockPokemon[0]) - .mockResolvedValueOnce(mockPokemon[1]); - mockFetchSpecies - .mockResolvedValueOnce(mockSpeciesJa[0]) - .mockResolvedValueOnce(mockSpeciesJa[1]); - - const { result } = renderHook(() => usePokemonByIds([25, 1])); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.pokemon).toHaveLength(2); - expect(mockFetchById).toHaveBeenCalledTimes(2); - expect(mockFetchSpecies).toHaveBeenCalledTimes(2); - }); - - it("ローカライズ名がある場合はローカライズ名を使用する", async () => { - mockFetchById.mockResolvedValueOnce(mockPokemon[0]); - mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); - - const { result } = renderHook(() => usePokemonByIds([25])); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.pokemon[0].name).toBe("ピカチュウ"); - }); - - it("ローカライズ名がnullの場合は英語名にフォールバックする", async () => { - mockFetchById.mockResolvedValueOnce(mockPokemon[0]); - mockFetchSpecies.mockResolvedValueOnce({ localizedName: null, flavorText: null }); - - const { result } = renderHook(() => usePokemonByIds([25])); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.pokemon[0].name).toBe("Pikachu"); - }); - - it("現在の言語をfetchPokemonSpeciesInfoに渡す", async () => { - mockFetchById.mockResolvedValueOnce(mockPokemon[0]); - mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); - - const { result } = renderHook(() => usePokemonByIds([25])); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(mockFetchSpecies).toHaveBeenCalledWith(25, "ja"); - }); - - it("一部取得に失敗してもエラーが設定される", async () => { - mockFetchById - .mockResolvedValueOnce(mockPokemon[0]) - .mockRejectedValueOnce(new Error("Not found")); - mockFetchSpecies - .mockResolvedValueOnce(mockSpeciesJa[0]) - .mockResolvedValueOnce(mockSpeciesJa[1]); - - const { result } = renderHook(() => usePokemonByIds([25, 999])); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.error).toBe("Not found"); - }); - - it("Error以外のエラーでもerror状態が設定される", async () => { - mockFetchById.mockRejectedValueOnce("string error"); - mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); - - const { result } = renderHook(() => usePokemonByIds([25])); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.error).toBe("Unknown error"); - }); - - it("アンマウント後にデータ取得が完了しても状態が更新されない", async () => { - let resolve!: (value: PokemonSummary) => void; - mockFetchById.mockReturnValue(new Promise((r) => { resolve = r; })); - mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); - const { result, unmount } = renderHook(() => usePokemonByIds([25])); - - expect(result.current.isLoading).toBe(true); - unmount(); - - await act(async () => { resolve(mockPokemon[0]); }); - - expect(result.current.pokemon).toEqual([]); - expect(result.current.isLoading).toBe(true); - }); - - it("IDリストが変わると再取得する", async () => { - mockFetchById.mockResolvedValueOnce(mockPokemon[0]); - mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); - - const { result, rerender } = renderHook( - (props: { ids: number[] }) => usePokemonByIds(props.ids), - { initialProps: { ids: [25] } } - ); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.pokemon).toHaveLength(1); - - mockFetchById - .mockResolvedValueOnce(mockPokemon[0]) - .mockResolvedValueOnce(mockPokemon[1]); - mockFetchSpecies - .mockResolvedValueOnce(mockSpeciesJa[0]) - .mockResolvedValueOnce(mockSpeciesJa[1]); - - rerender({ ids: [25, 1] }); - - await waitFor(() => { - expect(result.current.pokemon).toHaveLength(2); - }); - }); -}); diff --git a/__tests__/favorites/screens/FavoritesScreen.test.tsx b/__tests__/favorites/screens/FavoritesScreen.test.tsx deleted file mode 100644 index eb35155..0000000 --- a/__tests__/favorites/screens/FavoritesScreen.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { render, screen } from "@testing-library/react-native"; -import { FavoritesScreen } from "@/src/favorites"; -import type { PokemonSummary } from "@/src/shared"; - -const mockUsePokemonByIds = { - pokemon: [] as PokemonSummary[], - isLoading: false, - error: null as string | null, -}; - -jest.mock("@/src/favorites/hooks/usePokemonByIds", () => ({ - usePokemonByIds: () => mockUsePokemonByIds, -})); - -jest.mock("expo-router", () => ({ - Link: ({ - children, - href, - }: { - children: React.ReactNode; - href: string; - asChild?: boolean; - }) => { - const { View } = require("react-native"); - return {children}; - }, -})); - -const renderWithProvider = () => render(); - -describe("FavoritesScreen", () => { - beforeEach(() => { - mockUsePokemonByIds.pokemon = []; - mockUsePokemonByIds.isLoading = false; - mockUsePokemonByIds.error = null; - }); - - it("お気に入りが空の場合、プレースホルダーが表示される", () => { - renderWithProvider(); - expect( - screen.getByText("favorites.empty"), - ).toBeTruthy(); - }); - - it("ローディング中にActivityIndicatorが表示される", () => { - mockUsePokemonByIds.isLoading = true; - renderWithProvider(); - expect(screen.getByTestId("loading-indicator")).toBeTruthy(); - }); - - it("お気に入りのポケモンがカードとして表示される", () => { - mockUsePokemonByIds.pokemon = [ - { id: 25, name: "Pikachu", types: ["electric"] }, - ]; - renderWithProvider(); - expect(screen.getByText("Pikachu")).toBeTruthy(); - }); -}); diff --git a/__tests__/favorites/steps/favoritesScreen.steps.tsx b/__tests__/favorites/steps/favoritesScreen.steps.tsx new file mode 100644 index 0000000..33549c1 --- /dev/null +++ b/__tests__/favorites/steps/favoritesScreen.steps.tsx @@ -0,0 +1,84 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { render, screen } from "@testing-library/react-native"; +import { FavoritesScreen } from "@/src/favorites"; +import type { PokemonSummary } from "@/src/shared"; + +const mockUsePokemonByIds = { + pokemon: [] as PokemonSummary[], + isLoading: false, + error: null as string | null, +}; + +jest.mock("@/src/favorites/hooks/usePokemonByIds", () => ({ + usePokemonByIds: () => mockUsePokemonByIds, +})); + +jest.mock("expo-router", () => ({ + Link: ({ + children, + href, + }: { + children: React.ReactNode; + href: string; + asChild?: boolean; + }) => { + const { View } = require("react-native"); + return {children}; + }, +})); + +const feature = loadFeature( + "__tests__/favorites/features/favoritesScreen.feature" +); + +defineFeature(feature, (test) => { + beforeEach(() => { + mockUsePokemonByIds.pokemon = []; + mockUsePokemonByIds.isLoading = false; + mockUsePokemonByIds.error = null; + }); + + test("お気に入りが空の場合、プレースホルダーが表示される", ({ given, when, then }) => { + given("お気に入りポケモンがない", () => { + // default state: empty + }); + + when("お気に入り画面を表示する", () => { + render(); + }); + + then("プレースホルダーテキストが表示される", () => { + expect(screen.getByText("favorites.empty")).toBeTruthy(); + }); + }); + + test("ローディング中にActivityIndicatorが表示される", ({ given, when, then }) => { + given("データをローディング中である", () => { + mockUsePokemonByIds.isLoading = true; + }); + + when("お気に入り画面を表示する", () => { + render(); + }); + + then("ローディングインジケーターが表示される", () => { + expect(screen.getByTestId("loading-indicator")).toBeTruthy(); + }); + }); + + test("お気に入りのポケモンがカードとして表示される", ({ given, when, then }) => { + given("お気に入りにピカチュウが登録されている", () => { + mockUsePokemonByIds.pokemon = [ + { id: 25, name: "Pikachu", types: ["electric"] }, + ]; + }); + + when("お気に入り画面を表示する", () => { + render(); + }); + + then(/^"Pikachu" のカードが表示される$/, () => { + expect(screen.getByText("Pikachu")).toBeTruthy(); + }); + }); +}); diff --git a/__tests__/favorites/steps/usePokemonByIds.steps.ts b/__tests__/favorites/steps/usePokemonByIds.steps.ts new file mode 100644 index 0000000..3e81639 --- /dev/null +++ b/__tests__/favorites/steps/usePokemonByIds.steps.ts @@ -0,0 +1,261 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { renderHook, waitFor, act } from "@testing-library/react-native"; +import { usePokemonByIds } from "@/src/favorites/hooks/usePokemonByIds"; +import { fetchPokemonById } from "@/src/shared/repository/pokemonApi"; +import { fetchPokemonSpeciesInfo } from "@/src/shared/repository/pokemonSpeciesApi"; +import type { PokemonSummary, PokemonSpeciesInfo } from "@/src/shared"; + +jest.mock("@/src/shared/repository/pokemonApi"); +jest.mock("@/src/shared/repository/pokemonSpeciesApi"); + +const mockFetchById = fetchPokemonById as jest.MockedFunction; +const mockFetchSpecies = fetchPokemonSpeciesInfo as jest.MockedFunction; + +const mockPokemon: PokemonSummary[] = [ + { id: 25, name: "Pikachu", types: ["electric"] }, + { id: 1, name: "Bulbasaur", types: ["grass", "poison"] }, +]; + +const mockSpeciesJa: PokemonSpeciesInfo[] = [ + { localizedName: "ピカチュウ", flavorText: null }, + { localizedName: "フシギダネ", flavorText: null }, +]; + +const feature = loadFeature( + "__tests__/favorites/features/usePokemonByIds.feature" +); + +defineFeature(feature, (test) => { + beforeEach(() => { + mockFetchById.mockReset(); + mockFetchSpecies.mockReset(); + }); + + test("空配列の場合はローディングせず空配列を返す", ({ given, when, then, and }) => { + let result: ReturnType; + + given("IDリストが空配列である", () => { + // no setup needed + }); + + when("フックをレンダーする", () => { + const hook = renderHook(() => usePokemonByIds([])); + result = hook.result.current; + }); + + then("isLoadingはfalseである", () => { + expect(result.isLoading).toBe(false); + }); + + and("pokemonは空配列である", () => { + expect(result.pokemon).toEqual([]); + }); + }); + + test("複数IDのポケモンを並列取得する", ({ given, when, then, and }) => { + let hookResult: { current: ReturnType }; + + given(/^ID 25 と 1 のポケモンデータが存在する$/, () => { + mockFetchById + .mockResolvedValueOnce(mockPokemon[0]) + .mockResolvedValueOnce(mockPokemon[1]); + mockFetchSpecies + .mockResolvedValueOnce(mockSpeciesJa[0]) + .mockResolvedValueOnce(mockSpeciesJa[1]); + }); + + when(/^IDリスト \[25, 1\] でフックをレンダーする$/, () => { + const hook = renderHook(() => usePokemonByIds([25, 1])); + hookResult = hook.result; + }); + + then("ローディング完了後にポケモンが2件取得される", async () => { + await waitFor(() => { + expect(hookResult.current.isLoading).toBe(false); + }); + expect(hookResult.current.pokemon).toHaveLength(2); + }); + + and("fetchPokemonByIdが2回呼ばれる", () => { + expect(mockFetchById).toHaveBeenCalledTimes(2); + }); + + and("fetchPokemonSpeciesInfoが2回呼ばれる", () => { + expect(mockFetchSpecies).toHaveBeenCalledTimes(2); + }); + }); + + test("ローカライズ名がある場合はローカライズ名を使用する", ({ given, when, then }) => { + let hookResult: { current: ReturnType }; + + given(/^ID 25 のポケモンにローカライズ名 "ピカチュウ" が存在する$/, () => { + mockFetchById.mockResolvedValueOnce(mockPokemon[0]); + mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); + }); + + when(/^IDリスト \[25\] でフックをレンダーする$/, () => { + const hook = renderHook(() => usePokemonByIds([25])); + hookResult = hook.result; + }); + + then(/^ポケモンの名前は "ピカチュウ" である$/, async () => { + await waitFor(() => { + expect(hookResult.current.isLoading).toBe(false); + }); + expect(hookResult.current.pokemon[0].name).toBe("ピカチュウ"); + }); + }); + + test("ローカライズ名がnullの場合は英語名にフォールバックする", ({ given, when, then }) => { + let hookResult: { current: ReturnType }; + + given(/^ID 25 のポケモンにローカライズ名がnullである$/, () => { + mockFetchById.mockResolvedValueOnce(mockPokemon[0]); + mockFetchSpecies.mockResolvedValueOnce({ localizedName: null, flavorText: null }); + }); + + when(/^IDリスト \[25\] でフックをレンダーする$/, () => { + const hook = renderHook(() => usePokemonByIds([25])); + hookResult = hook.result; + }); + + then(/^ポケモンの名前は "Pikachu" である$/, async () => { + await waitFor(() => { + expect(hookResult.current.isLoading).toBe(false); + }); + expect(hookResult.current.pokemon[0].name).toBe("Pikachu"); + }); + }); + + test("現在の言語をfetchPokemonSpeciesInfoに渡す", ({ given, when, then }) => { + let hookResult: { current: ReturnType }; + + given(/^ID 25 のポケモンデータが存在する$/, () => { + mockFetchById.mockResolvedValueOnce(mockPokemon[0]); + mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); + }); + + when(/^IDリスト \[25\] でフックをレンダーする$/, () => { + const hook = renderHook(() => usePokemonByIds([25])); + hookResult = hook.result; + }); + + then(/^fetchPokemonSpeciesInfoにID 25 と言語 "ja" が渡される$/, async () => { + await waitFor(() => { + expect(hookResult.current.isLoading).toBe(false); + }); + expect(mockFetchSpecies).toHaveBeenCalledWith(25, "ja"); + }); + }); + + test("一部取得に失敗してもエラーが設定される", ({ given, when, then }) => { + let hookResult: { current: ReturnType }; + + given(/^ID 25 のポケモンは取得成功しID 999 は取得失敗する$/, () => { + mockFetchById + .mockResolvedValueOnce(mockPokemon[0]) + .mockRejectedValueOnce(new Error("Not found")); + mockFetchSpecies + .mockResolvedValueOnce(mockSpeciesJa[0]) + .mockResolvedValueOnce(mockSpeciesJa[1]); + }); + + when(/^IDリスト \[25, 999\] でフックをレンダーする$/, () => { + const hook = renderHook(() => usePokemonByIds([25, 999])); + hookResult = hook.result; + }); + + then(/^エラーメッセージは "Not found" である$/, async () => { + await waitFor(() => { + expect(hookResult.current.isLoading).toBe(false); + }); + expect(hookResult.current.error).toBe("Not found"); + }); + }); + + test("Error以外のエラーでもerror状態が設定される", ({ given, when, then }) => { + let hookResult: { current: ReturnType }; + + given(/^ID 25 のポケモン取得が文字列エラーで失敗する$/, () => { + mockFetchById.mockRejectedValueOnce("string error"); + mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); + }); + + when(/^IDリスト \[25\] でフックをレンダーする$/, () => { + const hook = renderHook(() => usePokemonByIds([25])); + hookResult = hook.result; + }); + + then(/^エラーメッセージは "Unknown error" である$/, async () => { + await waitFor(() => { + expect(hookResult.current.isLoading).toBe(false); + }); + expect(hookResult.current.error).toBe("Unknown error"); + }); + }); + + test("アンマウント後にデータ取得が完了しても状態が更新されない", ({ given, when, then, and }) => { + let hookResult: { current: ReturnType }; + let resolve!: (value: PokemonSummary) => void; + + given(/^ID 25 のポケモン取得が未解決のPromiseを返す$/, () => { + mockFetchById.mockReturnValue(new Promise((r) => { resolve = r; })); + mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); + }); + + when(/^IDリスト \[25\] でフックをレンダーしてアンマウントする$/, async () => { + const { result, unmount } = renderHook(() => usePokemonByIds([25])); + hookResult = result; + expect(hookResult.current.isLoading).toBe(true); + unmount(); + await act(async () => { resolve(mockPokemon[0]); }); + }); + + then("pokemonは空配列のままである", () => { + expect(hookResult.current.pokemon).toEqual([]); + }); + + and("isLoadingはtrueのままである", () => { + expect(hookResult.current.isLoading).toBe(true); + }); + }); + + test("IDリストが変わると再取得する", ({ given, when, then }) => { + let hookResult: { current: ReturnType }; + let rerender: (props: { ids: number[] }) => void; + + given(/^ID 25 と 1 のポケモンデータが存在する$/, () => { + mockFetchById.mockResolvedValueOnce(mockPokemon[0]); + mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); + }); + + when(/^IDリスト \[25\] でフックをレンダーし、その後 \[25, 1\] に変更する$/, async () => { + const hook = renderHook( + (props: { ids: number[] }) => usePokemonByIds(props.ids), + { initialProps: { ids: [25] } } + ); + hookResult = hook.result; + rerender = hook.rerender; + + await waitFor(() => { + expect(hookResult.current.isLoading).toBe(false); + }); + expect(hookResult.current.pokemon).toHaveLength(1); + + mockFetchById + .mockResolvedValueOnce(mockPokemon[0]) + .mockResolvedValueOnce(mockPokemon[1]); + mockFetchSpecies + .mockResolvedValueOnce(mockSpeciesJa[0]) + .mockResolvedValueOnce(mockSpeciesJa[1]); + + rerender({ ids: [25, 1] }); + }); + + then("ポケモンが2件取得される", async () => { + await waitFor(() => { + expect(hookResult.current.pokemon).toHaveLength(2); + }); + }); + }); +}); diff --git a/__tests__/home/components/FloatingSearchButton.test.tsx b/__tests__/home/components/FloatingSearchButton.test.tsx deleted file mode 100644 index 2eb8d52..0000000 --- a/__tests__/home/components/FloatingSearchButton.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { render, screen, fireEvent, act } from "@testing-library/react-native"; -import { Keyboard, Platform } from "react-native"; -import { FloatingSearchButton } from "@/src/home/components/FloatingSearchButton"; - -describe("FloatingSearchButton", () => { - const defaultProps = { - searchText: "", - onChangeText: jest.fn(), - placeholder: "Search...", - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it("FABボタンが表示される", () => { - render(); - expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); - }); - - it("FABをタップすると検索入力が表示される", () => { - render(); - fireEvent.press(screen.getByTestId("floating-search-fab")); - expect(screen.getByTestId("search-input")).toBeTruthy(); - }); - - it("検索入力にテキストを入力するとonChangeTextが呼ばれる", () => { - const onChangeText = jest.fn(); - render( - , - ); - fireEvent.press(screen.getByTestId("floating-search-fab")); - fireEvent.changeText(screen.getByTestId("search-input"), "Pika"); - expect(onChangeText).toHaveBeenCalledWith("Pika"); - }); - - it("閉じるボタンをタップすると折りたたまれテキストがクリアされる", () => { - const onChangeText = jest.fn(); - render( - , - ); - fireEvent.press(screen.getByTestId("floating-search-fab")); - fireEvent.press(screen.getByTestId("search-close-button")); - expect(onChangeText).toHaveBeenCalledWith(""); - }); - - it("プレースホルダーが表示される", () => { - render(); - fireEvent.press(screen.getByTestId("floating-search-fab")); - expect(screen.getByPlaceholderText("Search...")).toBeTruthy(); - }); - - it("キーボードが閉じたらFABボタンに戻る", () => { - const onChangeText = jest.fn(); - const hideEvent = - Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide"; - - let hideCallback: (() => void) | undefined; - const addListenerSpy = jest.spyOn(Keyboard, "addListener"); - addListenerSpy.mockImplementation((event, callback) => { - if (event === hideEvent) { - hideCallback = callback as () => void; - } - return { remove: jest.fn() } as unknown as ReturnType< - typeof Keyboard.addListener - >; - }); - - render( - , - ); - fireEvent.press(screen.getByTestId("floating-search-fab")); - expect(screen.getByTestId("search-input")).toBeTruthy(); - - act(() => { - hideCallback?.(); - }); - - expect(onChangeText).toHaveBeenCalledWith(""); - expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); - - addListenerSpy.mockRestore(); - }); -}); diff --git a/__tests__/home/domain/pokemonListItem.test.ts b/__tests__/home/domain/pokemonListItem.test.ts deleted file mode 100644 index 36e7dd5..0000000 --- a/__tests__/home/domain/pokemonListItem.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - extractPokemonId, - capitalizeName, - toPokemon, -} from "@/src/home/domain/pokemonListItem"; - -describe("extractPokemonId", () => { - it("URLからポケモンIDを数値として抽出する", () => { - expect( - extractPokemonId("https://pokeapi.co/api/v2/pokemon/25/") - ).toBe(25); - }); - - it("別のIDでも正しく抽出する", () => { - expect( - extractPokemonId("https://pokeapi.co/api/v2/pokemon/151/") - ).toBe(151); - }); - - it("末尾スラッシュなしのURLでも正しく抽出する", () => { - expect( - extractPokemonId("https://pokeapi.co/api/v2/pokemon/1") - ).toBe(1); - }); -}); - -describe("capitalizeName", () => { - it("小文字の名前を先頭大文字化する", () => { - expect(capitalizeName("bulbasaur")).toBe("Bulbasaur"); - }); - - it("空文字列を処理できる", () => { - expect(capitalizeName("")).toBe(""); - }); -}); - -describe("toPokemon", () => { - it("PokeApiListItemをPokemon型に変換する", () => { - const result = toPokemon({ - name: "pikachu", - url: "https://pokeapi.co/api/v2/pokemon/25/", - }); - - expect(result).toEqual({ - id: 25, - name: "Pikachu", - types: [], - }); - }); - - it("typesは空配列になる", () => { - const result = toPokemon({ - name: "bulbasaur", - url: "https://pokeapi.co/api/v2/pokemon/1/", - }); - - expect(result.types).toEqual([]); - }); -}); diff --git a/__tests__/home/features/floatingSearchButton.feature b/__tests__/home/features/floatingSearchButton.feature new file mode 100644 index 0000000..1820663 --- /dev/null +++ b/__tests__/home/features/floatingSearchButton.feature @@ -0,0 +1,34 @@ +Feature: フローティング検索ボタン + + Scenario: FABボタンが表示される + Given FloatingSearchButtonがレンダリングされている + Then FABボタンが表示される + + Scenario: FABをタップすると検索入力が表示される + Given FloatingSearchButtonがレンダリングされている + When FABボタンをタップする + Then 検索入力フィールドが表示される + + Scenario: 検索入力にテキストを入力するとonChangeTextが呼ばれる + Given FloatingSearchButtonがレンダリングされている + When FABボタンをタップする + And 検索入力に "Pika" と入力する + Then onChangeTextが "Pika" で呼ばれる + + Scenario: 閉じるボタンをタップすると折りたたまれテキストがクリアされる + Given 検索テキスト "Pika" でFloatingSearchButtonがレンダリングされている + When FABボタンをタップする + And 閉じるボタンをタップする + Then onChangeTextが "" で呼ばれる + + Scenario: プレースホルダーが表示される + Given FloatingSearchButtonがレンダリングされている + When FABボタンをタップする + Then プレースホルダー "Search..." が表示される + + Scenario: キーボードが閉じたらFABボタンに戻る + Given FloatingSearchButtonがレンダリングされている + When FABボタンをタップする + And キーボードが閉じられる + Then onChangeTextが "" で呼ばれる + And FABボタンが表示される diff --git a/__tests__/home/features/homeScreen.feature b/__tests__/home/features/homeScreen.feature new file mode 100644 index 0000000..7af6ee5 --- /dev/null +++ b/__tests__/home/features/homeScreen.feature @@ -0,0 +1,47 @@ +Feature: ホーム画面 + + Scenario: ポケモンカードが表示される + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + Then "Pikachu" が表示される + And "Bulbasaur" が表示される + + Scenario: 各カードが詳細画面へのリンクを持つ + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + Then ID 25 の詳細リンクが存在する + And ID 1 の詳細リンクが存在する + + Scenario: FABボタンが表示される + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + Then FABボタンが表示される + + Scenario: FABをタップすると検索入力フィールドが表示される + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + And FABボタンをタップする + Then 検索入力フィールドが表示される + + Scenario: 検索テキスト入力でポケモンがフィルタリングされる + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + And FABボタンをタップする + And 検索入力に "Pika" と入力する + Then "Pikachu" が表示される + And "Bulbasaur" は表示されない + + Scenario: 各カードにお気に入りボタンが表示される + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + Then お気に入りボタンが表示される + + Scenario: ローディング中にActivityIndicatorが表示される + Given ポケモンリストがローディング中である + When ホーム画面をレンダリングする + Then ローディングインジケーターが表示される + + Scenario: エラー時にエラーメッセージが表示される + Given ポケモンリストの取得でエラーが発生している + When ホーム画面をレンダリングする + Then エラーメッセージが表示される diff --git a/__tests__/home/features/pokemonApi.feature b/__tests__/home/features/pokemonApi.feature new file mode 100644 index 0000000..57f7d35 --- /dev/null +++ b/__tests__/home/features/pokemonApi.feature @@ -0,0 +1,25 @@ +Feature: ポケモンREST API + + Scenario: 正しいURLでfetchを呼び出す + Given fetchがモックされている + And fetchが正常なレスポンスを返す + When fetchPokemonListを limit 20 offset 0 で呼び出す + Then fetchが "https://pokeapi.co/api/v2/pokemon?limit=20&offset=0" で呼ばれる + + Scenario: レスポンスをパースして返す + Given fetchがモックされている + And fetchが正常なレスポンスを返す + When fetchPokemonListを limit 20 offset 0 で呼び出す + Then レスポンスがパースされて返される + + Scenario: HTTPエラー時にエラーをスローする + Given fetchがモックされている + And fetchがステータス 500 のエラーレスポンスを返す + When fetchPokemonListを limit 20 offset 0 で呼び出す + Then "Failed to fetch pokemon list: 500" エラーがスローされる + + Scenario: ネットワークエラー時にエラーをスローする + Given fetchがモックされている + And fetchが "Network error" ネットワークエラーを返す + When fetchPokemonListを limit 20 offset 0 で呼び出す + Then "Network error" エラーがスローされる diff --git a/__tests__/home/features/pokemonGraphqlApi.feature b/__tests__/home/features/pokemonGraphqlApi.feature new file mode 100644 index 0000000..49ece48 --- /dev/null +++ b/__tests__/home/features/pokemonGraphqlApi.feature @@ -0,0 +1,34 @@ +Feature: ポケモンGraphQL API + + Scenario: GraphQLエンドポイントにPOSTリクエストを送信する + Given fetchがモックされている + And fetchがGraphQL正常レスポンスを返す + When fetchPokemonListGraphQLを limit 20 offset 0 lang "ja" で呼び出す + Then fetchが "https://beta.pokeapi.co/graphql/v1beta" にPOSTで呼ばれる + + Scenario: ローカライズされたポケモン名とタイプを返す + Given fetchがモックされている + And fetchがGraphQL正常レスポンスを返す + When fetchPokemonListGraphQLを limit 20 offset 0 lang "ja" で呼び出す + Then 総件数は 1025 である + And ポケモンの件数は 2 である + And 1番目のポケモンはID 1 名前 "フシギダネ" タイプ "grass,poison" である + And 2番目のポケモンはID 4 名前 "ヒトカゲ" タイプ "fire" である + + Scenario: ローカライズ名がない場合はspecies名にフォールバックする + Given fetchがモックされている + And fetchがローカライズ名なしのGraphQLレスポンスを返す + When fetchPokemonListGraphQLを limit 20 offset 0 lang "ja" で呼び出す + Then 1番目のポケモンの名前は "Bulbasaur" である + + Scenario: HTTPエラー時にエラーをスローする + Given fetchがモックされている + And fetchがステータス 500 のHTTPエラーを返す + When fetchPokemonListGraphQLを limit 20 offset 0 lang "ja" で呼び出す + Then "GraphQL request failed: 500" エラーがスローされる + + Scenario: GraphQLエラー時にエラーをスローする + Given fetchがモックされている + And fetchがGraphQLエラーレスポンスを返す + When fetchPokemonListGraphQLを limit 20 offset 0 lang "ja" で呼び出す + Then "GraphQL error: Field not found" エラーがスローされる diff --git a/__tests__/home/features/pokemonListItem.feature b/__tests__/home/features/pokemonListItem.feature new file mode 100644 index 0000000..c84a4ff --- /dev/null +++ b/__tests__/home/features/pokemonListItem.feature @@ -0,0 +1,30 @@ +Feature: ポケモンリストアイテムの変換 + PokeAPIのレスポンスをアプリ内のPokemonSummary型に変換する + + Scenario Outline: URLからポケモンIDを抽出する + Given PokeAPIのURL "" が与えられている + When URLからIDを抽出する + Then IDは である + + Examples: + | url | id | + | https://pokeapi.co/api/v2/pokemon/25/ | 25 | + | https://pokeapi.co/api/v2/pokemon/151/ | 151 | + | https://pokeapi.co/api/v2/pokemon/1 | 1 | + + Scenario Outline: ポケモン名を先頭大文字化する + Given ポケモン名 "" が与えられている + When 名前を先頭大文字化する + Then 結果は "" である + + Examples: + | input | expected | + | bulbasaur | Bulbasaur | + | | | + + Scenario: PokeApiListItemをPokemonSummary型に変換する + Given PokeAPIリストアイテムの名前が "pikachu" でURLが "https://pokeapi.co/api/v2/pokemon/25/" である + When PokemonSummary型に変換する + Then IDは 25 である + And 名前は "Pikachu" である + And typesは空配列である diff --git a/__tests__/home/features/useFloatingSearch.feature b/__tests__/home/features/useFloatingSearch.feature new file mode 100644 index 0000000..5a7acaa --- /dev/null +++ b/__tests__/home/features/useFloatingSearch.feature @@ -0,0 +1,29 @@ +Feature: useFloatingSearchフック + + Scenario: 初期状態でisExpandedがfalse + Given useFloatingSearchフックがレンダリングされている + Then isExpandedはfalseである + + Scenario: toggle()で展開状態が切り替わる + Given useFloatingSearchフックがレンダリングされている + When toggleを実行する + Then isExpandedはtrueである + When toggleを再度実行する + Then isExpandedはfalseである + + Scenario: close()で常に折りたたまれる + Given useFloatingSearchフックがレンダリングされている + When toggleを実行する + And closeを実行する + Then isExpandedはfalseである + + Scenario: close()は折りたたみ状態でも安全に呼べる + Given useFloatingSearchフックがレンダリングされている + When closeを実行する + Then isExpandedはfalseである + + Scenario: アニメーションスタイルを返す + Given useFloatingSearchフックがレンダリングされている + Then fabAnimatedStyleが定義されている + And iconAnimatedStyleが定義されている + And inputAnimatedStyleが定義されている diff --git a/__tests__/home/features/usePokemonList.feature b/__tests__/home/features/usePokemonList.feature new file mode 100644 index 0000000..7686803 --- /dev/null +++ b/__tests__/home/features/usePokemonList.feature @@ -0,0 +1,72 @@ +Feature: usePokemonListフック + + Scenario: 初期ロード時にisLoadingがtrueになる + Given fetchPokemonListGraphQLが未解決のPromiseを返す + When usePokemonListフックがレンダリングされる + Then isLoadingはtrueである + + Scenario: データ取得後にポケモン一覧が設定される + Given fetchPokemonListGraphQLがオフセット0で総数40のデータを返す + When usePokemonListフックがレンダリングされる + And ローディングが完了する + Then ポケモン一覧の件数は2件である + And 1番目のポケモンの名前は "ポケモン1" である + And 1番目のポケモンのIDは 1 である + And 1番目のポケモンのタイプは "grass" を含む + + Scenario: 言語パラメータがGraphQL関数に渡される + Given fetchPokemonListGraphQLがオフセット0で総数40のデータを返す + When usePokemonListフックがレンダリングされる + And ローディングが完了する + Then fetchPokemonListGraphQLが 20 と 0 と "ja" で呼ばれる + + Scenario: loadMoreで追加データが追加される + Given fetchPokemonListGraphQLがオフセット0で総数40のデータを返す + When usePokemonListフックがレンダリングされる + And ローディングが完了する + And fetchPokemonListGraphQLがオフセット20で総数40の追加データを返す + And loadMoreを実行する + Then ポケモン一覧の件数は4件である + + Scenario: 総件数に達した場合hasMoreがfalseになる + Given fetchPokemonListGraphQLがオフセット0で総数2のデータを返す + When usePokemonListフックがレンダリングされる + And ローディングが完了する + Then hasMoreはfalseである + + Scenario: hasMoreがfalseの場合loadMoreは何もしない + Given fetchPokemonListGraphQLがオフセット0で総数2のデータを返す + When usePokemonListフックがレンダリングされる + And ローディングが完了する + And loadMoreを実行する + Then fetchPokemonListGraphQLは1回だけ呼ばれる + + Scenario: refreshでデータがリセットされる + Given fetchPokemonListGraphQLがオフセット0で総数40のデータを返す + When usePokemonListフックがレンダリングされる + And ローディングが完了する + And fetchPokemonListGraphQLがオフセット0で総数40のリフレッシュデータを返す + And refreshを実行する + Then ポケモン一覧の件数は2件である + And fetchPokemonListGraphQLは2回呼ばれる + And 最後のfetchPokemonListGraphQL呼び出しは 20 と 0 と "ja" である + + Scenario: エラー時にerror状態が設定される + Given fetchPokemonListGraphQLが "Network error" エラーを返す + When usePokemonListフックがレンダリングされる + And ローディングが完了する + Then errorは "Network error" である + + Scenario: 初期ロードでError以外のエラーでもerror状態が設定される + Given fetchPokemonListGraphQLが文字列エラーを返す + When usePokemonListフックがレンダリングされる + And ローディングが完了する + Then errorは "Unknown error" である + + Scenario: loadMoreでError以外のエラーでもerror状態が設定される + Given fetchPokemonListGraphQLがオフセット0で総数40のデータを返す + When usePokemonListフックがレンダリングされる + And ローディングが完了する + And fetchPokemonListGraphQLが文字列エラーを返すよう設定する + And loadMoreを実行する + Then errorは "Unknown error" である diff --git a/__tests__/home/features/useSearch.feature b/__tests__/home/features/useSearch.feature new file mode 100644 index 0000000..c15f65d --- /dev/null +++ b/__tests__/home/features/useSearch.feature @@ -0,0 +1,32 @@ +Feature: useSearchフック + + Scenario: 初期状態では全てのポケモンが返される + Given ポケモンリストが用意されている + When useSearchフックがレンダリングされる + Then 全てのポケモンが返される + And 検索テキストは空文字である + + Scenario: 検索テキストに一致するポケモンのみがフィルタリングされる + Given ポケモンリストが用意されている + When useSearchフックがレンダリングされる + And 検索テキストを "ピカチュウ" に設定する + Then フィルタリング結果にはIDが25のピカチュウのみが含まれる + + Scenario: 検索テキストが空文字の場合は全てのポケモンが返される + Given ポケモンリストが用意されている + When useSearchフックがレンダリングされる + And 検索テキストを "ピカ" に設定する + And 検索テキストを "" に設定する + Then 全てのポケモンが返される + + Scenario: 一致するポケモンがない場合は空配列が返される + Given ポケモンリストが用意されている + When useSearchフックがレンダリングされる + And 検索テキストを "ミュウツー" に設定する + Then フィルタリング結果は空配列である + + Scenario: 検索テキストが部分一致でもフィルタリングされる + Given ポケモンリストが用意されている + When useSearchフックがレンダリングされる + And 検索テキストを "ガメ" に設定する + Then フィルタリング結果にはIDが7のゼニガメのみが含まれる diff --git a/__tests__/home/hooks/useFloatingSearch.test.ts b/__tests__/home/hooks/useFloatingSearch.test.ts deleted file mode 100644 index 25d3c1e..0000000 --- a/__tests__/home/hooks/useFloatingSearch.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { renderHook, act } from "@testing-library/react-native"; -import { useFloatingSearch } from "@/src/home/hooks/useFloatingSearch"; - -describe("useFloatingSearch", () => { - it("初期状態でisExpandedがfalse", () => { - const { result } = renderHook(() => useFloatingSearch()); - expect(result.current.isExpanded).toBe(false); - }); - - it("toggle()で展開状態が切り替わる", () => { - const { result } = renderHook(() => useFloatingSearch()); - - act(() => { - result.current.toggle(); - }); - expect(result.current.isExpanded).toBe(true); - - act(() => { - result.current.toggle(); - }); - expect(result.current.isExpanded).toBe(false); - }); - - it("close()で常に折りたたまれる", () => { - const { result } = renderHook(() => useFloatingSearch()); - - act(() => { - result.current.toggle(); - }); - expect(result.current.isExpanded).toBe(true); - - act(() => { - result.current.close(); - }); - expect(result.current.isExpanded).toBe(false); - }); - - it("close()は折りたたみ状態でも安全に呼べる", () => { - const { result } = renderHook(() => useFloatingSearch()); - - act(() => { - result.current.close(); - }); - expect(result.current.isExpanded).toBe(false); - }); - - it("アニメーションスタイルを返す", () => { - const { result } = renderHook(() => useFloatingSearch()); - expect(result.current.fabAnimatedStyle).toBeDefined(); - expect(result.current.iconAnimatedStyle).toBeDefined(); - expect(result.current.inputAnimatedStyle).toBeDefined(); - }); -}); diff --git a/__tests__/home/hooks/usePokemonList.test.ts b/__tests__/home/hooks/usePokemonList.test.ts deleted file mode 100644 index 67f15b6..0000000 --- a/__tests__/home/hooks/usePokemonList.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { renderHook, act, waitFor } from "@testing-library/react-native"; -import { usePokemonList } from "@/src/home/hooks/usePokemonList"; -import { fetchPokemonListGraphQL } from "@/src/home/repository/pokemonGraphqlApi"; -import type { PokemonListResult } from "@/src/home/repository/pokemonGraphqlApi"; - -jest.mock("@/src/home/repository/pokemonGraphqlApi"); - -const mockFetch = fetchPokemonListGraphQL as jest.MockedFunction< - typeof fetchPokemonListGraphQL ->; - -const makePage = ( - offset: number, - totalCount: number, -): PokemonListResult => ({ - count: totalCount, - pokemon: [ - { id: offset + 1, name: `ポケモン${offset + 1}`, types: ["grass"] }, - { id: offset + 2, name: `ポケモン${offset + 2}`, types: ["fire"] }, - ], -}); - -describe("usePokemonList", () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - it("初期ロード時にisLoadingがtrueになる", () => { - mockFetch.mockReturnValue(new Promise(() => {})); - const { result } = renderHook(() => usePokemonList()); - - expect(result.current.isLoading).toBe(true); - }); - - it("データ取得後にポケモン一覧が設定される", async () => { - mockFetch.mockResolvedValueOnce(makePage(0, 40)); - const { result } = renderHook(() => usePokemonList()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.pokemon).toHaveLength(2); - expect(result.current.pokemon[0].name).toBe("ポケモン1"); - expect(result.current.pokemon[0].id).toBe(1); - expect(result.current.pokemon[0].types).toEqual(["grass"]); - }); - - it("言語パラメータがGraphQL関数に渡される", async () => { - mockFetch.mockResolvedValueOnce(makePage(0, 40)); - renderHook(() => usePokemonList()); - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith(20, 0, "ja"); - }); - }); - - it("loadMoreで追加データが追加される", async () => { - mockFetch.mockResolvedValueOnce(makePage(0, 40)); - const { result } = renderHook(() => usePokemonList()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - mockFetch.mockResolvedValueOnce(makePage(20, 40)); - await act(async () => { - result.current.loadMore(); - }); - - await waitFor(() => { - expect(result.current.isLoadingMore).toBe(false); - }); - - expect(result.current.pokemon).toHaveLength(4); - }); - - it("総件数に達した場合hasMoreがfalseになる", async () => { - mockFetch.mockResolvedValueOnce(makePage(0, 2)); - const { result } = renderHook(() => usePokemonList()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.hasMore).toBe(false); - }); - - it("hasMoreがfalseの場合loadMoreは何もしない", async () => { - mockFetch.mockResolvedValueOnce(makePage(0, 2)); - const { result } = renderHook(() => usePokemonList()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - await act(async () => { - result.current.loadMore(); - }); - - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it("refreshでデータがリセットされる", async () => { - mockFetch.mockResolvedValueOnce(makePage(0, 40)); - const { result } = renderHook(() => usePokemonList()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - mockFetch.mockResolvedValueOnce(makePage(0, 40)); - await act(async () => { - result.current.refresh(); - }); - - await waitFor(() => { - expect(result.current.isRefreshing).toBe(false); - }); - - expect(result.current.pokemon).toHaveLength(2); - expect(mockFetch).toHaveBeenCalledTimes(2); - expect(mockFetch).toHaveBeenLastCalledWith(20, 0, "ja"); - }); - - it("エラー時にerror状態が設定される", async () => { - mockFetch.mockRejectedValueOnce(new Error("Network error")); - const { result } = renderHook(() => usePokemonList()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.error).toBe("Network error"); - }); - - it("初期ロードでError以外のエラーでもerror状態が設定される", async () => { - mockFetch.mockRejectedValueOnce("string error"); - const { result } = renderHook(() => usePokemonList()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.error).toBe("Unknown error"); - }); - - it("loadMoreでError以外のエラーでもerror状態が設定される", async () => { - mockFetch.mockResolvedValueOnce(makePage(0, 40)); - const { result } = renderHook(() => usePokemonList()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - mockFetch.mockRejectedValueOnce("string error"); - await act(async () => { - result.current.loadMore(); - }); - - await waitFor(() => { - expect(result.current.isLoadingMore).toBe(false); - }); - - expect(result.current.error).toBe("Unknown error"); - }); -}); diff --git a/__tests__/home/hooks/useSearch.test.ts b/__tests__/home/hooks/useSearch.test.ts deleted file mode 100644 index cf1b8df..0000000 --- a/__tests__/home/hooks/useSearch.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { renderHook, act } from "@testing-library/react-native"; -import { useSearch } from "@/src/home"; -import type { PokemonSummary } from "@/src/shared"; - -const mockPokemon: PokemonSummary[] = [ - { id: 1, name: "フシギダネ", types: ["grass", "poison"] }, - { id: 4, name: "ヒトカゲ", types: ["fire"] }, - { id: 7, name: "ゼニガメ", types: ["water"] }, - { id: 25, name: "ピカチュウ", types: ["electric"] }, -]; - -describe("useSearch", () => { - it("初期状態では全てのポケモンが返される", () => { - const { result } = renderHook(() => useSearch(mockPokemon)); - expect(result.current.filteredItems).toEqual(mockPokemon); - expect(result.current.searchText).toBe(""); - }); - - it("検索テキストに一致するポケモンのみがフィルタリングされる", () => { - const { result } = renderHook(() => useSearch(mockPokemon)); - act(() => { - result.current.setSearchText("ピカチュウ"); - }); - expect(result.current.filteredItems).toEqual([ - { id: 25, name: "ピカチュウ", types: ["electric"] }, - ]); - }); - - it("検索テキストが空文字の場合は全てのポケモンが返される", () => { - const { result } = renderHook(() => useSearch(mockPokemon)); - act(() => { - result.current.setSearchText("ピカ"); - }); - act(() => { - result.current.setSearchText(""); - }); - expect(result.current.filteredItems).toEqual(mockPokemon); - }); - - it("一致するポケモンがない場合は空配列が返される", () => { - const { result } = renderHook(() => useSearch(mockPokemon)); - act(() => { - result.current.setSearchText("ミュウツー"); - }); - expect(result.current.filteredItems).toEqual([]); - }); - - it("検索テキストが部分一致でもフィルタリングされる", () => { - const { result } = renderHook(() => useSearch(mockPokemon)); - act(() => { - result.current.setSearchText("ガメ"); - }); - expect(result.current.filteredItems).toEqual([ - { id: 7, name: "ゼニガメ", types: ["water"] }, - ]); - }); -}); diff --git a/__tests__/home/repository/pokemonApi.test.ts b/__tests__/home/repository/pokemonApi.test.ts deleted file mode 100644 index ea38ad1..0000000 --- a/__tests__/home/repository/pokemonApi.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { fetchPokemonList } from "@/src/home/repository/pokemonApi"; -import type { PokeApiListResponse } from "@/src/home/domain/pokemonListItem"; - -const mockResponse: PokeApiListResponse = { - count: 1302, - next: "https://pokeapi.co/api/v2/pokemon?offset=20&limit=20", - previous: null, - results: [ - { name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" }, - { name: "ivysaur", url: "https://pokeapi.co/api/v2/pokemon/2/" }, - ], -}; - -const originalFetch = globalThis.fetch; - -beforeEach(() => { - globalThis.fetch = jest.fn(); -}); - -afterEach(() => { - globalThis.fetch = originalFetch; -}); - -describe("fetchPokemonList", () => { - it("正しいURLでfetchを呼び出す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - - await fetchPokemonList(20, 0); - - expect(globalThis.fetch).toHaveBeenCalledWith( - "https://pokeapi.co/api/v2/pokemon?limit=20&offset=0" - ); - }); - - it("レスポンスをパースして返す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - - const result = await fetchPokemonList(20, 0); - - expect(result).toEqual(mockResponse); - }); - - it("HTTPエラー時にエラーをスローする", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 500, - }); - - await expect(fetchPokemonList(20, 0)).rejects.toThrow( - "Failed to fetch pokemon list: 500" - ); - }); - - it("ネットワークエラー時にエラーをスローする", async () => { - (globalThis.fetch as jest.Mock).mockRejectedValueOnce( - new Error("Network error") - ); - - await expect(fetchPokemonList(20, 0)).rejects.toThrow("Network error"); - }); -}); diff --git a/__tests__/home/repository/pokemonGraphqlApi.test.ts b/__tests__/home/repository/pokemonGraphqlApi.test.ts deleted file mode 100644 index 4cbd34c..0000000 --- a/__tests__/home/repository/pokemonGraphqlApi.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { - fetchPokemonListGraphQL, -} from "@/src/home/repository/pokemonGraphqlApi"; - -const mockGraphQLResponse = { - data: { - pokemon_v2_pokemon: [ - { - id: 1, - pokemon_v2_pokemonspecy: { - name: "bulbasaur", - pokemon_v2_pokemonspeciesnames: [{ name: "フシギダネ" }], - }, - pokemon_v2_pokemontypes: [ - { pokemon_v2_type: { name: "grass" } }, - { pokemon_v2_type: { name: "poison" } }, - ], - }, - { - id: 4, - pokemon_v2_pokemonspecy: { - name: "charmander", - pokemon_v2_pokemonspeciesnames: [{ name: "ヒトカゲ" }], - }, - pokemon_v2_pokemontypes: [ - { pokemon_v2_type: { name: "fire" } }, - ], - }, - ], - pokemon_v2_pokemon_aggregate: { - aggregate: { count: 1025 }, - }, - }, -}; - -const mockEmptyNameResponse = { - data: { - pokemon_v2_pokemon: [ - { - id: 1, - pokemon_v2_pokemonspecy: { - name: "bulbasaur", - pokemon_v2_pokemonspeciesnames: [], - }, - pokemon_v2_pokemontypes: [ - { pokemon_v2_type: { name: "grass" } }, - ], - }, - ], - pokemon_v2_pokemon_aggregate: { - aggregate: { count: 1 }, - }, - }, -}; - -const originalFetch = globalThis.fetch; - -beforeEach(() => { - globalThis.fetch = jest.fn(); -}); - -afterEach(() => { - globalThis.fetch = originalFetch; -}); - -describe("fetchPokemonListGraphQL", () => { - it("GraphQLエンドポイントにPOSTリクエストを送信する", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockGraphQLResponse), - }); - - await fetchPokemonListGraphQL(20, 0, "ja"); - - expect(globalThis.fetch).toHaveBeenCalledWith( - "https://beta.pokeapi.co/graphql/v1beta", - expect.objectContaining({ - method: "POST", - headers: { "Content-Type": "application/json" }, - }), - ); - }); - - it("ローカライズされたポケモン名とタイプを返す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockGraphQLResponse), - }); - - const result = await fetchPokemonListGraphQL(20, 0, "ja"); - - expect(result.count).toBe(1025); - expect(result.pokemon).toHaveLength(2); - expect(result.pokemon[0]).toEqual({ - id: 1, - name: "フシギダネ", - types: ["grass", "poison"], - }); - expect(result.pokemon[1]).toEqual({ - id: 4, - name: "ヒトカゲ", - types: ["fire"], - }); - }); - - it("ローカライズ名がない場合はspecies名にフォールバックする", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockEmptyNameResponse), - }); - - const result = await fetchPokemonListGraphQL(20, 0, "ja"); - - expect(result.pokemon[0].name).toBe("Bulbasaur"); - }); - - it("HTTPエラー時にエラーをスローする", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 500, - }); - - await expect(fetchPokemonListGraphQL(20, 0, "ja")).rejects.toThrow( - "GraphQL request failed: 500", - ); - }); - - it("GraphQLエラー時にエラーをスローする", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - errors: [{ message: "Field not found" }], - }), - }); - - await expect(fetchPokemonListGraphQL(20, 0, "ja")).rejects.toThrow( - "GraphQL error: Field not found", - ); - }); -}); diff --git a/__tests__/home/screens/HomeScreen.test.tsx b/__tests__/home/screens/HomeScreen.test.tsx deleted file mode 100644 index 9ad6bf7..0000000 --- a/__tests__/home/screens/HomeScreen.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react-native"; -import { HomeScreen } from "@/src/home"; -import type { PokemonSummary } from "@/src/shared"; - -const mockPokemon: PokemonSummary[] = [ - { id: 1, name: "Bulbasaur", types: [] }, - { id: 4, name: "Charmander", types: [] }, - { id: 25, name: "Pikachu", types: [] }, -]; - -const mockUsePokemonList = { - pokemon: mockPokemon, - isLoading: false, - isLoadingMore: false, - isRefreshing: false, - hasMore: true, - error: null as string | null, - loadMore: jest.fn(), - refresh: jest.fn(), -}; - -jest.mock("@/src/home/hooks/usePokemonList", () => ({ - usePokemonList: () => mockUsePokemonList, -})); - -jest.mock("@react-navigation/bottom-tabs", () => ({ - useBottomTabBarHeight: () => 49, -})); - -jest.mock("expo-router", () => ({ - Link: ({ - children, - href, - }: { - children: React.ReactNode; - href: string; - asChild?: boolean; - }) => { - const { View } = require("react-native"); - return {children}; - }, -})); - -const renderWithProvider = () => render(); - -describe("HomeScreen", () => { - beforeEach(() => { - mockUsePokemonList.pokemon = mockPokemon; - mockUsePokemonList.isLoading = false; - mockUsePokemonList.isLoadingMore = false; - mockUsePokemonList.isRefreshing = false; - mockUsePokemonList.hasMore = true; - mockUsePokemonList.error = null; - mockUsePokemonList.loadMore = jest.fn(); - mockUsePokemonList.refresh = jest.fn(); - }); - - it("ポケモンカードが表示される", () => { - renderWithProvider(); - expect(screen.getByText("Pikachu")).toBeTruthy(); - expect(screen.getByText("Bulbasaur")).toBeTruthy(); - }); - - it("各カードが詳細画面へのリンクを持つ", () => { - renderWithProvider(); - expect(screen.getByTestId("link-/detail/25")).toBeTruthy(); - expect(screen.getByTestId("link-/detail/1")).toBeTruthy(); - }); - - it("FABボタンが表示される", () => { - renderWithProvider(); - expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); - }); - - it("FABをタップすると検索入力フィールドが表示される", () => { - renderWithProvider(); - fireEvent.press(screen.getByTestId("floating-search-fab")); - expect(screen.getByTestId("search-input")).toBeTruthy(); - }); - - it("検索テキスト入力でポケモンがフィルタリングされる", () => { - renderWithProvider(); - fireEvent.press(screen.getByTestId("floating-search-fab")); - fireEvent.changeText(screen.getByTestId("search-input"), "Pika"); - expect(screen.getByText("Pikachu")).toBeTruthy(); - expect(screen.queryByText("Bulbasaur")).toBeNull(); - }); - - it("各カードにお気に入りボタンが表示される", () => { - renderWithProvider(); - const buttons = screen.getAllByTestId("favorite-button"); - expect(buttons.length).toBeGreaterThan(0); - }); - - it("ローディング中にActivityIndicatorが表示される", () => { - mockUsePokemonList.isLoading = true; - renderWithProvider(); - expect(screen.getByTestId("loading-indicator")).toBeTruthy(); - }); - - it("エラー時にエラーメッセージが表示される", () => { - mockUsePokemonList.isLoading = false; - mockUsePokemonList.error = "Network error"; - mockUsePokemonList.pokemon = []; - renderWithProvider(); - expect(screen.getByTestId("error-text")).toBeTruthy(); - }); -}); diff --git a/__tests__/home/steps/floatingSearchButton.steps.tsx b/__tests__/home/steps/floatingSearchButton.steps.tsx new file mode 100644 index 0000000..920c932 --- /dev/null +++ b/__tests__/home/steps/floatingSearchButton.steps.tsx @@ -0,0 +1,176 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { render, screen, fireEvent, act } from "@testing-library/react-native"; +import { Keyboard, Platform } from "react-native"; +import { FloatingSearchButton } from "@/src/home"; + +const feature = loadFeature( + "__tests__/home/features/floatingSearchButton.feature" +); + +defineFeature(feature, (test) => { + let onChangeText: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + onChangeText = jest.fn(); + }); + + const defaultProps = { + searchText: "", + onChangeText: jest.fn(), + placeholder: "Search...", + }; + + test("FABボタンが表示される", ({ given, then }) => { + given("FloatingSearchButtonがレンダリングされている", () => { + render(); + }); + + then("FABボタンが表示される", () => { + expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); + }); + }); + + test("FABをタップすると検索入力が表示される", ({ given, when, then }) => { + given("FloatingSearchButtonがレンダリングされている", () => { + render(); + }); + + when("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + then("検索入力フィールドが表示される", () => { + expect(screen.getByTestId("search-input")).toBeTruthy(); + }); + }); + + test("検索入力にテキストを入力するとonChangeTextが呼ばれる", ({ + given, + when, + then, + and, + }) => { + given("FloatingSearchButtonがレンダリングされている", () => { + onChangeText = jest.fn(); + render( + + ); + }); + + when("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + and(/^検索入力に "(.*)" と入力する$/, (text: string) => { + fireEvent.changeText(screen.getByTestId("search-input"), text); + }); + + then(/^onChangeTextが "(.*)" で呼ばれる$/, (expected: string) => { + expect(onChangeText).toHaveBeenCalledWith(expected); + }); + }); + + test("閉じるボタンをタップすると折りたたまれテキストがクリアされる", ({ + given, + when, + then, + and, + }) => { + given( + /^検索テキスト "(.*)" でFloatingSearchButtonがレンダリングされている$/, + (searchText: string) => { + onChangeText = jest.fn(); + render( + + ); + } + ); + + when("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + and("閉じるボタンをタップする", () => { + fireEvent.press(screen.getByTestId("search-close-button")); + }); + + then(/^onChangeTextが "(.*)" で呼ばれる$/, (expected: string) => { + expect(onChangeText).toHaveBeenCalledWith(expected); + }); + }); + + test("プレースホルダーが表示される", ({ given, when, then }) => { + given("FloatingSearchButtonがレンダリングされている", () => { + render(); + }); + + when("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + then(/^プレースホルダー "(.*)" が表示される$/, (placeholder: string) => { + expect(screen.getByPlaceholderText(placeholder)).toBeTruthy(); + }); + }); + + test("キーボードが閉じたらFABボタンに戻る", ({ + given, + when, + then, + and, + }) => { + let hideCallback: (() => void) | undefined; + let addListenerSpy: jest.SpyInstance; + + given("FloatingSearchButtonがレンダリングされている", () => { + onChangeText = jest.fn(); + const hideEvent = + Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide"; + + addListenerSpy = jest.spyOn(Keyboard, "addListener"); + addListenerSpy.mockImplementation((event, callback) => { + if (event === hideEvent) { + hideCallback = callback as () => void; + } + return { remove: jest.fn() } as unknown as ReturnType< + typeof Keyboard.addListener + >; + }); + + render( + + ); + }); + + when("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + expect(screen.getByTestId("search-input")).toBeTruthy(); + }); + + and("キーボードが閉じられる", () => { + act(() => { + hideCallback?.(); + }); + }); + + then(/^onChangeTextが "(.*)" で呼ばれる$/, (expected: string) => { + expect(onChangeText).toHaveBeenCalledWith(expected); + }); + + and("FABボタンが表示される", () => { + expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); + addListenerSpy.mockRestore(); + }); + }); +}); diff --git a/__tests__/home/steps/homeScreen.steps.tsx b/__tests__/home/steps/homeScreen.steps.tsx new file mode 100644 index 0000000..382133c --- /dev/null +++ b/__tests__/home/steps/homeScreen.steps.tsx @@ -0,0 +1,215 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { render, screen, fireEvent } from "@testing-library/react-native"; +import { HomeScreen } from "@/src/home"; +import type { PokemonSummary } from "@/src/shared"; + +const feature = loadFeature("__tests__/home/features/homeScreen.feature"); + +const mockPokemon: PokemonSummary[] = [ + { id: 1, name: "Bulbasaur", types: [] }, + { id: 4, name: "Charmander", types: [] }, + { id: 25, name: "Pikachu", types: [] }, +]; + +const mockUsePokemonList = { + pokemon: mockPokemon, + isLoading: false, + isLoadingMore: false, + isRefreshing: false, + hasMore: true, + error: null as string | null, + loadMore: jest.fn(), + refresh: jest.fn(), +}; + +jest.mock("@/src/home/hooks/usePokemonList", () => ({ + usePokemonList: () => mockUsePokemonList, +})); + +jest.mock("@react-navigation/bottom-tabs", () => ({ + useBottomTabBarHeight: () => 49, +})); + +jest.mock("expo-router", () => ({ + Link: ({ + children, + href, + }: { + children: React.ReactNode; + href: string; + asChild?: boolean; + }) => { + const { View } = require("react-native"); + return {children}; + }, +})); + +const resetMockState = () => { + mockUsePokemonList.pokemon = mockPokemon; + mockUsePokemonList.isLoading = false; + mockUsePokemonList.isLoadingMore = false; + mockUsePokemonList.isRefreshing = false; + mockUsePokemonList.hasMore = true; + mockUsePokemonList.error = null; + mockUsePokemonList.loadMore = jest.fn(); + mockUsePokemonList.refresh = jest.fn(); +}; + +defineFeature(feature, (test) => { + beforeEach(() => { + resetMockState(); + }); + + test("ポケモンカードが表示される", ({ given, when, then, and }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state is already set + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then(/^"(.*)" が表示される$/, (name: string) => { + expect(screen.getByText(name)).toBeTruthy(); + }); + + and(/^"(.*)" が表示される$/, (name: string) => { + expect(screen.getByText(name)).toBeTruthy(); + }); + }); + + test("各カードが詳細画面へのリンクを持つ", ({ given, when, then, and }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then(/^ID (\d+) の詳細リンクが存在する$/, (id: string) => { + expect(screen.getByTestId(`link-/detail/${id}`)).toBeTruthy(); + }); + + and(/^ID (\d+) の詳細リンクが存在する$/, (id: string) => { + expect(screen.getByTestId(`link-/detail/${id}`)).toBeTruthy(); + }); + }); + + test("FABボタンが表示される", ({ given, when, then }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then("FABボタンが表示される", () => { + expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); + }); + }); + + test("FABをタップすると検索入力フィールドが表示される", ({ + given, + when, + then, + and, + }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + and("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + then("検索入力フィールドが表示される", () => { + expect(screen.getByTestId("search-input")).toBeTruthy(); + }); + }); + + test("検索テキスト入力でポケモンがフィルタリングされる", ({ + given, + when, + then, + and, + }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + and("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + and(/^検索入力に "(.*)" と入力する$/, (text: string) => { + fireEvent.changeText(screen.getByTestId("search-input"), text); + }); + + then(/^"(.*)" が表示される$/, (name: string) => { + expect(screen.getByText(name)).toBeTruthy(); + }); + + and(/^"(.*)" は表示されない$/, (name: string) => { + expect(screen.queryByText(name)).toBeNull(); + }); + }); + + test("各カードにお気に入りボタンが表示される", ({ given, when, then }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then("お気に入りボタンが表示される", () => { + const buttons = screen.getAllByTestId("favorite-button"); + expect(buttons.length).toBeGreaterThan(0); + }); + }); + + test("ローディング中にActivityIndicatorが表示される", ({ + given, + when, + then, + }) => { + given("ポケモンリストがローディング中である", () => { + mockUsePokemonList.isLoading = true; + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then("ローディングインジケーターが表示される", () => { + expect(screen.getByTestId("loading-indicator")).toBeTruthy(); + }); + }); + + test("エラー時にエラーメッセージが表示される", ({ given, when, then }) => { + given("ポケモンリストの取得でエラーが発生している", () => { + mockUsePokemonList.isLoading = false; + mockUsePokemonList.error = "Network error"; + mockUsePokemonList.pokemon = []; + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then("エラーメッセージが表示される", () => { + expect(screen.getByTestId("error-text")).toBeTruthy(); + }); + }); +}); diff --git a/__tests__/home/steps/pokemonApi.steps.ts b/__tests__/home/steps/pokemonApi.steps.ts new file mode 100644 index 0000000..ece642f --- /dev/null +++ b/__tests__/home/steps/pokemonApi.steps.ts @@ -0,0 +1,137 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { fetchPokemonList } from "@/src/home"; +import type { PokeApiListResponse } from "@/src/home/domain/pokemonListItem"; + +const feature = loadFeature("__tests__/home/features/pokemonApi.feature"); + +const mockResponse: PokeApiListResponse = { + count: 1302, + next: "https://pokeapi.co/api/v2/pokemon?offset=20&limit=20", + previous: null, + results: [ + { name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" }, + { name: "ivysaur", url: "https://pokeapi.co/api/v2/pokemon/2/" }, + ], +}; + +const originalFetch = globalThis.fetch; + +defineFeature(feature, (test) => { + let resultPromise: Promise; + + beforeEach(() => { + globalThis.fetch = jest.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("正しいURLでfetchを呼び出す", ({ given, when, then, and }) => { + given("fetchがモックされている", () => { + // Already mocked in beforeEach + }); + + and("fetchが正常なレスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + }); + + when( + /^fetchPokemonListを limit (\d+) offset (\d+) で呼び出す$/, + async (limit: string, offset: string) => { + resultPromise = fetchPokemonList(Number(limit), Number(offset)); + await resultPromise; + } + ); + + then(/^fetchが "(.*)" で呼ばれる$/, (url: string) => { + expect(globalThis.fetch).toHaveBeenCalledWith(url); + }); + }); + + test("レスポンスをパースして返す", ({ given, when, then, and }) => { + given("fetchがモックされている", () => { + // Already mocked in beforeEach + }); + + and("fetchが正常なレスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + }); + + when( + /^fetchPokemonListを limit (\d+) offset (\d+) で呼び出す$/, + async (limit: string, offset: string) => { + resultPromise = fetchPokemonList(Number(limit), Number(offset)); + } + ); + + then("レスポンスがパースされて返される", async () => { + const result = await resultPromise; + expect(result).toEqual(mockResponse); + }); + }); + + test("HTTPエラー時にエラーをスローする", ({ given, when, then, and }) => { + given("fetchがモックされている", () => { + // Already mocked in beforeEach + }); + + and( + /^fetchがステータス (\d+) のエラーレスポンスを返す$/, + (status: string) => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: Number(status), + }); + } + ); + + when( + /^fetchPokemonListを limit (\d+) offset (\d+) で呼び出す$/, + (limit: string, offset: string) => { + resultPromise = fetchPokemonList(Number(limit), Number(offset)); + } + ); + + then(/^"(.*)" エラーがスローされる$/, async (errorMessage: string) => { + await expect(resultPromise).rejects.toThrow(errorMessage); + }); + }); + + test("ネットワークエラー時にエラーをスローする", ({ + given, + when, + then, + and, + }) => { + given("fetchがモックされている", () => { + // Already mocked in beforeEach + }); + + and( + /^fetchが "(.*)" ネットワークエラーを返す$/, + (errorMessage: string) => { + (globalThis.fetch as jest.Mock).mockRejectedValueOnce( + new Error(errorMessage) + ); + } + ); + + when( + /^fetchPokemonListを limit (\d+) offset (\d+) で呼び出す$/, + (limit: string, offset: string) => { + resultPromise = fetchPokemonList(Number(limit), Number(offset)); + } + ); + + then(/^"(.*)" エラーがスローされる$/, async (errorMessage: string) => { + await expect(resultPromise).rejects.toThrow(errorMessage); + }); + }); +}); diff --git a/__tests__/home/steps/pokemonGraphqlApi.steps.ts b/__tests__/home/steps/pokemonGraphqlApi.steps.ts new file mode 100644 index 0000000..708d494 --- /dev/null +++ b/__tests__/home/steps/pokemonGraphqlApi.steps.ts @@ -0,0 +1,268 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { fetchPokemonListGraphQL } from "@/src/home/repository/pokemonGraphqlApi"; +import type { PokemonListResult } from "@/src/home/repository/pokemonGraphqlApi"; + +const feature = loadFeature( + "__tests__/home/features/pokemonGraphqlApi.feature" +); + +const mockGraphQLResponse = { + data: { + pokemon_v2_pokemon: [ + { + id: 1, + pokemon_v2_pokemonspecy: { + name: "bulbasaur", + pokemon_v2_pokemonspeciesnames: [{ name: "フシギダネ" }], + }, + pokemon_v2_pokemontypes: [ + { pokemon_v2_type: { name: "grass" } }, + { pokemon_v2_type: { name: "poison" } }, + ], + }, + { + id: 4, + pokemon_v2_pokemonspecy: { + name: "charmander", + pokemon_v2_pokemonspeciesnames: [{ name: "ヒトカゲ" }], + }, + pokemon_v2_pokemontypes: [{ pokemon_v2_type: { name: "fire" } }], + }, + ], + pokemon_v2_pokemon_aggregate: { + aggregate: { count: 1025 }, + }, + }, +}; + +const mockEmptyNameResponse = { + data: { + pokemon_v2_pokemon: [ + { + id: 1, + pokemon_v2_pokemonspecy: { + name: "bulbasaur", + pokemon_v2_pokemonspeciesnames: [], + }, + pokemon_v2_pokemontypes: [{ pokemon_v2_type: { name: "grass" } }], + }, + ], + pokemon_v2_pokemon_aggregate: { + aggregate: { count: 1 }, + }, + }, +}; + +const originalFetch = globalThis.fetch; + +defineFeature(feature, (test) => { + let resultPromise: Promise; + + beforeEach(() => { + globalThis.fetch = jest.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("GraphQLエンドポイントにPOSTリクエストを送信する", ({ + given, + when, + then, + and, + }) => { + given("fetchがモックされている", () => { + // Already mocked in beforeEach + }); + + and("fetchがGraphQL正常レスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockGraphQLResponse), + }); + }); + + when( + /^fetchPokemonListGraphQLを limit (\d+) offset (\d+) lang "(.*)" で呼び出す$/, + async (limit: string, offset: string, lang: string) => { + resultPromise = fetchPokemonListGraphQL( + Number(limit), + Number(offset), + lang + ); + await resultPromise; + } + ); + + then( + /^fetchが "(.*)" にPOSTで呼ばれる$/, + (url: string) => { + expect(globalThis.fetch).toHaveBeenCalledWith( + url, + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }) + ); + } + ); + }); + + test("ローカライズされたポケモン名とタイプを返す", ({ + given, + when, + then, + and, + }) => { + let result: PokemonListResult; + + given("fetchがモックされている", () => { + // Already mocked in beforeEach + }); + + and("fetchがGraphQL正常レスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockGraphQLResponse), + }); + }); + + when( + /^fetchPokemonListGraphQLを limit (\d+) offset (\d+) lang "(.*)" で呼び出す$/, + async (limit: string, offset: string, lang: string) => { + result = await fetchPokemonListGraphQL( + Number(limit), + Number(offset), + lang + ); + } + ); + + then(/^総件数は (\d+) である$/, (count: string) => { + expect(result.count).toBe(Number(count)); + }); + + and(/^ポケモンの件数は (\d+) である$/, (count: string) => { + expect(result.pokemon).toHaveLength(Number(count)); + }); + + and( + /^(\d+)番目のポケモンはID (\d+) 名前 "(.*)" タイプ "(.*)" である$/, + (index: string, id: string, name: string, types: string) => { + const i = Number(index) - 1; + expect(result.pokemon[i]).toEqual({ + id: Number(id), + name, + types: types.split(","), + }); + } + ); + + and( + /^(\d+)番目のポケモンはID (\d+) 名前 "(.*)" タイプ "(.*)" である$/, + (index: string, id: string, name: string, types: string) => { + const i = Number(index) - 1; + expect(result.pokemon[i]).toEqual({ + id: Number(id), + name, + types: types.split(","), + }); + } + ); + }); + + test("ローカライズ名がない場合はspecies名にフォールバックする", ({ + given, + when, + then, + and, + }) => { + let result: PokemonListResult; + + given("fetchがモックされている", () => { + // Already mocked in beforeEach + }); + + and("fetchがローカライズ名なしのGraphQLレスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockEmptyNameResponse), + }); + }); + + when( + /^fetchPokemonListGraphQLを limit (\d+) offset (\d+) lang "(.*)" で呼び出す$/, + async (limit: string, offset: string, lang: string) => { + result = await fetchPokemonListGraphQL( + Number(limit), + Number(offset), + lang + ); + } + ); + + then(/^1番目のポケモンの名前は "(.*)" である$/, (name: string) => { + expect(result.pokemon[0].name).toBe(name); + }); + }); + + test("HTTPエラー時にエラーをスローする", ({ given, when, then, and }) => { + given("fetchがモックされている", () => { + // Already mocked in beforeEach + }); + + and(/^fetchがステータス (\d+) のHTTPエラーを返す$/, (status: string) => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: Number(status), + }); + }); + + when( + /^fetchPokemonListGraphQLを limit (\d+) offset (\d+) lang "(.*)" で呼び出す$/, + (limit: string, offset: string, lang: string) => { + resultPromise = fetchPokemonListGraphQL( + Number(limit), + Number(offset), + lang + ); + } + ); + + then(/^"(.*)" エラーがスローされる$/, async (errorMessage: string) => { + await expect(resultPromise).rejects.toThrow(errorMessage); + }); + }); + + test("GraphQLエラー時にエラーをスローする", ({ given, when, then, and }) => { + given("fetchがモックされている", () => { + // Already mocked in beforeEach + }); + + and("fetchがGraphQLエラーレスポンスを返す", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + errors: [{ message: "Field not found" }], + }), + }); + }); + + when( + /^fetchPokemonListGraphQLを limit (\d+) offset (\d+) lang "(.*)" で呼び出す$/, + (limit: string, offset: string, lang: string) => { + resultPromise = fetchPokemonListGraphQL( + Number(limit), + Number(offset), + lang + ); + } + ); + + then(/^"(.*)" エラーがスローされる$/, async (errorMessage: string) => { + await expect(resultPromise).rejects.toThrow(errorMessage); + }); + }); +}); diff --git a/__tests__/home/steps/pokemonListItem.steps.ts b/__tests__/home/steps/pokemonListItem.steps.ts new file mode 100644 index 0000000..66c6659 --- /dev/null +++ b/__tests__/home/steps/pokemonListItem.steps.ts @@ -0,0 +1,78 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { + extractPokemonId, + capitalizeName, + toPokemon, +} from "@/src/home"; +import type { PokemonSummary } from "@/src/shared"; + +const feature = loadFeature( + "__tests__/home/features/pokemonListItem.feature" +); + +defineFeature(feature, (test) => { + let url: string; + let name: string; + let extractedId: number; + let capitalizedName: string; + let pokemon: PokemonSummary; + + test("URLからポケモンIDを抽出する", ({ given, when, then }) => { + given(/^PokeAPIのURL "(.*)" が与えられている$/, (givenUrl: string) => { + url = givenUrl; + }); + + when("URLからIDを抽出する", () => { + extractedId = extractPokemonId(url); + }); + + then(/^IDは (\d+) である$/, (expectedId: string) => { + expect(extractedId).toBe(Number(expectedId)); + }); + }); + + test("ポケモン名を先頭大文字化する", ({ given, when, then }) => { + given(/^ポケモン名 "(.*)" が与えられている$/, (givenName: string) => { + name = givenName; + }); + + when("名前を先頭大文字化する", () => { + capitalizedName = capitalizeName(name); + }); + + then(/^結果は "(.*)" である$/, (expected: string) => { + expect(capitalizedName).toBe(expected); + }); + }); + + test("PokeApiListItemをPokemonSummary型に変換する", ({ + given, + when, + then, + and, + }) => { + given( + /^PokeAPIリストアイテムの名前が "(.*)" でURLが "(.*)" である$/, + (givenName: string, givenUrl: string) => { + name = givenName; + url = givenUrl; + } + ); + + when("PokemonSummary型に変換する", () => { + pokemon = toPokemon({ name, url }); + }); + + then(/^IDは (\d+) である$/, (expectedId: string) => { + expect(pokemon.id).toBe(Number(expectedId)); + }); + + and(/^名前は "(.*)" である$/, (expectedName: string) => { + expect(pokemon.name).toBe(expectedName); + }); + + and("typesは空配列である", () => { + expect(pokemon.types).toEqual([]); + }); + }); +}); diff --git a/__tests__/home/steps/useFloatingSearch.steps.ts b/__tests__/home/steps/useFloatingSearch.steps.ts new file mode 100644 index 0000000..e9e591f --- /dev/null +++ b/__tests__/home/steps/useFloatingSearch.steps.ts @@ -0,0 +1,108 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { renderHook, act } from "@testing-library/react-native"; +import { useFloatingSearch } from "@/src/home"; + +const feature = loadFeature( + "__tests__/home/features/useFloatingSearch.feature" +); + +defineFeature(feature, (test) => { + let result: { current: ReturnType }; + + test("初期状態でisExpandedがfalse", ({ given, then }) => { + given("useFloatingSearchフックがレンダリングされている", () => { + const hook = renderHook(() => useFloatingSearch()); + result = hook.result; + }); + + then("isExpandedはfalseである", () => { + expect(result.current.isExpanded).toBe(false); + }); + }); + + test("toggle()で展開状態が切り替わる", ({ given, when, then }) => { + given("useFloatingSearchフックがレンダリングされている", () => { + const hook = renderHook(() => useFloatingSearch()); + result = hook.result; + }); + + when("toggleを実行する", () => { + act(() => { + result.current.toggle(); + }); + }); + + then("isExpandedはtrueである", () => { + expect(result.current.isExpanded).toBe(true); + }); + + when("toggleを再度実行する", () => { + act(() => { + result.current.toggle(); + }); + }); + + then("isExpandedはfalseである", () => { + expect(result.current.isExpanded).toBe(false); + }); + }); + + test("close()で常に折りたたまれる", ({ given, when, then, and }) => { + given("useFloatingSearchフックがレンダリングされている", () => { + const hook = renderHook(() => useFloatingSearch()); + result = hook.result; + }); + + when("toggleを実行する", () => { + act(() => { + result.current.toggle(); + }); + }); + + and("closeを実行する", () => { + act(() => { + result.current.close(); + }); + }); + + then("isExpandedはfalseである", () => { + expect(result.current.isExpanded).toBe(false); + }); + }); + + test("close()は折りたたみ状態でも安全に呼べる", ({ given, when, then }) => { + given("useFloatingSearchフックがレンダリングされている", () => { + const hook = renderHook(() => useFloatingSearch()); + result = hook.result; + }); + + when("closeを実行する", () => { + act(() => { + result.current.close(); + }); + }); + + then("isExpandedはfalseである", () => { + expect(result.current.isExpanded).toBe(false); + }); + }); + + test("アニメーションスタイルを返す", ({ given, then, and }) => { + given("useFloatingSearchフックがレンダリングされている", () => { + const hook = renderHook(() => useFloatingSearch()); + result = hook.result; + }); + + then("fabAnimatedStyleが定義されている", () => { + expect(result.current.fabAnimatedStyle).toBeDefined(); + }); + + and("iconAnimatedStyleが定義されている", () => { + expect(result.current.iconAnimatedStyle).toBeDefined(); + }); + + and("inputAnimatedStyleが定義されている", () => { + expect(result.current.inputAnimatedStyle).toBeDefined(); + }); + }); +}); diff --git a/__tests__/home/steps/usePokemonList.steps.ts b/__tests__/home/steps/usePokemonList.steps.ts new file mode 100644 index 0000000..ce958f2 --- /dev/null +++ b/__tests__/home/steps/usePokemonList.steps.ts @@ -0,0 +1,361 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { renderHook, act, waitFor } from "@testing-library/react-native"; +import { usePokemonList } from "@/src/home"; +import { fetchPokemonListGraphQL } from "@/src/home/repository/pokemonGraphqlApi"; +import type { PokemonListResult } from "@/src/home/repository/pokemonGraphqlApi"; + +jest.mock("@/src/home/repository/pokemonGraphqlApi"); + +const mockFetch = fetchPokemonListGraphQL as jest.MockedFunction< + typeof fetchPokemonListGraphQL +>; + +const makePage = ( + offset: number, + totalCount: number +): PokemonListResult => ({ + count: totalCount, + pokemon: [ + { id: offset + 1, name: `ポケモン${offset + 1}`, types: ["grass"] }, + { id: offset + 2, name: `ポケモン${offset + 2}`, types: ["fire"] }, + ], +}); + +const feature = loadFeature( + "__tests__/home/features/usePokemonList.feature" +); + +defineFeature(feature, (test) => { + let result: { current: ReturnType }; + + beforeEach(() => { + mockFetch.mockReset(); + }); + + test("初期ロード時にisLoadingがtrueになる", ({ given, when, then }) => { + given("fetchPokemonListGraphQLが未解決のPromiseを返す", () => { + mockFetch.mockReturnValue(new Promise(() => {})); + }); + + when("usePokemonListフックがレンダリングされる", () => { + const hook = renderHook(() => usePokemonList()); + result = hook.result; + }); + + then("isLoadingはtrueである", () => { + expect(result.current.isLoading).toBe(true); + }); + }); + + test("データ取得後にポケモン一覧が設定される", ({ + given, + when, + then, + and, + }) => { + given("fetchPokemonListGraphQLがオフセット0で総数40のデータを返す", () => { + mockFetch.mockResolvedValueOnce(makePage(0, 40)); + }); + + when("usePokemonListフックがレンダリングされる", () => { + const hook = renderHook(() => usePokemonList()); + result = hook.result; + }); + + and("ローディングが完了する", async () => { + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + then(/^ポケモン一覧の件数は(\d+)件である$/, (count: string) => { + expect(result.current.pokemon).toHaveLength(Number(count)); + }); + + and( + /^1番目のポケモンの名前は "(.*)" である$/, + (name: string) => { + expect(result.current.pokemon[0].name).toBe(name); + } + ); + + and(/^1番目のポケモンのIDは (\d+) である$/, (id: string) => { + expect(result.current.pokemon[0].id).toBe(Number(id)); + }); + + and( + /^1番目のポケモンのタイプは "(.*)" を含む$/, + (type: string) => { + expect(result.current.pokemon[0].types).toEqual([type]); + } + ); + }); + + test("言語パラメータがGraphQL関数に渡される", ({ + given, + when, + then, + and, + }) => { + given("fetchPokemonListGraphQLがオフセット0で総数40のデータを返す", () => { + mockFetch.mockResolvedValueOnce(makePage(0, 40)); + }); + + when("usePokemonListフックがレンダリングされる", () => { + renderHook(() => usePokemonList()); + }); + + and("ローディングが完了する", async () => { + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + }); + + then( + /^fetchPokemonListGraphQLが (\d+) と (\d+) と "(.*)" で呼ばれる$/, + (limit: string, offset: string, lang: string) => { + expect(mockFetch).toHaveBeenCalledWith( + Number(limit), + Number(offset), + lang + ); + } + ); + }); + + test("loadMoreで追加データが追加される", ({ given, when, then, and }) => { + given("fetchPokemonListGraphQLがオフセット0で総数40のデータを返す", () => { + mockFetch.mockResolvedValueOnce(makePage(0, 40)); + }); + + when("usePokemonListフックがレンダリングされる", () => { + const hook = renderHook(() => usePokemonList()); + result = hook.result; + }); + + and("ローディングが完了する", async () => { + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + and( + "fetchPokemonListGraphQLがオフセット20で総数40の追加データを返す", + () => { + mockFetch.mockResolvedValueOnce(makePage(20, 40)); + } + ); + + and("loadMoreを実行する", async () => { + await act(async () => { + result.current.loadMore(); + }); + await waitFor(() => { + expect(result.current.isLoadingMore).toBe(false); + }); + }); + + then(/^ポケモン一覧の件数は(\d+)件である$/, (count: string) => { + expect(result.current.pokemon).toHaveLength(Number(count)); + }); + }); + + test("総件数に達した場合hasMoreがfalseになる", ({ + given, + when, + then, + and, + }) => { + given("fetchPokemonListGraphQLがオフセット0で総数2のデータを返す", () => { + mockFetch.mockResolvedValueOnce(makePage(0, 2)); + }); + + when("usePokemonListフックがレンダリングされる", () => { + const hook = renderHook(() => usePokemonList()); + result = hook.result; + }); + + and("ローディングが完了する", async () => { + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + then("hasMoreはfalseである", () => { + expect(result.current.hasMore).toBe(false); + }); + }); + + test("hasMoreがfalseの場合loadMoreは何もしない", ({ + given, + when, + then, + and, + }) => { + given("fetchPokemonListGraphQLがオフセット0で総数2のデータを返す", () => { + mockFetch.mockResolvedValueOnce(makePage(0, 2)); + }); + + when("usePokemonListフックがレンダリングされる", () => { + const hook = renderHook(() => usePokemonList()); + result = hook.result; + }); + + and("ローディングが完了する", async () => { + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + and("loadMoreを実行する", async () => { + await act(async () => { + result.current.loadMore(); + }); + }); + + then(/^fetchPokemonListGraphQLは(\d+)回だけ呼ばれる$/, (count: string) => { + expect(mockFetch).toHaveBeenCalledTimes(Number(count)); + }); + }); + + test("refreshでデータがリセットされる", ({ given, when, then, and }) => { + given("fetchPokemonListGraphQLがオフセット0で総数40のデータを返す", () => { + mockFetch.mockResolvedValueOnce(makePage(0, 40)); + }); + + when("usePokemonListフックがレンダリングされる", () => { + const hook = renderHook(() => usePokemonList()); + result = hook.result; + }); + + and("ローディングが完了する", async () => { + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + and( + "fetchPokemonListGraphQLがオフセット0で総数40のリフレッシュデータを返す", + () => { + mockFetch.mockResolvedValueOnce(makePage(0, 40)); + } + ); + + and("refreshを実行する", async () => { + await act(async () => { + result.current.refresh(); + }); + await waitFor(() => { + expect(result.current.isRefreshing).toBe(false); + }); + }); + + then(/^ポケモン一覧の件数は(\d+)件である$/, (count: string) => { + expect(result.current.pokemon).toHaveLength(Number(count)); + }); + + and(/^fetchPokemonListGraphQLは(\d+)回呼ばれる$/, (count: string) => { + expect(mockFetch).toHaveBeenCalledTimes(Number(count)); + }); + + and( + /^最後のfetchPokemonListGraphQL呼び出しは (\d+) と (\d+) と "(.*)" である$/, + (limit: string, offset: string, lang: string) => { + expect(mockFetch).toHaveBeenLastCalledWith( + Number(limit), + Number(offset), + lang + ); + } + ); + }); + + test("エラー時にerror状態が設定される", ({ given, when, then, and }) => { + given( + /^fetchPokemonListGraphQLが "(.*)" エラーを返す$/, + (errorMessage: string) => { + mockFetch.mockRejectedValueOnce(new Error(errorMessage)); + } + ); + + when("usePokemonListフックがレンダリングされる", () => { + const hook = renderHook(() => usePokemonList()); + result = hook.result; + }); + + and("ローディングが完了する", async () => { + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + then(/^errorは "(.*)" である$/, (expected: string) => { + expect(result.current.error).toBe(expected); + }); + }); + + test("初期ロードでError以外のエラーでもerror状態が設定される", ({ + given, + when, + then, + and, + }) => { + given("fetchPokemonListGraphQLが文字列エラーを返す", () => { + mockFetch.mockRejectedValueOnce("string error"); + }); + + when("usePokemonListフックがレンダリングされる", () => { + const hook = renderHook(() => usePokemonList()); + result = hook.result; + }); + + and("ローディングが完了する", async () => { + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + then(/^errorは "(.*)" である$/, (expected: string) => { + expect(result.current.error).toBe(expected); + }); + }); + + test("loadMoreでError以外のエラーでもerror状態が設定される", ({ + given, + when, + then, + and, + }) => { + given("fetchPokemonListGraphQLがオフセット0で総数40のデータを返す", () => { + mockFetch.mockResolvedValueOnce(makePage(0, 40)); + }); + + when("usePokemonListフックがレンダリングされる", () => { + const hook = renderHook(() => usePokemonList()); + result = hook.result; + }); + + and("ローディングが完了する", async () => { + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + and("fetchPokemonListGraphQLが文字列エラーを返すよう設定する", () => { + mockFetch.mockRejectedValueOnce("string error"); + }); + + and("loadMoreを実行する", async () => { + await act(async () => { + result.current.loadMore(); + }); + await waitFor(() => { + expect(result.current.isLoadingMore).toBe(false); + }); + }); + + then(/^errorは "(.*)" である$/, (expected: string) => { + expect(result.current.error).toBe(expected); + }); + }); +}); diff --git a/__tests__/home/steps/useSearch.steps.ts b/__tests__/home/steps/useSearch.steps.ts new file mode 100644 index 0000000..6301a9e --- /dev/null +++ b/__tests__/home/steps/useSearch.steps.ts @@ -0,0 +1,150 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { renderHook, act } from "@testing-library/react-native"; +import { useSearch } from "@/src/home"; +import type { PokemonSummary } from "@/src/shared"; + +const feature = loadFeature("__tests__/home/features/useSearch.feature"); + +const mockPokemon: PokemonSummary[] = [ + { id: 1, name: "フシギダネ", types: ["grass", "poison"] }, + { id: 4, name: "ヒトカゲ", types: ["fire"] }, + { id: 7, name: "ゼニガメ", types: ["water"] }, + { id: 25, name: "ピカチュウ", types: ["electric"] }, +]; + +defineFeature(feature, (test) => { + let result: { current: ReturnType }; + + test("初期状態では全てのポケモンが返される", ({ given, when, then, and }) => { + given("ポケモンリストが用意されている", () => { + // mockPokemon is already defined + }); + + when("useSearchフックがレンダリングされる", () => { + const hook = renderHook(() => useSearch(mockPokemon)); + result = hook.result; + }); + + then("全てのポケモンが返される", () => { + expect(result.current.filteredItems).toEqual(mockPokemon); + }); + + and("検索テキストは空文字である", () => { + expect(result.current.searchText).toBe(""); + }); + }); + + test("検索テキストに一致するポケモンのみがフィルタリングされる", ({ + given, + when, + then, + and, + }) => { + given("ポケモンリストが用意されている", () => { + // mockPokemon is already defined + }); + + when("useSearchフックがレンダリングされる", () => { + const hook = renderHook(() => useSearch(mockPokemon)); + result = hook.result; + }); + + and(/^検索テキストを "(.*)" に設定する$/, (text: string) => { + act(() => { + result.current.setSearchText(text); + }); + }); + + then("フィルタリング結果にはIDが25のピカチュウのみが含まれる", () => { + expect(result.current.filteredItems).toEqual([ + { id: 25, name: "ピカチュウ", types: ["electric"] }, + ]); + }); + }); + + test("検索テキストが空文字の場合は全てのポケモンが返される", ({ + given, + when, + then, + and, + }) => { + given("ポケモンリストが用意されている", () => { + // mockPokemon is already defined + }); + + when("useSearchフックがレンダリングされる", () => { + const hook = renderHook(() => useSearch(mockPokemon)); + result = hook.result; + }); + + and(/^検索テキストを "(.*)" に設定する$/, (text: string) => { + act(() => { + result.current.setSearchText(text); + }); + }); + + and(/^検索テキストを "(.*)" に設定する$/, (text: string) => { + act(() => { + result.current.setSearchText(text); + }); + }); + + then("全てのポケモンが返される", () => { + expect(result.current.filteredItems).toEqual(mockPokemon); + }); + }); + + test("一致するポケモンがない場合は空配列が返される", ({ + given, + when, + then, + and, + }) => { + given("ポケモンリストが用意されている", () => { + // mockPokemon is already defined + }); + + when("useSearchフックがレンダリングされる", () => { + const hook = renderHook(() => useSearch(mockPokemon)); + result = hook.result; + }); + + and(/^検索テキストを "(.*)" に設定する$/, (text: string) => { + act(() => { + result.current.setSearchText(text); + }); + }); + + then("フィルタリング結果は空配列である", () => { + expect(result.current.filteredItems).toEqual([]); + }); + }); + + test("検索テキストが部分一致でもフィルタリングされる", ({ + given, + when, + then, + and, + }) => { + given("ポケモンリストが用意されている", () => { + // mockPokemon is already defined + }); + + when("useSearchフックがレンダリングされる", () => { + const hook = renderHook(() => useSearch(mockPokemon)); + result = hook.result; + }); + + and(/^検索テキストを "(.*)" に設定する$/, (text: string) => { + act(() => { + result.current.setSearchText(text); + }); + }); + + then("フィルタリング結果にはIDが7のゼニガメのみが含まれる", () => { + expect(result.current.filteredItems).toEqual([ + { id: 7, name: "ゼニガメ", types: ["water"] }, + ]); + }); + }); +}); diff --git a/__tests__/settings/components/LanguagePicker.test.tsx b/__tests__/settings/components/LanguagePicker.test.tsx deleted file mode 100644 index 1bb9e06..0000000 --- a/__tests__/settings/components/LanguagePicker.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react-native"; -import { LanguagePicker } from "@/src/settings/components/LanguagePicker"; - -const mockChangeLanguage = jest.fn(); -let mockLanguage = "ja"; - -jest.mock("@/src/shared", () => ({ - useLanguage: () => ({ - language: mockLanguage, - changeLanguage: mockChangeLanguage, - }), -})); - -describe("LanguagePicker", () => { - beforeEach(() => { - mockLanguage = "ja"; - mockChangeLanguage.mockReset(); - }); - - it("日本語と英語の選択肢が表示される", () => { - render(); - - expect(screen.getByTestId("language-option-ja")).toBeTruthy(); - expect(screen.getByTestId("language-option-en")).toBeTruthy(); - }); - - it("現在の言語にチェックマークが表示される", () => { - render(); - - expect(screen.getByTestId("checkmark-ja")).toBeTruthy(); - expect(screen.queryByTestId("checkmark-en")).toBeNull(); - }); - - it("言語を選択するとchangeLanguageが呼ばれる", () => { - render(); - - fireEvent.press(screen.getByTestId("language-option-en")); - - expect(mockChangeLanguage).toHaveBeenCalledWith("en"); - }); -}); diff --git a/__tests__/settings/features/languagePicker.feature b/__tests__/settings/features/languagePicker.feature new file mode 100644 index 0000000..ab6d000 --- /dev/null +++ b/__tests__/settings/features/languagePicker.feature @@ -0,0 +1,18 @@ +Feature: 言語選択コンポーネント + + Scenario: 日本語と英語の選択肢が表示される + Given 現在の言語が "ja" である + When 言語選択コンポーネントを表示する + Then 日本語の選択肢が表示される + And 英語の選択肢が表示される + + Scenario: 現在の言語にチェックマークが表示される + Given 現在の言語が "ja" である + When 言語選択コンポーネントを表示する + Then 日本語にチェックマークが表示される + And 英語にチェックマークが表示されない + + Scenario: 言語を選択するとchangeLanguageが呼ばれる + Given 現在の言語が "ja" である + When 英語の選択肢をタップする + Then changeLanguageが "en" で呼ばれる diff --git a/__tests__/settings/steps/languagePicker.steps.tsx b/__tests__/settings/steps/languagePicker.steps.tsx new file mode 100644 index 0000000..6d0c06c --- /dev/null +++ b/__tests__/settings/steps/languagePicker.steps.tsx @@ -0,0 +1,75 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { render, screen, fireEvent } from "@testing-library/react-native"; +import { LanguagePicker } from "@/src/settings/components/LanguagePicker"; + +const mockChangeLanguage = jest.fn(); +let mockLanguage = "ja"; + +jest.mock("@/src/shared", () => ({ + useLanguage: () => ({ + language: mockLanguage, + changeLanguage: mockChangeLanguage, + }), +})); + +const feature = loadFeature( + "__tests__/settings/features/languagePicker.feature" +); + +defineFeature(feature, (test) => { + beforeEach(() => { + mockLanguage = "ja"; + mockChangeLanguage.mockReset(); + }); + + test("日本語と英語の選択肢が表示される", ({ given, when, then, and }) => { + given(/^現在の言語が "ja" である$/, () => { + mockLanguage = "ja"; + }); + + when("言語選択コンポーネントを表示する", () => { + render(); + }); + + then("日本語の選択肢が表示される", () => { + expect(screen.getByTestId("language-option-ja")).toBeTruthy(); + }); + + and("英語の選択肢が表示される", () => { + expect(screen.getByTestId("language-option-en")).toBeTruthy(); + }); + }); + + test("現在の言語にチェックマークが表示される", ({ given, when, then, and }) => { + given(/^現在の言語が "ja" である$/, () => { + mockLanguage = "ja"; + }); + + when("言語選択コンポーネントを表示する", () => { + render(); + }); + + then("日本語にチェックマークが表示される", () => { + expect(screen.getByTestId("checkmark-ja")).toBeTruthy(); + }); + + and("英語にチェックマークが表示されない", () => { + expect(screen.queryByTestId("checkmark-en")).toBeNull(); + }); + }); + + test("言語を選択するとchangeLanguageが呼ばれる", ({ given, when, then }) => { + given(/^現在の言語が "ja" である$/, () => { + mockLanguage = "ja"; + }); + + when("英語の選択肢をタップする", () => { + render(); + fireEvent.press(screen.getByTestId("language-option-en")); + }); + + then(/^changeLanguageが "en" で呼ばれる$/, () => { + expect(mockChangeLanguage).toHaveBeenCalledWith("en"); + }); + }); +}); diff --git a/__tests__/shared/components/FavoriteButton.test.tsx b/__tests__/shared/components/FavoriteButton.test.tsx deleted file mode 100644 index fab3cae..0000000 --- a/__tests__/shared/components/FavoriteButton.test.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react-native"; -import { FavoriteButton } from "@/src/shared"; - -describe("FavoriteButton", () => { - it("Lottieアニメーションコンポーネントが描画される", () => { - render(); - expect(screen.getByTestId("favorite-lottie")).toBeTruthy(); - }); - - it("autoPlayが無効になっている", () => { - render(); - const lottie = screen.getByTestId("favorite-lottie"); - expect(lottie.props.autoPlay).toBe(false); - }); - - it("ループが無効になっている", () => { - render(); - const lottie = screen.getByTestId("favorite-lottie"); - expect(lottie.props.loop).toBe(false); - }); - - it("ボタン押下時にonToggleが即座に呼ばれる", () => { - const onToggle = jest.fn(); - render(); - fireEvent.press(screen.getByTestId("favorite-button")); - expect(onToggle).toHaveBeenCalledTimes(1); - }); - - it("お気に入り状態の場合、ONの最終フレームで初期表示される", () => { - render(); - const lottie = screen.getByTestId("favorite-lottie"); - expect(lottie.props.progress).toBeCloseTo(90 / 181); - }); - - it("非お気に入り状態の場合、progressが0で初期表示される", () => { - render(); - const lottie = screen.getByTestId("favorite-lottie"); - expect(lottie.props.progress).toBe(0); - }); - - it("外部からisFavoriteが変更された場合、progressが再同期される", () => { - const { rerender } = render( - , - ); - expect(screen.getByTestId("favorite-lottie").props.progress).toBe(0); - rerender(); - expect(screen.getByTestId("favorite-lottie").props.progress).toBeCloseTo( - 90 / 181, - ); - }); - - it("お気に入り状態のアクセシビリティラベルが正しい", () => { - render(); - expect(screen.getByLabelText("favoriteButton.remove")).toBeTruthy(); - }); - - it("非お気に入り状態のアクセシビリティラベルが正しい", () => { - render(); - expect(screen.getByLabelText("favoriteButton.add")).toBeTruthy(); - }); -}); diff --git a/__tests__/shared/components/PokemonCard.test.tsx b/__tests__/shared/components/PokemonCard.test.tsx deleted file mode 100644 index 9a11d2d..0000000 --- a/__tests__/shared/components/PokemonCard.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react-native"; -import { PokemonCard } from "@/src/shared"; -import type { PokemonSummary } from "@/src/shared"; - -const mockPokemon: PokemonSummary = { - id: 25, - name: "ピカチュウ", - types: ["electric"], -}; - -const mockMultiTypePokemon: PokemonSummary = { - id: 6, - name: "リザードン", - types: ["fire", "flying"], -}; - -describe("PokemonCard", () => { - it("ポケモンの名前が表示される", () => { - render(); - expect(screen.getByText("ピカチュウ")).toBeTruthy(); - }); - - it("ポケモンの画像が正しいURLで表示される", () => { - render(); - const image = screen.getByTestId("pokemon-image"); - expect(image.props.source.uri).toBe( - "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/25.png" - ); - }); - - it("タイプバッジが翻訳されて表示される", () => { - render(); - expect(screen.getByText("types.electric")).toBeTruthy(); - }); - - it("複数タイプの場合、全てのバッジが翻訳されて表示される", () => { - render(); - expect(screen.getByText("types.fire")).toBeTruthy(); - expect(screen.getByText("types.flying")).toBeTruthy(); - }); - - it("onPressコールバックが呼ばれる", () => { - const onPress = jest.fn(); - render(); - fireEvent.press(screen.getByTestId("pokemon-card")); - expect(onPress).toHaveBeenCalledTimes(1); - }); - - it("onPressが未指定の場合でもエラーにならない", () => { - render(); - expect(() => { - fireEvent.press(screen.getByTestId("pokemon-card")); - }).not.toThrow(); - }); - - it("isFavoriteとonToggleFavoriteが渡された場合、お気に入りボタンが表示される", () => { - render( - , - ); - expect(screen.getByTestId("favorite-button")).toBeTruthy(); - }); - - it("isFavoriteがtrueの場合、Lottieアニメーションが表示される", () => { - render( - , - ); - expect(screen.getByTestId("favorite-lottie")).toBeTruthy(); - }); - - it("お気に入りボタン押下後アニメーション完了でonToggleFavoriteが呼ばれる", () => { - const onToggleFavorite = jest.fn(); - render( - , - ); - fireEvent.press(screen.getByTestId("favorite-button")); - expect(onToggleFavorite).toHaveBeenCalledTimes(1); - }); - - it("isFavoriteが未指定の場合、お気に入りボタンが表示されない", () => { - render(); - expect(screen.queryByTestId("favorite-button")).toBeNull(); - }); -}); diff --git a/__tests__/shared/domain/typeColors.test.ts b/__tests__/shared/domain/typeColors.test.ts deleted file mode 100644 index 1bb3283..0000000 --- a/__tests__/shared/domain/typeColors.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { typeColors } from "@/src/shared"; -import type { PokemonType } from "@/src/shared"; - -const allTypes: PokemonType[] = [ - "normal", - "fire", - "water", - "electric", - "grass", - "ice", - "fighting", - "poison", - "ground", - "flying", - "psychic", - "bug", - "rock", - "ghost", - "dragon", - "dark", - "steel", - "fairy", -]; - -describe("typeColors", () => { - it("全18タイプの色が定義されている", () => { - for (const type of allTypes) { - expect(typeColors[type]).toBeDefined(); - } - expect(Object.keys(typeColors)).toHaveLength(18); - }); - - it("各色が有効なHEXカラーコードである", () => { - const hexPattern = /^#[0-9A-Fa-f]{6}$/; - for (const color of Object.values(typeColors)) { - expect(color).toMatch(hexPattern); - } - }); -}); diff --git a/__tests__/shared/features/favoriteButton.feature b/__tests__/shared/features/favoriteButton.feature new file mode 100644 index 0000000..f46a299 --- /dev/null +++ b/__tests__/shared/features/favoriteButton.feature @@ -0,0 +1,40 @@ +Feature: お気に入りボタン + + Scenario: Lottieアニメーションコンポーネントが描画される + Given 非お気に入り状態のFavoriteButtonを描画する + Then Lottieアニメーションコンポーネントが存在する + + Scenario: autoPlayが無効になっている + Given 非お気に入り状態のFavoriteButtonを描画する + Then autoPlayがfalseである + + Scenario: ループが無効になっている + Given 非お気に入り状態のFavoriteButtonを描画する + Then loopがfalseである + + Scenario: ボタン押下時にonToggleが即座に呼ばれる + Given 非お気に入り状態のFavoriteButtonを描画する + When お気に入りボタンを押す + Then onToggleが1回呼ばれる + + Scenario: お気に入り状態の場合、ONの最終フレームで初期表示される + Given お気に入り状態のFavoriteButtonを描画する + Then progressがON最終フレームの値である + + Scenario: 非お気に入り状態の場合、progressが0で初期表示される + Given 非お気に入り状態のFavoriteButtonを描画する + Then progressが0である + + Scenario: 外部からisFavoriteが変更された場合、progressが再同期される + Given 非お気に入り状態のFavoriteButtonを描画する + Then progressが0である + When isFavoriteをtrueに変更する + Then progressがON最終フレームの値である + + Scenario: お気に入り状態のアクセシビリティラベルが正しい + Given お気に入り状態のFavoriteButtonを描画する + Then アクセシビリティラベルが "favoriteButton.remove" である + + Scenario: 非お気に入り状態のアクセシビリティラベルが正しい + Given 非お気に入り状態のFavoriteButtonを描画する + Then アクセシビリティラベルが "favoriteButton.add" である diff --git a/__tests__/shared/features/i18n.feature b/__tests__/shared/features/i18n.feature new file mode 100644 index 0000000..0b74b9f --- /dev/null +++ b/__tests__/shared/features/i18n.feature @@ -0,0 +1,36 @@ +Feature: 国際化(i18n) + + Scenario: デフォルト言語が日本語で初期化される + Given AsyncStorageが空である + When i18nを初期化する + Then 言語が "ja" である + + Scenario: AsyncStorageに保存された言語で初期化される + Given AsyncStorageに言語 "en" が保存されている + When i18nを初期化する + Then 言語が "en" である + + Scenario: 不正な言語が保存されていた場合はデフォルトの日本語で初期化される + Given AsyncStorageに言語 "fr" が保存されている + When i18nを初期化する + Then 言語が "ja" である + + Scenario: 日本語の翻訳キーが正しく解決される + Given i18nが初期化されている + Then 翻訳キー "tabs.pokedex" が "ポケモン図鑑" に解決される + And 翻訳キー "favorites.empty" が "お気に入りのポケモンはまだいません" に解決される + + Scenario: 英語に切り替えると英語の翻訳が返される + Given i18nが初期化されている + When 言語を "en" に切り替える + Then 翻訳キー "tabs.pokedex" が "Pokédex" に解決される + And 翻訳キー "favorites.empty" が "No favorite Pokémon yet" に解決される + + Scenario: 日本語に戻すと日本語の翻訳が返される + Given i18nが初期化されている + When 言語を "en" に切り替える + And 言語を "ja" に切り替える + Then 翻訳キー "tabs.pokedex" が "ポケモン図鑑" に解決される + + Scenario: SUPPORTED_LANGUAGESに日本語と英語が含まれる + Then SUPPORTED_LANGUAGESが "ja" と "en" を含む diff --git a/__tests__/shared/features/pokemonApi.feature b/__tests__/shared/features/pokemonApi.feature new file mode 100644 index 0000000..48ffd17 --- /dev/null +++ b/__tests__/shared/features/pokemonApi.feature @@ -0,0 +1,26 @@ +Feature: ポケモンAPI + + Scenario: 正しいURLでfetchを呼び出す + Given fetchがモックされている + When ポケモンID 25 で取得する + Then fetchが "https://pokeapi.co/api/v2/pokemon/25" で呼ばれる + + Scenario: レスポンスをPokemon型に変換する + Given 単一タイプのAPIレスポンスが返される + When ポケモンID 25 で取得する + Then IDが 25 で名前が "Pikachu" でタイプが "electric" のPokemonが返される + + Scenario: 複数タイプを正しく変換する + Given 複数タイプのAPIレスポンスが返される + When ポケモンID 1 で取得する + Then IDが 1 で名前が "Bulbasaur" でタイプが "grass,poison" のPokemonが返される + + Scenario: 空文字の名前が正しく処理される + Given 空の名前のAPIレスポンスが返される + When ポケモンID 1 で取得する + Then 名前が空文字である + + Scenario: HTTPエラー時にエラーをスローする + Given HTTPエラー 404 が返される + When ポケモンID 99999 で取得する + Then "Failed to fetch pokemon: 404" エラーがスローされる diff --git a/__tests__/shared/features/pokemonCard.feature b/__tests__/shared/features/pokemonCard.feature new file mode 100644 index 0000000..b1710a7 --- /dev/null +++ b/__tests__/shared/features/pokemonCard.feature @@ -0,0 +1,54 @@ +Feature: ポケモンカード + + Scenario: ポケモンの名前が表示される + Given ピカチュウのデータが用意されている + When PokemonCardを描画する + Then "ピカチュウ" が表示される + + Scenario: ポケモンの画像が正しいURLで表示される + Given ピカチュウのデータが用意されている + When PokemonCardを描画する + Then 画像URLが "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/25.png" である + + Scenario: タイプバッジが翻訳されて表示される + Given ピカチュウのデータが用意されている + When PokemonCardを描画する + Then "types.electric" が表示される + + Scenario: 複数タイプの場合、全てのバッジが翻訳されて表示される + Given リザードンのデータが用意されている + When PokemonCardを描画する + Then "types.fire" が表示される + And "types.flying" が表示される + + Scenario: onPressコールバックが呼ばれる + Given ピカチュウのデータが用意されている + When onPress付きでPokemonCardを描画する + And カードを押す + Then onPressが1回呼ばれる + + Scenario: onPressが未指定の場合でもエラーにならない + Given ピカチュウのデータが用意されている + When PokemonCardを描画する + Then カードを押してもエラーにならない + + Scenario: isFavoriteとonToggleFavoriteが渡された場合、お気に入りボタンが表示される + Given ピカチュウのデータが用意されている + When お気に入り機能付きでPokemonCardを描画する + Then お気に入りボタンが表示される + + Scenario: isFavoriteがtrueの場合、Lottieアニメーションが表示される + Given ピカチュウのデータが用意されている + When お気に入り状態でPokemonCardを描画する + Then Lottieアニメーションが表示される + + Scenario: お気に入りボタン押下後アニメーション完了でonToggleFavoriteが呼ばれる + Given ピカチュウのデータが用意されている + When お気に入り機能付きでPokemonCardを描画する + And お気に入りボタンを押す + Then onToggleFavoriteが1回呼ばれる + + Scenario: isFavoriteが未指定の場合、お気に入りボタンが表示されない + Given ピカチュウのデータが用意されている + When PokemonCardを描画する + Then お気に入りボタンが表示されない diff --git a/__tests__/shared/features/typeColors.feature b/__tests__/shared/features/typeColors.feature new file mode 100644 index 0000000..5e7b684 --- /dev/null +++ b/__tests__/shared/features/typeColors.feature @@ -0,0 +1,12 @@ +Feature: タイプカラー定義 + + Scenario: 全18タイプの色が定義されている + Given typeColorsが定義されている + When 全18タイプのキーを確認する + Then 全てのタイプに色が定義されている + And キーの数が18である + + Scenario: 各色が有効なHEXカラーコードである + Given typeColorsが定義されている + When 全ての色の値を確認する + Then 全てHEXカラーコード形式である diff --git a/__tests__/shared/features/useFavoritesStore.feature b/__tests__/shared/features/useFavoritesStore.feature new file mode 100644 index 0000000..364fb50 --- /dev/null +++ b/__tests__/shared/features/useFavoritesStore.feature @@ -0,0 +1,54 @@ +Feature: お気に入りストア + + Scenario: 初期状態ではお気に入りが空である + Given お気に入りストアが初期状態である + When useFavoritesフックを実行する + Then お気に入りリストが空である + + Scenario: toggleFavoriteでポケモンをお気に入りに追加できる + Given お気に入りストアが初期状態である + When useFavoritesフックを実行する + And ポケモンID 25 をトグルする + Then お気に入りリストに 25 が含まれる + + Scenario: toggleFavoriteで既にお気に入りのポケモンを削除できる + Given お気に入りストアが初期状態である + When useFavoritesフックを実行する + And ポケモンID 25 をトグルする + And ポケモンID 25 をトグルする + Then お気に入りリストが空である + + Scenario: isFavoriteがお気に入り登録済みのポケモンに対してtrueを返す + Given お気に入りストアが初期状態である + When useFavoritesフックを実行する + And ポケモンID 25 をトグルする + Then ポケモンID 25 がお気に入りである + + Scenario: isFavoriteが未登録のポケモンに対してfalseを返す + Given お気に入りストアが初期状態である + When useFavoritesフックを実行する + Then ポケモンID 25 がお気に入りでない + + Scenario: お気に入りが6匹に達している場合、追加できずアラートが表示される + Given お気に入りストアが初期状態である + When useFavoritesフックを実行する + And 6匹のポケモンをお気に入りに追加する + And ポケモンID 7 をトグルする + Then お気に入りの数が 6 である + And お気に入りリストに 7 が含まれない + And アラートが表示される + + Scenario: 上限に達していても既存のお気に入りは削除できる + Given お気に入りストアが初期状態である + When useFavoritesフックを実行する + And 6匹のポケモンをお気に入りに追加する + And ポケモンID 3 をトグルする + Then お気に入りの数が 5 である + And お気に入りリストに 3 が含まれない + + Scenario: isFullが上限到達時にtrueを返す + Given お気に入りストアが初期状態である + When useFavoritesフックを実行する + Then isFullがfalseである + When 6匹のポケモンをお気に入りに追加する + Then isFullがtrueである diff --git a/__tests__/shared/features/useLanguage.feature b/__tests__/shared/features/useLanguage.feature new file mode 100644 index 0000000..9f23739 --- /dev/null +++ b/__tests__/shared/features/useLanguage.feature @@ -0,0 +1,18 @@ +Feature: useLanguageフック + + Scenario: 現在の言語を返す + Given i18nが初期化されている + When useLanguageフックを実行する + Then 言語が "ja" である + + Scenario: 言語を変更するとi18nextの言語が更新される + Given i18nが初期化されている + When useLanguageフックを実行する + And 言語を "en" に変更する + Then 言語が "en" である + + Scenario: 言語変更がAsyncStorageに保存される + Given i18nが初期化されている + When useLanguageフックを実行する + And 言語を "en" に変更する + Then AsyncStorageに "en" が保存されている diff --git a/__tests__/shared/i18n/i18n.test.ts b/__tests__/shared/i18n/i18n.test.ts deleted file mode 100644 index cd9b51d..0000000 --- a/__tests__/shared/i18n/i18n.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -jest.unmock("react-i18next"); - -import AsyncStorage from "@react-native-async-storage/async-storage"; -import i18n, { initI18n, SUPPORTED_LANGUAGES, STORAGE_KEY } from "@/src/shared/i18n/i18n"; - -describe("i18n", () => { - beforeEach(async () => { - await AsyncStorage.clear(); - if (i18n.isInitialized) { - await i18n.changeLanguage("ja"); - } - }); - - describe("initI18n", () => { - it("デフォルト言語が日本語で初期化される", async () => { - await initI18n(); - expect(i18n.language).toBe("ja"); - }); - - it("AsyncStorageに保存された言語で初期化される", async () => { - await AsyncStorage.setItem(STORAGE_KEY, "en"); - await initI18n(); - expect(i18n.language).toBe("en"); - }); - - it("不正な言語が保存されていた場合はデフォルトの日本語で初期化される", async () => { - await AsyncStorage.setItem(STORAGE_KEY, "fr"); - await initI18n(); - expect(i18n.language).toBe("ja"); - }); - }); - - describe("翻訳", () => { - beforeEach(async () => { - await initI18n(); - }); - - it("日本語の翻訳キーが正しく解決される", () => { - expect(i18n.t("tabs.pokedex")).toBe("ポケモン図鑑"); - expect(i18n.t("favorites.empty")).toBe("お気に入りのポケモンはまだいません"); - }); - - it("英語に切り替えると英語の翻訳が返される", async () => { - await i18n.changeLanguage("en"); - expect(i18n.t("tabs.pokedex")).toBe("Pokédex"); - expect(i18n.t("favorites.empty")).toBe("No favorite Pokémon yet"); - }); - - it("日本語に戻すと日本語の翻訳が返される", async () => { - await i18n.changeLanguage("en"); - await i18n.changeLanguage("ja"); - expect(i18n.t("tabs.pokedex")).toBe("ポケモン図鑑"); - }); - }); - - describe("SUPPORTED_LANGUAGES", () => { - it("日本語と英語が含まれる", () => { - expect(SUPPORTED_LANGUAGES).toEqual(["ja", "en"]); - }); - }); -}); diff --git a/__tests__/shared/i18n/useLanguage.test.ts b/__tests__/shared/i18n/useLanguage.test.ts deleted file mode 100644 index 7ae3c34..0000000 --- a/__tests__/shared/i18n/useLanguage.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -jest.unmock("react-i18next"); - -import { renderHook, act } from "@testing-library/react-native"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useLanguage } from "@/src/shared/i18n/useLanguage"; -import { initI18n, STORAGE_KEY } from "@/src/shared/i18n/i18n"; - -describe("useLanguage", () => { - beforeEach(async () => { - await AsyncStorage.clear(); - await initI18n(); - }); - - it("現在の言語を返す", () => { - const { result } = renderHook(() => useLanguage()); - expect(result.current.language).toBe("ja"); - }); - - it("言語を変更するとi18nextの言語が更新される", async () => { - const { result } = renderHook(() => useLanguage()); - - await act(async () => { - await result.current.changeLanguage("en"); - }); - - expect(result.current.language).toBe("en"); - }); - - it("言語変更がAsyncStorageに保存される", async () => { - const { result } = renderHook(() => useLanguage()); - - await act(async () => { - await result.current.changeLanguage("en"); - }); - - const saved = await AsyncStorage.getItem(STORAGE_KEY); - expect(saved).toBe("en"); - }); -}); diff --git a/__tests__/shared/repository/pokemonApi.test.ts b/__tests__/shared/repository/pokemonApi.test.ts deleted file mode 100644 index 5a2ac8b..0000000 --- a/__tests__/shared/repository/pokemonApi.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { fetchPokemonById } from "@/src/shared/repository/pokemonApi"; - -const mockApiResponse = { - id: 25, - name: "pikachu", - types: [ - { slot: 1, type: { name: "electric", url: "https://pokeapi.co/api/v2/type/13/" } }, - ], -}; - -const mockMultiTypeResponse = { - id: 1, - name: "bulbasaur", - types: [ - { slot: 1, type: { name: "grass", url: "https://pokeapi.co/api/v2/type/12/" } }, - { slot: 2, type: { name: "poison", url: "https://pokeapi.co/api/v2/type/4/" } }, - ], -}; - -const originalFetch = globalThis.fetch; - -beforeEach(() => { - globalThis.fetch = jest.fn(); -}); - -afterEach(() => { - globalThis.fetch = originalFetch; -}); - -describe("fetchPokemonById", () => { - it("正しいURLでfetchを呼び出す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockApiResponse), - }); - - await fetchPokemonById(25); - - expect(globalThis.fetch).toHaveBeenCalledWith( - "https://pokeapi.co/api/v2/pokemon/25" - ); - }); - - it("レスポンスをPokemon型に変換する", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockApiResponse), - }); - - const result = await fetchPokemonById(25); - - expect(result).toEqual({ - id: 25, - name: "Pikachu", - types: ["electric"], - }); - }); - - it("複数タイプを正しく変換する", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockMultiTypeResponse), - }); - - const result = await fetchPokemonById(1); - - expect(result).toEqual({ - id: 1, - name: "Bulbasaur", - types: ["grass", "poison"], - }); - }); - - it("空文字の名前が正しく処理される", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ id: 1, name: "", types: [] }), - }); - - const result = await fetchPokemonById(1); - - expect(result.name).toBe(""); - }); - - it("HTTPエラー時にエラーをスローする", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 404, - }); - - await expect(fetchPokemonById(99999)).rejects.toThrow( - "Failed to fetch pokemon: 404" - ); - }); -}); diff --git a/__tests__/shared/steps/favoriteButton.steps.tsx b/__tests__/shared/steps/favoriteButton.steps.tsx new file mode 100644 index 0000000..3e2531d --- /dev/null +++ b/__tests__/shared/steps/favoriteButton.steps.tsx @@ -0,0 +1,157 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { render, screen, fireEvent } from "@testing-library/react-native"; +import { FavoriteButton } from "@/src/shared"; + +const feature = loadFeature( + "__tests__/shared/features/favoriteButton.feature" +); + +defineFeature(feature, (test) => { + let onToggle: jest.Mock; + let rerender: ReturnType["rerender"]; + + test("Lottieアニメーションコンポーネントが描画される", ({ + given, + then, + }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + onToggle = jest.fn(); + render(); + }); + + then("Lottieアニメーションコンポーネントが存在する", () => { + expect(screen.getByTestId("favorite-lottie")).toBeTruthy(); + }); + }); + + test("autoPlayが無効になっている", ({ given, then }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + onToggle = jest.fn(); + render(); + }); + + then("autoPlayがfalseである", () => { + const lottie = screen.getByTestId("favorite-lottie"); + expect(lottie.props.autoPlay).toBe(false); + }); + }); + + test("ループが無効になっている", ({ given, then }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + onToggle = jest.fn(); + render(); + }); + + then("loopがfalseである", () => { + const lottie = screen.getByTestId("favorite-lottie"); + expect(lottie.props.loop).toBe(false); + }); + }); + + test("ボタン押下時にonToggleが即座に呼ばれる", ({ given, when, then }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + onToggle = jest.fn(); + render(); + }); + + when("お気に入りボタンを押す", () => { + fireEvent.press(screen.getByTestId("favorite-button")); + }); + + then("onToggleが1回呼ばれる", () => { + expect(onToggle).toHaveBeenCalledTimes(1); + }); + }); + + test("お気に入り状態の場合、ONの最終フレームで初期表示される", ({ + given, + then, + }) => { + given("お気に入り状態のFavoriteButtonを描画する", () => { + onToggle = jest.fn(); + render(); + }); + + then("progressがON最終フレームの値である", () => { + const lottie = screen.getByTestId("favorite-lottie"); + expect(lottie.props.progress).toBeCloseTo(90 / 181); + }); + }); + + test("非お気に入り状態の場合、progressが0で初期表示される", ({ + given, + then, + }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + onToggle = jest.fn(); + render(); + }); + + then("progressが0である", () => { + const lottie = screen.getByTestId("favorite-lottie"); + expect(lottie.props.progress).toBe(0); + }); + }); + + test("外部からisFavoriteが変更された場合、progressが再同期される", ({ + given, + then, + when, + }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + onToggle = jest.fn(); + const result = render( + + ); + rerender = result.rerender; + }); + + then("progressが0である", () => { + expect(screen.getByTestId("favorite-lottie").props.progress).toBe(0); + }); + + when("isFavoriteをtrueに変更する", () => { + rerender(); + }); + + then("progressがON最終フレームの値である", () => { + expect( + screen.getByTestId("favorite-lottie").props.progress + ).toBeCloseTo(90 / 181); + }); + }); + + test("お気に入り状態のアクセシビリティラベルが正しい", ({ + given, + then, + }) => { + given("お気に入り状態のFavoriteButtonを描画する", () => { + onToggle = jest.fn(); + render(); + }); + + then( + /^アクセシビリティラベルが "(.*)" である$/, + (label: string) => { + expect(screen.getByLabelText(label)).toBeTruthy(); + } + ); + }); + + test("非お気に入り状態のアクセシビリティラベルが正しい", ({ + given, + then, + }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + onToggle = jest.fn(); + render(); + }); + + then( + /^アクセシビリティラベルが "(.*)" である$/, + (label: string) => { + expect(screen.getByLabelText(label)).toBeTruthy(); + } + ); + }); +}); diff --git a/__tests__/shared/steps/i18n.steps.ts b/__tests__/shared/steps/i18n.steps.ts new file mode 100644 index 0000000..5f9a1d5 --- /dev/null +++ b/__tests__/shared/steps/i18n.steps.ts @@ -0,0 +1,156 @@ +jest.unmock("react-i18next"); + +import { defineFeature, loadFeature } from "jest-cucumber"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import i18n, { initI18n, SUPPORTED_LANGUAGES, STORAGE_KEY } from "@/src/shared/i18n/i18n"; + +const feature = loadFeature("__tests__/shared/features/i18n.feature"); + +defineFeature(feature, (test) => { + beforeEach(async () => { + await AsyncStorage.clear(); + if (i18n.isInitialized) { + await i18n.changeLanguage("ja"); + } + }); + + test("デフォルト言語が日本語で初期化される", ({ given, when, then }) => { + given("AsyncStorageが空である", () => { + // already cleared in beforeEach + }); + + when("i18nを初期化する", async () => { + await initI18n(); + }); + + then(/^言語が "(.*)" である$/, (lang: string) => { + expect(i18n.language).toBe(lang); + }); + }); + + test("AsyncStorageに保存された言語で初期化される", ({ + given, + when, + then, + }) => { + given( + /^AsyncStorageに言語 "(.*)" が保存されている$/, + async (lang: string) => { + await AsyncStorage.setItem(STORAGE_KEY, lang); + } + ); + + when("i18nを初期化する", async () => { + await initI18n(); + }); + + then(/^言語が "(.*)" である$/, (lang: string) => { + expect(i18n.language).toBe(lang); + }); + }); + + test("不正な言語が保存されていた場合はデフォルトの日本語で初期化される", ({ + given, + when, + then, + }) => { + given( + /^AsyncStorageに言語 "(.*)" が保存されている$/, + async (lang: string) => { + await AsyncStorage.setItem(STORAGE_KEY, lang); + } + ); + + when("i18nを初期化する", async () => { + await initI18n(); + }); + + then(/^言語が "(.*)" である$/, (lang: string) => { + expect(i18n.language).toBe(lang); + }); + }); + + test("日本語の翻訳キーが正しく解決される", ({ given, then, and }) => { + given("i18nが初期化されている", async () => { + await initI18n(); + }); + + then( + /^翻訳キー "(.*)" が "(.*)" に解決される$/, + (key: string, value: string) => { + expect(i18n.t(key)).toBe(value); + } + ); + + and( + /^翻訳キー "(.*)" が "(.*)" に解決される$/, + (key: string, value: string) => { + expect(i18n.t(key)).toBe(value); + } + ); + }); + + test("英語に切り替えると英語の翻訳が返される", ({ + given, + when, + then, + and, + }) => { + given("i18nが初期化されている", async () => { + await initI18n(); + }); + + when(/^言語を "(.*)" に切り替える$/, async (lang: string) => { + await i18n.changeLanguage(lang); + }); + + then( + /^翻訳キー "(.*)" が "(.*)" に解決される$/, + (key: string, value: string) => { + expect(i18n.t(key)).toBe(value); + } + ); + + and( + /^翻訳キー "(.*)" が "(.*)" に解決される$/, + (key: string, value: string) => { + expect(i18n.t(key)).toBe(value); + } + ); + }); + + test("日本語に戻すと日本語の翻訳が返される", ({ + given, + when, + then, + and, + }) => { + given("i18nが初期化されている", async () => { + await initI18n(); + }); + + when(/^言語を "(.*)" に切り替える$/, async (lang: string) => { + await i18n.changeLanguage(lang); + }); + + and(/^言語を "(.*)" に切り替える$/, async (lang: string) => { + await i18n.changeLanguage(lang); + }); + + then( + /^翻訳キー "(.*)" が "(.*)" に解決される$/, + (key: string, value: string) => { + expect(i18n.t(key)).toBe(value); + } + ); + }); + + test("SUPPORTED_LANGUAGESに日本語と英語が含まれる", ({ then }) => { + then( + /^SUPPORTED_LANGUAGESが "(.*)" と "(.*)" を含む$/, + (lang1: string, lang2: string) => { + expect(SUPPORTED_LANGUAGES).toEqual([lang1, lang2]); + } + ); + }); +}); diff --git a/__tests__/shared/steps/pokemonApi.steps.ts b/__tests__/shared/steps/pokemonApi.steps.ts new file mode 100644 index 0000000..564568d --- /dev/null +++ b/__tests__/shared/steps/pokemonApi.steps.ts @@ -0,0 +1,149 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { fetchPokemonById } from "@/src/shared/repository/pokemonApi"; + +const feature = loadFeature("__tests__/shared/features/pokemonApi.feature"); + +const mockApiResponse = { + id: 25, + name: "pikachu", + types: [ + { + slot: 1, + type: { name: "electric", url: "https://pokeapi.co/api/v2/type/13/" }, + }, + ], +}; + +const mockMultiTypeResponse = { + id: 1, + name: "bulbasaur", + types: [ + { + slot: 1, + type: { name: "grass", url: "https://pokeapi.co/api/v2/type/12/" }, + }, + { + slot: 2, + type: { name: "poison", url: "https://pokeapi.co/api/v2/type/4/" }, + }, + ], +}; + +const originalFetch = globalThis.fetch; + +defineFeature(feature, (test) => { + let result: Awaited>; + let error: Error; + + beforeEach(() => { + globalThis.fetch = jest.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("正しいURLでfetchを呼び出す", ({ given, when, then }) => { + given("fetchがモックされている", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockApiResponse), + }); + }); + + when(/^ポケモンID (\d+) で取得する$/, async (id: string) => { + await fetchPokemonById(Number(id)); + }); + + then(/^fetchが "(.*)" で呼ばれる$/, (url: string) => { + expect(globalThis.fetch).toHaveBeenCalledWith(url); + }); + }); + + test("レスポンスをPokemon型に変換する", ({ given, when, then }) => { + given("単一タイプのAPIレスポンスが返される", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockApiResponse), + }); + }); + + when(/^ポケモンID (\d+) で取得する$/, async (id: string) => { + result = await fetchPokemonById(Number(id)); + }); + + then( + /^IDが (\d+) で名前が "(.*)" でタイプが "(.*)" のPokemonが返される$/, + (id: string, name: string, types: string) => { + expect(result).toEqual({ + id: Number(id), + name, + types: types.split(","), + }); + } + ); + }); + + test("複数タイプを正しく変換する", ({ given, when, then }) => { + given("複数タイプのAPIレスポンスが返される", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockMultiTypeResponse), + }); + }); + + when(/^ポケモンID (\d+) で取得する$/, async (id: string) => { + result = await fetchPokemonById(Number(id)); + }); + + then( + /^IDが (\d+) で名前が "(.*)" でタイプが "(.*)" のPokemonが返される$/, + (id: string, name: string, types: string) => { + expect(result).toEqual({ + id: Number(id), + name, + types: types.split(","), + }); + } + ); + }); + + test("空文字の名前が正しく処理される", ({ given, when, then }) => { + given("空の名前のAPIレスポンスが返される", () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: 1, name: "", types: [] }), + }); + }); + + when(/^ポケモンID (\d+) で取得する$/, async (id: string) => { + result = await fetchPokemonById(Number(id)); + }); + + then("名前が空文字である", () => { + expect(result.name).toBe(""); + }); + }); + + test("HTTPエラー時にエラーをスローする", ({ given, when, then }) => { + given(/^HTTPエラー (\d+) が返される$/, (status: string) => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: Number(status), + }); + }); + + when(/^ポケモンID (\d+) で取得する$/, async (id: string) => { + try { + await fetchPokemonById(Number(id)); + } catch (e) { + error = e as Error; + } + }); + + then(/^"(.*)" エラーがスローされる$/, (message: string) => { + expect(error).toBeDefined(); + expect(error.message).toBe(message); + }); + }); +}); diff --git a/__tests__/shared/steps/pokemonCard.steps.tsx b/__tests__/shared/steps/pokemonCard.steps.tsx new file mode 100644 index 0000000..f136d91 --- /dev/null +++ b/__tests__/shared/steps/pokemonCard.steps.tsx @@ -0,0 +1,229 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { render, screen, fireEvent } from "@testing-library/react-native"; +import { PokemonCard } from "@/src/shared"; +import type { PokemonSummary } from "@/src/shared"; + +const feature = loadFeature( + "__tests__/shared/features/pokemonCard.feature" +); + +const mockPokemon: PokemonSummary = { + id: 25, + name: "ピカチュウ", + types: ["electric"], +}; + +const mockMultiTypePokemon: PokemonSummary = { + id: 6, + name: "リザードン", + types: ["fire", "flying"], +}; + +defineFeature(feature, (test) => { + let pokemon: PokemonSummary; + let onPress: jest.Mock; + let onToggleFavorite: jest.Mock; + + test("ポケモンの名前が表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + pokemon = mockPokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then(/^"(.*)" が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ポケモンの画像が正しいURLで表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + pokemon = mockPokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then(/^画像URLが "(.*)" である$/, (url: string) => { + const image = screen.getByTestId("pokemon-image"); + expect(image.props.source.uri).toBe(url); + }); + }); + + test("タイプバッジが翻訳されて表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + pokemon = mockPokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then(/^"(.*)" が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("複数タイプの場合、全てのバッジが翻訳されて表示される", ({ + given, + when, + then, + and, + }) => { + given("リザードンのデータが用意されている", () => { + pokemon = mockMultiTypePokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then(/^"(.*)" が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^"(.*)" が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("onPressコールバックが呼ばれる", ({ given, when, then, and }) => { + given("ピカチュウのデータが用意されている", () => { + pokemon = mockPokemon; + }); + + when("onPress付きでPokemonCardを描画する", () => { + onPress = jest.fn(); + render(); + }); + + and("カードを押す", () => { + fireEvent.press(screen.getByTestId("pokemon-card")); + }); + + then("onPressが1回呼ばれる", () => { + expect(onPress).toHaveBeenCalledTimes(1); + }); + }); + + test("onPressが未指定の場合でもエラーにならない", ({ + given, + when, + then, + }) => { + given("ピカチュウのデータが用意されている", () => { + pokemon = mockPokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then("カードを押してもエラーにならない", () => { + expect(() => { + fireEvent.press(screen.getByTestId("pokemon-card")); + }).not.toThrow(); + }); + }); + + test("isFavoriteとonToggleFavoriteが渡された場合、お気に入りボタンが表示される", ({ + given, + when, + then, + }) => { + given("ピカチュウのデータが用意されている", () => { + pokemon = mockPokemon; + }); + + when("お気に入り機能付きでPokemonCardを描画する", () => { + onToggleFavorite = jest.fn(); + render( + + ); + }); + + then("お気に入りボタンが表示される", () => { + expect(screen.getByTestId("favorite-button")).toBeTruthy(); + }); + }); + + test("isFavoriteがtrueの場合、Lottieアニメーションが表示される", ({ + given, + when, + then, + }) => { + given("ピカチュウのデータが用意されている", () => { + pokemon = mockPokemon; + }); + + when("お気に入り状態でPokemonCardを描画する", () => { + onToggleFavorite = jest.fn(); + render( + + ); + }); + + then("Lottieアニメーションが表示される", () => { + expect(screen.getByTestId("favorite-lottie")).toBeTruthy(); + }); + }); + + test("お気に入りボタン押下後アニメーション完了でonToggleFavoriteが呼ばれる", ({ + given, + when, + then, + and, + }) => { + given("ピカチュウのデータが用意されている", () => { + pokemon = mockPokemon; + }); + + when("お気に入り機能付きでPokemonCardを描画する", () => { + onToggleFavorite = jest.fn(); + render( + + ); + }); + + and("お気に入りボタンを押す", () => { + fireEvent.press(screen.getByTestId("favorite-button")); + }); + + then("onToggleFavoriteが1回呼ばれる", () => { + expect(onToggleFavorite).toHaveBeenCalledTimes(1); + }); + }); + + test("isFavoriteが未指定の場合、お気に入りボタンが表示されない", ({ + given, + when, + then, + }) => { + given("ピカチュウのデータが用意されている", () => { + pokemon = mockPokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then("お気に入りボタンが表示されない", () => { + expect(screen.queryByTestId("favorite-button")).toBeNull(); + }); + }); +}); diff --git a/__tests__/shared/steps/typeColors.steps.ts b/__tests__/shared/steps/typeColors.steps.ts new file mode 100644 index 0000000..ebef295 --- /dev/null +++ b/__tests__/shared/steps/typeColors.steps.ts @@ -0,0 +1,67 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { typeColors } from "@/src/shared"; +import type { PokemonType } from "@/src/shared"; + +const feature = loadFeature( + "__tests__/shared/features/typeColors.feature" +); + +const allTypes: PokemonType[] = [ + "normal", + "fire", + "water", + "electric", + "grass", + "ice", + "fighting", + "poison", + "ground", + "flying", + "psychic", + "bug", + "rock", + "ghost", + "dragon", + "dark", + "steel", + "fairy", +]; + +defineFeature(feature, (test) => { + test("全18タイプの色が定義されている", ({ given, when, then, and }) => { + given("typeColorsが定義されている", () => { + expect(typeColors).toBeDefined(); + }); + + when("全18タイプのキーを確認する", () => { + // verification happens in then + }); + + then("全てのタイプに色が定義されている", () => { + for (const type of allTypes) { + expect(typeColors[type]).toBeDefined(); + } + }); + + and("キーの数が18である", () => { + expect(Object.keys(typeColors)).toHaveLength(18); + }); + }); + + test("各色が有効なHEXカラーコードである", ({ given, when, then }) => { + given("typeColorsが定義されている", () => { + expect(typeColors).toBeDefined(); + }); + + when("全ての色の値を確認する", () => { + // verification happens in then + }); + + then("全てHEXカラーコード形式である", () => { + const hexPattern = /^#[0-9A-Fa-f]{6}$/; + for (const color of Object.values(typeColors)) { + expect(color).toMatch(hexPattern); + } + }); + }); +}); diff --git a/__tests__/shared/steps/useFavoritesStore.steps.tsx b/__tests__/shared/steps/useFavoritesStore.steps.tsx new file mode 100644 index 0000000..a0d5d97 --- /dev/null +++ b/__tests__/shared/steps/useFavoritesStore.steps.tsx @@ -0,0 +1,265 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { renderHook, act } from "@testing-library/react-native"; +import { Alert } from "react-native"; +import { useFavorites } from "@/src/shared"; +import { useFavoritesStore } from "@/src/shared/stores/useFavoritesStore"; + +jest.mock("@/src/shared/i18n", () => ({ + i18n: { + t: (key: string) => key, + }, +})); + +jest.spyOn(Alert, "alert").mockImplementation(() => {}); + +const feature = loadFeature( + "__tests__/shared/features/useFavoritesStore.feature" +); + +defineFeature(feature, (test) => { + let result: ReturnType< + typeof renderHook, unknown> + >["result"]; + + beforeEach(() => { + useFavoritesStore.setState({ favoriteIds: [] }); + (Alert.alert as jest.Mock).mockClear(); + }); + + test("初期状態ではお気に入りが空である", ({ given, when, then }) => { + given("お気に入りストアが初期状態である", () => { + // reset in beforeEach + }); + + when("useFavoritesフックを実行する", () => { + const hook = renderHook(() => useFavorites()); + result = hook.result; + }); + + then("お気に入りリストが空である", () => { + expect(result.current.favoriteIds).toEqual([]); + }); + }); + + test("toggleFavoriteでポケモンをお気に入りに追加できる", ({ + given, + when, + then, + and, + }) => { + given("お気に入りストアが初期状態である", () => { + // reset in beforeEach + }); + + when("useFavoritesフックを実行する", () => { + const hook = renderHook(() => useFavorites()); + result = hook.result; + }); + + and(/^ポケモンID (\d+) をトグルする$/, (id: string) => { + act(() => { + result.current.toggleFavorite(Number(id)); + }); + }); + + then(/^お気に入りリストに (\d+) が含まれる$/, (id: string) => { + expect(result.current.favoriteIds).toEqual([Number(id)]); + }); + }); + + test("toggleFavoriteで既にお気に入りのポケモンを削除できる", ({ + given, + when, + then, + and, + }) => { + given("お気に入りストアが初期状態である", () => { + // reset in beforeEach + }); + + when("useFavoritesフックを実行する", () => { + const hook = renderHook(() => useFavorites()); + result = hook.result; + }); + + and(/^ポケモンID (\d+) をトグルする$/, (id: string) => { + act(() => { + result.current.toggleFavorite(Number(id)); + }); + }); + + and(/^ポケモンID (\d+) をトグルする$/, (id: string) => { + act(() => { + result.current.toggleFavorite(Number(id)); + }); + }); + + then("お気に入りリストが空である", () => { + expect(result.current.favoriteIds).toEqual([]); + }); + }); + + test("isFavoriteがお気に入り登録済みのポケモンに対してtrueを返す", ({ + given, + when, + then, + and, + }) => { + given("お気に入りストアが初期状態である", () => { + // reset in beforeEach + }); + + when("useFavoritesフックを実行する", () => { + const hook = renderHook(() => useFavorites()); + result = hook.result; + }); + + and(/^ポケモンID (\d+) をトグルする$/, (id: string) => { + act(() => { + result.current.toggleFavorite(Number(id)); + }); + }); + + then(/^ポケモンID (\d+) がお気に入りである$/, (id: string) => { + expect(result.current.isFavorite(Number(id))).toBe(true); + }); + }); + + test("isFavoriteが未登録のポケモンに対してfalseを返す", ({ + given, + when, + then, + }) => { + given("お気に入りストアが初期状態である", () => { + // reset in beforeEach + }); + + when("useFavoritesフックを実行する", () => { + const hook = renderHook(() => useFavorites()); + result = hook.result; + }); + + then(/^ポケモンID (\d+) がお気に入りでない$/, (id: string) => { + expect(result.current.isFavorite(Number(id))).toBe(false); + }); + }); + + test("お気に入りが6匹に達している場合、追加できずアラートが表示される", ({ + given, + when, + then, + and, + }) => { + given("お気に入りストアが初期状態である", () => { + // reset in beforeEach + }); + + when("useFavoritesフックを実行する", () => { + const hook = renderHook(() => useFavorites()); + result = hook.result; + }); + + and("6匹のポケモンをお気に入りに追加する", () => { + act(() => { + result.current.toggleFavorite(1); + result.current.toggleFavorite(2); + result.current.toggleFavorite(3); + result.current.toggleFavorite(4); + result.current.toggleFavorite(5); + result.current.toggleFavorite(6); + }); + }); + + and(/^ポケモンID (\d+) をトグルする$/, (id: string) => { + act(() => { + result.current.toggleFavorite(Number(id)); + }); + }); + + then(/^お気に入りの数が (\d+) である$/, (count: string) => { + expect(result.current.favoriteIds).toHaveLength(Number(count)); + }); + + and(/^お気に入りリストに (\d+) が含まれない$/, (id: string) => { + expect(result.current.favoriteIds).not.toContain(Number(id)); + }); + + and("アラートが表示される", () => { + expect(Alert.alert).toHaveBeenCalledWith( + "favorites.limitTitle", + "favorites.limitMessage" + ); + }); + }); + + test("上限に達していても既存のお気に入りは削除できる", ({ + given, + when, + then, + and, + }) => { + given("お気に入りストアが初期状態である", () => { + // reset in beforeEach + }); + + when("useFavoritesフックを実行する", () => { + const hook = renderHook(() => useFavorites()); + result = hook.result; + }); + + and("6匹のポケモンをお気に入りに追加する", () => { + act(() => { + result.current.toggleFavorite(1); + result.current.toggleFavorite(2); + result.current.toggleFavorite(3); + result.current.toggleFavorite(4); + result.current.toggleFavorite(5); + result.current.toggleFavorite(6); + }); + }); + + and(/^ポケモンID (\d+) をトグルする$/, (id: string) => { + act(() => { + result.current.toggleFavorite(Number(id)); + }); + }); + + then(/^お気に入りの数が (\d+) である$/, (count: string) => { + expect(result.current.favoriteIds).toHaveLength(Number(count)); + }); + + and(/^お気に入りリストに (\d+) が含まれない$/, (id: string) => { + expect(result.current.favoriteIds).not.toContain(Number(id)); + }); + }); + + test("isFullが上限到達時にtrueを返す", ({ given, when, then }) => { + given("お気に入りストアが初期状態である", () => { + // reset in beforeEach + }); + + when("useFavoritesフックを実行する", () => { + const hook = renderHook(() => useFavorites()); + result = hook.result; + }); + + then("isFullがfalseである", () => { + expect(result.current.isFull).toBe(false); + }); + + when("6匹のポケモンをお気に入りに追加する", () => { + act(() => { + result.current.toggleFavorite(1); + result.current.toggleFavorite(2); + result.current.toggleFavorite(3); + result.current.toggleFavorite(4); + result.current.toggleFavorite(5); + result.current.toggleFavorite(6); + }); + }); + + then("isFullがtrueである", () => { + expect(result.current.isFull).toBe(true); + }); + }); +}); diff --git a/__tests__/shared/steps/useLanguage.steps.ts b/__tests__/shared/steps/useLanguage.steps.ts new file mode 100644 index 0000000..ca91b6d --- /dev/null +++ b/__tests__/shared/steps/useLanguage.steps.ts @@ -0,0 +1,87 @@ +jest.unmock("react-i18next"); + +import { defineFeature, loadFeature } from "jest-cucumber"; +import { renderHook, act } from "@testing-library/react-native"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useLanguage } from "@/src/shared/i18n/useLanguage"; +import { initI18n, STORAGE_KEY } from "@/src/shared/i18n/i18n"; +import type { SupportedLanguage } from "@/src/shared"; + +const feature = loadFeature("__tests__/shared/features/useLanguage.feature"); + +defineFeature(feature, (test) => { + let result: ReturnType, unknown>>["result"]; + + beforeEach(async () => { + await AsyncStorage.clear(); + await initI18n(); + }); + + test("現在の言語を返す", ({ given, when, then }) => { + given("i18nが初期化されている", () => { + // done in beforeEach + }); + + when("useLanguageフックを実行する", () => { + const hook = renderHook(() => useLanguage()); + result = hook.result; + }); + + then(/^言語が "(.*)" である$/, (lang: string) => { + expect(result.current.language).toBe(lang); + }); + }); + + test("言語を変更するとi18nextの言語が更新される", ({ + given, + when, + then, + and, + }) => { + given("i18nが初期化されている", () => { + // done in beforeEach + }); + + when("useLanguageフックを実行する", () => { + const hook = renderHook(() => useLanguage()); + result = hook.result; + }); + + and(/^言語を "(.*)" に変更する$/, async (lang: string) => { + await act(async () => { + await result.current.changeLanguage(lang as SupportedLanguage); + }); + }); + + then(/^言語が "(.*)" である$/, (lang: string) => { + expect(result.current.language).toBe(lang); + }); + }); + + test("言語変更がAsyncStorageに保存される", ({ + given, + when, + then, + and, + }) => { + given("i18nが初期化されている", () => { + // done in beforeEach + }); + + when("useLanguageフックを実行する", () => { + const hook = renderHook(() => useLanguage()); + result = hook.result; + }); + + and(/^言語を "(.*)" に変更する$/, async (lang: string) => { + await act(async () => { + await result.current.changeLanguage(lang as SupportedLanguage); + }); + }); + + then(/^AsyncStorageに "(.*)" が保存されている$/, async (lang: string) => { + const saved = await AsyncStorage.getItem(STORAGE_KEY); + expect(saved).toBe(lang); + }); + }); +}); diff --git a/__tests__/shared/stores/useFavoritesStore.test.tsx b/__tests__/shared/stores/useFavoritesStore.test.tsx deleted file mode 100644 index 3208dc1..0000000 --- a/__tests__/shared/stores/useFavoritesStore.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { renderHook, act } from "@testing-library/react-native"; -import { Alert } from "react-native"; -import { useFavorites } from "@/src/shared"; -import { useFavoritesStore } from "@/src/shared/stores/useFavoritesStore"; - -jest.mock("@/src/shared/i18n", () => ({ - i18n: { - t: (key: string) => key, - }, -})); - -jest.spyOn(Alert, "alert").mockImplementation(() => {}); - -describe("useFavoritesStore", () => { - beforeEach(() => { - useFavoritesStore.setState({ favoriteIds: [] }); - (Alert.alert as jest.Mock).mockClear(); - }); - - describe("useFavorites", () => { - it("初期状態ではお気に入りが空である", () => { - const { result } = renderHook(() => useFavorites()); - expect(result.current.favoriteIds).toEqual([]); - }); - - it("toggleFavoriteでポケモンをお気に入りに追加できる", () => { - const { result } = renderHook(() => useFavorites()); - act(() => { - result.current.toggleFavorite(25); - }); - expect(result.current.favoriteIds).toEqual([25]); - }); - - it("toggleFavoriteで既にお気に入りのポケモンを削除できる", () => { - const { result } = renderHook(() => useFavorites()); - act(() => { - result.current.toggleFavorite(25); - }); - act(() => { - result.current.toggleFavorite(25); - }); - expect(result.current.favoriteIds).toEqual([]); - }); - - it("isFavoriteがお気に入り登録済みのポケモンに対してtrueを返す", () => { - const { result } = renderHook(() => useFavorites()); - act(() => { - result.current.toggleFavorite(25); - }); - expect(result.current.isFavorite(25)).toBe(true); - }); - - it("isFavoriteが未登録のポケモンに対してfalseを返す", () => { - const { result } = renderHook(() => useFavorites()); - expect(result.current.isFavorite(25)).toBe(false); - }); - - it("お気に入りが6匹に達している場合、追加できずアラートが表示される", () => { - const { result } = renderHook(() => useFavorites()); - act(() => { - result.current.toggleFavorite(1); - result.current.toggleFavorite(2); - result.current.toggleFavorite(3); - result.current.toggleFavorite(4); - result.current.toggleFavorite(5); - result.current.toggleFavorite(6); - }); - expect(result.current.favoriteIds).toHaveLength(6); - - act(() => { - result.current.toggleFavorite(7); - }); - expect(result.current.favoriteIds).toHaveLength(6); - expect(result.current.favoriteIds).not.toContain(7); - expect(Alert.alert).toHaveBeenCalledWith( - "favorites.limitTitle", - "favorites.limitMessage" - ); - }); - - it("上限に達していても既存のお気に入りは削除できる", () => { - const { result } = renderHook(() => useFavorites()); - act(() => { - result.current.toggleFavorite(1); - result.current.toggleFavorite(2); - result.current.toggleFavorite(3); - result.current.toggleFavorite(4); - result.current.toggleFavorite(5); - result.current.toggleFavorite(6); - }); - - act(() => { - result.current.toggleFavorite(3); - }); - expect(result.current.favoriteIds).toHaveLength(5); - expect(result.current.favoriteIds).not.toContain(3); - }); - - it("isFullが上限到達時にtrueを返す", () => { - const { result } = renderHook(() => useFavorites()); - expect(result.current.isFull).toBe(false); - - act(() => { - result.current.toggleFavorite(1); - result.current.toggleFavorite(2); - result.current.toggleFavorite(3); - result.current.toggleFavorite(4); - result.current.toggleFavorite(5); - result.current.toggleFavorite(6); - }); - expect(result.current.isFull).toBe(true); - }); - }); -}); diff --git a/__tests__/splash/components/AnimatedSplash.test.tsx b/__tests__/splash/components/AnimatedSplash.test.tsx deleted file mode 100644 index f268caf..0000000 --- a/__tests__/splash/components/AnimatedSplash.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import { Text } from "react-native"; -import { render, act } from "@testing-library/react-native"; -import { AnimatedSplash } from "@/src/splash/components/AnimatedSplash"; - -let mockOnFinish: (() => void) | undefined; -jest.mock("@/src/splash/hooks/useAnimatedSplash", () => ({ - useAnimatedSplash: ({ onFinish }: { onFinish: () => void }) => { - mockOnFinish = onFinish; - return { animatedStyle: { opacity: 1, transform: [{ scale: 1 }] } }; - }, -})); - -describe("AnimatedSplash", () => { - beforeEach(() => { - mockOnFinish = undefined; - }); - - it("スプラッシュ画像が表示される", () => { - const { getByTestId } = render( - - Main - , - ); - - expect(getByTestId("splash-image")).toBeTruthy(); - }); - - it("スプラッシュコンテナがフルスクリーンで表示される", () => { - const { getByTestId } = render( - - Main - , - ); - - expect(getByTestId("animated-splash-container")).toBeTruthy(); - }); - - it("子要素が表示される", () => { - const { getByText } = render( - - Main Content - , - ); - - expect(getByText("Main Content")).toBeTruthy(); - }); - - it("アニメーション完了後にスプラッシュオーバーレイが非表示になる", () => { - const { queryByTestId } = render( - - Main - , - ); - - expect(queryByTestId("animated-splash-container")).toBeTruthy(); - - act(() => { - mockOnFinish?.(); - }); - - expect(queryByTestId("animated-splash-container")).toBeNull(); - }); -}); diff --git a/__tests__/splash/features/animatedSplash.feature b/__tests__/splash/features/animatedSplash.feature new file mode 100644 index 0000000..c2e86ed --- /dev/null +++ b/__tests__/splash/features/animatedSplash.feature @@ -0,0 +1,21 @@ +Feature: アニメーション付きスプラッシュ画面 + + Scenario: スプラッシュ画像が表示される + Given スプラッシュコンポーネントに子要素がある + When スプラッシュ画面をレンダーする + Then スプラッシュ画像が表示される + + Scenario: スプラッシュコンテナがフルスクリーンで表示される + Given スプラッシュコンポーネントに子要素がある + When スプラッシュ画面をレンダーする + Then スプラッシュコンテナが表示される + + Scenario: 子要素が表示される + Given スプラッシュコンポーネントに "Main Content" という子要素がある + When スプラッシュ画面をレンダーする + Then "Main Content" が表示される + + Scenario: アニメーション完了後にスプラッシュオーバーレイが非表示になる + Given スプラッシュコンポーネントに子要素がある + When スプラッシュ画面をレンダーしてアニメーションが完了する + Then スプラッシュオーバーレイが非表示になる diff --git a/__tests__/splash/features/useAnimatedSplash.feature b/__tests__/splash/features/useAnimatedSplash.feature new file mode 100644 index 0000000..51a6314 --- /dev/null +++ b/__tests__/splash/features/useAnimatedSplash.feature @@ -0,0 +1,27 @@ +Feature: スプラッシュアニメーションフック + + Scenario: マウント時にSplashScreen.hideAsyncが呼ばれる + Given onFinishコールバックが用意されている + When フックをレンダーする + Then SplashScreen.hideAsyncが1回呼ばれる + + Scenario: delay前にはonFinishが呼ばれない + Given onFinishコールバックとdelay 800ms が用意されている + When フックをレンダーして500ms経過する + Then onFinishは呼ばれていない + + Scenario: delay後にonFinishコールバックが呼ばれる + Given onFinishコールバックとdelay 800ms が用意されている + When フックをレンダーして800ms経過する + Then onFinishが1回呼ばれる + + Scenario: アンマウント時にタイマーがクリーンアップされる + Given onFinishコールバックとdelay 800ms が用意されている + When フックをレンダーしてアンマウントし1000ms経過する + Then onFinishは呼ばれていない + + Scenario: animatedStyleオブジェクトを返す + Given onFinishコールバックが用意されている + When フックをレンダーする + Then animatedStyleにopacityプロパティがある + And animatedStyleにtransformプロパティがある diff --git a/__tests__/splash/hooks/useAnimatedSplash.test.ts b/__tests__/splash/hooks/useAnimatedSplash.test.ts deleted file mode 100644 index f2662d7..0000000 --- a/__tests__/splash/hooks/useAnimatedSplash.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { renderHook, act } from "@testing-library/react-native"; -import * as SplashScreen from "expo-splash-screen"; -import { useAnimatedSplash } from "@/src/splash/hooks/useAnimatedSplash"; - -describe("useAnimatedSplash", () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.clearAllMocks(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it("マウント時にSplashScreen.hideAsyncが呼ばれる", () => { - const onFinish = jest.fn(); - renderHook(() => useAnimatedSplash({ onFinish })); - - expect(SplashScreen.hideAsync).toHaveBeenCalledTimes(1); - }); - - it("delay前にはonFinishが呼ばれない", () => { - const onFinish = jest.fn(); - renderHook(() => useAnimatedSplash({ onFinish, delay: 800 })); - - act(() => { - jest.advanceTimersByTime(500); - }); - - expect(onFinish).not.toHaveBeenCalled(); - }); - - it("delay後にonFinishコールバックが呼ばれる", () => { - const onFinish = jest.fn(); - renderHook(() => useAnimatedSplash({ onFinish, delay: 800 })); - - act(() => { - jest.advanceTimersByTime(800); - }); - - expect(onFinish).toHaveBeenCalledTimes(1); - }); - - it("アンマウント時にタイマーがクリーンアップされる", () => { - const onFinish = jest.fn(); - const { unmount } = renderHook(() => - useAnimatedSplash({ onFinish, delay: 800 }), - ); - - unmount(); - - act(() => { - jest.advanceTimersByTime(1000); - }); - - expect(onFinish).not.toHaveBeenCalled(); - }); - - it("animatedStyleオブジェクトを返す", () => { - const onFinish = jest.fn(); - const { result } = renderHook(() => useAnimatedSplash({ onFinish })); - - expect(result.current.animatedStyle).toBeDefined(); - expect(result.current.animatedStyle).toHaveProperty("opacity"); - expect(result.current.animatedStyle).toHaveProperty("transform"); - }); -}); diff --git a/__tests__/splash/steps/animatedSplash.steps.tsx b/__tests__/splash/steps/animatedSplash.steps.tsx new file mode 100644 index 0000000..61424db --- /dev/null +++ b/__tests__/splash/steps/animatedSplash.steps.tsx @@ -0,0 +1,113 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import React from "react"; +import { Text } from "react-native"; +import { render, act } from "@testing-library/react-native"; +import { AnimatedSplash } from "@/src/splash"; + +let mockOnFinish: (() => void) | undefined; +jest.mock("@/src/splash/hooks/useAnimatedSplash", () => ({ + useAnimatedSplash: ({ onFinish }: { onFinish: () => void }) => { + mockOnFinish = onFinish; + return { animatedStyle: { opacity: 1, transform: [{ scale: 1 }] } }; + }, +})); + +const feature = loadFeature( + "__tests__/splash/features/animatedSplash.feature" +); + +defineFeature(feature, (test) => { + beforeEach(() => { + mockOnFinish = undefined; + }); + + test("スプラッシュ画像が表示される", ({ given, when, then }) => { + let getByTestId: ReturnType["getByTestId"]; + + given("スプラッシュコンポーネントに子要素がある", () => { + // setup in when + }); + + when("スプラッシュ画面をレンダーする", () => { + const result = render( + + Main + + ); + getByTestId = result.getByTestId; + }); + + then("スプラッシュ画像が表示される", () => { + expect(getByTestId("splash-image")).toBeTruthy(); + }); + }); + + test("スプラッシュコンテナがフルスクリーンで表示される", ({ given, when, then }) => { + let getByTestId: ReturnType["getByTestId"]; + + given("スプラッシュコンポーネントに子要素がある", () => { + // setup in when + }); + + when("スプラッシュ画面をレンダーする", () => { + const result = render( + + Main + + ); + getByTestId = result.getByTestId; + }); + + then("スプラッシュコンテナが表示される", () => { + expect(getByTestId("animated-splash-container")).toBeTruthy(); + }); + }); + + test("子要素が表示される", ({ given, when, then }) => { + let getByText: ReturnType["getByText"]; + + given(/^スプラッシュコンポーネントに "Main Content" という子要素がある$/, () => { + // setup in when + }); + + when("スプラッシュ画面をレンダーする", () => { + const result = render( + + Main Content + + ); + getByText = result.getByText; + }); + + then(/^"Main Content" が表示される$/, () => { + expect(getByText("Main Content")).toBeTruthy(); + }); + }); + + test("アニメーション完了後にスプラッシュオーバーレイが非表示になる", ({ given, when, then }) => { + let queryByTestId: ReturnType["queryByTestId"]; + + given("スプラッシュコンポーネントに子要素がある", () => { + // setup in when + }); + + when("スプラッシュ画面をレンダーしてアニメーションが完了する", () => { + const result = render( + + Main + + ); + queryByTestId = result.queryByTestId; + + expect(queryByTestId("animated-splash-container")).toBeTruthy(); + + act(() => { + mockOnFinish?.(); + }); + }); + + then("スプラッシュオーバーレイが非表示になる", () => { + expect(queryByTestId("animated-splash-container")).toBeNull(); + }); + }); +}); diff --git a/__tests__/splash/steps/useAnimatedSplash.steps.ts b/__tests__/splash/steps/useAnimatedSplash.steps.ts new file mode 100644 index 0000000..bdc9ded --- /dev/null +++ b/__tests__/splash/steps/useAnimatedSplash.steps.ts @@ -0,0 +1,118 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { renderHook, act } from "@testing-library/react-native"; +import * as SplashScreen from "expo-splash-screen"; +import { useAnimatedSplash } from "@/src/splash/hooks/useAnimatedSplash"; + +const feature = loadFeature( + "__tests__/splash/features/useAnimatedSplash.feature" +); + +defineFeature(feature, (test) => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test("マウント時にSplashScreen.hideAsyncが呼ばれる", ({ given, when, then }) => { + let onFinish: jest.Mock; + + given("onFinishコールバックが用意されている", () => { + onFinish = jest.fn(); + }); + + when("フックをレンダーする", () => { + renderHook(() => useAnimatedSplash({ onFinish })); + }); + + then(/^SplashScreen\.hideAsyncが1回呼ばれる$/, () => { + expect(SplashScreen.hideAsync).toHaveBeenCalledTimes(1); + }); + }); + + test("delay前にはonFinishが呼ばれない", ({ given, when, then }) => { + let onFinish: jest.Mock; + + given(/^onFinishコールバックとdelay 800ms が用意されている$/, () => { + onFinish = jest.fn(); + }); + + when(/^フックをレンダーして500ms経過する$/, () => { + renderHook(() => useAnimatedSplash({ onFinish, delay: 800 })); + act(() => { + jest.advanceTimersByTime(500); + }); + }); + + then("onFinishは呼ばれていない", () => { + expect(onFinish).not.toHaveBeenCalled(); + }); + }); + + test("delay後にonFinishコールバックが呼ばれる", ({ given, when, then }) => { + let onFinish: jest.Mock; + + given(/^onFinishコールバックとdelay 800ms が用意されている$/, () => { + onFinish = jest.fn(); + }); + + when(/^フックをレンダーして800ms経過する$/, () => { + renderHook(() => useAnimatedSplash({ onFinish, delay: 800 })); + act(() => { + jest.advanceTimersByTime(800); + }); + }); + + then("onFinishが1回呼ばれる", () => { + expect(onFinish).toHaveBeenCalledTimes(1); + }); + }); + + test("アンマウント時にタイマーがクリーンアップされる", ({ given, when, then }) => { + let onFinish: jest.Mock; + + given(/^onFinishコールバックとdelay 800ms が用意されている$/, () => { + onFinish = jest.fn(); + }); + + when(/^フックをレンダーしてアンマウントし1000ms経過する$/, () => { + const { unmount } = renderHook(() => + useAnimatedSplash({ onFinish, delay: 800 }) + ); + unmount(); + act(() => { + jest.advanceTimersByTime(1000); + }); + }); + + then("onFinishは呼ばれていない", () => { + expect(onFinish).not.toHaveBeenCalled(); + }); + }); + + test("animatedStyleオブジェクトを返す", ({ given, when, then, and }) => { + let onFinish: jest.Mock; + let hookResult: { current: ReturnType }; + + given("onFinishコールバックが用意されている", () => { + onFinish = jest.fn(); + }); + + when("フックをレンダーする", () => { + const { result } = renderHook(() => useAnimatedSplash({ onFinish })); + hookResult = result; + }); + + then("animatedStyleにopacityプロパティがある", () => { + expect(hookResult.current.animatedStyle).toBeDefined(); + expect(hookResult.current.animatedStyle).toHaveProperty("opacity"); + }); + + and("animatedStyleにtransformプロパティがある", () => { + expect(hookResult.current.animatedStyle).toHaveProperty("transform"); + }); + }); +}); diff --git a/jest.config.js b/jest.config.js index 54d16e0..def38a1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,8 +1,12 @@ /** @type {import('jest').Config} */ module.exports = { preset: "jest-expo", - testMatch: ["**/__tests__/**/*.test.ts?(x)"], + testMatch: ["**/__tests__/**/*.test.ts?(x)", "**/__tests__/**/*.steps.ts?(x)"], setupFiles: ["./jest.setup.js"], + transformIgnorePatterns: [ + "/node_modules/(?!(.pnpm|react-native|@react-native|@react-native-community|expo|@expo|@expo-google-fonts|react-navigation|@react-navigation|@sentry/react-native|native-base|@cucumber|jest-cucumber|uuid))", + "/node_modules/react-native-reanimated/plugin/", + ], collectCoverageFrom: ["src/**/*.{ts,tsx}", "!src/**/*.d.ts", "!src/**/*Samples*", "!src/**/screens/**", "!src/**/index.ts"], coverageDirectory: "coverage", coverageReporters: ["text", "lcov", "json-summary"], diff --git a/package.json b/package.json index e115fc0..cf5338c 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "eslint": "^9.39.4", "eslint-config-expo": "~55.0.0", "jest": "~29.7.0", + "jest-cucumber": "^4.5.0", "jest-expo": "~55.0.16", "react-test-renderer": "19.2.0", "typescript": "~5.9.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd9cd30..05399e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: jest: specifier: ~29.7.0 version: 29.7.0(@types/node@25.6.0) + jest-cucumber: + specifier: ^4.5.0 + version: 4.5.0(@types/jest@29.5.14)(jest@29.7.0(@types/node@25.6.0)) jest-expo: specifier: ~55.0.16 version: 55.0.16(@babel/core@7.29.0)(expo@55.0.15)(jest@29.7.0(@types/node@25.6.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) @@ -640,6 +643,12 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@cucumber/gherkin@28.0.0': + resolution: {integrity: sha512-Ee6zJQq0OmIUPdW0mSnsCsrWA2PZAELNDPICD2pLfs0Oz7RAPgj80UsD2UCtqyAhw2qAR62aqlktKUlai5zl/A==} + + '@cucumber/messages@24.1.0': + resolution: {integrity: sha512-hxVHiBurORcobhVk80I9+JkaKaNXkW6YwGOEFIh/2aO+apAN+5XJgUUWjng9NwqaQrW1sCFuawLB1AuzmBaNdQ==} + '@egjs/hammerjs@2.0.17': resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} engines: {node: '>=0.8.0'} @@ -875,6 +884,10 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@isaacs/ttlcache@1.4.1': resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} engines: {node: '>=12'} @@ -995,6 +1008,10 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -1450,6 +1467,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/uuid@9.0.8': + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -1704,6 +1724,10 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -1877,6 +1901,9 @@ packages: brace-expansion@1.1.13: resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + brace-expansion@5.0.5: resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} @@ -1965,6 +1992,9 @@ packages: cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + cli-cursor@2.1.0: resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} engines: {node: '>=4'} @@ -2196,6 +2226,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -2209,6 +2242,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} @@ -2664,6 +2700,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -2737,6 +2777,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -3086,6 +3131,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jest-changed-files@29.7.0: resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3116,6 +3164,20 @@ packages: ts-node: optional: true + jest-cucumber@4.5.0: + resolution: {integrity: sha512-EGVqkeE6xM/wnpWuLuB3AMQs4vNkLDwOuH3bsH2AigphAqDp+k3E+AIh0FAKhJ/1IjLTfZKyupIPRlYN62YZ+A==} + peerDependencies: + '@types/jest': '>=29.5.12' + jest: '>=29.7.0' + vitest: '>=1.4.0' + peerDependenciesMeta: + '@types/jest': + optional: true + jest: + optional: true + vitest: + optional: true + jest-diff@29.7.0: resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3645,6 +3707,10 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -3835,6 +3901,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3869,6 +3938,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -4118,6 +4191,10 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + reflect-metadata@0.2.1: + resolution: {integrity: sha512-i5lLI6iw9AU3Uu4szRNPPEkomnkjRTaVt9hy/bn5g/oSzekBSMeLZblcjP74AW0vBabqERLLIrz+gR8QYR54Tw==} + deprecated: This version has a critical bug in fallback handling. Please upgrade to reflect-metadata@0.2.2 or newer. + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -4317,6 +4394,10 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + simple-plist@1.3.1: resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} @@ -4422,6 +4503,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string.prototype.matchall@4.0.12: resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} engines: {node: '>= 0.4'} @@ -4680,10 +4765,18 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} hasBin: true + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} @@ -4780,6 +4873,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -5526,6 +5623,17 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@cucumber/gherkin@28.0.0': + dependencies: + '@cucumber/messages': 24.1.0 + + '@cucumber/messages@24.1.0': + dependencies: + '@types/uuid': 9.0.8 + class-transformer: 0.5.1 + reflect-metadata: 0.2.1 + uuid: 9.0.1 + '@egjs/hammerjs@2.0.17': dependencies: '@types/hammerjs': 2.0.46 @@ -5950,6 +6058,15 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/ttlcache@1.4.1': {} '@istanbuljs/load-nyc-config@1.1.0': @@ -6169,6 +6286,9 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-collection@1.1.7(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': @@ -6735,6 +6855,8 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/uuid@9.0.8': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -6965,6 +7087,8 @@ snapshots: ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -7227,6 +7351,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 @@ -7322,6 +7450,8 @@ snapshots: cjs-module-lexer@1.4.3: {} + class-transformer@0.5.1: {} + cli-cursor@2.1.0: dependencies: restore-cursor: 2.0.0 @@ -7542,6 +7672,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} electron-to-chromium@1.5.334: {} @@ -7550,6 +7682,8 @@ snapshots: emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + encodeurl@1.0.2: {} encodeurl@2.0.0: {} @@ -8190,6 +8324,11 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -8264,6 +8403,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -8627,6 +8775,12 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jest-changed-files@29.7.0: dependencies: execa: 5.1.1 @@ -8708,6 +8862,16 @@ snapshots: - babel-plugin-macros - supports-color + jest-cucumber@4.5.0(@types/jest@29.5.14)(jest@29.7.0(@types/node@25.6.0)): + dependencies: + '@cucumber/gherkin': 28.0.0 + callsites: 3.1.0 + glob: 10.5.0 + uuid: 10.0.0 + optionalDependencies: + '@types/jest': 29.5.14 + jest: 29.7.0(@types/node@25.6.0) + jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -9607,6 +9771,10 @@ snapshots: dependencies: brace-expansion: 1.1.13 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + minimist@1.2.8: {} minipass@7.1.3: {} @@ -9791,6 +9959,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -9820,6 +9990,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-scurry@2.0.2: dependencies: lru-cache: 11.3.3 @@ -10114,6 +10289,8 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + reflect-metadata@0.2.1: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -10339,6 +10516,8 @@ snapshots: signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + simple-plist@1.3.1: dependencies: bplist-creator: 0.1.0 @@ -10435,6 +10614,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string.prototype.matchall@4.0.12: dependencies: call-bind: 1.0.9 @@ -10724,8 +10909,12 @@ snapshots: utils-merge@1.0.1: {} + uuid@10.0.0: {} + uuid@7.0.3: {} + uuid@9.0.1: {} + v8-to-istanbul@9.3.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -10840,6 +11029,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} write-file-atomic@4.0.2: From bba5d4ce834846c9d9a378530b078d7493d91bcc Mon Sep 17 00:00:00 2001 From: MrSmart00 Date: Sat, 18 Apr 2026 07:40:14 +0900 Subject: [PATCH 2/2] refactor: split BDD features from unit tests and consolidate into screens/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BDDテスト(画面・コンポーネント表示)とUnit Test(ロジック)を分離。 features/ + steps/ を screens/ ディレクトリに統合し、テスト構成を整理。 - screens/: .feature + .steps.tsx(画面・コンポーネントのBDDテスト) - domain/, hooks/, repository/: .test.ts(ロジックのUnit Test) - sharedモジュールのテストは使用する画面側 or 元のsharedに配置 Co-Authored-By: Claude --- CLAUDE.md | 15 +- .../detail/features/detailScreen.feature | 47 - .../detail/features/pokemonAbilities.feature | 22 - .../detail/features/pokemonDetail.feature | 57 -- .../detail/features/pokemonDetailApi.feature | 37 - .../detail/features/pokemonFlavorText.feature | 16 - .../features/pokemonPhysicalInfo.feature | 27 - .../detail/features/pokemonSpeciesApi.feature | 44 - .../detail/features/pokemonStats.feature | 23 - __tests__/detail/features/statBar.feature | 21 - .../detail/features/usePokemonDetail.feature | 38 - .../features/usePokemonFlavorText.feature | 25 - .../features/usePokemonSpeciesInfo.feature | 29 - .../detail/hooks/usePokemonDetail.test.ts | 109 ++ .../detail/hooks/usePokemonFlavorText.test.ts | 51 + .../hooks/usePokemonSpeciesInfo.test.ts | 58 ++ .../repository/pokemonDetailApi.test.ts | 105 ++ .../repository/pokemonSpeciesApi.test.ts | 104 ++ __tests__/detail/screens/detailScreen.feature | 278 +++++ .../detail/screens/detailScreen.steps.tsx | 950 ++++++++++++++++++ __tests__/detail/steps/detailScreen.steps.tsx | 193 ---- .../detail/steps/pokemonAbilities.steps.tsx | 75 -- .../detail/steps/pokemonDetail.steps.tsx | 224 ----- .../detail/steps/pokemonDetailApi.steps.ts | 183 ---- .../detail/steps/pokemonFlavorText.steps.tsx | 58 -- .../steps/pokemonPhysicalInfo.steps.tsx | 91 -- .../detail/steps/pokemonSpeciesApi.steps.ts | 199 ---- __tests__/detail/steps/pokemonStats.steps.tsx | 89 -- __tests__/detail/steps/statBar.steps.tsx | 87 -- .../detail/steps/usePokemonDetail.steps.ts | 185 ---- .../steps/usePokemonFlavorText.steps.ts | 102 -- .../steps/usePokemonSpeciesInfo.steps.ts | 121 --- .../features/usePokemonByIds.feature | 50 - .../favorites/hooks/usePokemonByIds.test.ts | 182 ++++ .../favoritesScreen.feature | 0 .../favoritesScreen.steps.tsx | 22 +- .../favorites/steps/usePokemonByIds.steps.ts | 261 ----- __tests__/home/domain/pokemonListItem.test.ts | 38 + .../features/floatingSearchButton.feature | 34 - __tests__/home/features/homeScreen.feature | 47 - __tests__/home/features/pokemonApi.feature | 25 - .../home/features/pokemonGraphqlApi.feature | 34 - .../home/features/pokemonListItem.feature | 30 - .../home/features/useFloatingSearch.feature | 29 - .../home/features/usePokemonList.feature | 72 -- __tests__/home/features/useSearch.feature | 32 - .../home/hooks/useFloatingSearch.test.ts | 56 ++ __tests__/home/hooks/usePokemonList.test.ts | 179 ++++ __tests__/home/hooks/useSearch.test.ts | 67 ++ __tests__/home/repository/pokemonApi.test.ts | 66 ++ .../home/repository/pokemonGraphqlApi.test.ts | 135 +++ __tests__/home/screens/homeScreen.feature | 140 +++ __tests__/home/screens/homeScreen.steps.tsx | 619 ++++++++++++ .../home/steps/floatingSearchButton.steps.tsx | 176 ---- __tests__/home/steps/homeScreen.steps.tsx | 215 ---- __tests__/home/steps/pokemonApi.steps.ts | 137 --- .../home/steps/pokemonGraphqlApi.steps.ts | 268 ----- __tests__/home/steps/pokemonListItem.steps.ts | 78 -- .../home/steps/useFloatingSearch.steps.ts | 108 -- __tests__/home/steps/usePokemonList.steps.ts | 361 ------- __tests__/home/steps/useSearch.steps.ts | 150 --- .../settingsScreen.feature} | 2 +- .../settingsScreen.steps.tsx} | 39 +- __tests__/shared/domain/typeColors.test.ts | 39 + .../shared/features/favoriteButton.feature | 40 - __tests__/shared/features/i18n.feature | 36 - __tests__/shared/features/pokemonApi.feature | 26 - __tests__/shared/features/pokemonCard.feature | 54 - __tests__/shared/features/typeColors.feature | 12 - .../shared/features/useFavoritesStore.feature | 54 - __tests__/shared/features/useLanguage.feature | 18 - __tests__/shared/i18n/i18n.test.ts | 54 + __tests__/shared/i18n/useLanguage.test.ts | 41 + .../shared/repository/pokemonApi.test.ts | 87 ++ .../shared/steps/favoriteButton.steps.tsx | 157 --- __tests__/shared/steps/i18n.steps.ts | 156 --- __tests__/shared/steps/pokemonApi.steps.ts | 149 --- __tests__/shared/steps/pokemonCard.steps.tsx | 229 ----- __tests__/shared/steps/typeColors.steps.ts | 67 -- .../shared/steps/useFavoritesStore.steps.tsx | 265 ----- __tests__/shared/steps/useLanguage.steps.ts | 87 -- .../shared/stores/useFavoritesStore.test.tsx | 107 ++ .../splash/features/useAnimatedSplash.feature | 27 - .../splash/hooks/useAnimatedSplash.test.ts | 81 ++ .../splashScreen.feature} | 2 +- .../splashScreen.steps.tsx} | 7 +- .../splash/steps/useAnimatedSplash.steps.ts | 118 --- 87 files changed, 3593 insertions(+), 5635 deletions(-) delete mode 100644 __tests__/detail/features/detailScreen.feature delete mode 100644 __tests__/detail/features/pokemonAbilities.feature delete mode 100644 __tests__/detail/features/pokemonDetail.feature delete mode 100644 __tests__/detail/features/pokemonDetailApi.feature delete mode 100644 __tests__/detail/features/pokemonFlavorText.feature delete mode 100644 __tests__/detail/features/pokemonPhysicalInfo.feature delete mode 100644 __tests__/detail/features/pokemonSpeciesApi.feature delete mode 100644 __tests__/detail/features/pokemonStats.feature delete mode 100644 __tests__/detail/features/statBar.feature delete mode 100644 __tests__/detail/features/usePokemonDetail.feature delete mode 100644 __tests__/detail/features/usePokemonFlavorText.feature delete mode 100644 __tests__/detail/features/usePokemonSpeciesInfo.feature create mode 100644 __tests__/detail/hooks/usePokemonDetail.test.ts create mode 100644 __tests__/detail/hooks/usePokemonFlavorText.test.ts create mode 100644 __tests__/detail/hooks/usePokemonSpeciesInfo.test.ts create mode 100644 __tests__/detail/repository/pokemonDetailApi.test.ts create mode 100644 __tests__/detail/repository/pokemonSpeciesApi.test.ts create mode 100644 __tests__/detail/screens/detailScreen.feature create mode 100644 __tests__/detail/screens/detailScreen.steps.tsx delete mode 100644 __tests__/detail/steps/detailScreen.steps.tsx delete mode 100644 __tests__/detail/steps/pokemonAbilities.steps.tsx delete mode 100644 __tests__/detail/steps/pokemonDetail.steps.tsx delete mode 100644 __tests__/detail/steps/pokemonDetailApi.steps.ts delete mode 100644 __tests__/detail/steps/pokemonFlavorText.steps.tsx delete mode 100644 __tests__/detail/steps/pokemonPhysicalInfo.steps.tsx delete mode 100644 __tests__/detail/steps/pokemonSpeciesApi.steps.ts delete mode 100644 __tests__/detail/steps/pokemonStats.steps.tsx delete mode 100644 __tests__/detail/steps/statBar.steps.tsx delete mode 100644 __tests__/detail/steps/usePokemonDetail.steps.ts delete mode 100644 __tests__/detail/steps/usePokemonFlavorText.steps.ts delete mode 100644 __tests__/detail/steps/usePokemonSpeciesInfo.steps.ts delete mode 100644 __tests__/favorites/features/usePokemonByIds.feature create mode 100644 __tests__/favorites/hooks/usePokemonByIds.test.ts rename __tests__/favorites/{features => screens}/favoritesScreen.feature (100%) rename __tests__/favorites/{steps => screens}/favoritesScreen.steps.tsx (81%) delete mode 100644 __tests__/favorites/steps/usePokemonByIds.steps.ts create mode 100644 __tests__/home/domain/pokemonListItem.test.ts delete mode 100644 __tests__/home/features/floatingSearchButton.feature delete mode 100644 __tests__/home/features/homeScreen.feature delete mode 100644 __tests__/home/features/pokemonApi.feature delete mode 100644 __tests__/home/features/pokemonGraphqlApi.feature delete mode 100644 __tests__/home/features/pokemonListItem.feature delete mode 100644 __tests__/home/features/useFloatingSearch.feature delete mode 100644 __tests__/home/features/usePokemonList.feature delete mode 100644 __tests__/home/features/useSearch.feature create mode 100644 __tests__/home/hooks/useFloatingSearch.test.ts create mode 100644 __tests__/home/hooks/usePokemonList.test.ts create mode 100644 __tests__/home/hooks/useSearch.test.ts create mode 100644 __tests__/home/repository/pokemonApi.test.ts create mode 100644 __tests__/home/repository/pokemonGraphqlApi.test.ts create mode 100644 __tests__/home/screens/homeScreen.feature create mode 100644 __tests__/home/screens/homeScreen.steps.tsx delete mode 100644 __tests__/home/steps/floatingSearchButton.steps.tsx delete mode 100644 __tests__/home/steps/homeScreen.steps.tsx delete mode 100644 __tests__/home/steps/pokemonApi.steps.ts delete mode 100644 __tests__/home/steps/pokemonGraphqlApi.steps.ts delete mode 100644 __tests__/home/steps/pokemonListItem.steps.ts delete mode 100644 __tests__/home/steps/useFloatingSearch.steps.ts delete mode 100644 __tests__/home/steps/usePokemonList.steps.ts delete mode 100644 __tests__/home/steps/useSearch.steps.ts rename __tests__/settings/{features/languagePicker.feature => screens/settingsScreen.feature} (94%) rename __tests__/settings/{steps/languagePicker.steps.tsx => screens/settingsScreen.steps.tsx} (71%) create mode 100644 __tests__/shared/domain/typeColors.test.ts delete mode 100644 __tests__/shared/features/favoriteButton.feature delete mode 100644 __tests__/shared/features/i18n.feature delete mode 100644 __tests__/shared/features/pokemonApi.feature delete mode 100644 __tests__/shared/features/pokemonCard.feature delete mode 100644 __tests__/shared/features/typeColors.feature delete mode 100644 __tests__/shared/features/useFavoritesStore.feature delete mode 100644 __tests__/shared/features/useLanguage.feature create mode 100644 __tests__/shared/i18n/i18n.test.ts create mode 100644 __tests__/shared/i18n/useLanguage.test.ts create mode 100644 __tests__/shared/repository/pokemonApi.test.ts delete mode 100644 __tests__/shared/steps/favoriteButton.steps.tsx delete mode 100644 __tests__/shared/steps/i18n.steps.ts delete mode 100644 __tests__/shared/steps/pokemonApi.steps.ts delete mode 100644 __tests__/shared/steps/pokemonCard.steps.tsx delete mode 100644 __tests__/shared/steps/typeColors.steps.ts delete mode 100644 __tests__/shared/steps/useFavoritesStore.steps.tsx delete mode 100644 __tests__/shared/steps/useLanguage.steps.ts create mode 100644 __tests__/shared/stores/useFavoritesStore.test.tsx delete mode 100644 __tests__/splash/features/useAnimatedSplash.feature create mode 100644 __tests__/splash/hooks/useAnimatedSplash.test.ts rename __tests__/splash/{features/animatedSplash.feature => screens/splashScreen.feature} (94%) rename __tests__/splash/{steps/animatedSplash.steps.tsx => screens/splashScreen.steps.tsx} (95%) delete mode 100644 __tests__/splash/steps/useAnimatedSplash.steps.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6f9e1cb..00fa9f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,18 +22,21 @@ GitHub Issues #1〜#5に各Stepの仕様が定義されている。 ### テストの書き方(Cucumber BDDスタイル) -新規テストは **jest-cucumber** を使い、Gherkin(.feature)+ ステップ定義で記述する。 -既存の `describe/it` 形式テストは段階的に移行する。 +テストは2層構成: +- **BDD Feature(画面・コンポーネント)**: jest-cucumber で Gherkin(.feature)+ ステップ定義 +- **Unit Test(ロジック)**: 従来の describe/it 形式で hooks / domain / repository をテスト #### ファイル配置 ``` __tests__/ / - features/ # .feature ファイル(Gherkin仕様) - .feature - steps/ # ステップ定義 - .steps.ts(x) + screens/ # BDD Feature(画面・コンポーネント表示テスト) + Screen.feature # Gherkin仕様 + Screen.steps.tsx # ステップ定義 + domain/ # Unit Test(ドメインロジック) + hooks/ # Unit Test(カスタムフック) + repository/ # Unit Test(API呼び出し) ``` - **`app/` ディレクトリにテストを置かないこと**(Expo Routerがルートとして認識するため) diff --git a/__tests__/detail/features/detailScreen.feature b/__tests__/detail/features/detailScreen.feature deleted file mode 100644 index f45d027..0000000 --- a/__tests__/detail/features/detailScreen.feature +++ /dev/null @@ -1,47 +0,0 @@ -Feature: 詳細画面 - - Scenario: ローカライズされたポケモン名が表示される - Given ピカチュウの詳細データとローカライズ名がモックされている - When 詳細画面をID「25」でレンダリングする - Then 「ピカチュウ」が表示される - - Scenario: ポケモンのIDが表示される - Given ピカチュウの詳細データとローカライズ名がモックされている - When 詳細画面をID「25」でレンダリングする - Then 「#025」が表示される - - Scenario: ローディング中にActivityIndicatorが表示される - Given ローディング中のモック状態が設定されている - When 詳細画面をID「25」でレンダリングする - Then ローディングインジケータが表示される - - Scenario: エラー時にエラーメッセージが表示される - Given エラー状態のモックが設定されている - When 詳細画面をID「999」でレンダリングする - Then 「detail.notFound」が表示される - - Scenario: お気に入りボタンが表示される - Given ピカチュウの詳細データとローカライズ名がモックされている - When 詳細画面をID「25」でレンダリングする - Then お気に入りボタンが表示される - - Scenario: ステータスが詳細画面に表示される - Given ピカチュウの詳細データとローカライズ名がモックされている - When 詳細画面をID「25」でレンダリングする - Then 「detail.baseStats」が表示される - - Scenario: 身長と体重が詳細画面に表示される - Given ピカチュウの詳細データとローカライズ名がモックされている - When 詳細画面をID「25」でレンダリングする - Then 「detail.height」が表示される - And 「detail.weight」が表示される - - Scenario: フレーバーテキストが表示される - Given ピカチュウの詳細データとローカライズ名がモックされている - When 詳細画面をID「25」でレンダリングする - Then 「It keeps its tail raised.」が表示される - - Scenario: ローカライズ名がnullの場合はAPI名が表示される - Given ローカライズ名がnullのモック状態が設定されている - When 詳細画面をID「25」でレンダリングする - Then 「Pikachu」が表示される diff --git a/__tests__/detail/features/pokemonAbilities.feature b/__tests__/detail/features/pokemonAbilities.feature deleted file mode 100644 index 50ed6a2..0000000 --- a/__tests__/detail/features/pokemonAbilities.feature +++ /dev/null @@ -1,22 +0,0 @@ -Feature: ポケモンとくせい表示 - - Scenario: セクションタイトルが表示される - Given とくせいリストが与えられている - When PokemonAbilitiesをレンダリングする - Then セクションタイトル「detail.abilities」が表示される - - Scenario: とくせい名がキャピタライズされて表示される - Given とくせいリストが与えられている - When PokemonAbilitiesをレンダリングする - Then とくせい名「Overgrow」が表示される - - Scenario: 隠れとくせいにラベルが付与される - Given とくせいリストが与えられている - When PokemonAbilitiesをレンダリングする - Then 隠れとくせい「Chlorophyll detail.hiddenAbility」が表示される - - Scenario: 複数のとくせいが全て表示される - Given とくせいリストが与えられている - When PokemonAbilitiesをレンダリングする - Then とくせい名「Overgrow」が表示される - And 隠れとくせい「Chlorophyll detail.hiddenAbility」が表示される diff --git a/__tests__/detail/features/pokemonDetail.feature b/__tests__/detail/features/pokemonDetail.feature deleted file mode 100644 index ff7acb6..0000000 --- a/__tests__/detail/features/pokemonDetail.feature +++ /dev/null @@ -1,57 +0,0 @@ -Feature: ポケモン詳細コンポーネント - - Scenario: ローカライズ名が渡された場合に表示される - Given ピカチュウのデータが用意されている - When ローカライズ名「ピカチュウ」を指定してPokemonDetailをレンダリングする - Then 「ピカチュウ」が表示される - - Scenario: ローカライズ名がnullの場合はAPI名が表示される - Given ピカチュウのデータが用意されている - When ローカライズ名をnullにしてPokemonDetailをレンダリングする - Then 「pikachu」が表示される - - Scenario: ポケモンのIDが3桁ゼロ埋めで表示される - Given ピカチュウのデータが用意されている - When PokemonDetailをレンダリングする - Then 「#025」が表示される - - Scenario: ポケモンの画像が表示される - Given ピカチュウのデータが用意されている - When PokemonDetailをレンダリングする - Then ポケモン画像のURIに「25.png」が含まれる - - Scenario: タイプバッジが翻訳されて表示される - Given ピカチュウのデータが用意されている - When PokemonDetailをレンダリングする - Then 「types.electric」が表示される - - Scenario: 複数タイプが翻訳されて全て表示される - Given リザードンのデータが用意されている - When PokemonDetailをレンダリングする - Then 「types.fire」が表示される - And 「types.flying」が表示される - - Scenario: お気に入りボタンが表示される - Given ピカチュウのデータが用意されている - When お気に入り機能付きでPokemonDetailをレンダリングする - Then お気に入りボタンが表示される - - Scenario: お気に入りボタン押下後にonToggleFavoriteが呼ばれる - Given ピカチュウのデータが用意されている - When お気に入り機能付きでPokemonDetailをレンダリングしてボタンを押す - Then onToggleFavoriteが1回呼ばれる - - Scenario: お気に入りが未指定の場合ボタンが表示されない - Given ピカチュウのデータが用意されている - When PokemonDetailをレンダリングする - Then お気に入りボタンが表示されない - - Scenario: フレーバーテキストが渡された場合に表示される - Given ピカチュウのデータが用意されている - When フレーバーテキスト付きでPokemonDetailをレンダリングする - Then フレーバーテキストが表示される - - Scenario: フレーバーテキストが未指定の場合は表示されない - Given ピカチュウのデータが用意されている - When PokemonDetailをレンダリングする - Then フレーバーテキストのローディングが表示されない diff --git a/__tests__/detail/features/pokemonDetailApi.feature b/__tests__/detail/features/pokemonDetailApi.feature deleted file mode 100644 index 2ecca81..0000000 --- a/__tests__/detail/features/pokemonDetailApi.feature +++ /dev/null @@ -1,37 +0,0 @@ -Feature: ポケモン詳細API - - Scenario: 正しいURLでfetchを呼び出す - Given PokeAPIがピカチュウのレスポンスを返す - When fetchPokemonDetailをID25で呼び出す - Then fetchが「https://pokeapi.co/api/v2/pokemon/25」で呼ばれる - - Scenario: レスポンスからステータスを正しく変換する - Given PokeAPIがピカチュウのレスポンスを返す - When fetchPokemonDetailをID25で呼び出す - Then ステータスが正しく変換される - - Scenario: 身長と体重を正しく変換する - Given PokeAPIがピカチュウのレスポンスを返す - When fetchPokemonDetailをID25で呼び出す - Then 身長が4である - And 体重が60である - - Scenario: とくせいを正しく変換する - Given PokeAPIがピカチュウのレスポンスを返す - When fetchPokemonDetailをID25で呼び出す - Then とくせいが正しく変換される - - Scenario: 名前がキャピタライズされる - Given PokeAPIがピカチュウのレスポンスを返す - When fetchPokemonDetailをID25で呼び出す - Then 名前が「Pikachu」である - - Scenario: タイプが正しく変換される - Given PokeAPIがピカチュウのレスポンスを返す - When fetchPokemonDetailをID25で呼び出す - Then タイプが「electric」である - - Scenario: HTTPエラー時にエラーをスローする - Given PokeAPIが404エラーを返す - When fetchPokemonDetailをID99999で呼び出す - Then 「Failed to fetch pokemon detail: 404」エラーがスローされる diff --git a/__tests__/detail/features/pokemonFlavorText.feature b/__tests__/detail/features/pokemonFlavorText.feature deleted file mode 100644 index d21b1d8..0000000 --- a/__tests__/detail/features/pokemonFlavorText.feature +++ /dev/null @@ -1,16 +0,0 @@ -Feature: ポケモンフレーバーテキスト表示 - - Scenario: フレーバーテキストが表示される - Given フレーバーテキストが与えられている - When PokemonFlavorTextをレンダリングする - Then テキスト「でんきを ためこむ せいしつ。」が表示される - - Scenario: ローディング中にインジケータが表示される - Given テキストがnullでローディング中である - When PokemonFlavorTextをレンダリングする - Then ローディングインジケータが表示される - - Scenario: テキストがnullでローディングでない場合は何も表示されない - Given テキストがnullでローディングでない - When PokemonFlavorTextをレンダリングする - Then 何も表示されない diff --git a/__tests__/detail/features/pokemonPhysicalInfo.feature b/__tests__/detail/features/pokemonPhysicalInfo.feature deleted file mode 100644 index f064374..0000000 --- a/__tests__/detail/features/pokemonPhysicalInfo.feature +++ /dev/null @@ -1,27 +0,0 @@ -Feature: ポケモン体格情報の表示 - - Scenario: 身長の値が表示される - Given 身長7、体重69のポケモンデータが与えられている - When PokemonPhysicalInfoをレンダリングする - Then 「0.7detail.heightUnit」が表示される - - Scenario: 体重の値が表示される - Given 身長7、体重69のポケモンデータが与えられている - When PokemonPhysicalInfoをレンダリングする - Then 「6.9detail.weightUnit」が表示される - - Scenario: 身長ラベルが表示される - Given 身長7、体重69のポケモンデータが与えられている - When PokemonPhysicalInfoをレンダリングする - Then 「detail.height」が表示される - - Scenario: 体重ラベルが表示される - Given 身長7、体重69のポケモンデータが与えられている - When PokemonPhysicalInfoをレンダリングする - Then 「detail.weight」が表示される - - Scenario: 整数の身長が正しくフォーマットされる - Given 身長20、体重1000のポケモンデータが与えられている - When PokemonPhysicalInfoをレンダリングする - Then 「2.0detail.heightUnit」が表示される - And 「100.0detail.weightUnit」が表示される diff --git a/__tests__/detail/features/pokemonSpeciesApi.feature b/__tests__/detail/features/pokemonSpeciesApi.feature deleted file mode 100644 index 85b7eaf..0000000 --- a/__tests__/detail/features/pokemonSpeciesApi.feature +++ /dev/null @@ -1,44 +0,0 @@ -Feature: ポケモン種族API - - Scenario: fetchPokemonFlavorTextが正しいURLでfetchを呼び出す - Given PokeAPIが種族レスポンスを返す - When fetchPokemonFlavorTextをID25で呼び出す - Then fetchが「https://pokeapi.co/api/v2/pokemon-species/25」で呼ばれる - - Scenario: 英語のフレーバーテキストを返す - Given PokeAPIが種族レスポンスを返す - When fetchPokemonFlavorTextをID25で呼び出す - Then 英語のフレーバーテキストが返される - - Scenario: フレーバーテキストがない場合はnullを返す - Given PokeAPIが空の種族レスポンスを返す - When fetchPokemonFlavorTextをID25で呼び出す - Then nullが返される - - Scenario: fetchPokemonFlavorTextでHTTPエラー時にエラーをスローする - Given PokeAPIが404エラーを返す - When fetchPokemonFlavorTextをID99999で呼び出す - Then 「Failed to fetch pokemon species: 404」エラーがスローされる - - Scenario: 指定した言語のポケモン名とフレーバーテキストを返す - Given PokeAPIが種族レスポンスを返す - When fetchPokemonSpeciesInfoをID25と言語「ja」で呼び出す - Then localizedNameが「ピカチュウ」である - And flavorTextが「でんきを ためこむ せいしつ。」である - - Scenario: 英語を指定した場合に英語のデータを返す - Given PokeAPIが種族レスポンスを返す - When fetchPokemonSpeciesInfoをID25と言語「en」で呼び出す - Then localizedNameが「Pikachu」である - And flavorTextが英語である - - Scenario: 該当する言語がない場合はnullを返す - Given PokeAPIが空の種族レスポンスを返す - When fetchPokemonSpeciesInfoをID25と言語「ja」で呼び出す - Then localizedNameがnullである - And flavorTextがnullである - - Scenario: fetchPokemonSpeciesInfoでHTTPエラー時にエラーをスローする - Given PokeAPIが404エラーを返す - When fetchPokemonSpeciesInfoをID99999と言語「ja」で呼び出す - Then 「Failed to fetch pokemon species: 404」エラーがスローされる diff --git a/__tests__/detail/features/pokemonStats.feature b/__tests__/detail/features/pokemonStats.feature deleted file mode 100644 index 7ac8238..0000000 --- a/__tests__/detail/features/pokemonStats.feature +++ /dev/null @@ -1,23 +0,0 @@ -Feature: ポケモンステータス表示 - - Scenario: セクションタイトルが表示される - Given ステータスデータが与えられている - When PokemonStatsをレンダリングする - Then 「detail.baseStats」が表示される - - Scenario: 6つのステータスバーが表示される - Given ステータスデータが与えられている - When PokemonStatsをレンダリングする - Then 「detail.stats.hp」が表示される - And 「detail.stats.attack」が表示される - And 「detail.stats.defense」が表示される - And 「detail.stats.special-attack」が表示される - And 「detail.stats.special-defense」が表示される - And 「detail.stats.speed」が表示される - - Scenario: 各ステータスの値が正しく表示される - Given ステータスデータが与えられている - When PokemonStatsをレンダリングする - Then 値「45」が2つ表示される - And 値「49」が2つ表示される - And 値「65」が2つ表示される diff --git a/__tests__/detail/features/statBar.feature b/__tests__/detail/features/statBar.feature deleted file mode 100644 index 02670c8..0000000 --- a/__tests__/detail/features/statBar.feature +++ /dev/null @@ -1,21 +0,0 @@ -Feature: ステータスバー表示 - - Scenario: ステータス名が表示される - Given ラベル「HP」、値45のStatBarが与えられている - When StatBarをレンダリングする - Then 「HP」が表示される - - Scenario: ステータス値が表示される - Given ラベル「HP」、値45のStatBarが与えられている - When StatBarをレンダリングする - Then 「45」が表示される - - Scenario: バーの幅がステータス値に比例する - Given ラベル「HP」、値128、最大値256のStatBarが与えられている - When StatBarをレンダリングする - Then バーの幅が「50%」である - - Scenario: maxValueのデフォルトは180 - Given ラベル「Speed」、値90のStatBarが与えられている - When StatBarをレンダリングする - Then バーの幅が「50%」である diff --git a/__tests__/detail/features/usePokemonDetail.feature b/__tests__/detail/features/usePokemonDetail.feature deleted file mode 100644 index 8e3d34e..0000000 --- a/__tests__/detail/features/usePokemonDetail.feature +++ /dev/null @@ -1,38 +0,0 @@ -Feature: usePokemonDetailフック - - Scenario: 初期ロード時にisLoadingがtrueになる - Given fetchPokemonDetailが未解決のPromiseを返す - When usePokemonDetailをID25で呼び出す - Then isLoadingがtrueである - And pokemonがnullである - - Scenario: データ取得後にポケモン詳細が設定される - Given fetchPokemonDetailがピカチュウのデータを返す - When usePokemonDetailをID25で呼び出して完了を待つ - Then isLoadingがfalseである - And ポケモン詳細データが設定される - And fetchPokemonDetailがID25で呼ばれる - - Scenario: エラー時にerror状態が設定される - Given fetchPokemonDetailがエラー「Not found」を返す - When usePokemonDetailをID25で呼び出して完了を待つ - Then isLoadingがfalseである - And errorが「Not found」である - And pokemonがnullである - - Scenario: Error以外のエラーでもerror状態が設定される - Given fetchPokemonDetailが文字列エラーを返す - When usePokemonDetailをID25で呼び出して完了を待つ - Then isLoadingがfalseである - And errorが「Unknown error」である - - Scenario: アンマウント後にデータ取得が完了しても状態が更新されない - Given fetchPokemonDetailが遅延Promiseを返す - When usePokemonDetailをID25で呼び出してアンマウントしてからPromiseを解決する - Then pokemonがnullである - And isLoadingがtrueである - - Scenario: IDが変わると再取得する - Given fetchPokemonDetailがピカチュウのデータを返す - When usePokemonDetailをID25で呼び出して完了後にID1に変更する - Then 新しいポケモンデータが設定される diff --git a/__tests__/detail/features/usePokemonFlavorText.feature b/__tests__/detail/features/usePokemonFlavorText.feature deleted file mode 100644 index 2923013..0000000 --- a/__tests__/detail/features/usePokemonFlavorText.feature +++ /dev/null @@ -1,25 +0,0 @@ -Feature: usePokemonFlavorTextフック - - Scenario: 初期ロード時にisLoadingがtrueになる - Given fetchPokemonFlavorTextが未解決のPromiseを返す - When usePokemonFlavorTextをID25で呼び出す - Then isLoadingがtrueである - And flavorTextがnullである - - Scenario: データ取得後にフレーバーテキストが設定される - Given fetchPokemonFlavorTextがテキストを返す - When usePokemonFlavorTextをID25で呼び出して完了を待つ - Then isLoadingがfalseである - And flavorTextが「でんきを ためこむ せいしつ。」である - - Scenario: エラー時はflavorTextがnullのままになる - Given fetchPokemonFlavorTextがエラーを返す - When usePokemonFlavorTextをID25で呼び出して完了を待つ - Then isLoadingがfalseである - And flavorTextがnullである - - Scenario: アンマウント後にデータ取得が完了しても状態が更新されない - Given fetchPokemonFlavorTextが遅延Promiseを返す - When usePokemonFlavorTextをID25で呼び出してアンマウントしてからPromiseを解決する - Then flavorTextがnullである - And isLoadingがtrueである diff --git a/__tests__/detail/features/usePokemonSpeciesInfo.feature b/__tests__/detail/features/usePokemonSpeciesInfo.feature deleted file mode 100644 index f63d7b1..0000000 --- a/__tests__/detail/features/usePokemonSpeciesInfo.feature +++ /dev/null @@ -1,29 +0,0 @@ -Feature: usePokemonSpeciesInfoフック - - Scenario: 初期ロード時にisLoadingがtrueになる - Given fetchPokemonSpeciesInfoが未解決のPromiseを返す - When usePokemonSpeciesInfoをID25で呼び出す - Then isLoadingがtrueである - And localizedNameがnullである - And flavorTextがnullである - - Scenario: ローカライズされたポケモン名とフレーバーテキストを返す - Given fetchPokemonSpeciesInfoが日本語データを返す - When usePokemonSpeciesInfoをID25で呼び出して完了を待つ - Then isLoadingがfalseである - And localizedNameが「ピカチュウ」である - And flavorTextが「でんきを ためこむ せいしつ。」である - And fetchPokemonSpeciesInfoがID25と言語「ja」で呼ばれる - - Scenario: エラー時にnullを返す - Given fetchPokemonSpeciesInfoがエラーを返す - When usePokemonSpeciesInfoをID25で呼び出して完了を待つ - Then isLoadingがfalseである - And localizedNameがnullである - And flavorTextがnullである - - Scenario: アンマウント後にデータ取得が完了しても状態が更新されない - Given fetchPokemonSpeciesInfoが遅延Promiseを返す - When usePokemonSpeciesInfoをID25で呼び出してアンマウントしてからPromiseを解決する - Then localizedNameがnullである - And isLoadingがtrueである diff --git a/__tests__/detail/hooks/usePokemonDetail.test.ts b/__tests__/detail/hooks/usePokemonDetail.test.ts new file mode 100644 index 0000000..d70f1f0 --- /dev/null +++ b/__tests__/detail/hooks/usePokemonDetail.test.ts @@ -0,0 +1,109 @@ +import { renderHook, waitFor, act } from "@testing-library/react-native"; +import { fetchPokemonDetail } from "@/src/detail/repository/pokemonDetailApi"; +import { usePokemonDetail } from "@/src/detail/hooks/usePokemonDetail"; +import type { Pokemon } from "@/src/shared"; + +jest.mock("@/src/detail/repository/pokemonDetailApi"); + +const mockFetchDetail = fetchPokemonDetail as jest.MockedFunction; + +const mockScreenPokemon: Pokemon = { + id: 25, + name: "Pikachu", + types: ["electric"], + stats: [ + { name: "hp", baseStat: 35 }, + { name: "attack", baseStat: 55 }, + { name: "defense", baseStat: 40 }, + { name: "special-attack", baseStat: 50 }, + { name: "special-defense", baseStat: 50 }, + { name: "speed", baseStat: 90 }, + ], + height: 4, + weight: 60, + abilities: [ + { name: "static", isHidden: false }, + { name: "lightning-rod", isHidden: true }, + ], +}; + +describe("usePokemonDetail", () => { + beforeEach(() => { + mockFetchDetail.mockReset(); + }); + + it("初期ロード時にisLoadingがtrueになる", () => { + mockFetchDetail.mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => usePokemonDetail(25)); + expect(result.current.isLoading).toBe(true); + expect(result.current.pokemon).toBeNull(); + }); + + it("データ取得後にポケモン詳細が設定される", async () => { + mockFetchDetail.mockResolvedValueOnce(mockScreenPokemon); + const { result } = renderHook(() => usePokemonDetail(25)); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.pokemon).toEqual(mockScreenPokemon); + expect(mockFetchDetail).toHaveBeenCalledWith(25); + }); + + it("エラー時にerror状態が設定される", async () => { + mockFetchDetail.mockRejectedValueOnce(new Error("Not found")); + const { result } = renderHook(() => usePokemonDetail(25)); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe("Not found"); + expect(result.current.pokemon).toBeNull(); + }); + + it("Error以外のエラーでもerror状態が設定される", async () => { + mockFetchDetail.mockRejectedValueOnce("string error"); + const { result } = renderHook(() => usePokemonDetail(25)); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe("Unknown error"); + }); + + it("アンマウント後にデータ取得が完了しても状態が更新されない", async () => { + let resolve: (value: Pokemon) => void; + mockFetchDetail.mockReturnValue(new Promise((r) => { resolve = r; })); + const { result, unmount } = renderHook(() => usePokemonDetail(25)); + expect(result.current.isLoading).toBe(true); + unmount(); + await act(async () => { resolve!(mockScreenPokemon); }); + expect(result.current.pokemon).toBeNull(); + expect(result.current.isLoading).toBe(true); + }); + + it("IDが変わると再取得する", async () => { + mockFetchDetail.mockResolvedValueOnce(mockScreenPokemon); + const { result, rerender } = renderHook( + (props: { id: number }) => usePokemonDetail(props.id), + { initialProps: { id: 25 } } + ); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const newPokemon: Pokemon = { + ...mockScreenPokemon, + id: 1, + name: "Bulbasaur", + types: ["grass", "poison"], + }; + mockFetchDetail.mockResolvedValueOnce(newPokemon); + rerender({ id: 1 }); + + await waitFor(() => { + expect(result.current.pokemon).toEqual(newPokemon); + }); + expect(result.current.pokemon?.id).toBe(1); + }); +}); diff --git a/__tests__/detail/hooks/usePokemonFlavorText.test.ts b/__tests__/detail/hooks/usePokemonFlavorText.test.ts new file mode 100644 index 0000000..0d096f6 --- /dev/null +++ b/__tests__/detail/hooks/usePokemonFlavorText.test.ts @@ -0,0 +1,51 @@ +import { renderHook, waitFor, act } from "@testing-library/react-native"; +import { fetchPokemonFlavorText } from "@/src/detail/repository/pokemonSpeciesApi"; +import { usePokemonFlavorText } from "@/src/detail/hooks/usePokemonFlavorText"; + +jest.mock("@/src/detail/repository/pokemonSpeciesApi"); + +const mockFetchFlavorText = fetchPokemonFlavorText as jest.MockedFunction; + +describe("usePokemonFlavorText", () => { + beforeEach(() => { + mockFetchFlavorText.mockReset(); + }); + + it("初期ロード時にisLoadingがtrueになる", () => { + mockFetchFlavorText.mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => usePokemonFlavorText(25)); + expect(result.current.isLoading).toBe(true); + expect(result.current.flavorText).toBeNull(); + }); + + it("データ取得後にフレーバーテキストが設定される", async () => { + mockFetchFlavorText.mockResolvedValueOnce("でんきを ためこむ せいしつ。"); + const { result } = renderHook(() => usePokemonFlavorText(25)); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.flavorText).toBe("でんきを ためこむ せいしつ。"); + }); + + it("エラー時はflavorTextがnullのままになる", async () => { + mockFetchFlavorText.mockRejectedValueOnce(new Error("Not found")); + const { result } = renderHook(() => usePokemonFlavorText(25)); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.flavorText).toBeNull(); + }); + + it("アンマウント後にデータ取得が完了しても状態が更新されない", async () => { + let resolve: (value: string | null) => void; + mockFetchFlavorText.mockReturnValue(new Promise((r) => { resolve = r; })); + const { result, unmount } = renderHook(() => usePokemonFlavorText(25)); + expect(result.current.isLoading).toBe(true); + unmount(); + await act(async () => { resolve!("テスト"); }); + expect(result.current.flavorText).toBeNull(); + expect(result.current.isLoading).toBe(true); + }); +}); diff --git a/__tests__/detail/hooks/usePokemonSpeciesInfo.test.ts b/__tests__/detail/hooks/usePokemonSpeciesInfo.test.ts new file mode 100644 index 0000000..03dd475 --- /dev/null +++ b/__tests__/detail/hooks/usePokemonSpeciesInfo.test.ts @@ -0,0 +1,58 @@ +import { renderHook, waitFor, act } from "@testing-library/react-native"; +import { fetchPokemonSpeciesInfo } from "@/src/detail/repository/pokemonSpeciesApi"; +import { usePokemonSpeciesInfo } from "@/src/detail/hooks/usePokemonSpeciesInfo"; + +jest.mock("@/src/detail/repository/pokemonSpeciesApi"); + +const mockFetchSpeciesInfo = fetchPokemonSpeciesInfo as jest.MockedFunction; + +describe("usePokemonSpeciesInfo", () => { + beforeEach(() => { + mockFetchSpeciesInfo.mockReset(); + }); + + it("初期ロード時にisLoadingがtrueになる", () => { + mockFetchSpeciesInfo.mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => usePokemonSpeciesInfo(25)); + expect(result.current.isLoading).toBe(true); + expect(result.current.localizedName).toBeNull(); + expect(result.current.flavorText).toBeNull(); + }); + + it("ローカライズされたポケモン名とフレーバーテキストを返す", async () => { + mockFetchSpeciesInfo.mockResolvedValueOnce({ + localizedName: "ピカチュウ", + flavorText: "でんきを ためこむ せいしつ。", + }); + const { result } = renderHook(() => usePokemonSpeciesInfo(25)); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.localizedName).toBe("ピカチュウ"); + expect(result.current.flavorText).toBe("でんきを ためこむ せいしつ。"); + expect(mockFetchSpeciesInfo).toHaveBeenCalledWith(25, "ja"); + }); + + it("エラー時にnullを返す", async () => { + mockFetchSpeciesInfo.mockRejectedValueOnce(new Error("Not found")); + const { result } = renderHook(() => usePokemonSpeciesInfo(25)); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.localizedName).toBeNull(); + expect(result.current.flavorText).toBeNull(); + }); + + it("アンマウント後にデータ取得が完了しても状態が更新されない", async () => { + let resolve: (value: { localizedName: string | null; flavorText: string | null }) => void; + mockFetchSpeciesInfo.mockReturnValue(new Promise((r) => { resolve = r; })); + const { result, unmount } = renderHook(() => usePokemonSpeciesInfo(25)); + expect(result.current.isLoading).toBe(true); + unmount(); + await act(async () => { resolve!({ localizedName: "ピカチュウ", flavorText: "テスト" }); }); + expect(result.current.localizedName).toBeNull(); + expect(result.current.isLoading).toBe(true); + }); +}); diff --git a/__tests__/detail/repository/pokemonDetailApi.test.ts b/__tests__/detail/repository/pokemonDetailApi.test.ts new file mode 100644 index 0000000..88f88e2 --- /dev/null +++ b/__tests__/detail/repository/pokemonDetailApi.test.ts @@ -0,0 +1,105 @@ +import { fetchPokemonDetail } from "@/src/detail/repository/pokemonDetailApi"; +import type { Pokemon } from "@/src/shared"; + +const mockDetailApiResponse = { + id: 25, + name: "pikachu", + types: [ + { slot: 1, type: { name: "electric", url: "https://pokeapi.co/api/v2/type/13/" } }, + ], + stats: [ + { base_stat: 35, effort: 0, stat: { name: "hp", url: "" } }, + { base_stat: 55, effort: 0, stat: { name: "attack", url: "" } }, + { base_stat: 40, effort: 0, stat: { name: "defense", url: "" } }, + { base_stat: 50, effort: 0, stat: { name: "special-attack", url: "" } }, + { base_stat: 50, effort: 0, stat: { name: "special-defense", url: "" } }, + { base_stat: 90, effort: 0, stat: { name: "speed", url: "" } }, + ], + height: 4, + weight: 60, + abilities: [ + { ability: { name: "static", url: "" }, is_hidden: false, slot: 1 }, + { ability: { name: "lightning-rod", url: "" }, is_hidden: true, slot: 3 }, + ], +}; + +const originalFetch = globalThis.fetch; + +describe("pokemonDetailApi", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("正しいURLでfetchを呼び出す", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockDetailApiResponse), + }); + await fetchPokemonDetail(25); + expect(globalThis.fetch).toHaveBeenCalledWith("https://pokeapi.co/api/v2/pokemon/25"); + }); + + it("レスポンスからステータスを正しく変換する", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockDetailApiResponse), + }); + const result = await fetchPokemonDetail(25); + expect(result.stats).toEqual([ + { name: "hp", baseStat: 35 }, + { name: "attack", baseStat: 55 }, + { name: "defense", baseStat: 40 }, + { name: "special-attack", baseStat: 50 }, + { name: "special-defense", baseStat: 50 }, + { name: "speed", baseStat: 90 }, + ]); + }); + + it("身長と体重を正しく変換する", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockDetailApiResponse), + }); + const result = await fetchPokemonDetail(25); + expect(result.height).toBe(4); + expect(result.weight).toBe(60); + }); + + it("とくせいを正しく変換する", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockDetailApiResponse), + }); + const result = await fetchPokemonDetail(25); + expect(result.abilities).toEqual([ + { name: "static", isHidden: false }, + { name: "lightning-rod", isHidden: true }, + ]); + }); + + it("名前がキャピタライズされる", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockDetailApiResponse), + }); + const result = await fetchPokemonDetail(25); + expect(result.name).toBe("Pikachu"); + }); + + it("タイプが正しく変換される", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockDetailApiResponse), + }); + const result = await fetchPokemonDetail(25); + expect(result.types).toEqual(["electric"]); + }); + + it("HTTPエラー時にエラーをスローする", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: false, + status: 404, + }); + await expect(fetchPokemonDetail(99999)).rejects.toThrow("Failed to fetch pokemon detail: 404"); + }); +}); diff --git a/__tests__/detail/repository/pokemonSpeciesApi.test.ts b/__tests__/detail/repository/pokemonSpeciesApi.test.ts new file mode 100644 index 0000000..2d7f551 --- /dev/null +++ b/__tests__/detail/repository/pokemonSpeciesApi.test.ts @@ -0,0 +1,104 @@ +import { fetchPokemonFlavorText, fetchPokemonSpeciesInfo } from "@/src/detail/repository/pokemonSpeciesApi"; + +const mockSpeciesResponse = { + flavor_text_entries: [ + { flavor_text: "When several of these POKéMON gather, their electricity could build and cause lightning storms.", language: { name: "en", url: "" }, version: { name: "red", url: "" } }, + { flavor_text: "でんきを ためこむ せいしつ。", language: { name: "ja", url: "" }, version: { name: "red", url: "" } }, + ], + names: [ + { name: "Pikachu", language: { name: "en", url: "" } }, + { name: "ピカチュウ", language: { name: "ja", url: "" } }, + ], +}; + +const mockEmptySpeciesResponse = { + flavor_text_entries: [], + names: [], +}; + +const originalFetch = globalThis.fetch; + +describe("pokemonSpeciesApi", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe("fetchPokemonFlavorText", () => { + it("正しいURLでfetchを呼び出す", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSpeciesResponse), + }); + await fetchPokemonFlavorText(25); + expect(globalThis.fetch).toHaveBeenCalledWith("https://pokeapi.co/api/v2/pokemon-species/25"); + }); + + it("英語のフレーバーテキストを返す", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSpeciesResponse), + }); + const result = await fetchPokemonFlavorText(25); + expect(result).toBe("When several of these POKéMON gather, their electricity could build and cause lightning storms."); + }); + + it("フレーバーテキストがない場合はnullを返す", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockEmptySpeciesResponse), + }); + const result = await fetchPokemonFlavorText(25); + expect(result).toBeNull(); + }); + + it("HTTPエラー時にエラーをスローする", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: false, + status: 404, + }); + await expect(fetchPokemonFlavorText(99999)).rejects.toThrow("Failed to fetch pokemon species: 404"); + }); + }); + + describe("fetchPokemonSpeciesInfo", () => { + it("指定した言語のポケモン名とフレーバーテキストを返す", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSpeciesResponse), + }); + const result = await fetchPokemonSpeciesInfo(25, "ja"); + expect(result.localizedName).toBe("ピカチュウ"); + expect(result.flavorText).toBe("でんきを ためこむ せいしつ。"); + }); + + it("英語を指定した場合に英語のデータを返す", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSpeciesResponse), + }); + const result = await fetchPokemonSpeciesInfo(25, "en"); + expect(result.localizedName).toBe("Pikachu"); + expect(result.flavorText).toBe( + "When several of these POKéMON gather, their electricity could build and cause lightning storms." + ); + }); + + it("該当する言語がない場合はnullを返す", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockEmptySpeciesResponse), + }); + const result = await fetchPokemonSpeciesInfo(25, "ja"); + expect(result.localizedName).toBeNull(); + expect(result.flavorText).toBeNull(); + }); + + it("HTTPエラー時にエラーをスローする", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: false, + status: 404, + }); + await expect(fetchPokemonSpeciesInfo(99999, "ja")).rejects.toThrow("Failed to fetch pokemon species: 404"); + }); + }); +}); diff --git a/__tests__/detail/screens/detailScreen.feature b/__tests__/detail/screens/detailScreen.feature new file mode 100644 index 0000000..bf219de --- /dev/null +++ b/__tests__/detail/screens/detailScreen.feature @@ -0,0 +1,278 @@ +Feature: ポケモン詳細画面 + + # ============================================================ + # DetailScreen(画面統合テスト) + # ============================================================ + + Scenario: ローカライズされたポケモン名が表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then 「ピカチュウ」が表示される + + Scenario: ポケモンのIDが表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then 「#025」が表示される + + Scenario: ローディング中にActivityIndicatorが表示される + Given ローディング中のモック状態が設定されている + When 詳細画面をID「25」でレンダリングする + Then ローディングインジケータが表示される + + Scenario: エラー時にエラーメッセージが表示される + Given エラー状態のモックが設定されている + When 詳細画面をID「999」でレンダリングする + Then 「detail.notFound」が表示される + + Scenario: お気に入りボタンが詳細画面に表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then お気に入りボタンが詳細画面に表示される + + Scenario: ステータスが詳細画面に表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then 「detail.baseStats」が表示される + + Scenario: 身長と体重が詳細画面に表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then 「detail.height」が詳細画面に表示される + And 「detail.weight」が詳細画面に表示される + + Scenario: フレーバーテキストが詳細画面に表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then 「It keeps its tail raised.」が表示される + + Scenario: ローカライズ名がnullの場合はAPI名が表示される + Given ローカライズ名がnullのモック状態が設定されている + When 詳細画面をID「25」でレンダリングする + Then 「Pikachu」が表示される + + # ============================================================ + # PokemonDetailコンポーネント + # ============================================================ + + Scenario: ローカライズ名が渡された場合に表示される + Given ピカチュウのデータが用意されている + When ローカライズ名「ピカチュウ」を指定してPokemonDetailをレンダリングする + Then PokemonDetailに「ピカチュウ」が表示される + + Scenario: ローカライズ名がnullの場合はPokemonDetailでAPI名が表示される + Given ピカチュウのデータが用意されている + When ローカライズ名をnullにしてPokemonDetailをレンダリングする + Then PokemonDetailに「pikachu」が表示される + + Scenario: ポケモンのIDが3桁ゼロ埋めで表示される + Given ピカチュウのデータが用意されている + When PokemonDetailをレンダリングする + Then PokemonDetailに「#025」が表示される + + Scenario: ポケモンの画像が表示される + Given ピカチュウのデータが用意されている + When PokemonDetailをレンダリングする + Then ポケモン画像のURIに「25.png」が含まれる + + Scenario: タイプバッジが翻訳されて表示される + Given ピカチュウのデータが用意されている + When PokemonDetailをレンダリングする + Then PokemonDetailに「types.electric」が表示される + + Scenario: 複数タイプが翻訳されて全て表示される + Given リザードンのデータが用意されている + When PokemonDetailをリザードンでレンダリングする + Then PokemonDetailに「types.fire」が表示される + And PokemonDetailに「types.flying」も表示される + + Scenario: PokemonDetailにお気に入りボタンが表示される + Given ピカチュウのデータが用意されている + When お気に入り機能付きでPokemonDetailをレンダリングする + Then PokemonDetailのお気に入りボタンが表示される + + Scenario: お気に入りボタン押下後にonToggleFavoriteが呼ばれる + Given ピカチュウのデータが用意されている + When お気に入り機能付きでPokemonDetailをレンダリングしてボタンを押す + Then onToggleFavoriteが1回呼ばれる + + Scenario: お気に入りが未指定の場合ボタンが表示されない + Given ピカチュウのデータが用意されている + When PokemonDetailをレンダリングする + Then PokemonDetailのお気に入りボタンが表示されない + + Scenario: フレーバーテキストが渡された場合にPokemonDetailで表示される + Given ピカチュウのデータが用意されている + When フレーバーテキスト付きでPokemonDetailをレンダリングする + Then PokemonDetailにフレーバーテキストが表示される + + Scenario: フレーバーテキストが未指定の場合は表示されない + Given ピカチュウのデータが用意されている + When PokemonDetailをレンダリングする + Then フレーバーテキストのローディングが表示されない + + # ============================================================ + # PokemonAbilitiesコンポーネント + # ============================================================ + + Scenario: とくせいセクションタイトルが表示される + Given とくせいリストが与えられている + When PokemonAbilitiesをレンダリングする + Then とくせいセクションタイトル「detail.abilities」が表示される + + Scenario: とくせい名がキャピタライズされて表示される + Given とくせいリストが与えられている + When PokemonAbilitiesをレンダリングする + Then とくせい名「Overgrow」が表示される + + Scenario: 隠れとくせいにラベルが付与される + Given とくせいリストが与えられている + When PokemonAbilitiesをレンダリングする + Then 隠れとくせい「Chlorophyll detail.hiddenAbility」が表示される + + Scenario: 複数のとくせいが全て表示される + Given とくせいリストが与えられている + When PokemonAbilitiesをレンダリングする + Then とくせい名「Overgrow」が表示される + And 隠れとくせい「Chlorophyll detail.hiddenAbility」が表示される + + # ============================================================ + # PokemonFlavorTextコンポーネント + # ============================================================ + + Scenario: フレーバーテキストコンポーネントにテキストが表示される + Given フレーバーテキストが与えられている + When PokemonFlavorTextをレンダリングする + Then テキスト「でんきを ためこむ せいしつ。」が表示される + + Scenario: ローディング中にフレーバーテキストのインジケータが表示される + Given テキストがnullでローディング中である + When PokemonFlavorTextをレンダリングする + Then フレーバーテキストのローディングインジケータが表示される + + Scenario: テキストがnullでローディングでない場合は何も表示されない + Given テキストがnullでローディングでない + When PokemonFlavorTextをレンダリングする + Then 何も表示されない + + # ============================================================ + # PokemonPhysicalInfoコンポーネント + # ============================================================ + + Scenario: 身長の値が表示される + Given 身長7、体重69のポケモンデータが与えられている + When PokemonPhysicalInfoをレンダリングする + Then 体格情報「0.7detail.heightUnit」が表示される + + Scenario: 体重の値が表示される + Given 身長7、体重69のポケモンデータが与えられている + When PokemonPhysicalInfoをレンダリングする + Then 体格情報「6.9detail.weightUnit」が表示される + + Scenario: 身長ラベルが表示される + Given 身長7、体重69のポケモンデータが与えられている + When PokemonPhysicalInfoをレンダリングする + Then 体格情報「detail.height」が表示される + + Scenario: 体重ラベルが表示される + Given 身長7、体重69のポケモンデータが与えられている + When PokemonPhysicalInfoをレンダリングする + Then 体格情報「detail.weight」が表示される + + Scenario: 整数の身長が正しくフォーマットされる + Given 身長20、体重1000のポケモンデータが与えられている + When PokemonPhysicalInfoをレンダリングする + Then 体格情報「2.0detail.heightUnit」が表示される + And 体格情報「100.0detail.weightUnit」も表示される + + # ============================================================ + # PokemonStatsコンポーネント + # ============================================================ + + Scenario: ステータスセクションタイトルが表示される + Given ステータスデータが与えられている + When PokemonStatsをレンダリングする + Then ステータス「detail.baseStats」が表示される + + Scenario: 6つのステータスバーが表示される + Given ステータスデータが与えられている + When PokemonStatsをレンダリングする + Then ステータス「detail.stats.hp」が表示される + And ステータス「detail.stats.attack」が表示される + And ステータス「detail.stats.defense」が表示される + And ステータス「detail.stats.special-attack」が表示される + And ステータス「detail.stats.special-defense」が表示される + And ステータス「detail.stats.speed」が表示される + + Scenario: 各ステータスの値が正しく表示される + Given ステータスデータが与えられている + When PokemonStatsをレンダリングする + Then 値「45」が2つ表示される + And 値「49」が2つ表示される + And 値「65」が2つ表示される + + # ============================================================ + # StatBarコンポーネント + # ============================================================ + + Scenario: StatBarにステータス名が表示される + Given ラベル「HP」、値45のStatBarが与えられている + When StatBarをレンダリングする + Then StatBarに「HP」が表示される + + Scenario: StatBarにステータス値が表示される + Given ラベル「HP」、値45のStatBarが与えられている + When StatBarをレンダリングする + Then StatBarに「45」が表示される + + Scenario: バーの幅がステータス値に比例する + Given ラベル「HP」、値128、最大値256のStatBarが与えられている + When StatBarをレンダリングする + Then バーの幅が「50%」である + + Scenario: maxValueのデフォルトは180 + Given ラベル「Speed」、値90のStatBarが与えられている + When StatBarをレンダリングする + Then バーの幅が「50%」である + + # ============================================================ + # FavoriteButtonコンポーネント(shared) + # ============================================================ + + Scenario: Lottieアニメーションコンポーネントが描画される + Given 非お気に入り状態のFavoriteButtonを描画する + Then Lottieアニメーションコンポーネントが存在する + + Scenario: autoPlayが無効になっている + Given 非お気に入り状態のFavoriteButtonを描画する + Then autoPlayがfalseである + + Scenario: ループが無効になっている + Given 非お気に入り状態のFavoriteButtonを描画する + Then loopがfalseである + + Scenario: ボタン押下時にonToggleが即座に呼ばれる + Given 非お気に入り状態のFavoriteButtonを描画する + When お気に入りボタンを押す + Then onToggleが1回呼ばれる + + Scenario: お気に入り状態の場合、ONの最終フレームで初期表示される + Given お気に入り状態のFavoriteButtonを描画する + Then progressがON最終フレームの値である + + Scenario: 非お気に入り状態の場合、progressが0で初期表示される + Given 非お気に入り状態のFavoriteButtonを描画する + Then progressが0である + + Scenario: 外部からisFavoriteが変更された場合、progressが再同期される + Given 非お気に入り状態のFavoriteButtonを描画する + Then progressが0である + When isFavoriteをtrueに変更する + Then progressがON最終フレームの値である + + Scenario: お気に入り状態のアクセシビリティラベルが正しい + Given お気に入り状態のFavoriteButtonを描画する + Then アクセシビリティラベルが "favoriteButton.remove" である + + Scenario: 非お気に入り状態のアクセシビリティラベルが正しい + Given 非お気に入り状態のFavoriteButtonを描画する + Then アクセシビリティラベルが "favoriteButton.add" である diff --git a/__tests__/detail/screens/detailScreen.steps.tsx b/__tests__/detail/screens/detailScreen.steps.tsx new file mode 100644 index 0000000..7576249 --- /dev/null +++ b/__tests__/detail/screens/detailScreen.steps.tsx @@ -0,0 +1,950 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { render, screen, fireEvent } from "@testing-library/react-native"; +import { DetailScreen } from "@/src/detail"; +import { PokemonDetail } from "@/src/detail/components/PokemonDetail"; +import { PokemonAbilities } from "@/src/detail/components/PokemonAbilities"; +import { PokemonFlavorText } from "@/src/detail/components/PokemonFlavorText"; +import { PokemonPhysicalInfo } from "@/src/detail/components/PokemonPhysicalInfo"; +import { PokemonStats } from "@/src/detail/components/PokemonStats"; +import { StatBar } from "@/src/detail/components/StatBar"; +import { usePokemonDetail } from "@/src/detail/hooks/usePokemonDetail"; +import { usePokemonSpeciesInfo } from "@/src/detail/hooks/usePokemonSpeciesInfo"; +import { FavoriteButton } from "@/src/shared"; +import type { Pokemon, PokemonAbility, PokemonStat } from "@/src/shared"; + +// ============================================================ +// jest.mock — ファイルトップレベル +// ============================================================ + +jest.mock("@/src/detail/repository/pokemonDetailApi"); +jest.mock("@/src/detail/repository/pokemonSpeciesApi"); + +// DetailScreen用モック +const mockUsePokemonDetail = { + pokemon: null as Pokemon | null, + isLoading: false, + error: null as string | null, +}; + +const mockUsePokemonSpeciesInfo = { + flavorText: "It keeps its tail raised." as string | null, + localizedName: "ピカチュウ" as string | null, + isLoading: false, +}; + +jest.mock("@/src/detail/hooks/usePokemonDetail", () => ({ + usePokemonDetail: jest.fn(() => mockUsePokemonDetail), +})); + +jest.mock("@/src/detail/hooks/usePokemonSpeciesInfo", () => ({ + usePokemonSpeciesInfo: jest.fn(() => mockUsePokemonSpeciesInfo), +})); + +jest.mock("@/src/detail/hooks/usePokemonFlavorText", () => ({ + usePokemonFlavorText: jest.fn(), +})); + +// ============================================================ +// 共通テストデータ +// ============================================================ + +const mockPokemonForDetail: Pokemon = { + id: 25, + name: "pikachu", + types: ["electric"], + stats: [ + { name: "hp", baseStat: 35 }, + { name: "attack", baseStat: 55 }, + { name: "defense", baseStat: 40 }, + { name: "special-attack", baseStat: 50 }, + { name: "special-defense", baseStat: 50 }, + { name: "speed", baseStat: 90 }, + ], + height: 4, + weight: 60, + abilities: [ + { name: "static", isHidden: false }, + { name: "lightning-rod", isHidden: true }, + ], +}; + +const multiTypePokemon: Pokemon = { + id: 6, + name: "charizard", + types: ["fire", "flying"], + stats: [ + { name: "hp", baseStat: 78 }, + { name: "attack", baseStat: 84 }, + { name: "defense", baseStat: 78 }, + { name: "special-attack", baseStat: 109 }, + { name: "special-defense", baseStat: 85 }, + { name: "speed", baseStat: 100 }, + ], + height: 17, + weight: 905, + abilities: [ + { name: "blaze", isHidden: false }, + { name: "solar-power", isHidden: true }, + ], +}; + +const mockScreenPokemon: Pokemon = { + id: 25, + name: "Pikachu", + types: ["electric"], + stats: [ + { name: "hp", baseStat: 35 }, + { name: "attack", baseStat: 55 }, + { name: "defense", baseStat: 40 }, + { name: "special-attack", baseStat: 50 }, + { name: "special-defense", baseStat: 50 }, + { name: "speed", baseStat: 90 }, + ], + height: 4, + weight: 60, + abilities: [ + { name: "static", isHidden: false }, + { name: "lightning-rod", isHidden: true }, + ], +}; + +const mockAbilities: PokemonAbility[] = [ + { name: "overgrow", isHidden: false }, + { name: "chlorophyll", isHidden: true }, +]; + +const mockStats: PokemonStat[] = [ + { name: "hp", baseStat: 45 }, + { name: "attack", baseStat: 49 }, + { name: "defense", baseStat: 49 }, + { name: "special-attack", baseStat: 65 }, + { name: "special-defense", baseStat: 65 }, + { name: "speed", baseStat: 45 }, +]; + +// ============================================================ +// Feature 定義 +// ============================================================ + +const feature = loadFeature( + "__tests__/detail/screens/detailScreen.feature" +); + +const renderWithProvider = (id: string) => render(); + +defineFeature(feature, (test) => { + // ============================================================ + // DetailScreen + // ============================================================ + + beforeEach(() => { + mockUsePokemonDetail.pokemon = mockScreenPokemon; + mockUsePokemonDetail.isLoading = false; + mockUsePokemonDetail.error = null; + mockUsePokemonSpeciesInfo.flavorText = "It keeps its tail raised."; + mockUsePokemonSpeciesInfo.localizedName = "ピカチュウ"; + mockUsePokemonSpeciesInfo.isLoading = false; + }); + + test("ローカライズされたポケモン名が表示される", ({ given, when, then }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ポケモンのIDが表示される", ({ given, when, then }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ローディング中にActivityIndicatorが表示される", ({ given, when, then }) => { + given("ローディング中のモック状態が設定されている", () => { + mockUsePokemonDetail.isLoading = true; + mockUsePokemonDetail.pokemon = null; + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then("ローディングインジケータが表示される", () => { + expect(screen.getByTestId("loading-indicator")).toBeTruthy(); + }); + }); + + test("エラー時にエラーメッセージが表示される", ({ given, when, then }) => { + given("エラー状態のモックが設定されている", () => { + mockUsePokemonDetail.pokemon = null; + mockUsePokemonDetail.error = "Not found"; + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("お気に入りボタンが詳細画面に表示される", ({ given, when, then }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then("お気に入りボタンが詳細画面に表示される", () => { + expect(screen.getByTestId("favorite-button")).toBeTruthy(); + }); + }); + + test("ステータスが詳細画面に表示される", ({ given, when, then }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("身長と体重が詳細画面に表示される", ({ given, when, then, and }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が詳細画面に表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^「(.*)」が詳細画面に表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("フレーバーテキストが詳細画面に表示される", ({ given, when, then }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ローカライズ名がnullの場合はAPI名が表示される", ({ given, when, then }) => { + given("ローカライズ名がnullのモック状態が設定されている", () => { + mockUsePokemonSpeciesInfo.localizedName = null; + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + // ============================================================ + // PokemonDetailコンポーネント + // ============================================================ + + let onToggleFavorite: jest.Mock; + + test("ローカライズ名が渡された場合に表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when(/^ローカライズ名「(.*)」を指定してPokemonDetailをレンダリングする$/, (name: string) => { + render(); + }); + + then(/^PokemonDetailに「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ローカライズ名がnullの場合はPokemonDetailでAPI名が表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when("ローカライズ名をnullにしてPokemonDetailをレンダリングする", () => { + render(); + }); + + then(/^PokemonDetailに「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ポケモンのIDが3桁ゼロ埋めで表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when("PokemonDetailをレンダリングする", () => { + render(); + }); + + then(/^PokemonDetailに「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ポケモンの画像が表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when("PokemonDetailをレンダリングする", () => { + render(); + }); + + then(/^ポケモン画像のURIに「(.*)」が含まれる$/, (fragment: string) => { + const image = screen.getByTestId("pokemon-detail-image"); + expect(image.props.source.uri).toContain(fragment); + }); + }); + + test("タイプバッジが翻訳されて表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when("PokemonDetailをレンダリングする", () => { + render(); + }); + + then(/^PokemonDetailに「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("複数タイプが翻訳されて全て表示される", ({ given, when, then, and }) => { + given("リザードンのデータが用意されている", () => { + // multiTypePokemon is predefined + }); + + when("PokemonDetailをリザードンでレンダリングする", () => { + render(); + }); + + then(/^PokemonDetailに「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^PokemonDetailに「(.*)」も表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("PokemonDetailにお気に入りボタンが表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when("お気に入り機能付きでPokemonDetailをレンダリングする", () => { + render( + , + ); + }); + + then("PokemonDetailのお気に入りボタンが表示される", () => { + expect(screen.getByTestId("favorite-button")).toBeTruthy(); + }); + }); + + test("お気に入りボタン押下後にonToggleFavoriteが呼ばれる", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + onToggleFavorite = jest.fn(); + }); + + when("お気に入り機能付きでPokemonDetailをレンダリングしてボタンを押す", () => { + render( + , + ); + fireEvent.press(screen.getByTestId("favorite-button")); + }); + + then("onToggleFavoriteが1回呼ばれる", () => { + expect(onToggleFavorite).toHaveBeenCalledTimes(1); + }); + }); + + test("お気に入りが未指定の場合ボタンが表示されない", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when("PokemonDetailをレンダリングする", () => { + render(); + }); + + then("PokemonDetailのお気に入りボタンが表示されない", () => { + expect(screen.queryByTestId("favorite-button")).toBeNull(); + }); + }); + + test("フレーバーテキストが渡された場合にPokemonDetailで表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when("フレーバーテキスト付きでPokemonDetailをレンダリングする", () => { + render(); + }); + + then("PokemonDetailにフレーバーテキストが表示される", () => { + expect(screen.getByText("でんきを ためこむ せいしつ。")).toBeTruthy(); + }); + }); + + test("フレーバーテキストが未指定の場合は表示されない", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when("PokemonDetailをレンダリングする", () => { + render(); + }); + + then("フレーバーテキストのローディングが表示されない", () => { + expect(screen.queryByTestId("flavor-text-loading")).toBeNull(); + }); + }); + + // ============================================================ + // PokemonAbilitiesコンポーネント + // ============================================================ + + test("とくせいセクションタイトルが表示される", ({ given, when, then }) => { + given("とくせいリストが与えられている", () => { + // mockAbilities is predefined + }); + + when("PokemonAbilitiesをレンダリングする", () => { + render(); + }); + + then(/^とくせいセクションタイトル「(.*)」が表示される$/, (title: string) => { + expect(screen.getByText(title)).toBeTruthy(); + }); + }); + + test("とくせい名がキャピタライズされて表示される", ({ given, when, then }) => { + given("とくせいリストが与えられている", () => { + // mockAbilities is predefined + }); + + when("PokemonAbilitiesをレンダリングする", () => { + render(); + }); + + then(/^とくせい名「(.*)」が表示される$/, (name: string) => { + expect(screen.getByText(name)).toBeTruthy(); + }); + }); + + test("隠れとくせいにラベルが付与される", ({ given, when, then }) => { + given("とくせいリストが与えられている", () => { + // mockAbilities is predefined + }); + + when("PokemonAbilitiesをレンダリングする", () => { + render(); + }); + + then(/^隠れとくせい「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("複数のとくせいが全て表示される", ({ given, when, then, and }) => { + given("とくせいリストが与えられている", () => { + // mockAbilities is predefined + }); + + when("PokemonAbilitiesをレンダリングする", () => { + render(); + }); + + then(/^とくせい名「(.*)」が表示される$/, (name: string) => { + expect(screen.getByText(name)).toBeTruthy(); + }); + + and(/^隠れとくせい「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + // ============================================================ + // PokemonFlavorTextコンポーネント + // ============================================================ + + let flavorTextValue: string | null; + let flavorTextIsLoading: boolean; + let flavorTextRenderResult: ReturnType | null; + + test("フレーバーテキストコンポーネントにテキストが表示される", ({ given, when, then }) => { + given("フレーバーテキストが与えられている", () => { + flavorTextValue = "でんきを ためこむ せいしつ。"; + flavorTextIsLoading = false; + }); + + when("PokemonFlavorTextをレンダリングする", () => { + render(); + }); + + then(/^テキスト「(.*)」が表示される$/, (expected: string) => { + expect(screen.getByText(expected)).toBeTruthy(); + }); + }); + + test("ローディング中にフレーバーテキストのインジケータが表示される", ({ given, when, then }) => { + given("テキストがnullでローディング中である", () => { + flavorTextValue = null; + flavorTextIsLoading = true; + }); + + when("PokemonFlavorTextをレンダリングする", () => { + render(); + }); + + then("フレーバーテキストのローディングインジケータが表示される", () => { + expect(screen.getByTestId("flavor-text-loading")).toBeTruthy(); + }); + }); + + test("テキストがnullでローディングでない場合は何も表示されない", ({ given, when, then }) => { + given("テキストがnullでローディングでない", () => { + flavorTextValue = null; + flavorTextIsLoading = false; + }); + + when("PokemonFlavorTextをレンダリングする", () => { + flavorTextRenderResult = render(); + }); + + then("何も表示されない", () => { + expect(flavorTextRenderResult!.toJSON()).toBeNull(); + }); + }); + + // ============================================================ + // PokemonPhysicalInfoコンポーネント + // ============================================================ + + let physicalHeight: number; + let physicalWeight: number; + + test("身長の値が表示される", ({ given, when, then }) => { + given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { + physicalHeight = Number(h); + physicalWeight = Number(w); + }); + + when("PokemonPhysicalInfoをレンダリングする", () => { + render(); + }); + + then(/^体格情報「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("体重の値が表示される", ({ given, when, then }) => { + given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { + physicalHeight = Number(h); + physicalWeight = Number(w); + }); + + when("PokemonPhysicalInfoをレンダリングする", () => { + render(); + }); + + then(/^体格情報「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("身長ラベルが表示される", ({ given, when, then }) => { + given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { + physicalHeight = Number(h); + physicalWeight = Number(w); + }); + + when("PokemonPhysicalInfoをレンダリングする", () => { + render(); + }); + + then(/^体格情報「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("体重ラベルが表示される", ({ given, when, then }) => { + given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { + physicalHeight = Number(h); + physicalWeight = Number(w); + }); + + when("PokemonPhysicalInfoをレンダリングする", () => { + render(); + }); + + then(/^体格情報「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("整数の身長が正しくフォーマットされる", ({ given, when, then, and }) => { + given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { + physicalHeight = Number(h); + physicalWeight = Number(w); + }); + + when("PokemonPhysicalInfoをレンダリングする", () => { + render(); + }); + + then(/^体格情報「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^体格情報「(.*)」も表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + // ============================================================ + // PokemonStatsコンポーネント + // ============================================================ + + test("ステータスセクションタイトルが表示される", ({ given, when, then }) => { + given("ステータスデータが与えられている", () => { + // mockStats is predefined + }); + + when("PokemonStatsをレンダリングする", () => { + render(); + }); + + then(/^ステータス「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("6つのステータスバーが表示される", ({ given, when, then, and }) => { + given("ステータスデータが与えられている", () => { + // mockStats is predefined + }); + + when("PokemonStatsをレンダリングする", () => { + render(); + }); + + then(/^ステータス「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^ステータス「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^ステータス「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^ステータス「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^ステータス「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^ステータス「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("各ステータスの値が正しく表示される", ({ given, when, then, and }) => { + given("ステータスデータが与えられている", () => { + // mockStats is predefined + }); + + when("PokemonStatsをレンダリングする", () => { + render(); + }); + + then(/^値「(\d+)」が(\d+)つ表示される$/, (value: string, count: string) => { + expect(screen.getAllByText(value)).toHaveLength(Number(count)); + }); + + and(/^値「(\d+)」が(\d+)つ表示される$/, (value: string, count: string) => { + expect(screen.getAllByText(value)).toHaveLength(Number(count)); + }); + + and(/^値「(\d+)」が(\d+)つ表示される$/, (value: string, count: string) => { + expect(screen.getAllByText(value)).toHaveLength(Number(count)); + }); + }); + + // ============================================================ + // StatBarコンポーネント + // ============================================================ + + let statBarLabel: string; + let statBarValue: number; + let statBarMaxValue: number | undefined; + + test("StatBarにステータス名が表示される", ({ given, when, then }) => { + given(/^ラベル「(.*)」、値(\d+)のStatBarが与えられている$/, (l: string, v: string) => { + statBarLabel = l; + statBarValue = Number(v); + statBarMaxValue = undefined; + }); + + when("StatBarをレンダリングする", () => { + render(); + }); + + then(/^StatBarに「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("StatBarにステータス値が表示される", ({ given, when, then }) => { + given(/^ラベル「(.*)」、値(\d+)のStatBarが与えられている$/, (l: string, v: string) => { + statBarLabel = l; + statBarValue = Number(v); + statBarMaxValue = undefined; + }); + + when("StatBarをレンダリングする", () => { + render(); + }); + + then(/^StatBarに「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("バーの幅がステータス値に比例する", ({ given, when, then }) => { + given(/^ラベル「(.*)」、値(\d+)、最大値(\d+)のStatBarが与えられている$/, (l: string, v: string, m: string) => { + statBarLabel = l; + statBarValue = Number(v); + statBarMaxValue = Number(m); + }); + + when("StatBarをレンダリングする", () => { + render(); + }); + + then(/^バーの幅が「(.*)」である$/, (width: string) => { + const bar = screen.getByTestId("stat-bar-fill"); + expect(bar.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ width }), + ]) + ); + }); + }); + + test("maxValueのデフォルトは180", ({ given, when, then }) => { + given(/^ラベル「(.*)」、値(\d+)のStatBarが与えられている$/, (l: string, v: string) => { + statBarLabel = l; + statBarValue = Number(v); + statBarMaxValue = undefined; + }); + + when("StatBarをレンダリングする", () => { + render(); + }); + + then(/^バーの幅が「(.*)」である$/, (width: string) => { + const bar = screen.getByTestId("stat-bar-fill"); + expect(bar.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ width }), + ]) + ); + }); + }); + + // ============================================================ + // FavoriteButtonコンポーネント(shared) + // ============================================================ + + let favoriteOnToggle: jest.Mock; + let favoriteRerender: ReturnType["rerender"]; + + test("Lottieアニメーションコンポーネントが描画される", ({ given, then }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + render(); + }); + + then("Lottieアニメーションコンポーネントが存在する", () => { + expect(screen.getByTestId("favorite-lottie")).toBeTruthy(); + }); + }); + + test("autoPlayが無効になっている", ({ given, then }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + render(); + }); + + then("autoPlayがfalseである", () => { + const lottie = screen.getByTestId("favorite-lottie"); + expect(lottie.props.autoPlay).toBe(false); + }); + }); + + test("ループが無効になっている", ({ given, then }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + render(); + }); + + then("loopがfalseである", () => { + const lottie = screen.getByTestId("favorite-lottie"); + expect(lottie.props.loop).toBe(false); + }); + }); + + test("ボタン押下時にonToggleが即座に呼ばれる", ({ given, when, then }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + render(); + }); + + when("お気に入りボタンを押す", () => { + fireEvent.press(screen.getByTestId("favorite-button")); + }); + + then("onToggleが1回呼ばれる", () => { + expect(favoriteOnToggle).toHaveBeenCalledTimes(1); + }); + }); + + test("お気に入り状態の場合、ONの最終フレームで初期表示される", ({ given, then }) => { + given("お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + render(); + }); + + then("progressがON最終フレームの値である", () => { + const lottie = screen.getByTestId("favorite-lottie"); + expect(lottie.props.progress).toBeCloseTo(90 / 181); + }); + }); + + test("非お気に入り状態の場合、progressが0で初期表示される", ({ given, then }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + render(); + }); + + then("progressが0である", () => { + const lottie = screen.getByTestId("favorite-lottie"); + expect(lottie.props.progress).toBe(0); + }); + }); + + test("外部からisFavoriteが変更された場合、progressが再同期される", ({ given, then, when }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + const result = render( + + ); + favoriteRerender = result.rerender; + }); + + then("progressが0である", () => { + expect(screen.getByTestId("favorite-lottie").props.progress).toBe(0); + }); + + when("isFavoriteをtrueに変更する", () => { + favoriteRerender(); + }); + + then("progressがON最終フレームの値である", () => { + expect( + screen.getByTestId("favorite-lottie").props.progress + ).toBeCloseTo(90 / 181); + }); + }); + + test("お気に入り状態のアクセシビリティラベルが正しい", ({ given, then }) => { + given("お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + render(); + }); + + then( + /^アクセシビリティラベルが "(.*)" である$/, + (label: string) => { + expect(screen.getByLabelText(label)).toBeTruthy(); + } + ); + }); + + test("非お気に入り状態のアクセシビリティラベルが正しい", ({ given, then }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + render(); + }); + + then( + /^アクセシビリティラベルが "(.*)" である$/, + (label: string) => { + expect(screen.getByLabelText(label)).toBeTruthy(); + } + ); + }); +}); diff --git a/__tests__/detail/steps/detailScreen.steps.tsx b/__tests__/detail/steps/detailScreen.steps.tsx deleted file mode 100644 index 3072950..0000000 --- a/__tests__/detail/steps/detailScreen.steps.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { render, screen } from "@testing-library/react-native"; -import { DetailScreen } from "@/src/detail"; -import type { Pokemon } from "@/src/shared"; - -const feature = loadFeature( - "__tests__/detail/features/detailScreen.feature" -); - -const mockPokemon: Pokemon = { - id: 25, - name: "Pikachu", - types: ["electric"], - stats: [ - { name: "hp", baseStat: 35 }, - { name: "attack", baseStat: 55 }, - { name: "defense", baseStat: 40 }, - { name: "special-attack", baseStat: 50 }, - { name: "special-defense", baseStat: 50 }, - { name: "speed", baseStat: 90 }, - ], - height: 4, - weight: 60, - abilities: [ - { name: "static", isHidden: false }, - { name: "lightning-rod", isHidden: true }, - ], -}; - -const mockUsePokemonDetail = { - pokemon: mockPokemon as Pokemon | null, - isLoading: false, - error: null as string | null, -}; - -const mockUsePokemonSpeciesInfo = { - flavorText: "It keeps its tail raised." as string | null, - localizedName: "ピカチュウ" as string | null, - isLoading: false, -}; - -jest.mock("@/src/detail/hooks/usePokemonDetail", () => ({ - usePokemonDetail: () => mockUsePokemonDetail, -})); - -jest.mock("@/src/detail/hooks/usePokemonSpeciesInfo", () => ({ - usePokemonSpeciesInfo: () => mockUsePokemonSpeciesInfo, -})); - -const renderWithProvider = (id: string) => render(); - -defineFeature(feature, (test) => { - beforeEach(() => { - mockUsePokemonDetail.pokemon = mockPokemon; - mockUsePokemonDetail.isLoading = false; - mockUsePokemonDetail.error = null; - mockUsePokemonSpeciesInfo.flavorText = "It keeps its tail raised."; - mockUsePokemonSpeciesInfo.localizedName = "ピカチュウ"; - mockUsePokemonSpeciesInfo.isLoading = false; - }); - - test("ローカライズされたポケモン名が表示される", ({ given, when, then }) => { - given("ピカチュウの詳細データとローカライズ名がモックされている", () => { - // defaults from beforeEach - }); - - when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { - renderWithProvider(id); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("ポケモンのIDが表示される", ({ given, when, then }) => { - given("ピカチュウの詳細データとローカライズ名がモックされている", () => { - // defaults from beforeEach - }); - - when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { - renderWithProvider(id); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("ローディング中にActivityIndicatorが表示される", ({ given, when, then }) => { - given("ローディング中のモック状態が設定されている", () => { - mockUsePokemonDetail.isLoading = true; - mockUsePokemonDetail.pokemon = null; - }); - - when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { - renderWithProvider(id); - }); - - then("ローディングインジケータが表示される", () => { - expect(screen.getByTestId("loading-indicator")).toBeTruthy(); - }); - }); - - test("エラー時にエラーメッセージが表示される", ({ given, when, then }) => { - given("エラー状態のモックが設定されている", () => { - mockUsePokemonDetail.pokemon = null; - mockUsePokemonDetail.error = "Not found"; - }); - - when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { - renderWithProvider(id); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("お気に入りボタンが表示される", ({ given, when, then }) => { - given("ピカチュウの詳細データとローカライズ名がモックされている", () => { - // defaults from beforeEach - }); - - when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { - renderWithProvider(id); - }); - - then("お気に入りボタンが表示される", () => { - expect(screen.getByTestId("favorite-button")).toBeTruthy(); - }); - }); - - test("ステータスが詳細画面に表示される", ({ given, when, then }) => { - given("ピカチュウの詳細データとローカライズ名がモックされている", () => { - // defaults from beforeEach - }); - - when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { - renderWithProvider(id); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("身長と体重が詳細画面に表示される", ({ given, when, then, and }) => { - given("ピカチュウの詳細データとローカライズ名がモックされている", () => { - // defaults from beforeEach - }); - - when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { - renderWithProvider(id); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - - and(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("フレーバーテキストが表示される", ({ given, when, then }) => { - given("ピカチュウの詳細データとローカライズ名がモックされている", () => { - // defaults from beforeEach - }); - - when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { - renderWithProvider(id); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("ローカライズ名がnullの場合はAPI名が表示される", ({ given, when, then }) => { - given("ローカライズ名がnullのモック状態が設定されている", () => { - mockUsePokemonSpeciesInfo.localizedName = null; - }); - - when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { - renderWithProvider(id); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); -}); diff --git a/__tests__/detail/steps/pokemonAbilities.steps.tsx b/__tests__/detail/steps/pokemonAbilities.steps.tsx deleted file mode 100644 index ab9b418..0000000 --- a/__tests__/detail/steps/pokemonAbilities.steps.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { render, screen } from "@testing-library/react-native"; -import { PokemonAbilities } from "@/src/detail/components/PokemonAbilities"; -import type { PokemonAbility } from "@/src/shared"; - -const feature = loadFeature( - "__tests__/detail/features/pokemonAbilities.feature" -); - -const mockAbilities: PokemonAbility[] = [ - { name: "overgrow", isHidden: false }, - { name: "chlorophyll", isHidden: true }, -]; - -defineFeature(feature, (test) => { - test("セクションタイトルが表示される", ({ given, when, then }) => { - given("とくせいリストが与えられている", () => { - // mockAbilities is predefined - }); - - when("PokemonAbilitiesをレンダリングする", () => { - render(); - }); - - then(/^セクションタイトル「(.*)」が表示される$/, (title: string) => { - expect(screen.getByText(title)).toBeTruthy(); - }); - }); - - test("とくせい名がキャピタライズされて表示される", ({ given, when, then }) => { - given("とくせいリストが与えられている", () => { - // mockAbilities is predefined - }); - - when("PokemonAbilitiesをレンダリングする", () => { - render(); - }); - - then(/^とくせい名「(.*)」が表示される$/, (name: string) => { - expect(screen.getByText(name)).toBeTruthy(); - }); - }); - - test("隠れとくせいにラベルが付与される", ({ given, when, then }) => { - given("とくせいリストが与えられている", () => { - // mockAbilities is predefined - }); - - when("PokemonAbilitiesをレンダリングする", () => { - render(); - }); - - then(/^隠れとくせい「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("複数のとくせいが全て表示される", ({ given, when, then, and }) => { - given("とくせいリストが与えられている", () => { - // mockAbilities is predefined - }); - - when("PokemonAbilitiesをレンダリングする", () => { - render(); - }); - - then(/^とくせい名「(.*)」が表示される$/, (name: string) => { - expect(screen.getByText(name)).toBeTruthy(); - }); - - and(/^隠れとくせい「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); -}); diff --git a/__tests__/detail/steps/pokemonDetail.steps.tsx b/__tests__/detail/steps/pokemonDetail.steps.tsx deleted file mode 100644 index a3d51b2..0000000 --- a/__tests__/detail/steps/pokemonDetail.steps.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { render, screen, fireEvent } from "@testing-library/react-native"; -import { PokemonDetail } from "@/src/detail/components/PokemonDetail"; -import type { Pokemon } from "@/src/shared"; - -const feature = loadFeature( - "__tests__/detail/features/pokemonDetail.feature" -); - -const mockPokemon: Pokemon = { - id: 25, - name: "pikachu", - types: ["electric"], - stats: [ - { name: "hp", baseStat: 35 }, - { name: "attack", baseStat: 55 }, - { name: "defense", baseStat: 40 }, - { name: "special-attack", baseStat: 50 }, - { name: "special-defense", baseStat: 50 }, - { name: "speed", baseStat: 90 }, - ], - height: 4, - weight: 60, - abilities: [ - { name: "static", isHidden: false }, - { name: "lightning-rod", isHidden: true }, - ], -}; - -const multiTypePokemon: Pokemon = { - id: 6, - name: "charizard", - types: ["fire", "flying"], - stats: [ - { name: "hp", baseStat: 78 }, - { name: "attack", baseStat: 84 }, - { name: "defense", baseStat: 78 }, - { name: "special-attack", baseStat: 109 }, - { name: "special-defense", baseStat: 85 }, - { name: "speed", baseStat: 100 }, - ], - height: 17, - weight: 905, - abilities: [ - { name: "blaze", isHidden: false }, - { name: "solar-power", isHidden: true }, - ], -}; - -defineFeature(feature, (test) => { - let onToggleFavorite: jest.Mock; - - test("ローカライズ名が渡された場合に表示される", ({ given, when, then }) => { - given("ピカチュウのデータが用意されている", () => { - // mockPokemon is predefined - }); - - when(/^ローカライズ名「(.*)」を指定してPokemonDetailをレンダリングする$/, (name: string) => { - render(); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("ローカライズ名がnullの場合はAPI名が表示される", ({ given, when, then }) => { - given("ピカチュウのデータが用意されている", () => { - // mockPokemon is predefined - }); - - when("ローカライズ名をnullにしてPokemonDetailをレンダリングする", () => { - render(); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("ポケモンのIDが3桁ゼロ埋めで表示される", ({ given, when, then }) => { - given("ピカチュウのデータが用意されている", () => { - // mockPokemon is predefined - }); - - when("PokemonDetailをレンダリングする", () => { - render(); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("ポケモンの画像が表示される", ({ given, when, then }) => { - given("ピカチュウのデータが用意されている", () => { - // mockPokemon is predefined - }); - - when("PokemonDetailをレンダリングする", () => { - render(); - }); - - then(/^ポケモン画像のURIに「(.*)」が含まれる$/, (fragment: string) => { - const image = screen.getByTestId("pokemon-detail-image"); - expect(image.props.source.uri).toContain(fragment); - }); - }); - - test("タイプバッジが翻訳されて表示される", ({ given, when, then }) => { - given("ピカチュウのデータが用意されている", () => { - // mockPokemon is predefined - }); - - when("PokemonDetailをレンダリングする", () => { - render(); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("複数タイプが翻訳されて全て表示される", ({ given, when, then, and }) => { - given("リザードンのデータが用意されている", () => { - // multiTypePokemon is predefined - }); - - when("PokemonDetailをレンダリングする", () => { - render(); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - - and(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("お気に入りボタンが表示される", ({ given, when, then }) => { - given("ピカチュウのデータが用意されている", () => { - // mockPokemon is predefined - }); - - when("お気に入り機能付きでPokemonDetailをレンダリングする", () => { - render( - , - ); - }); - - then("お気に入りボタンが表示される", () => { - expect(screen.getByTestId("favorite-button")).toBeTruthy(); - }); - }); - - test("お気に入りボタン押下後にonToggleFavoriteが呼ばれる", ({ given, when, then }) => { - given("ピカチュウのデータが用意されている", () => { - onToggleFavorite = jest.fn(); - }); - - when("お気に入り機能付きでPokemonDetailをレンダリングしてボタンを押す", () => { - render( - , - ); - fireEvent.press(screen.getByTestId("favorite-button")); - }); - - then("onToggleFavoriteが1回呼ばれる", () => { - expect(onToggleFavorite).toHaveBeenCalledTimes(1); - }); - }); - - test("お気に入りが未指定の場合ボタンが表示されない", ({ given, when, then }) => { - given("ピカチュウのデータが用意されている", () => { - // mockPokemon is predefined - }); - - when("PokemonDetailをレンダリングする", () => { - render(); - }); - - then("お気に入りボタンが表示されない", () => { - expect(screen.queryByTestId("favorite-button")).toBeNull(); - }); - }); - - test("フレーバーテキストが渡された場合に表示される", ({ given, when, then }) => { - given("ピカチュウのデータが用意されている", () => { - // mockPokemon is predefined - }); - - when("フレーバーテキスト付きでPokemonDetailをレンダリングする", () => { - render(); - }); - - then("フレーバーテキストが表示される", () => { - expect(screen.getByText("でんきを ためこむ せいしつ。")).toBeTruthy(); - }); - }); - - test("フレーバーテキストが未指定の場合は表示されない", ({ given, when, then }) => { - given("ピカチュウのデータが用意されている", () => { - // mockPokemon is predefined - }); - - when("PokemonDetailをレンダリングする", () => { - render(); - }); - - then("フレーバーテキストのローディングが表示されない", () => { - expect(screen.queryByTestId("flavor-text-loading")).toBeNull(); - }); - }); -}); diff --git a/__tests__/detail/steps/pokemonDetailApi.steps.ts b/__tests__/detail/steps/pokemonDetailApi.steps.ts deleted file mode 100644 index e1dee72..0000000 --- a/__tests__/detail/steps/pokemonDetailApi.steps.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { fetchPokemonDetail } from "@/src/detail/repository/pokemonDetailApi"; -import type { Pokemon } from "@/src/shared"; - -const feature = loadFeature( - "__tests__/detail/features/pokemonDetailApi.feature" -); - -const mockApiResponse = { - id: 25, - name: "pikachu", - types: [ - { slot: 1, type: { name: "electric", url: "https://pokeapi.co/api/v2/type/13/" } }, - ], - stats: [ - { base_stat: 35, effort: 0, stat: { name: "hp", url: "" } }, - { base_stat: 55, effort: 0, stat: { name: "attack", url: "" } }, - { base_stat: 40, effort: 0, stat: { name: "defense", url: "" } }, - { base_stat: 50, effort: 0, stat: { name: "special-attack", url: "" } }, - { base_stat: 50, effort: 0, stat: { name: "special-defense", url: "" } }, - { base_stat: 90, effort: 0, stat: { name: "speed", url: "" } }, - ], - height: 4, - weight: 60, - abilities: [ - { ability: { name: "static", url: "" }, is_hidden: false, slot: 1 }, - { ability: { name: "lightning-rod", url: "" }, is_hidden: true, slot: 3 }, - ], -}; - -const originalFetch = globalThis.fetch; - -defineFeature(feature, (test) => { - let result: Pokemon; - let fetchError: Error | null; - - beforeEach(() => { - globalThis.fetch = jest.fn(); - fetchError = null; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - test("正しいURLでfetchを呼び出す", ({ given, when, then }) => { - given("PokeAPIがピカチュウのレスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockApiResponse), - }); - }); - - when("fetchPokemonDetailをID25で呼び出す", async () => { - result = await fetchPokemonDetail(25); - }); - - then(/^fetchが「(.*)」で呼ばれる$/, (url: string) => { - expect(globalThis.fetch).toHaveBeenCalledWith(url); - }); - }); - - test("レスポンスからステータスを正しく変換する", ({ given, when, then }) => { - given("PokeAPIがピカチュウのレスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockApiResponse), - }); - }); - - when("fetchPokemonDetailをID25で呼び出す", async () => { - result = await fetchPokemonDetail(25); - }); - - then("ステータスが正しく変換される", () => { - expect(result.stats).toEqual([ - { name: "hp", baseStat: 35 }, - { name: "attack", baseStat: 55 }, - { name: "defense", baseStat: 40 }, - { name: "special-attack", baseStat: 50 }, - { name: "special-defense", baseStat: 50 }, - { name: "speed", baseStat: 90 }, - ]); - }); - }); - - test("身長と体重を正しく変換する", ({ given, when, then, and }) => { - given("PokeAPIがピカチュウのレスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockApiResponse), - }); - }); - - when("fetchPokemonDetailをID25で呼び出す", async () => { - result = await fetchPokemonDetail(25); - }); - - then(/^身長が(\d+)である$/, (height: string) => { - expect(result.height).toBe(Number(height)); - }); - - and(/^体重が(\d+)である$/, (weight: string) => { - expect(result.weight).toBe(Number(weight)); - }); - }); - - test("とくせいを正しく変換する", ({ given, when, then }) => { - given("PokeAPIがピカチュウのレスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockApiResponse), - }); - }); - - when("fetchPokemonDetailをID25で呼び出す", async () => { - result = await fetchPokemonDetail(25); - }); - - then("とくせいが正しく変換される", () => { - expect(result.abilities).toEqual([ - { name: "static", isHidden: false }, - { name: "lightning-rod", isHidden: true }, - ]); - }); - }); - - test("名前がキャピタライズされる", ({ given, when, then }) => { - given("PokeAPIがピカチュウのレスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockApiResponse), - }); - }); - - when("fetchPokemonDetailをID25で呼び出す", async () => { - result = await fetchPokemonDetail(25); - }); - - then(/^名前が「(.*)」である$/, (name: string) => { - expect(result.name).toBe(name); - }); - }); - - test("タイプが正しく変換される", ({ given, when, then }) => { - given("PokeAPIがピカチュウのレスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockApiResponse), - }); - }); - - when("fetchPokemonDetailをID25で呼び出す", async () => { - result = await fetchPokemonDetail(25); - }); - - then(/^タイプが「(.*)」である$/, (type: string) => { - expect(result.types).toEqual([type]); - }); - }); - - test("HTTPエラー時にエラーをスローする", ({ given, when, then }) => { - given("PokeAPIが404エラーを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 404, - }); - }); - - when("fetchPokemonDetailをID99999で呼び出す", async () => { - try { - result = await fetchPokemonDetail(99999); - } catch (e) { - fetchError = e as Error; - } - }); - - then(/^「(.*)」エラーがスローされる$/, (message: string) => { - expect(fetchError).not.toBeNull(); - expect(fetchError!.message).toBe(message); - }); - }); -}); diff --git a/__tests__/detail/steps/pokemonFlavorText.steps.tsx b/__tests__/detail/steps/pokemonFlavorText.steps.tsx deleted file mode 100644 index 3a16630..0000000 --- a/__tests__/detail/steps/pokemonFlavorText.steps.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { render, screen } from "@testing-library/react-native"; -import { PokemonFlavorText } from "@/src/detail/components/PokemonFlavorText"; - -const feature = loadFeature( - "__tests__/detail/features/pokemonFlavorText.feature" -); - -defineFeature(feature, (test) => { - let text: string | null; - let isLoading: boolean; - let renderResult: ReturnType | null; - - test("フレーバーテキストが表示される", ({ given, when, then }) => { - given("フレーバーテキストが与えられている", () => { - text = "でんきを ためこむ せいしつ。"; - isLoading = false; - }); - - when("PokemonFlavorTextをレンダリングする", () => { - render(); - }); - - then(/^テキスト「(.*)」が表示される$/, (expected: string) => { - expect(screen.getByText(expected)).toBeTruthy(); - }); - }); - - test("ローディング中にインジケータが表示される", ({ given, when, then }) => { - given("テキストがnullでローディング中である", () => { - text = null; - isLoading = true; - }); - - when("PokemonFlavorTextをレンダリングする", () => { - render(); - }); - - then("ローディングインジケータが表示される", () => { - expect(screen.getByTestId("flavor-text-loading")).toBeTruthy(); - }); - }); - - test("テキストがnullでローディングでない場合は何も表示されない", ({ given, when, then }) => { - given("テキストがnullでローディングでない", () => { - text = null; - isLoading = false; - }); - - when("PokemonFlavorTextをレンダリングする", () => { - renderResult = render(); - }); - - then("何も表示されない", () => { - expect(renderResult!.toJSON()).toBeNull(); - }); - }); -}); diff --git a/__tests__/detail/steps/pokemonPhysicalInfo.steps.tsx b/__tests__/detail/steps/pokemonPhysicalInfo.steps.tsx deleted file mode 100644 index aea7ec8..0000000 --- a/__tests__/detail/steps/pokemonPhysicalInfo.steps.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { render, screen } from "@testing-library/react-native"; -import { PokemonPhysicalInfo } from "@/src/detail/components/PokemonPhysicalInfo"; - -const feature = loadFeature( - "__tests__/detail/features/pokemonPhysicalInfo.feature" -); - -defineFeature(feature, (test) => { - let height: number; - let weight: number; - - test("身長の値が表示される", ({ given, when, then }) => { - given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { - height = Number(h); - weight = Number(w); - }); - - when("PokemonPhysicalInfoをレンダリングする", () => { - render(); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("体重の値が表示される", ({ given, when, then }) => { - given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { - height = Number(h); - weight = Number(w); - }); - - when("PokemonPhysicalInfoをレンダリングする", () => { - render(); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("身長ラベルが表示される", ({ given, when, then }) => { - given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { - height = Number(h); - weight = Number(w); - }); - - when("PokemonPhysicalInfoをレンダリングする", () => { - render(); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("体重ラベルが表示される", ({ given, when, then }) => { - given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { - height = Number(h); - weight = Number(w); - }); - - when("PokemonPhysicalInfoをレンダリングする", () => { - render(); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("整数の身長が正しくフォーマットされる", ({ given, when, then, and }) => { - given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { - height = Number(h); - weight = Number(w); - }); - - when("PokemonPhysicalInfoをレンダリングする", () => { - render(); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - - and(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); -}); diff --git a/__tests__/detail/steps/pokemonSpeciesApi.steps.ts b/__tests__/detail/steps/pokemonSpeciesApi.steps.ts deleted file mode 100644 index dedce8e..0000000 --- a/__tests__/detail/steps/pokemonSpeciesApi.steps.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { fetchPokemonFlavorText, fetchPokemonSpeciesInfo } from "@/src/detail/repository/pokemonSpeciesApi"; - -const feature = loadFeature( - "__tests__/detail/features/pokemonSpeciesApi.feature" -); - -const mockSpeciesResponse = { - flavor_text_entries: [ - { flavor_text: "When several of these POKéMON gather, their electricity could build and cause lightning storms.", language: { name: "en", url: "" }, version: { name: "red", url: "" } }, - { flavor_text: "でんきを ためこむ せいしつ。", language: { name: "ja", url: "" }, version: { name: "red", url: "" } }, - ], - names: [ - { name: "Pikachu", language: { name: "en", url: "" } }, - { name: "ピカチュウ", language: { name: "ja", url: "" } }, - ], -}; - -const mockEmptyResponse = { - flavor_text_entries: [], - names: [], -}; - -const originalFetch = globalThis.fetch; - -defineFeature(feature, (test) => { - let flavorTextResult: string | null; - let speciesInfoResult: { localizedName: string | null; flavorText: string | null }; - let fetchError: Error | null; - - beforeEach(() => { - globalThis.fetch = jest.fn(); - fetchError = null; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - test("fetchPokemonFlavorTextが正しいURLでfetchを呼び出す", ({ given, when, then }) => { - given("PokeAPIが種族レスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockSpeciesResponse), - }); - }); - - when("fetchPokemonFlavorTextをID25で呼び出す", async () => { - flavorTextResult = await fetchPokemonFlavorText(25); - }); - - then(/^fetchが「(.*)」で呼ばれる$/, (url: string) => { - expect(globalThis.fetch).toHaveBeenCalledWith(url); - }); - }); - - test("英語のフレーバーテキストを返す", ({ given, when, then }) => { - given("PokeAPIが種族レスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockSpeciesResponse), - }); - }); - - when("fetchPokemonFlavorTextをID25で呼び出す", async () => { - flavorTextResult = await fetchPokemonFlavorText(25); - }); - - then("英語のフレーバーテキストが返される", () => { - expect(flavorTextResult).toBe("When several of these POKéMON gather, their electricity could build and cause lightning storms."); - }); - }); - - test("フレーバーテキストがない場合はnullを返す", ({ given, when, then }) => { - given("PokeAPIが空の種族レスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockEmptyResponse), - }); - }); - - when("fetchPokemonFlavorTextをID25で呼び出す", async () => { - flavorTextResult = await fetchPokemonFlavorText(25); - }); - - then("nullが返される", () => { - expect(flavorTextResult).toBeNull(); - }); - }); - - test("fetchPokemonFlavorTextでHTTPエラー時にエラーをスローする", ({ given, when, then }) => { - given("PokeAPIが404エラーを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 404, - }); - }); - - when("fetchPokemonFlavorTextをID99999で呼び出す", async () => { - try { - flavorTextResult = await fetchPokemonFlavorText(99999); - } catch (e) { - fetchError = e as Error; - } - }); - - then(/^「(.*)」エラーがスローされる$/, (message: string) => { - expect(fetchError).not.toBeNull(); - expect(fetchError!.message).toBe(message); - }); - }); - - test("指定した言語のポケモン名とフレーバーテキストを返す", ({ given, when, then, and }) => { - given("PokeAPIが種族レスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockSpeciesResponse), - }); - }); - - when(/^fetchPokemonSpeciesInfoをID(\d+)と言語「(.*)」で呼び出す$/, async (id: string, lang: string) => { - speciesInfoResult = await fetchPokemonSpeciesInfo(Number(id), lang); - }); - - then(/^localizedNameが「(.*)」である$/, (name: string) => { - expect(speciesInfoResult.localizedName).toBe(name); - }); - - and(/^flavorTextが「(.*)」である$/, (text: string) => { - expect(speciesInfoResult.flavorText).toBe(text); - }); - }); - - test("英語を指定した場合に英語のデータを返す", ({ given, when, then, and }) => { - given("PokeAPIが種族レスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockSpeciesResponse), - }); - }); - - when(/^fetchPokemonSpeciesInfoをID(\d+)と言語「(.*)」で呼び出す$/, async (id: string, lang: string) => { - speciesInfoResult = await fetchPokemonSpeciesInfo(Number(id), lang); - }); - - then(/^localizedNameが「(.*)」である$/, (name: string) => { - expect(speciesInfoResult.localizedName).toBe(name); - }); - - and("flavorTextが英語である", () => { - expect(speciesInfoResult.flavorText).toBe( - "When several of these POKéMON gather, their electricity could build and cause lightning storms." - ); - }); - }); - - test("該当する言語がない場合はnullを返す", ({ given, when, then, and }) => { - given("PokeAPIが空の種族レスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockEmptyResponse), - }); - }); - - when(/^fetchPokemonSpeciesInfoをID(\d+)と言語「(.*)」で呼び出す$/, async (id: string, lang: string) => { - speciesInfoResult = await fetchPokemonSpeciesInfo(Number(id), lang); - }); - - then("localizedNameがnullである", () => { - expect(speciesInfoResult.localizedName).toBeNull(); - }); - - and("flavorTextがnullである", () => { - expect(speciesInfoResult.flavorText).toBeNull(); - }); - }); - - test("fetchPokemonSpeciesInfoでHTTPエラー時にエラーをスローする", ({ given, when, then }) => { - given("PokeAPIが404エラーを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 404, - }); - }); - - when(/^fetchPokemonSpeciesInfoをID(\d+)と言語「(.*)」で呼び出す$/, async (id: string, lang: string) => { - try { - speciesInfoResult = await fetchPokemonSpeciesInfo(Number(id), lang); - } catch (e) { - fetchError = e as Error; - } - }); - - then(/^「(.*)」エラーがスローされる$/, (message: string) => { - expect(fetchError).not.toBeNull(); - expect(fetchError!.message).toBe(message); - }); - }); -}); diff --git a/__tests__/detail/steps/pokemonStats.steps.tsx b/__tests__/detail/steps/pokemonStats.steps.tsx deleted file mode 100644 index 755e018..0000000 --- a/__tests__/detail/steps/pokemonStats.steps.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { render, screen } from "@testing-library/react-native"; -import { PokemonStats } from "@/src/detail/components/PokemonStats"; -import type { PokemonStat } from "@/src/shared"; - -const feature = loadFeature( - "__tests__/detail/features/pokemonStats.feature" -); - -const mockStats: PokemonStat[] = [ - { name: "hp", baseStat: 45 }, - { name: "attack", baseStat: 49 }, - { name: "defense", baseStat: 49 }, - { name: "special-attack", baseStat: 65 }, - { name: "special-defense", baseStat: 65 }, - { name: "speed", baseStat: 45 }, -]; - -defineFeature(feature, (test) => { - test("セクションタイトルが表示される", ({ given, when, then }) => { - given("ステータスデータが与えられている", () => { - // mockStats is predefined - }); - - when("PokemonStatsをレンダリングする", () => { - render(); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("6つのステータスバーが表示される", ({ given, when, then, and }) => { - given("ステータスデータが与えられている", () => { - // mockStats is predefined - }); - - when("PokemonStatsをレンダリングする", () => { - render(); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - - and(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - - and(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - - and(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - - and(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - - and(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("各ステータスの値が正しく表示される", ({ given, when, then, and }) => { - given("ステータスデータが与えられている", () => { - // mockStats is predefined - }); - - when("PokemonStatsをレンダリングする", () => { - render(); - }); - - then(/^値「(\d+)」が(\d+)つ表示される$/, (value: string, count: string) => { - expect(screen.getAllByText(value)).toHaveLength(Number(count)); - }); - - and(/^値「(\d+)」が(\d+)つ表示される$/, (value: string, count: string) => { - expect(screen.getAllByText(value)).toHaveLength(Number(count)); - }); - - and(/^値「(\d+)」が(\d+)つ表示される$/, (value: string, count: string) => { - expect(screen.getAllByText(value)).toHaveLength(Number(count)); - }); - }); -}); diff --git a/__tests__/detail/steps/statBar.steps.tsx b/__tests__/detail/steps/statBar.steps.tsx deleted file mode 100644 index e43a83c..0000000 --- a/__tests__/detail/steps/statBar.steps.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { render, screen } from "@testing-library/react-native"; -import { StatBar } from "@/src/detail/components/StatBar"; - -const feature = loadFeature( - "__tests__/detail/features/statBar.feature" -); - -defineFeature(feature, (test) => { - let label: string; - let value: number; - let maxValue: number | undefined; - - test("ステータス名が表示される", ({ given, when, then }) => { - given(/^ラベル「(.*)」、値(\d+)のStatBarが与えられている$/, (l: string, v: string) => { - label = l; - value = Number(v); - maxValue = undefined; - }); - - when("StatBarをレンダリングする", () => { - render(); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("ステータス値が表示される", ({ given, when, then }) => { - given(/^ラベル「(.*)」、値(\d+)のStatBarが与えられている$/, (l: string, v: string) => { - label = l; - value = Number(v); - maxValue = undefined; - }); - - when("StatBarをレンダリングする", () => { - render(); - }); - - then(/^「(.*)」が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("バーの幅がステータス値に比例する", ({ given, when, then }) => { - given(/^ラベル「(.*)」、値(\d+)、最大値(\d+)のStatBarが与えられている$/, (l: string, v: string, m: string) => { - label = l; - value = Number(v); - maxValue = Number(m); - }); - - when("StatBarをレンダリングする", () => { - render(); - }); - - then(/^バーの幅が「(.*)」である$/, (width: string) => { - const bar = screen.getByTestId("stat-bar-fill"); - expect(bar.props.style).toEqual( - expect.arrayContaining([ - expect.objectContaining({ width }), - ]) - ); - }); - }); - - test("maxValueのデフォルトは180", ({ given, when, then }) => { - given(/^ラベル「(.*)」、値(\d+)のStatBarが与えられている$/, (l: string, v: string) => { - label = l; - value = Number(v); - maxValue = undefined; - }); - - when("StatBarをレンダリングする", () => { - render(); - }); - - then(/^バーの幅が「(.*)」である$/, (width: string) => { - const bar = screen.getByTestId("stat-bar-fill"); - expect(bar.props.style).toEqual( - expect.arrayContaining([ - expect.objectContaining({ width }), - ]) - ); - }); - }); -}); diff --git a/__tests__/detail/steps/usePokemonDetail.steps.ts b/__tests__/detail/steps/usePokemonDetail.steps.ts deleted file mode 100644 index a951a8f..0000000 --- a/__tests__/detail/steps/usePokemonDetail.steps.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { renderHook, waitFor, act } from "@testing-library/react-native"; -import { usePokemonDetail } from "@/src/detail/hooks/usePokemonDetail"; -import { fetchPokemonDetail } from "@/src/detail/repository/pokemonDetailApi"; -import type { Pokemon } from "@/src/shared"; - -jest.mock("@/src/detail/repository/pokemonDetailApi"); - -const mockFetch = fetchPokemonDetail as jest.MockedFunction; - -const feature = loadFeature( - "__tests__/detail/features/usePokemonDetail.feature" -); - -const mockPokemon: Pokemon = { - id: 25, - name: "Pikachu", - types: ["electric"], - stats: [ - { name: "hp", baseStat: 35 }, - { name: "attack", baseStat: 55 }, - { name: "defense", baseStat: 40 }, - { name: "special-attack", baseStat: 50 }, - { name: "special-defense", baseStat: 50 }, - { name: "speed", baseStat: 90 }, - ], - height: 4, - weight: 60, - abilities: [ - { name: "static", isHidden: false }, - { name: "lightning-rod", isHidden: true }, - ], -}; - -defineFeature(feature, (test) => { - let hookResult: ReturnType, { id: number }>>; - let resolve: (value: Pokemon) => void; - - beforeEach(() => { - mockFetch.mockReset(); - }); - - test("初期ロード時にisLoadingがtrueになる", ({ given, when, then, and }) => { - given("fetchPokemonDetailが未解決のPromiseを返す", () => { - mockFetch.mockReturnValue(new Promise(() => {})); - }); - - when("usePokemonDetailをID25で呼び出す", () => { - hookResult = renderHook(() => usePokemonDetail(25)); - }); - - then("isLoadingがtrueである", () => { - expect(hookResult.result.current.isLoading).toBe(true); - }); - - and("pokemonがnullである", () => { - expect(hookResult.result.current.pokemon).toBeNull(); - }); - }); - - test("データ取得後にポケモン詳細が設定される", ({ given, when, then, and }) => { - given("fetchPokemonDetailがピカチュウのデータを返す", () => { - mockFetch.mockResolvedValueOnce(mockPokemon); - }); - - when("usePokemonDetailをID25で呼び出して完了を待つ", async () => { - hookResult = renderHook(() => usePokemonDetail(25)); - await waitFor(() => { - expect(hookResult.result.current.isLoading).toBe(false); - }); - }); - - then("isLoadingがfalseである", () => { - expect(hookResult.result.current.isLoading).toBe(false); - }); - - and("ポケモン詳細データが設定される", () => { - expect(hookResult.result.current.pokemon).toEqual(mockPokemon); - }); - - and("fetchPokemonDetailがID25で呼ばれる", () => { - expect(mockFetch).toHaveBeenCalledWith(25); - }); - }); - - test("エラー時にerror状態が設定される", ({ given, when, then, and }) => { - given(/^fetchPokemonDetailがエラー「(.*)」を返す$/, (message: string) => { - mockFetch.mockRejectedValueOnce(new Error(message)); - }); - - when("usePokemonDetailをID25で呼び出して完了を待つ", async () => { - hookResult = renderHook(() => usePokemonDetail(25)); - await waitFor(() => { - expect(hookResult.result.current.isLoading).toBe(false); - }); - }); - - then("isLoadingがfalseである", () => { - expect(hookResult.result.current.isLoading).toBe(false); - }); - - and(/^errorが「(.*)」である$/, (message: string) => { - expect(hookResult.result.current.error).toBe(message); - }); - - and("pokemonがnullである", () => { - expect(hookResult.result.current.pokemon).toBeNull(); - }); - }); - - test("Error以外のエラーでもerror状態が設定される", ({ given, when, then, and }) => { - given("fetchPokemonDetailが文字列エラーを返す", () => { - mockFetch.mockRejectedValueOnce("string error"); - }); - - when("usePokemonDetailをID25で呼び出して完了を待つ", async () => { - hookResult = renderHook(() => usePokemonDetail(25)); - await waitFor(() => { - expect(hookResult.result.current.isLoading).toBe(false); - }); - }); - - then("isLoadingがfalseである", () => { - expect(hookResult.result.current.isLoading).toBe(false); - }); - - and(/^errorが「(.*)」である$/, (message: string) => { - expect(hookResult.result.current.error).toBe(message); - }); - }); - - test("アンマウント後にデータ取得が完了しても状態が更新されない", ({ given, when, then, and }) => { - given("fetchPokemonDetailが遅延Promiseを返す", () => { - mockFetch.mockReturnValue(new Promise((r) => { resolve = r; })); - }); - - when("usePokemonDetailをID25で呼び出してアンマウントしてからPromiseを解決する", async () => { - hookResult = renderHook(() => usePokemonDetail(25)); - expect(hookResult.result.current.isLoading).toBe(true); - hookResult.unmount(); - await act(async () => { resolve(mockPokemon); }); - }); - - then("pokemonがnullである", () => { - expect(hookResult.result.current.pokemon).toBeNull(); - }); - - and("isLoadingがtrueである", () => { - expect(hookResult.result.current.isLoading).toBe(true); - }); - }); - - test("IDが変わると再取得する", ({ given, when, then }) => { - given("fetchPokemonDetailがピカチュウのデータを返す", () => { - mockFetch.mockResolvedValueOnce(mockPokemon); - }); - - when("usePokemonDetailをID25で呼び出して完了後にID1に変更する", async () => { - hookResult = renderHook( - (props: { id: number }) => usePokemonDetail(props.id), - { initialProps: { id: 25 } } - ); - await waitFor(() => { - expect(hookResult.result.current.isLoading).toBe(false); - }); - - const newPokemon: Pokemon = { - ...mockPokemon, - id: 1, - name: "Bulbasaur", - types: ["grass", "poison"], - }; - mockFetch.mockResolvedValueOnce(newPokemon); - hookResult.rerender({ id: 1 }); - - await waitFor(() => { - expect(hookResult.result.current.pokemon).toEqual(newPokemon); - }); - }); - - then("新しいポケモンデータが設定される", () => { - expect(hookResult.result.current.pokemon?.id).toBe(1); - }); - }); -}); diff --git a/__tests__/detail/steps/usePokemonFlavorText.steps.ts b/__tests__/detail/steps/usePokemonFlavorText.steps.ts deleted file mode 100644 index 0afc345..0000000 --- a/__tests__/detail/steps/usePokemonFlavorText.steps.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { renderHook, waitFor, act } from "@testing-library/react-native"; -import { usePokemonFlavorText } from "@/src/detail/hooks/usePokemonFlavorText"; -import { fetchPokemonFlavorText } from "@/src/detail/repository/pokemonSpeciesApi"; - -jest.mock("@/src/detail/repository/pokemonSpeciesApi"); - -const mockFetch = fetchPokemonFlavorText as jest.MockedFunction; - -const feature = loadFeature( - "__tests__/detail/features/usePokemonFlavorText.feature" -); - -defineFeature(feature, (test) => { - let hookResult: ReturnType, unknown>>; - let resolve: (value: string | null) => void; - - beforeEach(() => { - mockFetch.mockReset(); - }); - - test("初期ロード時にisLoadingがtrueになる", ({ given, when, then, and }) => { - given("fetchPokemonFlavorTextが未解決のPromiseを返す", () => { - mockFetch.mockReturnValue(new Promise(() => {})); - }); - - when("usePokemonFlavorTextをID25で呼び出す", () => { - hookResult = renderHook(() => usePokemonFlavorText(25)); - }); - - then("isLoadingがtrueである", () => { - expect(hookResult.result.current.isLoading).toBe(true); - }); - - and("flavorTextがnullである", () => { - expect(hookResult.result.current.flavorText).toBeNull(); - }); - }); - - test("データ取得後にフレーバーテキストが設定される", ({ given, when, then, and }) => { - given("fetchPokemonFlavorTextがテキストを返す", () => { - mockFetch.mockResolvedValueOnce("でんきを ためこむ せいしつ。"); - }); - - when("usePokemonFlavorTextをID25で呼び出して完了を待つ", async () => { - hookResult = renderHook(() => usePokemonFlavorText(25)); - await waitFor(() => { - expect(hookResult.result.current.isLoading).toBe(false); - }); - }); - - then("isLoadingがfalseである", () => { - expect(hookResult.result.current.isLoading).toBe(false); - }); - - and(/^flavorTextが「(.*)」である$/, (text: string) => { - expect(hookResult.result.current.flavorText).toBe(text); - }); - }); - - test("エラー時はflavorTextがnullのままになる", ({ given, when, then, and }) => { - given("fetchPokemonFlavorTextがエラーを返す", () => { - mockFetch.mockRejectedValueOnce(new Error("Not found")); - }); - - when("usePokemonFlavorTextをID25で呼び出して完了を待つ", async () => { - hookResult = renderHook(() => usePokemonFlavorText(25)); - await waitFor(() => { - expect(hookResult.result.current.isLoading).toBe(false); - }); - }); - - then("isLoadingがfalseである", () => { - expect(hookResult.result.current.isLoading).toBe(false); - }); - - and("flavorTextがnullである", () => { - expect(hookResult.result.current.flavorText).toBeNull(); - }); - }); - - test("アンマウント後にデータ取得が完了しても状態が更新されない", ({ given, when, then, and }) => { - given("fetchPokemonFlavorTextが遅延Promiseを返す", () => { - mockFetch.mockReturnValue(new Promise((r) => { resolve = r; })); - }); - - when("usePokemonFlavorTextをID25で呼び出してアンマウントしてからPromiseを解決する", async () => { - hookResult = renderHook(() => usePokemonFlavorText(25)); - expect(hookResult.result.current.isLoading).toBe(true); - hookResult.unmount(); - await act(async () => { resolve("テスト"); }); - }); - - then("flavorTextがnullである", () => { - expect(hookResult.result.current.flavorText).toBeNull(); - }); - - and("isLoadingがtrueである", () => { - expect(hookResult.result.current.isLoading).toBe(true); - }); - }); -}); diff --git a/__tests__/detail/steps/usePokemonSpeciesInfo.steps.ts b/__tests__/detail/steps/usePokemonSpeciesInfo.steps.ts deleted file mode 100644 index 22c3504..0000000 --- a/__tests__/detail/steps/usePokemonSpeciesInfo.steps.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { renderHook, waitFor, act } from "@testing-library/react-native"; -import { usePokemonSpeciesInfo } from "@/src/detail/hooks/usePokemonSpeciesInfo"; -import { fetchPokemonSpeciesInfo } from "@/src/detail/repository/pokemonSpeciesApi"; - -jest.mock("@/src/detail/repository/pokemonSpeciesApi"); - -const mockFetch = fetchPokemonSpeciesInfo as jest.MockedFunction; - -const feature = loadFeature( - "__tests__/detail/features/usePokemonSpeciesInfo.feature" -); - -defineFeature(feature, (test) => { - let hookResult: ReturnType, unknown>>; - let resolve: (value: { localizedName: string | null; flavorText: string | null }) => void; - - beforeEach(() => { - mockFetch.mockReset(); - }); - - test("初期ロード時にisLoadingがtrueになる", ({ given, when, then, and }) => { - given("fetchPokemonSpeciesInfoが未解決のPromiseを返す", () => { - mockFetch.mockReturnValue(new Promise(() => {})); - }); - - when("usePokemonSpeciesInfoをID25で呼び出す", () => { - hookResult = renderHook(() => usePokemonSpeciesInfo(25)); - }); - - then("isLoadingがtrueである", () => { - expect(hookResult.result.current.isLoading).toBe(true); - }); - - and("localizedNameがnullである", () => { - expect(hookResult.result.current.localizedName).toBeNull(); - }); - - and("flavorTextがnullである", () => { - expect(hookResult.result.current.flavorText).toBeNull(); - }); - }); - - test("ローカライズされたポケモン名とフレーバーテキストを返す", ({ given, when, then, and }) => { - given("fetchPokemonSpeciesInfoが日本語データを返す", () => { - mockFetch.mockResolvedValueOnce({ - localizedName: "ピカチュウ", - flavorText: "でんきを ためこむ せいしつ。", - }); - }); - - when("usePokemonSpeciesInfoをID25で呼び出して完了を待つ", async () => { - hookResult = renderHook(() => usePokemonSpeciesInfo(25)); - await waitFor(() => { - expect(hookResult.result.current.isLoading).toBe(false); - }); - }); - - then("isLoadingがfalseである", () => { - expect(hookResult.result.current.isLoading).toBe(false); - }); - - and(/^localizedNameが「(.*)」である$/, (name: string) => { - expect(hookResult.result.current.localizedName).toBe(name); - }); - - and(/^flavorTextが「(.*)」である$/, (text: string) => { - expect(hookResult.result.current.flavorText).toBe(text); - }); - - and(/^fetchPokemonSpeciesInfoがID(\d+)と言語「(.*)」で呼ばれる$/, (id: string, lang: string) => { - expect(mockFetch).toHaveBeenCalledWith(Number(id), lang); - }); - }); - - test("エラー時にnullを返す", ({ given, when, then, and }) => { - given("fetchPokemonSpeciesInfoがエラーを返す", () => { - mockFetch.mockRejectedValueOnce(new Error("Not found")); - }); - - when("usePokemonSpeciesInfoをID25で呼び出して完了を待つ", async () => { - hookResult = renderHook(() => usePokemonSpeciesInfo(25)); - await waitFor(() => { - expect(hookResult.result.current.isLoading).toBe(false); - }); - }); - - then("isLoadingがfalseである", () => { - expect(hookResult.result.current.isLoading).toBe(false); - }); - - and("localizedNameがnullである", () => { - expect(hookResult.result.current.localizedName).toBeNull(); - }); - - and("flavorTextがnullである", () => { - expect(hookResult.result.current.flavorText).toBeNull(); - }); - }); - - test("アンマウント後にデータ取得が完了しても状態が更新されない", ({ given, when, then, and }) => { - given("fetchPokemonSpeciesInfoが遅延Promiseを返す", () => { - mockFetch.mockReturnValue(new Promise((r) => { resolve = r; })); - }); - - when("usePokemonSpeciesInfoをID25で呼び出してアンマウントしてからPromiseを解決する", async () => { - hookResult = renderHook(() => usePokemonSpeciesInfo(25)); - expect(hookResult.result.current.isLoading).toBe(true); - hookResult.unmount(); - await act(async () => { resolve({ localizedName: "ピカチュウ", flavorText: "テスト" }); }); - }); - - then("localizedNameがnullである", () => { - expect(hookResult.result.current.localizedName).toBeNull(); - }); - - and("isLoadingがtrueである", () => { - expect(hookResult.result.current.isLoading).toBe(true); - }); - }); -}); diff --git a/__tests__/favorites/features/usePokemonByIds.feature b/__tests__/favorites/features/usePokemonByIds.feature deleted file mode 100644 index 49d42b8..0000000 --- a/__tests__/favorites/features/usePokemonByIds.feature +++ /dev/null @@ -1,50 +0,0 @@ -Feature: IDリストによるポケモン取得フック - - Scenario: 空配列の場合はローディングせず空配列を返す - Given IDリストが空配列である - When フックをレンダーする - Then isLoadingはfalseである - And pokemonは空配列である - - Scenario: 複数IDのポケモンを並列取得する - Given ID 25 と 1 のポケモンデータが存在する - When IDリスト [25, 1] でフックをレンダーする - Then ローディング完了後にポケモンが2件取得される - And fetchPokemonByIdが2回呼ばれる - And fetchPokemonSpeciesInfoが2回呼ばれる - - Scenario: ローカライズ名がある場合はローカライズ名を使用する - Given ID 25 のポケモンにローカライズ名 "ピカチュウ" が存在する - When IDリスト [25] でフックをレンダーする - Then ポケモンの名前は "ピカチュウ" である - - Scenario: ローカライズ名がnullの場合は英語名にフォールバックする - Given ID 25 のポケモンにローカライズ名がnullである - When IDリスト [25] でフックをレンダーする - Then ポケモンの名前は "Pikachu" である - - Scenario: 現在の言語をfetchPokemonSpeciesInfoに渡す - Given ID 25 のポケモンデータが存在する - When IDリスト [25] でフックをレンダーする - Then fetchPokemonSpeciesInfoにID 25 と言語 "ja" が渡される - - Scenario: 一部取得に失敗してもエラーが設定される - Given ID 25 のポケモンは取得成功しID 999 は取得失敗する - When IDリスト [25, 999] でフックをレンダーする - Then エラーメッセージは "Not found" である - - Scenario: Error以外のエラーでもerror状態が設定される - Given ID 25 のポケモン取得が文字列エラーで失敗する - When IDリスト [25] でフックをレンダーする - Then エラーメッセージは "Unknown error" である - - Scenario: アンマウント後にデータ取得が完了しても状態が更新されない - Given ID 25 のポケモン取得が未解決のPromiseを返す - When IDリスト [25] でフックをレンダーしてアンマウントする - Then pokemonは空配列のままである - And isLoadingはtrueのままである - - Scenario: IDリストが変わると再取得する - Given ID 25 と 1 のポケモンデータが存在する - When IDリスト [25] でフックをレンダーし、その後 [25, 1] に変更する - Then ポケモンが2件取得される diff --git a/__tests__/favorites/hooks/usePokemonByIds.test.ts b/__tests__/favorites/hooks/usePokemonByIds.test.ts new file mode 100644 index 0000000..a061dc2 --- /dev/null +++ b/__tests__/favorites/hooks/usePokemonByIds.test.ts @@ -0,0 +1,182 @@ +import { renderHook, waitFor, act } from "@testing-library/react-native"; +import { fetchPokemonById, fetchPokemonSpeciesInfo } from "@/src/shared"; +import type { PokemonSummary, PokemonSpeciesInfo } from "@/src/shared"; + +jest.mock("@/src/shared/repository/pokemonApi"); +jest.mock("@/src/shared/repository/pokemonSpeciesApi"); + +jest.mock("@/src/shared/i18n", () => ({ + i18n: { + t: (key: string) => key, + language: "ja", + }, +})); + +const mockFetchById = fetchPokemonById as jest.MockedFunction; +const mockFetchSpecies = fetchPokemonSpeciesInfo as jest.MockedFunction; + +const mockPokemon: PokemonSummary[] = [ + { id: 25, name: "Pikachu", types: ["electric"] }, + { id: 1, name: "Bulbasaur", types: ["grass", "poison"] }, +]; + +const mockSpeciesJa: PokemonSpeciesInfo[] = [ + { localizedName: "ピカチュウ", flavorText: null }, + { localizedName: "フシギダネ", flavorText: null }, +]; + +// usePokemonByIdsはバレルにエクスポートされていないため直接import +// eslint-disable-next-line @typescript-eslint/no-require-imports +function getUsePokemonByIds() { + const { usePokemonByIds } = jest.requireActual< + typeof import("@/src/favorites/hooks/usePokemonByIds") + >("@/src/favorites/hooks/usePokemonByIds"); + return usePokemonByIds; +} + +describe("usePokemonByIds", () => { + beforeEach(() => { + mockFetchById.mockReset(); + mockFetchSpecies.mockReset(); + }); + + it("空配列の場合はローディングせず空配列を返す", () => { + const usePokemonByIds = getUsePokemonByIds(); + const { result } = renderHook(() => usePokemonByIds([])); + expect(result.current.isLoading).toBe(false); + expect(result.current.pokemon).toEqual([]); + }); + + it("複数IDのポケモンを並列取得する", async () => { + mockFetchById + .mockResolvedValueOnce(mockPokemon[0]) + .mockResolvedValueOnce(mockPokemon[1]); + mockFetchSpecies + .mockResolvedValueOnce(mockSpeciesJa[0]) + .mockResolvedValueOnce(mockSpeciesJa[1]); + + const usePokemonByIds = getUsePokemonByIds(); + const { result } = renderHook(() => usePokemonByIds([25, 1])); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.pokemon).toHaveLength(2); + expect(mockFetchById).toHaveBeenCalledTimes(2); + expect(mockFetchSpecies).toHaveBeenCalledTimes(2); + }); + + it("ローカライズ名がある場合はローカライズ名を使用する", async () => { + mockFetchById.mockResolvedValueOnce(mockPokemon[0]); + mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); + + const usePokemonByIds = getUsePokemonByIds(); + const { result } = renderHook(() => usePokemonByIds([25])); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.pokemon[0].name).toBe("ピカチュウ"); + }); + + it("ローカライズ名がnullの場合は英語名にフォールバックする", async () => { + mockFetchById.mockResolvedValueOnce(mockPokemon[0]); + mockFetchSpecies.mockResolvedValueOnce({ localizedName: null, flavorText: null }); + + const usePokemonByIds = getUsePokemonByIds(); + const { result } = renderHook(() => usePokemonByIds([25])); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.pokemon[0].name).toBe("Pikachu"); + }); + + it("現在の言語をfetchPokemonSpeciesInfoに渡す", async () => { + mockFetchById.mockResolvedValueOnce(mockPokemon[0]); + mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); + + const usePokemonByIds = getUsePokemonByIds(); + const { result } = renderHook(() => usePokemonByIds([25])); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(mockFetchSpecies).toHaveBeenCalledWith(25, "ja"); + }); + + it("一部取得に失敗してもエラーが設定される", async () => { + mockFetchById + .mockResolvedValueOnce(mockPokemon[0]) + .mockRejectedValueOnce(new Error("Not found")); + mockFetchSpecies + .mockResolvedValueOnce(mockSpeciesJa[0]) + .mockResolvedValueOnce(mockSpeciesJa[1]); + + const usePokemonByIds = getUsePokemonByIds(); + const { result } = renderHook(() => usePokemonByIds([25, 999])); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.error).toBe("Not found"); + }); + + it("Error以外のエラーでもerror状態が設定される", async () => { + mockFetchById.mockRejectedValueOnce("string error"); + mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); + + const usePokemonByIds = getUsePokemonByIds(); + const { result } = renderHook(() => usePokemonByIds([25])); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.error).toBe("Unknown error"); + }); + + it("アンマウント後にデータ取得が完了しても状態が更新されない", async () => { + let resolve!: (value: PokemonSummary) => void; + mockFetchById.mockReturnValue(new Promise((r) => { resolve = r; })); + mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); + + const usePokemonByIds = getUsePokemonByIds(); + const { result, unmount } = renderHook(() => usePokemonByIds([25])); + + expect(result.current.isLoading).toBe(true); + unmount(); + await act(async () => { resolve(mockPokemon[0]); }); + + expect(result.current.pokemon).toEqual([]); + expect(result.current.isLoading).toBe(true); + }); + + it("IDリストが変わると再取得する", async () => { + mockFetchById.mockResolvedValueOnce(mockPokemon[0]); + mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); + + const usePokemonByIds = getUsePokemonByIds(); + const { result, rerender } = renderHook( + (props: { ids: number[] }) => usePokemonByIds(props.ids), + { initialProps: { ids: [25] } } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.pokemon).toHaveLength(1); + + mockFetchById + .mockResolvedValueOnce(mockPokemon[0]) + .mockResolvedValueOnce(mockPokemon[1]); + mockFetchSpecies + .mockResolvedValueOnce(mockSpeciesJa[0]) + .mockResolvedValueOnce(mockSpeciesJa[1]); + + rerender({ ids: [25, 1] }); + + await waitFor(() => { + expect(result.current.pokemon).toHaveLength(2); + }); + }); +}); diff --git a/__tests__/favorites/features/favoritesScreen.feature b/__tests__/favorites/screens/favoritesScreen.feature similarity index 100% rename from __tests__/favorites/features/favoritesScreen.feature rename to __tests__/favorites/screens/favoritesScreen.feature diff --git a/__tests__/favorites/steps/favoritesScreen.steps.tsx b/__tests__/favorites/screens/favoritesScreen.steps.tsx similarity index 81% rename from __tests__/favorites/steps/favoritesScreen.steps.tsx rename to __tests__/favorites/screens/favoritesScreen.steps.tsx index 33549c1..cdd6dec 100644 --- a/__tests__/favorites/steps/favoritesScreen.steps.tsx +++ b/__tests__/favorites/screens/favoritesScreen.steps.tsx @@ -3,14 +3,14 @@ import { render, screen } from "@testing-library/react-native"; import { FavoritesScreen } from "@/src/favorites"; import type { PokemonSummary } from "@/src/shared"; -const mockUsePokemonByIds = { +const mockUsePokemonByIdsReturn = { pokemon: [] as PokemonSummary[], isLoading: false, error: null as string | null, }; jest.mock("@/src/favorites/hooks/usePokemonByIds", () => ({ - usePokemonByIds: () => mockUsePokemonByIds, + usePokemonByIds: jest.fn(() => mockUsePokemonByIdsReturn), })); jest.mock("expo-router", () => ({ @@ -27,15 +27,21 @@ jest.mock("expo-router", () => ({ }, })); +jest.mock("@/src/shared/i18n", () => ({ + i18n: { + t: (key: string) => key, + }, +})); + const feature = loadFeature( - "__tests__/favorites/features/favoritesScreen.feature" + "__tests__/favorites/screens/favoritesScreen.feature" ); defineFeature(feature, (test) => { beforeEach(() => { - mockUsePokemonByIds.pokemon = []; - mockUsePokemonByIds.isLoading = false; - mockUsePokemonByIds.error = null; + mockUsePokemonByIdsReturn.pokemon = []; + mockUsePokemonByIdsReturn.isLoading = false; + mockUsePokemonByIdsReturn.error = null; }); test("お気に入りが空の場合、プレースホルダーが表示される", ({ given, when, then }) => { @@ -54,7 +60,7 @@ defineFeature(feature, (test) => { test("ローディング中にActivityIndicatorが表示される", ({ given, when, then }) => { given("データをローディング中である", () => { - mockUsePokemonByIds.isLoading = true; + mockUsePokemonByIdsReturn.isLoading = true; }); when("お気に入り画面を表示する", () => { @@ -68,7 +74,7 @@ defineFeature(feature, (test) => { test("お気に入りのポケモンがカードとして表示される", ({ given, when, then }) => { given("お気に入りにピカチュウが登録されている", () => { - mockUsePokemonByIds.pokemon = [ + mockUsePokemonByIdsReturn.pokemon = [ { id: 25, name: "Pikachu", types: ["electric"] }, ]; }); diff --git a/__tests__/favorites/steps/usePokemonByIds.steps.ts b/__tests__/favorites/steps/usePokemonByIds.steps.ts deleted file mode 100644 index 3e81639..0000000 --- a/__tests__/favorites/steps/usePokemonByIds.steps.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { renderHook, waitFor, act } from "@testing-library/react-native"; -import { usePokemonByIds } from "@/src/favorites/hooks/usePokemonByIds"; -import { fetchPokemonById } from "@/src/shared/repository/pokemonApi"; -import { fetchPokemonSpeciesInfo } from "@/src/shared/repository/pokemonSpeciesApi"; -import type { PokemonSummary, PokemonSpeciesInfo } from "@/src/shared"; - -jest.mock("@/src/shared/repository/pokemonApi"); -jest.mock("@/src/shared/repository/pokemonSpeciesApi"); - -const mockFetchById = fetchPokemonById as jest.MockedFunction; -const mockFetchSpecies = fetchPokemonSpeciesInfo as jest.MockedFunction; - -const mockPokemon: PokemonSummary[] = [ - { id: 25, name: "Pikachu", types: ["electric"] }, - { id: 1, name: "Bulbasaur", types: ["grass", "poison"] }, -]; - -const mockSpeciesJa: PokemonSpeciesInfo[] = [ - { localizedName: "ピカチュウ", flavorText: null }, - { localizedName: "フシギダネ", flavorText: null }, -]; - -const feature = loadFeature( - "__tests__/favorites/features/usePokemonByIds.feature" -); - -defineFeature(feature, (test) => { - beforeEach(() => { - mockFetchById.mockReset(); - mockFetchSpecies.mockReset(); - }); - - test("空配列の場合はローディングせず空配列を返す", ({ given, when, then, and }) => { - let result: ReturnType; - - given("IDリストが空配列である", () => { - // no setup needed - }); - - when("フックをレンダーする", () => { - const hook = renderHook(() => usePokemonByIds([])); - result = hook.result.current; - }); - - then("isLoadingはfalseである", () => { - expect(result.isLoading).toBe(false); - }); - - and("pokemonは空配列である", () => { - expect(result.pokemon).toEqual([]); - }); - }); - - test("複数IDのポケモンを並列取得する", ({ given, when, then, and }) => { - let hookResult: { current: ReturnType }; - - given(/^ID 25 と 1 のポケモンデータが存在する$/, () => { - mockFetchById - .mockResolvedValueOnce(mockPokemon[0]) - .mockResolvedValueOnce(mockPokemon[1]); - mockFetchSpecies - .mockResolvedValueOnce(mockSpeciesJa[0]) - .mockResolvedValueOnce(mockSpeciesJa[1]); - }); - - when(/^IDリスト \[25, 1\] でフックをレンダーする$/, () => { - const hook = renderHook(() => usePokemonByIds([25, 1])); - hookResult = hook.result; - }); - - then("ローディング完了後にポケモンが2件取得される", async () => { - await waitFor(() => { - expect(hookResult.current.isLoading).toBe(false); - }); - expect(hookResult.current.pokemon).toHaveLength(2); - }); - - and("fetchPokemonByIdが2回呼ばれる", () => { - expect(mockFetchById).toHaveBeenCalledTimes(2); - }); - - and("fetchPokemonSpeciesInfoが2回呼ばれる", () => { - expect(mockFetchSpecies).toHaveBeenCalledTimes(2); - }); - }); - - test("ローカライズ名がある場合はローカライズ名を使用する", ({ given, when, then }) => { - let hookResult: { current: ReturnType }; - - given(/^ID 25 のポケモンにローカライズ名 "ピカチュウ" が存在する$/, () => { - mockFetchById.mockResolvedValueOnce(mockPokemon[0]); - mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); - }); - - when(/^IDリスト \[25\] でフックをレンダーする$/, () => { - const hook = renderHook(() => usePokemonByIds([25])); - hookResult = hook.result; - }); - - then(/^ポケモンの名前は "ピカチュウ" である$/, async () => { - await waitFor(() => { - expect(hookResult.current.isLoading).toBe(false); - }); - expect(hookResult.current.pokemon[0].name).toBe("ピカチュウ"); - }); - }); - - test("ローカライズ名がnullの場合は英語名にフォールバックする", ({ given, when, then }) => { - let hookResult: { current: ReturnType }; - - given(/^ID 25 のポケモンにローカライズ名がnullである$/, () => { - mockFetchById.mockResolvedValueOnce(mockPokemon[0]); - mockFetchSpecies.mockResolvedValueOnce({ localizedName: null, flavorText: null }); - }); - - when(/^IDリスト \[25\] でフックをレンダーする$/, () => { - const hook = renderHook(() => usePokemonByIds([25])); - hookResult = hook.result; - }); - - then(/^ポケモンの名前は "Pikachu" である$/, async () => { - await waitFor(() => { - expect(hookResult.current.isLoading).toBe(false); - }); - expect(hookResult.current.pokemon[0].name).toBe("Pikachu"); - }); - }); - - test("現在の言語をfetchPokemonSpeciesInfoに渡す", ({ given, when, then }) => { - let hookResult: { current: ReturnType }; - - given(/^ID 25 のポケモンデータが存在する$/, () => { - mockFetchById.mockResolvedValueOnce(mockPokemon[0]); - mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); - }); - - when(/^IDリスト \[25\] でフックをレンダーする$/, () => { - const hook = renderHook(() => usePokemonByIds([25])); - hookResult = hook.result; - }); - - then(/^fetchPokemonSpeciesInfoにID 25 と言語 "ja" が渡される$/, async () => { - await waitFor(() => { - expect(hookResult.current.isLoading).toBe(false); - }); - expect(mockFetchSpecies).toHaveBeenCalledWith(25, "ja"); - }); - }); - - test("一部取得に失敗してもエラーが設定される", ({ given, when, then }) => { - let hookResult: { current: ReturnType }; - - given(/^ID 25 のポケモンは取得成功しID 999 は取得失敗する$/, () => { - mockFetchById - .mockResolvedValueOnce(mockPokemon[0]) - .mockRejectedValueOnce(new Error("Not found")); - mockFetchSpecies - .mockResolvedValueOnce(mockSpeciesJa[0]) - .mockResolvedValueOnce(mockSpeciesJa[1]); - }); - - when(/^IDリスト \[25, 999\] でフックをレンダーする$/, () => { - const hook = renderHook(() => usePokemonByIds([25, 999])); - hookResult = hook.result; - }); - - then(/^エラーメッセージは "Not found" である$/, async () => { - await waitFor(() => { - expect(hookResult.current.isLoading).toBe(false); - }); - expect(hookResult.current.error).toBe("Not found"); - }); - }); - - test("Error以外のエラーでもerror状態が設定される", ({ given, when, then }) => { - let hookResult: { current: ReturnType }; - - given(/^ID 25 のポケモン取得が文字列エラーで失敗する$/, () => { - mockFetchById.mockRejectedValueOnce("string error"); - mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); - }); - - when(/^IDリスト \[25\] でフックをレンダーする$/, () => { - const hook = renderHook(() => usePokemonByIds([25])); - hookResult = hook.result; - }); - - then(/^エラーメッセージは "Unknown error" である$/, async () => { - await waitFor(() => { - expect(hookResult.current.isLoading).toBe(false); - }); - expect(hookResult.current.error).toBe("Unknown error"); - }); - }); - - test("アンマウント後にデータ取得が完了しても状態が更新されない", ({ given, when, then, and }) => { - let hookResult: { current: ReturnType }; - let resolve!: (value: PokemonSummary) => void; - - given(/^ID 25 のポケモン取得が未解決のPromiseを返す$/, () => { - mockFetchById.mockReturnValue(new Promise((r) => { resolve = r; })); - mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); - }); - - when(/^IDリスト \[25\] でフックをレンダーしてアンマウントする$/, async () => { - const { result, unmount } = renderHook(() => usePokemonByIds([25])); - hookResult = result; - expect(hookResult.current.isLoading).toBe(true); - unmount(); - await act(async () => { resolve(mockPokemon[0]); }); - }); - - then("pokemonは空配列のままである", () => { - expect(hookResult.current.pokemon).toEqual([]); - }); - - and("isLoadingはtrueのままである", () => { - expect(hookResult.current.isLoading).toBe(true); - }); - }); - - test("IDリストが変わると再取得する", ({ given, when, then }) => { - let hookResult: { current: ReturnType }; - let rerender: (props: { ids: number[] }) => void; - - given(/^ID 25 と 1 のポケモンデータが存在する$/, () => { - mockFetchById.mockResolvedValueOnce(mockPokemon[0]); - mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); - }); - - when(/^IDリスト \[25\] でフックをレンダーし、その後 \[25, 1\] に変更する$/, async () => { - const hook = renderHook( - (props: { ids: number[] }) => usePokemonByIds(props.ids), - { initialProps: { ids: [25] } } - ); - hookResult = hook.result; - rerender = hook.rerender; - - await waitFor(() => { - expect(hookResult.current.isLoading).toBe(false); - }); - expect(hookResult.current.pokemon).toHaveLength(1); - - mockFetchById - .mockResolvedValueOnce(mockPokemon[0]) - .mockResolvedValueOnce(mockPokemon[1]); - mockFetchSpecies - .mockResolvedValueOnce(mockSpeciesJa[0]) - .mockResolvedValueOnce(mockSpeciesJa[1]); - - rerender({ ids: [25, 1] }); - }); - - then("ポケモンが2件取得される", async () => { - await waitFor(() => { - expect(hookResult.current.pokemon).toHaveLength(2); - }); - }); - }); -}); diff --git a/__tests__/home/domain/pokemonListItem.test.ts b/__tests__/home/domain/pokemonListItem.test.ts new file mode 100644 index 0000000..77167f0 --- /dev/null +++ b/__tests__/home/domain/pokemonListItem.test.ts @@ -0,0 +1,38 @@ +import { extractPokemonId, capitalizeName, toPokemon } from "@/src/home"; + +describe("extractPokemonId", () => { + it("URLからポケモンIDを数値として抽出する", () => { + expect(extractPokemonId("https://pokeapi.co/api/v2/pokemon/25/")).toBe(25); + }); + + it("末尾スラッシュなしのURLからもIDを抽出する", () => { + expect(extractPokemonId("https://pokeapi.co/api/v2/pokemon/1")).toBe(1); + }); + + it("3桁のIDも正しく抽出する", () => { + expect(extractPokemonId("https://pokeapi.co/api/v2/pokemon/151/")).toBe(151); + }); +}); + +describe("capitalizeName", () => { + it("先頭文字を大文字にする", () => { + expect(capitalizeName("bulbasaur")).toBe("Bulbasaur"); + }); + + it("空文字の場合はそのまま返す", () => { + expect(capitalizeName("")).toBe(""); + }); +}); + +describe("toPokemon", () => { + it("PokeApiListItemをPokemonSummary型に変換する", () => { + const result = toPokemon({ + name: "pikachu", + url: "https://pokeapi.co/api/v2/pokemon/25/", + }); + + expect(result.id).toBe(25); + expect(result.name).toBe("Pikachu"); + expect(result.types).toEqual([]); + }); +}); diff --git a/__tests__/home/features/floatingSearchButton.feature b/__tests__/home/features/floatingSearchButton.feature deleted file mode 100644 index 1820663..0000000 --- a/__tests__/home/features/floatingSearchButton.feature +++ /dev/null @@ -1,34 +0,0 @@ -Feature: フローティング検索ボタン - - Scenario: FABボタンが表示される - Given FloatingSearchButtonがレンダリングされている - Then FABボタンが表示される - - Scenario: FABをタップすると検索入力が表示される - Given FloatingSearchButtonがレンダリングされている - When FABボタンをタップする - Then 検索入力フィールドが表示される - - Scenario: 検索入力にテキストを入力するとonChangeTextが呼ばれる - Given FloatingSearchButtonがレンダリングされている - When FABボタンをタップする - And 検索入力に "Pika" と入力する - Then onChangeTextが "Pika" で呼ばれる - - Scenario: 閉じるボタンをタップすると折りたたまれテキストがクリアされる - Given 検索テキスト "Pika" でFloatingSearchButtonがレンダリングされている - When FABボタンをタップする - And 閉じるボタンをタップする - Then onChangeTextが "" で呼ばれる - - Scenario: プレースホルダーが表示される - Given FloatingSearchButtonがレンダリングされている - When FABボタンをタップする - Then プレースホルダー "Search..." が表示される - - Scenario: キーボードが閉じたらFABボタンに戻る - Given FloatingSearchButtonがレンダリングされている - When FABボタンをタップする - And キーボードが閉じられる - Then onChangeTextが "" で呼ばれる - And FABボタンが表示される diff --git a/__tests__/home/features/homeScreen.feature b/__tests__/home/features/homeScreen.feature deleted file mode 100644 index 7af6ee5..0000000 --- a/__tests__/home/features/homeScreen.feature +++ /dev/null @@ -1,47 +0,0 @@ -Feature: ホーム画面 - - Scenario: ポケモンカードが表示される - Given ポケモンリストが正常にロードされている - When ホーム画面をレンダリングする - Then "Pikachu" が表示される - And "Bulbasaur" が表示される - - Scenario: 各カードが詳細画面へのリンクを持つ - Given ポケモンリストが正常にロードされている - When ホーム画面をレンダリングする - Then ID 25 の詳細リンクが存在する - And ID 1 の詳細リンクが存在する - - Scenario: FABボタンが表示される - Given ポケモンリストが正常にロードされている - When ホーム画面をレンダリングする - Then FABボタンが表示される - - Scenario: FABをタップすると検索入力フィールドが表示される - Given ポケモンリストが正常にロードされている - When ホーム画面をレンダリングする - And FABボタンをタップする - Then 検索入力フィールドが表示される - - Scenario: 検索テキスト入力でポケモンがフィルタリングされる - Given ポケモンリストが正常にロードされている - When ホーム画面をレンダリングする - And FABボタンをタップする - And 検索入力に "Pika" と入力する - Then "Pikachu" が表示される - And "Bulbasaur" は表示されない - - Scenario: 各カードにお気に入りボタンが表示される - Given ポケモンリストが正常にロードされている - When ホーム画面をレンダリングする - Then お気に入りボタンが表示される - - Scenario: ローディング中にActivityIndicatorが表示される - Given ポケモンリストがローディング中である - When ホーム画面をレンダリングする - Then ローディングインジケーターが表示される - - Scenario: エラー時にエラーメッセージが表示される - Given ポケモンリストの取得でエラーが発生している - When ホーム画面をレンダリングする - Then エラーメッセージが表示される diff --git a/__tests__/home/features/pokemonApi.feature b/__tests__/home/features/pokemonApi.feature deleted file mode 100644 index 57f7d35..0000000 --- a/__tests__/home/features/pokemonApi.feature +++ /dev/null @@ -1,25 +0,0 @@ -Feature: ポケモンREST API - - Scenario: 正しいURLでfetchを呼び出す - Given fetchがモックされている - And fetchが正常なレスポンスを返す - When fetchPokemonListを limit 20 offset 0 で呼び出す - Then fetchが "https://pokeapi.co/api/v2/pokemon?limit=20&offset=0" で呼ばれる - - Scenario: レスポンスをパースして返す - Given fetchがモックされている - And fetchが正常なレスポンスを返す - When fetchPokemonListを limit 20 offset 0 で呼び出す - Then レスポンスがパースされて返される - - Scenario: HTTPエラー時にエラーをスローする - Given fetchがモックされている - And fetchがステータス 500 のエラーレスポンスを返す - When fetchPokemonListを limit 20 offset 0 で呼び出す - Then "Failed to fetch pokemon list: 500" エラーがスローされる - - Scenario: ネットワークエラー時にエラーをスローする - Given fetchがモックされている - And fetchが "Network error" ネットワークエラーを返す - When fetchPokemonListを limit 20 offset 0 で呼び出す - Then "Network error" エラーがスローされる diff --git a/__tests__/home/features/pokemonGraphqlApi.feature b/__tests__/home/features/pokemonGraphqlApi.feature deleted file mode 100644 index 49ece48..0000000 --- a/__tests__/home/features/pokemonGraphqlApi.feature +++ /dev/null @@ -1,34 +0,0 @@ -Feature: ポケモンGraphQL API - - Scenario: GraphQLエンドポイントにPOSTリクエストを送信する - Given fetchがモックされている - And fetchがGraphQL正常レスポンスを返す - When fetchPokemonListGraphQLを limit 20 offset 0 lang "ja" で呼び出す - Then fetchが "https://beta.pokeapi.co/graphql/v1beta" にPOSTで呼ばれる - - Scenario: ローカライズされたポケモン名とタイプを返す - Given fetchがモックされている - And fetchがGraphQL正常レスポンスを返す - When fetchPokemonListGraphQLを limit 20 offset 0 lang "ja" で呼び出す - Then 総件数は 1025 である - And ポケモンの件数は 2 である - And 1番目のポケモンはID 1 名前 "フシギダネ" タイプ "grass,poison" である - And 2番目のポケモンはID 4 名前 "ヒトカゲ" タイプ "fire" である - - Scenario: ローカライズ名がない場合はspecies名にフォールバックする - Given fetchがモックされている - And fetchがローカライズ名なしのGraphQLレスポンスを返す - When fetchPokemonListGraphQLを limit 20 offset 0 lang "ja" で呼び出す - Then 1番目のポケモンの名前は "Bulbasaur" である - - Scenario: HTTPエラー時にエラーをスローする - Given fetchがモックされている - And fetchがステータス 500 のHTTPエラーを返す - When fetchPokemonListGraphQLを limit 20 offset 0 lang "ja" で呼び出す - Then "GraphQL request failed: 500" エラーがスローされる - - Scenario: GraphQLエラー時にエラーをスローする - Given fetchがモックされている - And fetchがGraphQLエラーレスポンスを返す - When fetchPokemonListGraphQLを limit 20 offset 0 lang "ja" で呼び出す - Then "GraphQL error: Field not found" エラーがスローされる diff --git a/__tests__/home/features/pokemonListItem.feature b/__tests__/home/features/pokemonListItem.feature deleted file mode 100644 index c84a4ff..0000000 --- a/__tests__/home/features/pokemonListItem.feature +++ /dev/null @@ -1,30 +0,0 @@ -Feature: ポケモンリストアイテムの変換 - PokeAPIのレスポンスをアプリ内のPokemonSummary型に変換する - - Scenario Outline: URLからポケモンIDを抽出する - Given PokeAPIのURL "" が与えられている - When URLからIDを抽出する - Then IDは である - - Examples: - | url | id | - | https://pokeapi.co/api/v2/pokemon/25/ | 25 | - | https://pokeapi.co/api/v2/pokemon/151/ | 151 | - | https://pokeapi.co/api/v2/pokemon/1 | 1 | - - Scenario Outline: ポケモン名を先頭大文字化する - Given ポケモン名 "" が与えられている - When 名前を先頭大文字化する - Then 結果は "" である - - Examples: - | input | expected | - | bulbasaur | Bulbasaur | - | | | - - Scenario: PokeApiListItemをPokemonSummary型に変換する - Given PokeAPIリストアイテムの名前が "pikachu" でURLが "https://pokeapi.co/api/v2/pokemon/25/" である - When PokemonSummary型に変換する - Then IDは 25 である - And 名前は "Pikachu" である - And typesは空配列である diff --git a/__tests__/home/features/useFloatingSearch.feature b/__tests__/home/features/useFloatingSearch.feature deleted file mode 100644 index 5a7acaa..0000000 --- a/__tests__/home/features/useFloatingSearch.feature +++ /dev/null @@ -1,29 +0,0 @@ -Feature: useFloatingSearchフック - - Scenario: 初期状態でisExpandedがfalse - Given useFloatingSearchフックがレンダリングされている - Then isExpandedはfalseである - - Scenario: toggle()で展開状態が切り替わる - Given useFloatingSearchフックがレンダリングされている - When toggleを実行する - Then isExpandedはtrueである - When toggleを再度実行する - Then isExpandedはfalseである - - Scenario: close()で常に折りたたまれる - Given useFloatingSearchフックがレンダリングされている - When toggleを実行する - And closeを実行する - Then isExpandedはfalseである - - Scenario: close()は折りたたみ状態でも安全に呼べる - Given useFloatingSearchフックがレンダリングされている - When closeを実行する - Then isExpandedはfalseである - - Scenario: アニメーションスタイルを返す - Given useFloatingSearchフックがレンダリングされている - Then fabAnimatedStyleが定義されている - And iconAnimatedStyleが定義されている - And inputAnimatedStyleが定義されている diff --git a/__tests__/home/features/usePokemonList.feature b/__tests__/home/features/usePokemonList.feature deleted file mode 100644 index 7686803..0000000 --- a/__tests__/home/features/usePokemonList.feature +++ /dev/null @@ -1,72 +0,0 @@ -Feature: usePokemonListフック - - Scenario: 初期ロード時にisLoadingがtrueになる - Given fetchPokemonListGraphQLが未解決のPromiseを返す - When usePokemonListフックがレンダリングされる - Then isLoadingはtrueである - - Scenario: データ取得後にポケモン一覧が設定される - Given fetchPokemonListGraphQLがオフセット0で総数40のデータを返す - When usePokemonListフックがレンダリングされる - And ローディングが完了する - Then ポケモン一覧の件数は2件である - And 1番目のポケモンの名前は "ポケモン1" である - And 1番目のポケモンのIDは 1 である - And 1番目のポケモンのタイプは "grass" を含む - - Scenario: 言語パラメータがGraphQL関数に渡される - Given fetchPokemonListGraphQLがオフセット0で総数40のデータを返す - When usePokemonListフックがレンダリングされる - And ローディングが完了する - Then fetchPokemonListGraphQLが 20 と 0 と "ja" で呼ばれる - - Scenario: loadMoreで追加データが追加される - Given fetchPokemonListGraphQLがオフセット0で総数40のデータを返す - When usePokemonListフックがレンダリングされる - And ローディングが完了する - And fetchPokemonListGraphQLがオフセット20で総数40の追加データを返す - And loadMoreを実行する - Then ポケモン一覧の件数は4件である - - Scenario: 総件数に達した場合hasMoreがfalseになる - Given fetchPokemonListGraphQLがオフセット0で総数2のデータを返す - When usePokemonListフックがレンダリングされる - And ローディングが完了する - Then hasMoreはfalseである - - Scenario: hasMoreがfalseの場合loadMoreは何もしない - Given fetchPokemonListGraphQLがオフセット0で総数2のデータを返す - When usePokemonListフックがレンダリングされる - And ローディングが完了する - And loadMoreを実行する - Then fetchPokemonListGraphQLは1回だけ呼ばれる - - Scenario: refreshでデータがリセットされる - Given fetchPokemonListGraphQLがオフセット0で総数40のデータを返す - When usePokemonListフックがレンダリングされる - And ローディングが完了する - And fetchPokemonListGraphQLがオフセット0で総数40のリフレッシュデータを返す - And refreshを実行する - Then ポケモン一覧の件数は2件である - And fetchPokemonListGraphQLは2回呼ばれる - And 最後のfetchPokemonListGraphQL呼び出しは 20 と 0 と "ja" である - - Scenario: エラー時にerror状態が設定される - Given fetchPokemonListGraphQLが "Network error" エラーを返す - When usePokemonListフックがレンダリングされる - And ローディングが完了する - Then errorは "Network error" である - - Scenario: 初期ロードでError以外のエラーでもerror状態が設定される - Given fetchPokemonListGraphQLが文字列エラーを返す - When usePokemonListフックがレンダリングされる - And ローディングが完了する - Then errorは "Unknown error" である - - Scenario: loadMoreでError以外のエラーでもerror状態が設定される - Given fetchPokemonListGraphQLがオフセット0で総数40のデータを返す - When usePokemonListフックがレンダリングされる - And ローディングが完了する - And fetchPokemonListGraphQLが文字列エラーを返すよう設定する - And loadMoreを実行する - Then errorは "Unknown error" である diff --git a/__tests__/home/features/useSearch.feature b/__tests__/home/features/useSearch.feature deleted file mode 100644 index c15f65d..0000000 --- a/__tests__/home/features/useSearch.feature +++ /dev/null @@ -1,32 +0,0 @@ -Feature: useSearchフック - - Scenario: 初期状態では全てのポケモンが返される - Given ポケモンリストが用意されている - When useSearchフックがレンダリングされる - Then 全てのポケモンが返される - And 検索テキストは空文字である - - Scenario: 検索テキストに一致するポケモンのみがフィルタリングされる - Given ポケモンリストが用意されている - When useSearchフックがレンダリングされる - And 検索テキストを "ピカチュウ" に設定する - Then フィルタリング結果にはIDが25のピカチュウのみが含まれる - - Scenario: 検索テキストが空文字の場合は全てのポケモンが返される - Given ポケモンリストが用意されている - When useSearchフックがレンダリングされる - And 検索テキストを "ピカ" に設定する - And 検索テキストを "" に設定する - Then 全てのポケモンが返される - - Scenario: 一致するポケモンがない場合は空配列が返される - Given ポケモンリストが用意されている - When useSearchフックがレンダリングされる - And 検索テキストを "ミュウツー" に設定する - Then フィルタリング結果は空配列である - - Scenario: 検索テキストが部分一致でもフィルタリングされる - Given ポケモンリストが用意されている - When useSearchフックがレンダリングされる - And 検索テキストを "ガメ" に設定する - Then フィルタリング結果にはIDが7のゼニガメのみが含まれる diff --git a/__tests__/home/hooks/useFloatingSearch.test.ts b/__tests__/home/hooks/useFloatingSearch.test.ts new file mode 100644 index 0000000..598732d --- /dev/null +++ b/__tests__/home/hooks/useFloatingSearch.test.ts @@ -0,0 +1,56 @@ +import { renderHook, act } from "@testing-library/react-native"; +import { useFloatingSearch } from "@/src/home"; + +describe("useFloatingSearch", () => { + it("初期状態でisExpandedがfalseである", () => { + const { result } = renderHook(() => useFloatingSearch()); + + expect(result.current.isExpanded).toBe(false); + }); + + it("toggle()で展開状態が切り替わる", () => { + const { result } = renderHook(() => useFloatingSearch()); + + act(() => { + result.current.toggle(); + }); + expect(result.current.isExpanded).toBe(true); + + act(() => { + result.current.toggle(); + }); + expect(result.current.isExpanded).toBe(false); + }); + + it("close()で常に折りたたまれる", () => { + const { result } = renderHook(() => useFloatingSearch()); + + act(() => { + result.current.toggle(); + }); + + act(() => { + result.current.close(); + }); + + expect(result.current.isExpanded).toBe(false); + }); + + it("close()は折りたたみ状態でも安全に呼べる", () => { + const { result } = renderHook(() => useFloatingSearch()); + + act(() => { + result.current.close(); + }); + + expect(result.current.isExpanded).toBe(false); + }); + + it("アニメーションスタイルを返す", () => { + const { result } = renderHook(() => useFloatingSearch()); + + expect(result.current.fabAnimatedStyle).toBeDefined(); + expect(result.current.iconAnimatedStyle).toBeDefined(); + expect(result.current.inputAnimatedStyle).toBeDefined(); + }); +}); diff --git a/__tests__/home/hooks/usePokemonList.test.ts b/__tests__/home/hooks/usePokemonList.test.ts new file mode 100644 index 0000000..b5dc871 --- /dev/null +++ b/__tests__/home/hooks/usePokemonList.test.ts @@ -0,0 +1,179 @@ +import { renderHook, act, waitFor } from "@testing-library/react-native"; +import { usePokemonList } from "@/src/home"; +import { fetchPokemonListGraphQL } from "@/src/home/repository/pokemonGraphqlApi"; +import type { PokemonListResult } from "@/src/home/repository/pokemonGraphqlApi"; + +jest.mock("@/src/home/repository/pokemonGraphqlApi"); + +const mockFetchGraphQL = fetchPokemonListGraphQL as jest.MockedFunction< + typeof fetchPokemonListGraphQL +>; + +const makePage = ( + offset: number, + totalCount: number +): PokemonListResult => ({ + count: totalCount, + pokemon: [ + { id: offset + 1, name: `ポケモン${offset + 1}`, types: ["grass"] }, + { id: offset + 2, name: `ポケモン${offset + 2}`, types: ["fire"] }, + ], +}); + +describe("usePokemonList", () => { + beforeEach(() => { + mockFetchGraphQL.mockReset(); + }); + + it("初期ロード時にisLoadingがtrueになる", () => { + mockFetchGraphQL.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => usePokemonList()); + + expect(result.current.isLoading).toBe(true); + }); + + it("データ取得後にポケモン一覧が設定される", async () => { + mockFetchGraphQL.mockResolvedValueOnce(makePage(0, 40)); + + const { result } = renderHook(() => usePokemonList()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.pokemon).toHaveLength(2); + expect(result.current.pokemon[0].name).toBe("ポケモン1"); + expect(result.current.pokemon[0].id).toBe(1); + expect(result.current.pokemon[0].types).toEqual(["grass"]); + }); + + it("言語パラメータがGraphQL関数に渡される", async () => { + mockFetchGraphQL.mockResolvedValueOnce(makePage(0, 40)); + + renderHook(() => usePokemonList()); + + await waitFor(() => { + expect(mockFetchGraphQL).toHaveBeenCalled(); + }); + + expect(mockFetchGraphQL).toHaveBeenCalledWith(20, 0, "ja"); + }); + + it("loadMoreで追加データが追加される", async () => { + mockFetchGraphQL.mockResolvedValueOnce(makePage(0, 40)); + + const { result } = renderHook(() => usePokemonList()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + mockFetchGraphQL.mockResolvedValueOnce(makePage(20, 40)); + + await act(async () => { + result.current.loadMore(); + }); + await waitFor(() => { + expect(result.current.isLoadingMore).toBe(false); + }); + + expect(result.current.pokemon).toHaveLength(4); + }); + + it("総件数に達した場合hasMoreがfalseになる", async () => { + mockFetchGraphQL.mockResolvedValueOnce(makePage(0, 2)); + + const { result } = renderHook(() => usePokemonList()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.hasMore).toBe(false); + }); + + it("hasMoreがfalseの場合loadMoreは何もしない", async () => { + mockFetchGraphQL.mockResolvedValueOnce(makePage(0, 2)); + + const { result } = renderHook(() => usePokemonList()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + result.current.loadMore(); + }); + + expect(mockFetchGraphQL).toHaveBeenCalledTimes(1); + }); + + it("refreshでデータがリセットされる", async () => { + mockFetchGraphQL.mockResolvedValueOnce(makePage(0, 40)); + + const { result } = renderHook(() => usePokemonList()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + mockFetchGraphQL.mockResolvedValueOnce(makePage(0, 40)); + + await act(async () => { + result.current.refresh(); + }); + await waitFor(() => { + expect(result.current.isRefreshing).toBe(false); + }); + + expect(result.current.pokemon).toHaveLength(2); + expect(mockFetchGraphQL).toHaveBeenCalledTimes(2); + expect(mockFetchGraphQL).toHaveBeenLastCalledWith(20, 0, "ja"); + }); + + it("エラー時にerror状態が設定される", async () => { + mockFetchGraphQL.mockRejectedValueOnce(new Error("Network error")); + + const { result } = renderHook(() => usePokemonList()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe("Network error"); + }); + + it("初期ロードでError以外のエラーでもerror状態が設定される", async () => { + mockFetchGraphQL.mockRejectedValueOnce("string error"); + + const { result } = renderHook(() => usePokemonList()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe("Unknown error"); + }); + + it("loadMoreでError以外のエラーでもerror状態が設定される", async () => { + mockFetchGraphQL.mockResolvedValueOnce(makePage(0, 40)); + + const { result } = renderHook(() => usePokemonList()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + mockFetchGraphQL.mockRejectedValueOnce("string error"); + + await act(async () => { + result.current.loadMore(); + }); + await waitFor(() => { + expect(result.current.isLoadingMore).toBe(false); + }); + + expect(result.current.error).toBe("Unknown error"); + }); +}); diff --git a/__tests__/home/hooks/useSearch.test.ts b/__tests__/home/hooks/useSearch.test.ts new file mode 100644 index 0000000..8dba253 --- /dev/null +++ b/__tests__/home/hooks/useSearch.test.ts @@ -0,0 +1,67 @@ +import { renderHook, act } from "@testing-library/react-native"; +import { useSearch } from "@/src/home"; +import type { PokemonSummary } from "@/src/shared"; + +const mockSearchPokemon: PokemonSummary[] = [ + { id: 1, name: "フシギダネ", types: ["grass", "poison"] }, + { id: 4, name: "ヒトカゲ", types: ["fire"] }, + { id: 7, name: "ゼニガメ", types: ["water"] }, + { id: 25, name: "ピカチュウ", types: ["electric"] }, +]; + +describe("useSearch", () => { + it("初期状態では全てのポケモンが返される", () => { + const { result } = renderHook(() => useSearch(mockSearchPokemon)); + + expect(result.current.filteredItems).toEqual(mockSearchPokemon); + expect(result.current.searchText).toBe(""); + }); + + it("検索テキストに一致するポケモンのみがフィルタリングされる", () => { + const { result } = renderHook(() => useSearch(mockSearchPokemon)); + + act(() => { + result.current.setSearchText("ピカチュウ"); + }); + + expect(result.current.filteredItems).toEqual([ + { id: 25, name: "ピカチュウ", types: ["electric"] }, + ]); + }); + + it("検索テキストが空文字の場合は全てのポケモンが返される", () => { + const { result } = renderHook(() => useSearch(mockSearchPokemon)); + + act(() => { + result.current.setSearchText("ピカ"); + }); + + act(() => { + result.current.setSearchText(""); + }); + + expect(result.current.filteredItems).toEqual(mockSearchPokemon); + }); + + it("一致するポケモンがない場合は空配列が返される", () => { + const { result } = renderHook(() => useSearch(mockSearchPokemon)); + + act(() => { + result.current.setSearchText("ミュウツー"); + }); + + expect(result.current.filteredItems).toEqual([]); + }); + + it("検索テキストが部分一致でもフィルタリングされる", () => { + const { result } = renderHook(() => useSearch(mockSearchPokemon)); + + act(() => { + result.current.setSearchText("ガメ"); + }); + + expect(result.current.filteredItems).toEqual([ + { id: 7, name: "ゼニガメ", types: ["water"] }, + ]); + }); +}); diff --git a/__tests__/home/repository/pokemonApi.test.ts b/__tests__/home/repository/pokemonApi.test.ts new file mode 100644 index 0000000..3e62241 --- /dev/null +++ b/__tests__/home/repository/pokemonApi.test.ts @@ -0,0 +1,66 @@ +import { fetchPokemonList } from "@/src/home"; +import type { PokeApiListResponse } from "@/src/home"; + +const mockRestResponse: PokeApiListResponse = { + count: 1302, + next: "https://pokeapi.co/api/v2/pokemon?offset=20&limit=20", + previous: null, + results: [ + { name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" }, + { name: "ivysaur", url: "https://pokeapi.co/api/v2/pokemon/2/" }, + ], +}; + +const originalFetch = globalThis.fetch; + +describe("fetchPokemonList", () => { + beforeEach(() => { + globalThis.fetch = jest.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("正しいURLでfetchを呼び出す", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockRestResponse), + }); + + await fetchPokemonList(20, 0); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://pokeapi.co/api/v2/pokemon?limit=20&offset=0" + ); + }); + + it("レスポンスをパースして返す", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockRestResponse), + }); + + const result = await fetchPokemonList(20, 0); + expect(result).toEqual(mockRestResponse); + }); + + it("HTTPエラー時にエラーをスローする", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + await expect(fetchPokemonList(20, 0)).rejects.toThrow( + "Failed to fetch pokemon list: 500" + ); + }); + + it("ネットワークエラー時にエラーをスローする", async () => { + (globalThis.fetch as jest.Mock).mockRejectedValueOnce( + new Error("Network error") + ); + + await expect(fetchPokemonList(20, 0)).rejects.toThrow("Network error"); + }); +}); diff --git a/__tests__/home/repository/pokemonGraphqlApi.test.ts b/__tests__/home/repository/pokemonGraphqlApi.test.ts new file mode 100644 index 0000000..43bb18e --- /dev/null +++ b/__tests__/home/repository/pokemonGraphqlApi.test.ts @@ -0,0 +1,135 @@ +import { fetchPokemonListGraphQL } from "@/src/home/repository/pokemonGraphqlApi"; + +const mockGraphQLResponse = { + data: { + pokemon_v2_pokemon: [ + { + id: 1, + pokemon_v2_pokemonspecy: { + name: "bulbasaur", + pokemon_v2_pokemonspeciesnames: [{ name: "フシギダネ" }], + }, + pokemon_v2_pokemontypes: [ + { pokemon_v2_type: { name: "grass" } }, + { pokemon_v2_type: { name: "poison" } }, + ], + }, + { + id: 4, + pokemon_v2_pokemonspecy: { + name: "charmander", + pokemon_v2_pokemonspeciesnames: [{ name: "ヒトカゲ" }], + }, + pokemon_v2_pokemontypes: [{ pokemon_v2_type: { name: "fire" } }], + }, + ], + pokemon_v2_pokemon_aggregate: { + aggregate: { count: 1025 }, + }, + }, +}; + +const mockEmptyNameResponse = { + data: { + pokemon_v2_pokemon: [ + { + id: 1, + pokemon_v2_pokemonspecy: { + name: "bulbasaur", + pokemon_v2_pokemonspeciesnames: [], + }, + pokemon_v2_pokemontypes: [{ pokemon_v2_type: { name: "grass" } }], + }, + ], + pokemon_v2_pokemon_aggregate: { + aggregate: { count: 1 }, + }, + }, +}; + +const originalFetch = globalThis.fetch; + +describe("fetchPokemonListGraphQL", () => { + beforeEach(() => { + globalThis.fetch = jest.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("GraphQLエンドポイントにPOSTリクエストを送信する", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockGraphQLResponse), + }); + + await fetchPokemonListGraphQL(20, 0, "ja"); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://beta.pokeapi.co/graphql/v1beta", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }) + ); + }); + + it("ローカライズされたポケモン名とタイプを返す", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockGraphQLResponse), + }); + + const result = await fetchPokemonListGraphQL(20, 0, "ja"); + + expect(result.count).toBe(1025); + expect(result.pokemon).toHaveLength(2); + expect(result.pokemon[0]).toEqual({ + id: 1, + name: "フシギダネ", + types: ["grass", "poison"], + }); + expect(result.pokemon[1]).toEqual({ + id: 4, + name: "ヒトカゲ", + types: ["fire"], + }); + }); + + it("ローカライズ名がない場合はspecies名にフォールバックする", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockEmptyNameResponse), + }); + + const result = await fetchPokemonListGraphQL(20, 0, "ja"); + + expect(result.pokemon[0].name).toBe("Bulbasaur"); + }); + + it("HTTPエラー時にGraphQLエラーをスローする", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + await expect(fetchPokemonListGraphQL(20, 0, "ja")).rejects.toThrow( + "GraphQL request failed: 500" + ); + }); + + it("GraphQLエラー時にエラーをスローする", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + errors: [{ message: "Field not found" }], + }), + }); + + await expect(fetchPokemonListGraphQL(20, 0, "ja")).rejects.toThrow( + "GraphQL error: Field not found" + ); + }); +}); diff --git a/__tests__/home/screens/homeScreen.feature b/__tests__/home/screens/homeScreen.feature new file mode 100644 index 0000000..3034aa3 --- /dev/null +++ b/__tests__/home/screens/homeScreen.feature @@ -0,0 +1,140 @@ +Feature: ホーム画面 + ホーム画面のUI表示とコンポーネントのテスト + + # --- ポケモンカード --- + + Scenario: ポケモンの名前が表示される + Given ピカチュウのデータが用意されている + When PokemonCardを描画する + Then "ピカチュウ" が表示される + + Scenario: ポケモンの画像が正しいURLで表示される + Given ピカチュウのデータが用意されている + When PokemonCardを描画する + Then 画像URLが "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/25.png" である + + Scenario: タイプバッジが翻訳されて表示される + Given ピカチュウのデータが用意されている + When PokemonCardを描画する + Then "types.electric" が表示される + + Scenario: 複数タイプの場合、全てのバッジが翻訳されて表示される + Given リザードンのデータが用意されている + When PokemonCardを描画する + Then "types.fire" が表示される + And "types.flying" が表示される + + Scenario: onPressコールバックが呼ばれる + Given ピカチュウのデータが用意されている + When onPress付きでPokemonCardを描画する + And カードを押す + Then onPressが1回呼ばれる + + Scenario: onPressが未指定の場合でもエラーにならない + Given ピカチュウのデータが用意されている + When PokemonCardを描画する + Then カードを押してもエラーにならない + + Scenario: isFavoriteとonToggleFavoriteが渡された場合、お気に入りボタンが表示される + Given ピカチュウのデータが用意されている + When お気に入り機能付きでPokemonCardを描画する + Then お気に入りボタンが表示される + + Scenario: isFavoriteがtrueの場合、Lottieアニメーションが表示される + Given ピカチュウのデータが用意されている + When お気に入り状態でPokemonCardを描画する + Then Lottieアニメーションが表示される + + Scenario: お気に入りボタン押下後アニメーション完了でonToggleFavoriteが呼ばれる + Given ピカチュウのデータが用意されている + When お気に入り機能付きでPokemonCardを描画する + And お気に入りボタンを押す + Then onToggleFavoriteが1回呼ばれる + + Scenario: isFavoriteが未指定の場合、お気に入りボタンが表示されない + Given ピカチュウのデータが用意されている + When PokemonCardを描画する + Then お気に入りボタンが表示されない + + # --- フローティング検索ボタン --- + + Scenario: FloatingSearchButtonのFABボタンが表示される + Given FloatingSearchButtonがレンダリングされている + Then FABボタンが表示される + + Scenario: FABをタップすると検索入力が表示される + Given FloatingSearchButtonがレンダリングされている + When FABボタンをタップする + Then 検索入力フィールドが表示される + + Scenario: 検索入力にテキストを入力するとonChangeTextが呼ばれる + Given FloatingSearchButtonがレンダリングされている + When FABボタンをタップする + And 検索入力に "Pika" と入力する + Then onChangeTextが "Pika" で呼ばれる + + Scenario: 閉じるボタンをタップすると折りたたまれテキストがクリアされる + Given 検索テキスト "Pika" でFloatingSearchButtonがレンダリングされている + When FABボタンをタップする + And 閉じるボタンをタップする + Then onChangeTextが "" で呼ばれる + + Scenario: プレースホルダーが表示される + Given FloatingSearchButtonがレンダリングされている + When FABボタンをタップする + Then プレースホルダー "Search..." が表示される + + Scenario: キーボードが閉じたらFABボタンに戻る + Given FloatingSearchButtonがレンダリングされている + When FABボタンをタップする + And キーボードが閉じられる + Then onChangeTextが "" で呼ばれる + And FABボタンが表示される + + # --- ホーム画面統合 --- + + Scenario: ホーム画面にポケモンカードが表示される + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + Then "Pikachu" が表示される + And "Bulbasaur" が表示される + + Scenario: 各カードが詳細画面へのリンクを持つ + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + Then ID 25 の詳細リンクが存在する + And ID 1 の詳細リンクが存在する + + Scenario: ホーム画面にFABボタンが表示される + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + Then FABボタンが表示される + + Scenario: ホーム画面でFABをタップすると検索入力フィールドが表示される + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + And FABボタンをタップする + Then 検索入力フィールドが表示される + + Scenario: 検索テキスト入力でポケモンがフィルタリングされる + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + And FABボタンをタップする + And 検索入力に "Pika" と入力する + Then "Pikachu" が表示される + And "Bulbasaur" は表示されない + + Scenario: 各カードにお気に入りボタンが表示される + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + Then お気に入りボタンが表示される + + Scenario: ローディング中にActivityIndicatorが表示される + Given ポケモンリストがローディング中である + When ホーム画面をレンダリングする + Then ローディングインジケーターが表示される + + Scenario: エラー時にエラーメッセージが表示される + Given ポケモンリストの取得でエラーが発生している + When ホーム画面をレンダリングする + Then エラーメッセージが表示される diff --git a/__tests__/home/screens/homeScreen.steps.tsx b/__tests__/home/screens/homeScreen.steps.tsx new file mode 100644 index 0000000..0efeba6 --- /dev/null +++ b/__tests__/home/screens/homeScreen.steps.tsx @@ -0,0 +1,619 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { + render, + screen, + fireEvent, +} from "@testing-library/react-native"; +import { Keyboard, Platform } from "react-native"; +import { + FloatingSearchButton, + HomeScreen, +} from "@/src/home"; +import { PokemonCard } from "@/src/shared"; +import type { PokemonSummary } from "@/src/shared"; + +// --- jest.mock (トップレベル) --- + +const mockHomeScreenPokemon: PokemonSummary[] = [ + { id: 1, name: "Bulbasaur", types: [] }, + { id: 4, name: "Charmander", types: [] }, + { id: 25, name: "Pikachu", types: [] }, +]; + +const mockUsePokemonList = { + pokemon: mockHomeScreenPokemon, + isLoading: false, + isLoadingMore: false, + isRefreshing: false, + hasMore: true, + error: null as string | null, + loadMore: jest.fn(), + refresh: jest.fn(), +}; + +jest.mock("@/src/home/hooks/usePokemonList", () => ({ + usePokemonList: () => mockUsePokemonList, +})); + +jest.mock("@/src/home/repository/pokemonGraphqlApi"); + +jest.mock("@react-navigation/bottom-tabs", () => ({ + useBottomTabBarHeight: () => 49, +})); + +jest.mock("expo-router", () => ({ + Link: ({ + children, + href, + }: { + children: React.ReactNode; + href: string; + asChild?: boolean; + }) => { + const { View } = require("react-native"); + return {children}; + }, +})); + +// --- テストデータ --- + +const mockCardPokemon: PokemonSummary = { + id: 25, + name: "ピカチュウ", + types: ["electric"], +}; + +const mockMultiTypePokemon: PokemonSummary = { + id: 6, + name: "リザードン", + types: ["fire", "flying"], +}; + +const resetMockState = () => { + mockUsePokemonList.pokemon = mockHomeScreenPokemon; + mockUsePokemonList.isLoading = false; + mockUsePokemonList.isLoadingMore = false; + mockUsePokemonList.isRefreshing = false; + mockUsePokemonList.hasMore = true; + mockUsePokemonList.error = null; + mockUsePokemonList.loadMore = jest.fn(); + mockUsePokemonList.refresh = jest.fn(); +}; + +// --- Feature定義 --- + +const feature = loadFeature("__tests__/home/screens/homeScreen.feature"); + +defineFeature(feature, (test) => { + // --- ポケモンカード --- + + let cardPokemon: PokemonSummary; + let cardOnPress: jest.Mock; + let cardOnToggleFavorite: jest.Mock; + + beforeEach(() => { + resetMockState(); + }); + + test("ポケモンの名前が表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then(/^"(.*)" が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ポケモンの画像が正しいURLで表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then(/^画像URLが "(.*)" である$/, (url: string) => { + const image = screen.getByTestId("pokemon-image"); + expect(image.props.source.uri).toBe(url); + }); + }); + + test("タイプバッジが翻訳されて表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then(/^"(.*)" が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("複数タイプの場合、全てのバッジが翻訳されて表示される", ({ + given, + when, + then, + and, + }) => { + given("リザードンのデータが用意されている", () => { + cardPokemon = mockMultiTypePokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then(/^"(.*)" が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^"(.*)" が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("onPressコールバックが呼ばれる", ({ given, when, then, and }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("onPress付きでPokemonCardを描画する", () => { + cardOnPress = jest.fn(); + render(); + }); + + and("カードを押す", () => { + fireEvent.press(screen.getByTestId("pokemon-card")); + }); + + then("onPressが1回呼ばれる", () => { + expect(cardOnPress).toHaveBeenCalledTimes(1); + }); + }); + + test("onPressが未指定の場合でもエラーにならない", ({ + given, + when, + then, + }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then("カードを押してもエラーにならない", () => { + expect(() => { + fireEvent.press(screen.getByTestId("pokemon-card")); + }).not.toThrow(); + }); + }); + + test("isFavoriteとonToggleFavoriteが渡された場合、お気に入りボタンが表示される", ({ + given, + when, + then, + }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("お気に入り機能付きでPokemonCardを描画する", () => { + cardOnToggleFavorite = jest.fn(); + render( + + ); + }); + + then("お気に入りボタンが表示される", () => { + expect(screen.getByTestId("favorite-button")).toBeTruthy(); + }); + }); + + test("isFavoriteがtrueの場合、Lottieアニメーションが表示される", ({ + given, + when, + then, + }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("お気に入り状態でPokemonCardを描画する", () => { + cardOnToggleFavorite = jest.fn(); + render( + + ); + }); + + then("Lottieアニメーションが表示される", () => { + expect(screen.getByTestId("favorite-lottie")).toBeTruthy(); + }); + }); + + test("お気に入りボタン押下後アニメーション完了でonToggleFavoriteが呼ばれる", ({ + given, + when, + then, + and, + }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("お気に入り機能付きでPokemonCardを描画する", () => { + cardOnToggleFavorite = jest.fn(); + render( + + ); + }); + + and("お気に入りボタンを押す", () => { + fireEvent.press(screen.getByTestId("favorite-button")); + }); + + then("onToggleFavoriteが1回呼ばれる", () => { + expect(cardOnToggleFavorite).toHaveBeenCalledTimes(1); + }); + }); + + test("isFavoriteが未指定の場合、お気に入りボタンが表示されない", ({ + given, + when, + then, + }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then("お気に入りボタンが表示されない", () => { + expect(screen.queryByTestId("favorite-button")).toBeNull(); + }); + }); + + // --- フローティング検索ボタン --- + + let fabOnChangeText: jest.Mock; + + const defaultFabProps = { + searchText: "", + onChangeText: jest.fn(), + placeholder: "Search...", + }; + + test("FloatingSearchButtonのFABボタンが表示される", ({ given, then }) => { + given("FloatingSearchButtonがレンダリングされている", () => { + render(); + }); + + then("FABボタンが表示される", () => { + expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); + }); + }); + + test("FABをタップすると検索入力が表示される", ({ given, when, then }) => { + given("FloatingSearchButtonがレンダリングされている", () => { + render(); + }); + + when("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + then("検索入力フィールドが表示される", () => { + expect(screen.getByTestId("search-input")).toBeTruthy(); + }); + }); + + test("検索入力にテキストを入力するとonChangeTextが呼ばれる", ({ + given, + when, + then, + and, + }) => { + given("FloatingSearchButtonがレンダリングされている", () => { + fabOnChangeText = jest.fn(); + render( + + ); + }); + + when("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + and(/^検索入力に "(.*)" と入力する$/, (text: string) => { + fireEvent.changeText(screen.getByTestId("search-input"), text); + }); + + then(/^onChangeTextが "(.*)" で呼ばれる$/, (expected: string) => { + expect(fabOnChangeText).toHaveBeenCalledWith(expected); + }); + }); + + test("閉じるボタンをタップすると折りたたまれテキストがクリアされる", ({ + given, + when, + then, + and, + }) => { + given( + /^検索テキスト "(.*)" でFloatingSearchButtonがレンダリングされている$/, + (searchText: string) => { + fabOnChangeText = jest.fn(); + render( + + ); + } + ); + + when("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + and("閉じるボタンをタップする", () => { + fireEvent.press(screen.getByTestId("search-close-button")); + }); + + then(/^onChangeTextが "(.*)" で呼ばれる$/, (expected: string) => { + expect(fabOnChangeText).toHaveBeenCalledWith(expected); + }); + }); + + test("プレースホルダーが表示される", ({ given, when, then }) => { + given("FloatingSearchButtonがレンダリングされている", () => { + render(); + }); + + when("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + then(/^プレースホルダー "(.*)" が表示される$/, (placeholder: string) => { + expect(screen.getByPlaceholderText(placeholder)).toBeTruthy(); + }); + }); + + test("キーボードが閉じたらFABボタンに戻る", ({ + given, + when, + then, + and, + }) => { + let hideCallback: (() => void) | undefined; + let addListenerSpy: jest.SpyInstance; + + given("FloatingSearchButtonがレンダリングされている", () => { + fabOnChangeText = jest.fn(); + const hideEvent = + Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide"; + + addListenerSpy = jest.spyOn(Keyboard, "addListener"); + addListenerSpy.mockImplementation((event, callback) => { + if (event === hideEvent) { + hideCallback = callback as () => void; + } + return { remove: jest.fn() } as unknown as ReturnType< + typeof Keyboard.addListener + >; + }); + + render( + + ); + }); + + when("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + expect(screen.getByTestId("search-input")).toBeTruthy(); + }); + + and("キーボードが閉じられる", () => { + const { act } = require("@testing-library/react-native"); + act(() => { + hideCallback?.(); + }); + }); + + then(/^onChangeTextが "(.*)" で呼ばれる$/, (expected: string) => { + expect(fabOnChangeText).toHaveBeenCalledWith(expected); + }); + + and("FABボタンが表示される", () => { + expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); + addListenerSpy.mockRestore(); + }); + }); + + // --- ホーム画面統合 --- + + test("ホーム画面にポケモンカードが表示される", ({ given, when, then, and }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state is already set + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then(/^"(.*)" が表示される$/, (name: string) => { + expect(screen.getByText(name)).toBeTruthy(); + }); + + and(/^"(.*)" が表示される$/, (name: string) => { + expect(screen.getByText(name)).toBeTruthy(); + }); + }); + + test("各カードが詳細画面へのリンクを持つ", ({ given, when, then, and }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then(/^ID (\d+) の詳細リンクが存在する$/, (id: string) => { + expect(screen.getByTestId(`link-/detail/${id}`)).toBeTruthy(); + }); + + and(/^ID (\d+) の詳細リンクが存在する$/, (id: string) => { + expect(screen.getByTestId(`link-/detail/${id}`)).toBeTruthy(); + }); + }); + + test("ホーム画面にFABボタンが表示される", ({ given, when, then }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then("FABボタンが表示される", () => { + expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); + }); + }); + + test("ホーム画面でFABをタップすると検索入力フィールドが表示される", ({ + given, + when, + then, + and, + }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + and("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + then("検索入力フィールドが表示される", () => { + expect(screen.getByTestId("search-input")).toBeTruthy(); + }); + }); + + test("検索テキスト入力でポケモンがフィルタリングされる", ({ + given, + when, + then, + and, + }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + and("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + and(/^検索入力に "(.*)" と入力する$/, (text: string) => { + fireEvent.changeText(screen.getByTestId("search-input"), text); + }); + + then(/^"(.*)" が表示される$/, (name: string) => { + expect(screen.getByText(name)).toBeTruthy(); + }); + + and(/^"(.*)" は表示されない$/, (name: string) => { + expect(screen.queryByText(name)).toBeNull(); + }); + }); + + test("各カードにお気に入りボタンが表示される", ({ given, when, then }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then("お気に入りボタンが表示される", () => { + const buttons = screen.getAllByTestId("favorite-button"); + expect(buttons.length).toBeGreaterThan(0); + }); + }); + + test("ローディング中にActivityIndicatorが表示される", ({ + given, + when, + then, + }) => { + given("ポケモンリストがローディング中である", () => { + mockUsePokemonList.isLoading = true; + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then("ローディングインジケーターが表示される", () => { + expect(screen.getByTestId("loading-indicator")).toBeTruthy(); + }); + }); + + test("エラー時にエラーメッセージが表示される", ({ given, when, then }) => { + given("ポケモンリストの取得でエラーが発生している", () => { + mockUsePokemonList.isLoading = false; + mockUsePokemonList.error = "Network error"; + mockUsePokemonList.pokemon = []; + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then("エラーメッセージが表示される", () => { + expect(screen.getByTestId("error-text")).toBeTruthy(); + }); + }); +}); diff --git a/__tests__/home/steps/floatingSearchButton.steps.tsx b/__tests__/home/steps/floatingSearchButton.steps.tsx deleted file mode 100644 index 920c932..0000000 --- a/__tests__/home/steps/floatingSearchButton.steps.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { render, screen, fireEvent, act } from "@testing-library/react-native"; -import { Keyboard, Platform } from "react-native"; -import { FloatingSearchButton } from "@/src/home"; - -const feature = loadFeature( - "__tests__/home/features/floatingSearchButton.feature" -); - -defineFeature(feature, (test) => { - let onChangeText: jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - onChangeText = jest.fn(); - }); - - const defaultProps = { - searchText: "", - onChangeText: jest.fn(), - placeholder: "Search...", - }; - - test("FABボタンが表示される", ({ given, then }) => { - given("FloatingSearchButtonがレンダリングされている", () => { - render(); - }); - - then("FABボタンが表示される", () => { - expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); - }); - }); - - test("FABをタップすると検索入力が表示される", ({ given, when, then }) => { - given("FloatingSearchButtonがレンダリングされている", () => { - render(); - }); - - when("FABボタンをタップする", () => { - fireEvent.press(screen.getByTestId("floating-search-fab")); - }); - - then("検索入力フィールドが表示される", () => { - expect(screen.getByTestId("search-input")).toBeTruthy(); - }); - }); - - test("検索入力にテキストを入力するとonChangeTextが呼ばれる", ({ - given, - when, - then, - and, - }) => { - given("FloatingSearchButtonがレンダリングされている", () => { - onChangeText = jest.fn(); - render( - - ); - }); - - when("FABボタンをタップする", () => { - fireEvent.press(screen.getByTestId("floating-search-fab")); - }); - - and(/^検索入力に "(.*)" と入力する$/, (text: string) => { - fireEvent.changeText(screen.getByTestId("search-input"), text); - }); - - then(/^onChangeTextが "(.*)" で呼ばれる$/, (expected: string) => { - expect(onChangeText).toHaveBeenCalledWith(expected); - }); - }); - - test("閉じるボタンをタップすると折りたたまれテキストがクリアされる", ({ - given, - when, - then, - and, - }) => { - given( - /^検索テキスト "(.*)" でFloatingSearchButtonがレンダリングされている$/, - (searchText: string) => { - onChangeText = jest.fn(); - render( - - ); - } - ); - - when("FABボタンをタップする", () => { - fireEvent.press(screen.getByTestId("floating-search-fab")); - }); - - and("閉じるボタンをタップする", () => { - fireEvent.press(screen.getByTestId("search-close-button")); - }); - - then(/^onChangeTextが "(.*)" で呼ばれる$/, (expected: string) => { - expect(onChangeText).toHaveBeenCalledWith(expected); - }); - }); - - test("プレースホルダーが表示される", ({ given, when, then }) => { - given("FloatingSearchButtonがレンダリングされている", () => { - render(); - }); - - when("FABボタンをタップする", () => { - fireEvent.press(screen.getByTestId("floating-search-fab")); - }); - - then(/^プレースホルダー "(.*)" が表示される$/, (placeholder: string) => { - expect(screen.getByPlaceholderText(placeholder)).toBeTruthy(); - }); - }); - - test("キーボードが閉じたらFABボタンに戻る", ({ - given, - when, - then, - and, - }) => { - let hideCallback: (() => void) | undefined; - let addListenerSpy: jest.SpyInstance; - - given("FloatingSearchButtonがレンダリングされている", () => { - onChangeText = jest.fn(); - const hideEvent = - Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide"; - - addListenerSpy = jest.spyOn(Keyboard, "addListener"); - addListenerSpy.mockImplementation((event, callback) => { - if (event === hideEvent) { - hideCallback = callback as () => void; - } - return { remove: jest.fn() } as unknown as ReturnType< - typeof Keyboard.addListener - >; - }); - - render( - - ); - }); - - when("FABボタンをタップする", () => { - fireEvent.press(screen.getByTestId("floating-search-fab")); - expect(screen.getByTestId("search-input")).toBeTruthy(); - }); - - and("キーボードが閉じられる", () => { - act(() => { - hideCallback?.(); - }); - }); - - then(/^onChangeTextが "(.*)" で呼ばれる$/, (expected: string) => { - expect(onChangeText).toHaveBeenCalledWith(expected); - }); - - and("FABボタンが表示される", () => { - expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); - addListenerSpy.mockRestore(); - }); - }); -}); diff --git a/__tests__/home/steps/homeScreen.steps.tsx b/__tests__/home/steps/homeScreen.steps.tsx deleted file mode 100644 index 382133c..0000000 --- a/__tests__/home/steps/homeScreen.steps.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { render, screen, fireEvent } from "@testing-library/react-native"; -import { HomeScreen } from "@/src/home"; -import type { PokemonSummary } from "@/src/shared"; - -const feature = loadFeature("__tests__/home/features/homeScreen.feature"); - -const mockPokemon: PokemonSummary[] = [ - { id: 1, name: "Bulbasaur", types: [] }, - { id: 4, name: "Charmander", types: [] }, - { id: 25, name: "Pikachu", types: [] }, -]; - -const mockUsePokemonList = { - pokemon: mockPokemon, - isLoading: false, - isLoadingMore: false, - isRefreshing: false, - hasMore: true, - error: null as string | null, - loadMore: jest.fn(), - refresh: jest.fn(), -}; - -jest.mock("@/src/home/hooks/usePokemonList", () => ({ - usePokemonList: () => mockUsePokemonList, -})); - -jest.mock("@react-navigation/bottom-tabs", () => ({ - useBottomTabBarHeight: () => 49, -})); - -jest.mock("expo-router", () => ({ - Link: ({ - children, - href, - }: { - children: React.ReactNode; - href: string; - asChild?: boolean; - }) => { - const { View } = require("react-native"); - return {children}; - }, -})); - -const resetMockState = () => { - mockUsePokemonList.pokemon = mockPokemon; - mockUsePokemonList.isLoading = false; - mockUsePokemonList.isLoadingMore = false; - mockUsePokemonList.isRefreshing = false; - mockUsePokemonList.hasMore = true; - mockUsePokemonList.error = null; - mockUsePokemonList.loadMore = jest.fn(); - mockUsePokemonList.refresh = jest.fn(); -}; - -defineFeature(feature, (test) => { - beforeEach(() => { - resetMockState(); - }); - - test("ポケモンカードが表示される", ({ given, when, then, and }) => { - given("ポケモンリストが正常にロードされている", () => { - // Default mock state is already set - }); - - when("ホーム画面をレンダリングする", () => { - render(); - }); - - then(/^"(.*)" が表示される$/, (name: string) => { - expect(screen.getByText(name)).toBeTruthy(); - }); - - and(/^"(.*)" が表示される$/, (name: string) => { - expect(screen.getByText(name)).toBeTruthy(); - }); - }); - - test("各カードが詳細画面へのリンクを持つ", ({ given, when, then, and }) => { - given("ポケモンリストが正常にロードされている", () => { - // Default mock state - }); - - when("ホーム画面をレンダリングする", () => { - render(); - }); - - then(/^ID (\d+) の詳細リンクが存在する$/, (id: string) => { - expect(screen.getByTestId(`link-/detail/${id}`)).toBeTruthy(); - }); - - and(/^ID (\d+) の詳細リンクが存在する$/, (id: string) => { - expect(screen.getByTestId(`link-/detail/${id}`)).toBeTruthy(); - }); - }); - - test("FABボタンが表示される", ({ given, when, then }) => { - given("ポケモンリストが正常にロードされている", () => { - // Default mock state - }); - - when("ホーム画面をレンダリングする", () => { - render(); - }); - - then("FABボタンが表示される", () => { - expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); - }); - }); - - test("FABをタップすると検索入力フィールドが表示される", ({ - given, - when, - then, - and, - }) => { - given("ポケモンリストが正常にロードされている", () => { - // Default mock state - }); - - when("ホーム画面をレンダリングする", () => { - render(); - }); - - and("FABボタンをタップする", () => { - fireEvent.press(screen.getByTestId("floating-search-fab")); - }); - - then("検索入力フィールドが表示される", () => { - expect(screen.getByTestId("search-input")).toBeTruthy(); - }); - }); - - test("検索テキスト入力でポケモンがフィルタリングされる", ({ - given, - when, - then, - and, - }) => { - given("ポケモンリストが正常にロードされている", () => { - // Default mock state - }); - - when("ホーム画面をレンダリングする", () => { - render(); - }); - - and("FABボタンをタップする", () => { - fireEvent.press(screen.getByTestId("floating-search-fab")); - }); - - and(/^検索入力に "(.*)" と入力する$/, (text: string) => { - fireEvent.changeText(screen.getByTestId("search-input"), text); - }); - - then(/^"(.*)" が表示される$/, (name: string) => { - expect(screen.getByText(name)).toBeTruthy(); - }); - - and(/^"(.*)" は表示されない$/, (name: string) => { - expect(screen.queryByText(name)).toBeNull(); - }); - }); - - test("各カードにお気に入りボタンが表示される", ({ given, when, then }) => { - given("ポケモンリストが正常にロードされている", () => { - // Default mock state - }); - - when("ホーム画面をレンダリングする", () => { - render(); - }); - - then("お気に入りボタンが表示される", () => { - const buttons = screen.getAllByTestId("favorite-button"); - expect(buttons.length).toBeGreaterThan(0); - }); - }); - - test("ローディング中にActivityIndicatorが表示される", ({ - given, - when, - then, - }) => { - given("ポケモンリストがローディング中である", () => { - mockUsePokemonList.isLoading = true; - }); - - when("ホーム画面をレンダリングする", () => { - render(); - }); - - then("ローディングインジケーターが表示される", () => { - expect(screen.getByTestId("loading-indicator")).toBeTruthy(); - }); - }); - - test("エラー時にエラーメッセージが表示される", ({ given, when, then }) => { - given("ポケモンリストの取得でエラーが発生している", () => { - mockUsePokemonList.isLoading = false; - mockUsePokemonList.error = "Network error"; - mockUsePokemonList.pokemon = []; - }); - - when("ホーム画面をレンダリングする", () => { - render(); - }); - - then("エラーメッセージが表示される", () => { - expect(screen.getByTestId("error-text")).toBeTruthy(); - }); - }); -}); diff --git a/__tests__/home/steps/pokemonApi.steps.ts b/__tests__/home/steps/pokemonApi.steps.ts deleted file mode 100644 index ece642f..0000000 --- a/__tests__/home/steps/pokemonApi.steps.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { fetchPokemonList } from "@/src/home"; -import type { PokeApiListResponse } from "@/src/home/domain/pokemonListItem"; - -const feature = loadFeature("__tests__/home/features/pokemonApi.feature"); - -const mockResponse: PokeApiListResponse = { - count: 1302, - next: "https://pokeapi.co/api/v2/pokemon?offset=20&limit=20", - previous: null, - results: [ - { name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" }, - { name: "ivysaur", url: "https://pokeapi.co/api/v2/pokemon/2/" }, - ], -}; - -const originalFetch = globalThis.fetch; - -defineFeature(feature, (test) => { - let resultPromise: Promise; - - beforeEach(() => { - globalThis.fetch = jest.fn(); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - test("正しいURLでfetchを呼び出す", ({ given, when, then, and }) => { - given("fetchがモックされている", () => { - // Already mocked in beforeEach - }); - - and("fetchが正常なレスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - }); - - when( - /^fetchPokemonListを limit (\d+) offset (\d+) で呼び出す$/, - async (limit: string, offset: string) => { - resultPromise = fetchPokemonList(Number(limit), Number(offset)); - await resultPromise; - } - ); - - then(/^fetchが "(.*)" で呼ばれる$/, (url: string) => { - expect(globalThis.fetch).toHaveBeenCalledWith(url); - }); - }); - - test("レスポンスをパースして返す", ({ given, when, then, and }) => { - given("fetchがモックされている", () => { - // Already mocked in beforeEach - }); - - and("fetchが正常なレスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - }); - - when( - /^fetchPokemonListを limit (\d+) offset (\d+) で呼び出す$/, - async (limit: string, offset: string) => { - resultPromise = fetchPokemonList(Number(limit), Number(offset)); - } - ); - - then("レスポンスがパースされて返される", async () => { - const result = await resultPromise; - expect(result).toEqual(mockResponse); - }); - }); - - test("HTTPエラー時にエラーをスローする", ({ given, when, then, and }) => { - given("fetchがモックされている", () => { - // Already mocked in beforeEach - }); - - and( - /^fetchがステータス (\d+) のエラーレスポンスを返す$/, - (status: string) => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: Number(status), - }); - } - ); - - when( - /^fetchPokemonListを limit (\d+) offset (\d+) で呼び出す$/, - (limit: string, offset: string) => { - resultPromise = fetchPokemonList(Number(limit), Number(offset)); - } - ); - - then(/^"(.*)" エラーがスローされる$/, async (errorMessage: string) => { - await expect(resultPromise).rejects.toThrow(errorMessage); - }); - }); - - test("ネットワークエラー時にエラーをスローする", ({ - given, - when, - then, - and, - }) => { - given("fetchがモックされている", () => { - // Already mocked in beforeEach - }); - - and( - /^fetchが "(.*)" ネットワークエラーを返す$/, - (errorMessage: string) => { - (globalThis.fetch as jest.Mock).mockRejectedValueOnce( - new Error(errorMessage) - ); - } - ); - - when( - /^fetchPokemonListを limit (\d+) offset (\d+) で呼び出す$/, - (limit: string, offset: string) => { - resultPromise = fetchPokemonList(Number(limit), Number(offset)); - } - ); - - then(/^"(.*)" エラーがスローされる$/, async (errorMessage: string) => { - await expect(resultPromise).rejects.toThrow(errorMessage); - }); - }); -}); diff --git a/__tests__/home/steps/pokemonGraphqlApi.steps.ts b/__tests__/home/steps/pokemonGraphqlApi.steps.ts deleted file mode 100644 index 708d494..0000000 --- a/__tests__/home/steps/pokemonGraphqlApi.steps.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { fetchPokemonListGraphQL } from "@/src/home/repository/pokemonGraphqlApi"; -import type { PokemonListResult } from "@/src/home/repository/pokemonGraphqlApi"; - -const feature = loadFeature( - "__tests__/home/features/pokemonGraphqlApi.feature" -); - -const mockGraphQLResponse = { - data: { - pokemon_v2_pokemon: [ - { - id: 1, - pokemon_v2_pokemonspecy: { - name: "bulbasaur", - pokemon_v2_pokemonspeciesnames: [{ name: "フシギダネ" }], - }, - pokemon_v2_pokemontypes: [ - { pokemon_v2_type: { name: "grass" } }, - { pokemon_v2_type: { name: "poison" } }, - ], - }, - { - id: 4, - pokemon_v2_pokemonspecy: { - name: "charmander", - pokemon_v2_pokemonspeciesnames: [{ name: "ヒトカゲ" }], - }, - pokemon_v2_pokemontypes: [{ pokemon_v2_type: { name: "fire" } }], - }, - ], - pokemon_v2_pokemon_aggregate: { - aggregate: { count: 1025 }, - }, - }, -}; - -const mockEmptyNameResponse = { - data: { - pokemon_v2_pokemon: [ - { - id: 1, - pokemon_v2_pokemonspecy: { - name: "bulbasaur", - pokemon_v2_pokemonspeciesnames: [], - }, - pokemon_v2_pokemontypes: [{ pokemon_v2_type: { name: "grass" } }], - }, - ], - pokemon_v2_pokemon_aggregate: { - aggregate: { count: 1 }, - }, - }, -}; - -const originalFetch = globalThis.fetch; - -defineFeature(feature, (test) => { - let resultPromise: Promise; - - beforeEach(() => { - globalThis.fetch = jest.fn(); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - test("GraphQLエンドポイントにPOSTリクエストを送信する", ({ - given, - when, - then, - and, - }) => { - given("fetchがモックされている", () => { - // Already mocked in beforeEach - }); - - and("fetchがGraphQL正常レスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockGraphQLResponse), - }); - }); - - when( - /^fetchPokemonListGraphQLを limit (\d+) offset (\d+) lang "(.*)" で呼び出す$/, - async (limit: string, offset: string, lang: string) => { - resultPromise = fetchPokemonListGraphQL( - Number(limit), - Number(offset), - lang - ); - await resultPromise; - } - ); - - then( - /^fetchが "(.*)" にPOSTで呼ばれる$/, - (url: string) => { - expect(globalThis.fetch).toHaveBeenCalledWith( - url, - expect.objectContaining({ - method: "POST", - headers: { "Content-Type": "application/json" }, - }) - ); - } - ); - }); - - test("ローカライズされたポケモン名とタイプを返す", ({ - given, - when, - then, - and, - }) => { - let result: PokemonListResult; - - given("fetchがモックされている", () => { - // Already mocked in beforeEach - }); - - and("fetchがGraphQL正常レスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockGraphQLResponse), - }); - }); - - when( - /^fetchPokemonListGraphQLを limit (\d+) offset (\d+) lang "(.*)" で呼び出す$/, - async (limit: string, offset: string, lang: string) => { - result = await fetchPokemonListGraphQL( - Number(limit), - Number(offset), - lang - ); - } - ); - - then(/^総件数は (\d+) である$/, (count: string) => { - expect(result.count).toBe(Number(count)); - }); - - and(/^ポケモンの件数は (\d+) である$/, (count: string) => { - expect(result.pokemon).toHaveLength(Number(count)); - }); - - and( - /^(\d+)番目のポケモンはID (\d+) 名前 "(.*)" タイプ "(.*)" である$/, - (index: string, id: string, name: string, types: string) => { - const i = Number(index) - 1; - expect(result.pokemon[i]).toEqual({ - id: Number(id), - name, - types: types.split(","), - }); - } - ); - - and( - /^(\d+)番目のポケモンはID (\d+) 名前 "(.*)" タイプ "(.*)" である$/, - (index: string, id: string, name: string, types: string) => { - const i = Number(index) - 1; - expect(result.pokemon[i]).toEqual({ - id: Number(id), - name, - types: types.split(","), - }); - } - ); - }); - - test("ローカライズ名がない場合はspecies名にフォールバックする", ({ - given, - when, - then, - and, - }) => { - let result: PokemonListResult; - - given("fetchがモックされている", () => { - // Already mocked in beforeEach - }); - - and("fetchがローカライズ名なしのGraphQLレスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockEmptyNameResponse), - }); - }); - - when( - /^fetchPokemonListGraphQLを limit (\d+) offset (\d+) lang "(.*)" で呼び出す$/, - async (limit: string, offset: string, lang: string) => { - result = await fetchPokemonListGraphQL( - Number(limit), - Number(offset), - lang - ); - } - ); - - then(/^1番目のポケモンの名前は "(.*)" である$/, (name: string) => { - expect(result.pokemon[0].name).toBe(name); - }); - }); - - test("HTTPエラー時にエラーをスローする", ({ given, when, then, and }) => { - given("fetchがモックされている", () => { - // Already mocked in beforeEach - }); - - and(/^fetchがステータス (\d+) のHTTPエラーを返す$/, (status: string) => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: Number(status), - }); - }); - - when( - /^fetchPokemonListGraphQLを limit (\d+) offset (\d+) lang "(.*)" で呼び出す$/, - (limit: string, offset: string, lang: string) => { - resultPromise = fetchPokemonListGraphQL( - Number(limit), - Number(offset), - lang - ); - } - ); - - then(/^"(.*)" エラーがスローされる$/, async (errorMessage: string) => { - await expect(resultPromise).rejects.toThrow(errorMessage); - }); - }); - - test("GraphQLエラー時にエラーをスローする", ({ given, when, then, and }) => { - given("fetchがモックされている", () => { - // Already mocked in beforeEach - }); - - and("fetchがGraphQLエラーレスポンスを返す", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - errors: [{ message: "Field not found" }], - }), - }); - }); - - when( - /^fetchPokemonListGraphQLを limit (\d+) offset (\d+) lang "(.*)" で呼び出す$/, - (limit: string, offset: string, lang: string) => { - resultPromise = fetchPokemonListGraphQL( - Number(limit), - Number(offset), - lang - ); - } - ); - - then(/^"(.*)" エラーがスローされる$/, async (errorMessage: string) => { - await expect(resultPromise).rejects.toThrow(errorMessage); - }); - }); -}); diff --git a/__tests__/home/steps/pokemonListItem.steps.ts b/__tests__/home/steps/pokemonListItem.steps.ts deleted file mode 100644 index 66c6659..0000000 --- a/__tests__/home/steps/pokemonListItem.steps.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { - extractPokemonId, - capitalizeName, - toPokemon, -} from "@/src/home"; -import type { PokemonSummary } from "@/src/shared"; - -const feature = loadFeature( - "__tests__/home/features/pokemonListItem.feature" -); - -defineFeature(feature, (test) => { - let url: string; - let name: string; - let extractedId: number; - let capitalizedName: string; - let pokemon: PokemonSummary; - - test("URLからポケモンIDを抽出する", ({ given, when, then }) => { - given(/^PokeAPIのURL "(.*)" が与えられている$/, (givenUrl: string) => { - url = givenUrl; - }); - - when("URLからIDを抽出する", () => { - extractedId = extractPokemonId(url); - }); - - then(/^IDは (\d+) である$/, (expectedId: string) => { - expect(extractedId).toBe(Number(expectedId)); - }); - }); - - test("ポケモン名を先頭大文字化する", ({ given, when, then }) => { - given(/^ポケモン名 "(.*)" が与えられている$/, (givenName: string) => { - name = givenName; - }); - - when("名前を先頭大文字化する", () => { - capitalizedName = capitalizeName(name); - }); - - then(/^結果は "(.*)" である$/, (expected: string) => { - expect(capitalizedName).toBe(expected); - }); - }); - - test("PokeApiListItemをPokemonSummary型に変換する", ({ - given, - when, - then, - and, - }) => { - given( - /^PokeAPIリストアイテムの名前が "(.*)" でURLが "(.*)" である$/, - (givenName: string, givenUrl: string) => { - name = givenName; - url = givenUrl; - } - ); - - when("PokemonSummary型に変換する", () => { - pokemon = toPokemon({ name, url }); - }); - - then(/^IDは (\d+) である$/, (expectedId: string) => { - expect(pokemon.id).toBe(Number(expectedId)); - }); - - and(/^名前は "(.*)" である$/, (expectedName: string) => { - expect(pokemon.name).toBe(expectedName); - }); - - and("typesは空配列である", () => { - expect(pokemon.types).toEqual([]); - }); - }); -}); diff --git a/__tests__/home/steps/useFloatingSearch.steps.ts b/__tests__/home/steps/useFloatingSearch.steps.ts deleted file mode 100644 index e9e591f..0000000 --- a/__tests__/home/steps/useFloatingSearch.steps.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { renderHook, act } from "@testing-library/react-native"; -import { useFloatingSearch } from "@/src/home"; - -const feature = loadFeature( - "__tests__/home/features/useFloatingSearch.feature" -); - -defineFeature(feature, (test) => { - let result: { current: ReturnType }; - - test("初期状態でisExpandedがfalse", ({ given, then }) => { - given("useFloatingSearchフックがレンダリングされている", () => { - const hook = renderHook(() => useFloatingSearch()); - result = hook.result; - }); - - then("isExpandedはfalseである", () => { - expect(result.current.isExpanded).toBe(false); - }); - }); - - test("toggle()で展開状態が切り替わる", ({ given, when, then }) => { - given("useFloatingSearchフックがレンダリングされている", () => { - const hook = renderHook(() => useFloatingSearch()); - result = hook.result; - }); - - when("toggleを実行する", () => { - act(() => { - result.current.toggle(); - }); - }); - - then("isExpandedはtrueである", () => { - expect(result.current.isExpanded).toBe(true); - }); - - when("toggleを再度実行する", () => { - act(() => { - result.current.toggle(); - }); - }); - - then("isExpandedはfalseである", () => { - expect(result.current.isExpanded).toBe(false); - }); - }); - - test("close()で常に折りたたまれる", ({ given, when, then, and }) => { - given("useFloatingSearchフックがレンダリングされている", () => { - const hook = renderHook(() => useFloatingSearch()); - result = hook.result; - }); - - when("toggleを実行する", () => { - act(() => { - result.current.toggle(); - }); - }); - - and("closeを実行する", () => { - act(() => { - result.current.close(); - }); - }); - - then("isExpandedはfalseである", () => { - expect(result.current.isExpanded).toBe(false); - }); - }); - - test("close()は折りたたみ状態でも安全に呼べる", ({ given, when, then }) => { - given("useFloatingSearchフックがレンダリングされている", () => { - const hook = renderHook(() => useFloatingSearch()); - result = hook.result; - }); - - when("closeを実行する", () => { - act(() => { - result.current.close(); - }); - }); - - then("isExpandedはfalseである", () => { - expect(result.current.isExpanded).toBe(false); - }); - }); - - test("アニメーションスタイルを返す", ({ given, then, and }) => { - given("useFloatingSearchフックがレンダリングされている", () => { - const hook = renderHook(() => useFloatingSearch()); - result = hook.result; - }); - - then("fabAnimatedStyleが定義されている", () => { - expect(result.current.fabAnimatedStyle).toBeDefined(); - }); - - and("iconAnimatedStyleが定義されている", () => { - expect(result.current.iconAnimatedStyle).toBeDefined(); - }); - - and("inputAnimatedStyleが定義されている", () => { - expect(result.current.inputAnimatedStyle).toBeDefined(); - }); - }); -}); diff --git a/__tests__/home/steps/usePokemonList.steps.ts b/__tests__/home/steps/usePokemonList.steps.ts deleted file mode 100644 index ce958f2..0000000 --- a/__tests__/home/steps/usePokemonList.steps.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { renderHook, act, waitFor } from "@testing-library/react-native"; -import { usePokemonList } from "@/src/home"; -import { fetchPokemonListGraphQL } from "@/src/home/repository/pokemonGraphqlApi"; -import type { PokemonListResult } from "@/src/home/repository/pokemonGraphqlApi"; - -jest.mock("@/src/home/repository/pokemonGraphqlApi"); - -const mockFetch = fetchPokemonListGraphQL as jest.MockedFunction< - typeof fetchPokemonListGraphQL ->; - -const makePage = ( - offset: number, - totalCount: number -): PokemonListResult => ({ - count: totalCount, - pokemon: [ - { id: offset + 1, name: `ポケモン${offset + 1}`, types: ["grass"] }, - { id: offset + 2, name: `ポケモン${offset + 2}`, types: ["fire"] }, - ], -}); - -const feature = loadFeature( - "__tests__/home/features/usePokemonList.feature" -); - -defineFeature(feature, (test) => { - let result: { current: ReturnType }; - - beforeEach(() => { - mockFetch.mockReset(); - }); - - test("初期ロード時にisLoadingがtrueになる", ({ given, when, then }) => { - given("fetchPokemonListGraphQLが未解決のPromiseを返す", () => { - mockFetch.mockReturnValue(new Promise(() => {})); - }); - - when("usePokemonListフックがレンダリングされる", () => { - const hook = renderHook(() => usePokemonList()); - result = hook.result; - }); - - then("isLoadingはtrueである", () => { - expect(result.current.isLoading).toBe(true); - }); - }); - - test("データ取得後にポケモン一覧が設定される", ({ - given, - when, - then, - and, - }) => { - given("fetchPokemonListGraphQLがオフセット0で総数40のデータを返す", () => { - mockFetch.mockResolvedValueOnce(makePage(0, 40)); - }); - - when("usePokemonListフックがレンダリングされる", () => { - const hook = renderHook(() => usePokemonList()); - result = hook.result; - }); - - and("ローディングが完了する", async () => { - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - }); - - then(/^ポケモン一覧の件数は(\d+)件である$/, (count: string) => { - expect(result.current.pokemon).toHaveLength(Number(count)); - }); - - and( - /^1番目のポケモンの名前は "(.*)" である$/, - (name: string) => { - expect(result.current.pokemon[0].name).toBe(name); - } - ); - - and(/^1番目のポケモンのIDは (\d+) である$/, (id: string) => { - expect(result.current.pokemon[0].id).toBe(Number(id)); - }); - - and( - /^1番目のポケモンのタイプは "(.*)" を含む$/, - (type: string) => { - expect(result.current.pokemon[0].types).toEqual([type]); - } - ); - }); - - test("言語パラメータがGraphQL関数に渡される", ({ - given, - when, - then, - and, - }) => { - given("fetchPokemonListGraphQLがオフセット0で総数40のデータを返す", () => { - mockFetch.mockResolvedValueOnce(makePage(0, 40)); - }); - - when("usePokemonListフックがレンダリングされる", () => { - renderHook(() => usePokemonList()); - }); - - and("ローディングが完了する", async () => { - await waitFor(() => { - expect(mockFetch).toHaveBeenCalled(); - }); - }); - - then( - /^fetchPokemonListGraphQLが (\d+) と (\d+) と "(.*)" で呼ばれる$/, - (limit: string, offset: string, lang: string) => { - expect(mockFetch).toHaveBeenCalledWith( - Number(limit), - Number(offset), - lang - ); - } - ); - }); - - test("loadMoreで追加データが追加される", ({ given, when, then, and }) => { - given("fetchPokemonListGraphQLがオフセット0で総数40のデータを返す", () => { - mockFetch.mockResolvedValueOnce(makePage(0, 40)); - }); - - when("usePokemonListフックがレンダリングされる", () => { - const hook = renderHook(() => usePokemonList()); - result = hook.result; - }); - - and("ローディングが完了する", async () => { - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - }); - - and( - "fetchPokemonListGraphQLがオフセット20で総数40の追加データを返す", - () => { - mockFetch.mockResolvedValueOnce(makePage(20, 40)); - } - ); - - and("loadMoreを実行する", async () => { - await act(async () => { - result.current.loadMore(); - }); - await waitFor(() => { - expect(result.current.isLoadingMore).toBe(false); - }); - }); - - then(/^ポケモン一覧の件数は(\d+)件である$/, (count: string) => { - expect(result.current.pokemon).toHaveLength(Number(count)); - }); - }); - - test("総件数に達した場合hasMoreがfalseになる", ({ - given, - when, - then, - and, - }) => { - given("fetchPokemonListGraphQLがオフセット0で総数2のデータを返す", () => { - mockFetch.mockResolvedValueOnce(makePage(0, 2)); - }); - - when("usePokemonListフックがレンダリングされる", () => { - const hook = renderHook(() => usePokemonList()); - result = hook.result; - }); - - and("ローディングが完了する", async () => { - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - }); - - then("hasMoreはfalseである", () => { - expect(result.current.hasMore).toBe(false); - }); - }); - - test("hasMoreがfalseの場合loadMoreは何もしない", ({ - given, - when, - then, - and, - }) => { - given("fetchPokemonListGraphQLがオフセット0で総数2のデータを返す", () => { - mockFetch.mockResolvedValueOnce(makePage(0, 2)); - }); - - when("usePokemonListフックがレンダリングされる", () => { - const hook = renderHook(() => usePokemonList()); - result = hook.result; - }); - - and("ローディングが完了する", async () => { - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - }); - - and("loadMoreを実行する", async () => { - await act(async () => { - result.current.loadMore(); - }); - }); - - then(/^fetchPokemonListGraphQLは(\d+)回だけ呼ばれる$/, (count: string) => { - expect(mockFetch).toHaveBeenCalledTimes(Number(count)); - }); - }); - - test("refreshでデータがリセットされる", ({ given, when, then, and }) => { - given("fetchPokemonListGraphQLがオフセット0で総数40のデータを返す", () => { - mockFetch.mockResolvedValueOnce(makePage(0, 40)); - }); - - when("usePokemonListフックがレンダリングされる", () => { - const hook = renderHook(() => usePokemonList()); - result = hook.result; - }); - - and("ローディングが完了する", async () => { - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - }); - - and( - "fetchPokemonListGraphQLがオフセット0で総数40のリフレッシュデータを返す", - () => { - mockFetch.mockResolvedValueOnce(makePage(0, 40)); - } - ); - - and("refreshを実行する", async () => { - await act(async () => { - result.current.refresh(); - }); - await waitFor(() => { - expect(result.current.isRefreshing).toBe(false); - }); - }); - - then(/^ポケモン一覧の件数は(\d+)件である$/, (count: string) => { - expect(result.current.pokemon).toHaveLength(Number(count)); - }); - - and(/^fetchPokemonListGraphQLは(\d+)回呼ばれる$/, (count: string) => { - expect(mockFetch).toHaveBeenCalledTimes(Number(count)); - }); - - and( - /^最後のfetchPokemonListGraphQL呼び出しは (\d+) と (\d+) と "(.*)" である$/, - (limit: string, offset: string, lang: string) => { - expect(mockFetch).toHaveBeenLastCalledWith( - Number(limit), - Number(offset), - lang - ); - } - ); - }); - - test("エラー時にerror状態が設定される", ({ given, when, then, and }) => { - given( - /^fetchPokemonListGraphQLが "(.*)" エラーを返す$/, - (errorMessage: string) => { - mockFetch.mockRejectedValueOnce(new Error(errorMessage)); - } - ); - - when("usePokemonListフックがレンダリングされる", () => { - const hook = renderHook(() => usePokemonList()); - result = hook.result; - }); - - and("ローディングが完了する", async () => { - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - }); - - then(/^errorは "(.*)" である$/, (expected: string) => { - expect(result.current.error).toBe(expected); - }); - }); - - test("初期ロードでError以外のエラーでもerror状態が設定される", ({ - given, - when, - then, - and, - }) => { - given("fetchPokemonListGraphQLが文字列エラーを返す", () => { - mockFetch.mockRejectedValueOnce("string error"); - }); - - when("usePokemonListフックがレンダリングされる", () => { - const hook = renderHook(() => usePokemonList()); - result = hook.result; - }); - - and("ローディングが完了する", async () => { - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - }); - - then(/^errorは "(.*)" である$/, (expected: string) => { - expect(result.current.error).toBe(expected); - }); - }); - - test("loadMoreでError以外のエラーでもerror状態が設定される", ({ - given, - when, - then, - and, - }) => { - given("fetchPokemonListGraphQLがオフセット0で総数40のデータを返す", () => { - mockFetch.mockResolvedValueOnce(makePage(0, 40)); - }); - - when("usePokemonListフックがレンダリングされる", () => { - const hook = renderHook(() => usePokemonList()); - result = hook.result; - }); - - and("ローディングが完了する", async () => { - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - }); - - and("fetchPokemonListGraphQLが文字列エラーを返すよう設定する", () => { - mockFetch.mockRejectedValueOnce("string error"); - }); - - and("loadMoreを実行する", async () => { - await act(async () => { - result.current.loadMore(); - }); - await waitFor(() => { - expect(result.current.isLoadingMore).toBe(false); - }); - }); - - then(/^errorは "(.*)" である$/, (expected: string) => { - expect(result.current.error).toBe(expected); - }); - }); -}); diff --git a/__tests__/home/steps/useSearch.steps.ts b/__tests__/home/steps/useSearch.steps.ts deleted file mode 100644 index 6301a9e..0000000 --- a/__tests__/home/steps/useSearch.steps.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { renderHook, act } from "@testing-library/react-native"; -import { useSearch } from "@/src/home"; -import type { PokemonSummary } from "@/src/shared"; - -const feature = loadFeature("__tests__/home/features/useSearch.feature"); - -const mockPokemon: PokemonSummary[] = [ - { id: 1, name: "フシギダネ", types: ["grass", "poison"] }, - { id: 4, name: "ヒトカゲ", types: ["fire"] }, - { id: 7, name: "ゼニガメ", types: ["water"] }, - { id: 25, name: "ピカチュウ", types: ["electric"] }, -]; - -defineFeature(feature, (test) => { - let result: { current: ReturnType }; - - test("初期状態では全てのポケモンが返される", ({ given, when, then, and }) => { - given("ポケモンリストが用意されている", () => { - // mockPokemon is already defined - }); - - when("useSearchフックがレンダリングされる", () => { - const hook = renderHook(() => useSearch(mockPokemon)); - result = hook.result; - }); - - then("全てのポケモンが返される", () => { - expect(result.current.filteredItems).toEqual(mockPokemon); - }); - - and("検索テキストは空文字である", () => { - expect(result.current.searchText).toBe(""); - }); - }); - - test("検索テキストに一致するポケモンのみがフィルタリングされる", ({ - given, - when, - then, - and, - }) => { - given("ポケモンリストが用意されている", () => { - // mockPokemon is already defined - }); - - when("useSearchフックがレンダリングされる", () => { - const hook = renderHook(() => useSearch(mockPokemon)); - result = hook.result; - }); - - and(/^検索テキストを "(.*)" に設定する$/, (text: string) => { - act(() => { - result.current.setSearchText(text); - }); - }); - - then("フィルタリング結果にはIDが25のピカチュウのみが含まれる", () => { - expect(result.current.filteredItems).toEqual([ - { id: 25, name: "ピカチュウ", types: ["electric"] }, - ]); - }); - }); - - test("検索テキストが空文字の場合は全てのポケモンが返される", ({ - given, - when, - then, - and, - }) => { - given("ポケモンリストが用意されている", () => { - // mockPokemon is already defined - }); - - when("useSearchフックがレンダリングされる", () => { - const hook = renderHook(() => useSearch(mockPokemon)); - result = hook.result; - }); - - and(/^検索テキストを "(.*)" に設定する$/, (text: string) => { - act(() => { - result.current.setSearchText(text); - }); - }); - - and(/^検索テキストを "(.*)" に設定する$/, (text: string) => { - act(() => { - result.current.setSearchText(text); - }); - }); - - then("全てのポケモンが返される", () => { - expect(result.current.filteredItems).toEqual(mockPokemon); - }); - }); - - test("一致するポケモンがない場合は空配列が返される", ({ - given, - when, - then, - and, - }) => { - given("ポケモンリストが用意されている", () => { - // mockPokemon is already defined - }); - - when("useSearchフックがレンダリングされる", () => { - const hook = renderHook(() => useSearch(mockPokemon)); - result = hook.result; - }); - - and(/^検索テキストを "(.*)" に設定する$/, (text: string) => { - act(() => { - result.current.setSearchText(text); - }); - }); - - then("フィルタリング結果は空配列である", () => { - expect(result.current.filteredItems).toEqual([]); - }); - }); - - test("検索テキストが部分一致でもフィルタリングされる", ({ - given, - when, - then, - and, - }) => { - given("ポケモンリストが用意されている", () => { - // mockPokemon is already defined - }); - - when("useSearchフックがレンダリングされる", () => { - const hook = renderHook(() => useSearch(mockPokemon)); - result = hook.result; - }); - - and(/^検索テキストを "(.*)" に設定する$/, (text: string) => { - act(() => { - result.current.setSearchText(text); - }); - }); - - then("フィルタリング結果にはIDが7のゼニガメのみが含まれる", () => { - expect(result.current.filteredItems).toEqual([ - { id: 7, name: "ゼニガメ", types: ["water"] }, - ]); - }); - }); -}); diff --git a/__tests__/settings/features/languagePicker.feature b/__tests__/settings/screens/settingsScreen.feature similarity index 94% rename from __tests__/settings/features/languagePicker.feature rename to __tests__/settings/screens/settingsScreen.feature index ab6d000..3818c5a 100644 --- a/__tests__/settings/features/languagePicker.feature +++ b/__tests__/settings/screens/settingsScreen.feature @@ -1,4 +1,4 @@ -Feature: 言語選択コンポーネント +Feature: 設定画面 Scenario: 日本語と英語の選択肢が表示される Given 現在の言語が "ja" である diff --git a/__tests__/settings/steps/languagePicker.steps.tsx b/__tests__/settings/screens/settingsScreen.steps.tsx similarity index 71% rename from __tests__/settings/steps/languagePicker.steps.tsx rename to __tests__/settings/screens/settingsScreen.steps.tsx index 6d0c06c..4b5b4aa 100644 --- a/__tests__/settings/steps/languagePicker.steps.tsx +++ b/__tests__/settings/screens/settingsScreen.steps.tsx @@ -1,30 +1,25 @@ +jest.unmock("react-i18next"); + import { defineFeature, loadFeature } from "jest-cucumber"; import { render, screen, fireEvent } from "@testing-library/react-native"; import { LanguagePicker } from "@/src/settings/components/LanguagePicker"; - -const mockChangeLanguage = jest.fn(); -let mockLanguage = "ja"; - -jest.mock("@/src/shared", () => ({ - useLanguage: () => ({ - language: mockLanguage, - changeLanguage: mockChangeLanguage, - }), -})); +import i18n, { initI18n } from "@/src/shared/i18n/i18n"; const feature = loadFeature( - "__tests__/settings/features/languagePicker.feature" + "__tests__/settings/screens/settingsScreen.feature" ); defineFeature(feature, (test) => { - beforeEach(() => { - mockLanguage = "ja"; - mockChangeLanguage.mockReset(); + beforeEach(async () => { + if (i18n.isInitialized) { + await i18n.changeLanguage("ja"); + } }); test("日本語と英語の選択肢が表示される", ({ given, when, then, and }) => { - given(/^現在の言語が "ja" である$/, () => { - mockLanguage = "ja"; + given(/^現在の言語が "ja" である$/, async () => { + await initI18n(); + await i18n.changeLanguage("ja"); }); when("言語選択コンポーネントを表示する", () => { @@ -41,8 +36,9 @@ defineFeature(feature, (test) => { }); test("現在の言語にチェックマークが表示される", ({ given, when, then, and }) => { - given(/^現在の言語が "ja" である$/, () => { - mockLanguage = "ja"; + given(/^現在の言語が "ja" である$/, async () => { + await initI18n(); + await i18n.changeLanguage("ja"); }); when("言語選択コンポーネントを表示する", () => { @@ -59,8 +55,9 @@ defineFeature(feature, (test) => { }); test("言語を選択するとchangeLanguageが呼ばれる", ({ given, when, then }) => { - given(/^現在の言語が "ja" である$/, () => { - mockLanguage = "ja"; + given(/^現在の言語が "ja" である$/, async () => { + await initI18n(); + await i18n.changeLanguage("ja"); }); when("英語の選択肢をタップする", () => { @@ -69,7 +66,7 @@ defineFeature(feature, (test) => { }); then(/^changeLanguageが "en" で呼ばれる$/, () => { - expect(mockChangeLanguage).toHaveBeenCalledWith("en"); + expect(i18n.language).toBe("en"); }); }); }); diff --git a/__tests__/shared/domain/typeColors.test.ts b/__tests__/shared/domain/typeColors.test.ts new file mode 100644 index 0000000..1bb3283 --- /dev/null +++ b/__tests__/shared/domain/typeColors.test.ts @@ -0,0 +1,39 @@ +import { typeColors } from "@/src/shared"; +import type { PokemonType } from "@/src/shared"; + +const allTypes: PokemonType[] = [ + "normal", + "fire", + "water", + "electric", + "grass", + "ice", + "fighting", + "poison", + "ground", + "flying", + "psychic", + "bug", + "rock", + "ghost", + "dragon", + "dark", + "steel", + "fairy", +]; + +describe("typeColors", () => { + it("全18タイプの色が定義されている", () => { + for (const type of allTypes) { + expect(typeColors[type]).toBeDefined(); + } + expect(Object.keys(typeColors)).toHaveLength(18); + }); + + it("各色が有効なHEXカラーコードである", () => { + const hexPattern = /^#[0-9A-Fa-f]{6}$/; + for (const color of Object.values(typeColors)) { + expect(color).toMatch(hexPattern); + } + }); +}); diff --git a/__tests__/shared/features/favoriteButton.feature b/__tests__/shared/features/favoriteButton.feature deleted file mode 100644 index f46a299..0000000 --- a/__tests__/shared/features/favoriteButton.feature +++ /dev/null @@ -1,40 +0,0 @@ -Feature: お気に入りボタン - - Scenario: Lottieアニメーションコンポーネントが描画される - Given 非お気に入り状態のFavoriteButtonを描画する - Then Lottieアニメーションコンポーネントが存在する - - Scenario: autoPlayが無効になっている - Given 非お気に入り状態のFavoriteButtonを描画する - Then autoPlayがfalseである - - Scenario: ループが無効になっている - Given 非お気に入り状態のFavoriteButtonを描画する - Then loopがfalseである - - Scenario: ボタン押下時にonToggleが即座に呼ばれる - Given 非お気に入り状態のFavoriteButtonを描画する - When お気に入りボタンを押す - Then onToggleが1回呼ばれる - - Scenario: お気に入り状態の場合、ONの最終フレームで初期表示される - Given お気に入り状態のFavoriteButtonを描画する - Then progressがON最終フレームの値である - - Scenario: 非お気に入り状態の場合、progressが0で初期表示される - Given 非お気に入り状態のFavoriteButtonを描画する - Then progressが0である - - Scenario: 外部からisFavoriteが変更された場合、progressが再同期される - Given 非お気に入り状態のFavoriteButtonを描画する - Then progressが0である - When isFavoriteをtrueに変更する - Then progressがON最終フレームの値である - - Scenario: お気に入り状態のアクセシビリティラベルが正しい - Given お気に入り状態のFavoriteButtonを描画する - Then アクセシビリティラベルが "favoriteButton.remove" である - - Scenario: 非お気に入り状態のアクセシビリティラベルが正しい - Given 非お気に入り状態のFavoriteButtonを描画する - Then アクセシビリティラベルが "favoriteButton.add" である diff --git a/__tests__/shared/features/i18n.feature b/__tests__/shared/features/i18n.feature deleted file mode 100644 index 0b74b9f..0000000 --- a/__tests__/shared/features/i18n.feature +++ /dev/null @@ -1,36 +0,0 @@ -Feature: 国際化(i18n) - - Scenario: デフォルト言語が日本語で初期化される - Given AsyncStorageが空である - When i18nを初期化する - Then 言語が "ja" である - - Scenario: AsyncStorageに保存された言語で初期化される - Given AsyncStorageに言語 "en" が保存されている - When i18nを初期化する - Then 言語が "en" である - - Scenario: 不正な言語が保存されていた場合はデフォルトの日本語で初期化される - Given AsyncStorageに言語 "fr" が保存されている - When i18nを初期化する - Then 言語が "ja" である - - Scenario: 日本語の翻訳キーが正しく解決される - Given i18nが初期化されている - Then 翻訳キー "tabs.pokedex" が "ポケモン図鑑" に解決される - And 翻訳キー "favorites.empty" が "お気に入りのポケモンはまだいません" に解決される - - Scenario: 英語に切り替えると英語の翻訳が返される - Given i18nが初期化されている - When 言語を "en" に切り替える - Then 翻訳キー "tabs.pokedex" が "Pokédex" に解決される - And 翻訳キー "favorites.empty" が "No favorite Pokémon yet" に解決される - - Scenario: 日本語に戻すと日本語の翻訳が返される - Given i18nが初期化されている - When 言語を "en" に切り替える - And 言語を "ja" に切り替える - Then 翻訳キー "tabs.pokedex" が "ポケモン図鑑" に解決される - - Scenario: SUPPORTED_LANGUAGESに日本語と英語が含まれる - Then SUPPORTED_LANGUAGESが "ja" と "en" を含む diff --git a/__tests__/shared/features/pokemonApi.feature b/__tests__/shared/features/pokemonApi.feature deleted file mode 100644 index 48ffd17..0000000 --- a/__tests__/shared/features/pokemonApi.feature +++ /dev/null @@ -1,26 +0,0 @@ -Feature: ポケモンAPI - - Scenario: 正しいURLでfetchを呼び出す - Given fetchがモックされている - When ポケモンID 25 で取得する - Then fetchが "https://pokeapi.co/api/v2/pokemon/25" で呼ばれる - - Scenario: レスポンスをPokemon型に変換する - Given 単一タイプのAPIレスポンスが返される - When ポケモンID 25 で取得する - Then IDが 25 で名前が "Pikachu" でタイプが "electric" のPokemonが返される - - Scenario: 複数タイプを正しく変換する - Given 複数タイプのAPIレスポンスが返される - When ポケモンID 1 で取得する - Then IDが 1 で名前が "Bulbasaur" でタイプが "grass,poison" のPokemonが返される - - Scenario: 空文字の名前が正しく処理される - Given 空の名前のAPIレスポンスが返される - When ポケモンID 1 で取得する - Then 名前が空文字である - - Scenario: HTTPエラー時にエラーをスローする - Given HTTPエラー 404 が返される - When ポケモンID 99999 で取得する - Then "Failed to fetch pokemon: 404" エラーがスローされる diff --git a/__tests__/shared/features/pokemonCard.feature b/__tests__/shared/features/pokemonCard.feature deleted file mode 100644 index b1710a7..0000000 --- a/__tests__/shared/features/pokemonCard.feature +++ /dev/null @@ -1,54 +0,0 @@ -Feature: ポケモンカード - - Scenario: ポケモンの名前が表示される - Given ピカチュウのデータが用意されている - When PokemonCardを描画する - Then "ピカチュウ" が表示される - - Scenario: ポケモンの画像が正しいURLで表示される - Given ピカチュウのデータが用意されている - When PokemonCardを描画する - Then 画像URLが "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/25.png" である - - Scenario: タイプバッジが翻訳されて表示される - Given ピカチュウのデータが用意されている - When PokemonCardを描画する - Then "types.electric" が表示される - - Scenario: 複数タイプの場合、全てのバッジが翻訳されて表示される - Given リザードンのデータが用意されている - When PokemonCardを描画する - Then "types.fire" が表示される - And "types.flying" が表示される - - Scenario: onPressコールバックが呼ばれる - Given ピカチュウのデータが用意されている - When onPress付きでPokemonCardを描画する - And カードを押す - Then onPressが1回呼ばれる - - Scenario: onPressが未指定の場合でもエラーにならない - Given ピカチュウのデータが用意されている - When PokemonCardを描画する - Then カードを押してもエラーにならない - - Scenario: isFavoriteとonToggleFavoriteが渡された場合、お気に入りボタンが表示される - Given ピカチュウのデータが用意されている - When お気に入り機能付きでPokemonCardを描画する - Then お気に入りボタンが表示される - - Scenario: isFavoriteがtrueの場合、Lottieアニメーションが表示される - Given ピカチュウのデータが用意されている - When お気に入り状態でPokemonCardを描画する - Then Lottieアニメーションが表示される - - Scenario: お気に入りボタン押下後アニメーション完了でonToggleFavoriteが呼ばれる - Given ピカチュウのデータが用意されている - When お気に入り機能付きでPokemonCardを描画する - And お気に入りボタンを押す - Then onToggleFavoriteが1回呼ばれる - - Scenario: isFavoriteが未指定の場合、お気に入りボタンが表示されない - Given ピカチュウのデータが用意されている - When PokemonCardを描画する - Then お気に入りボタンが表示されない diff --git a/__tests__/shared/features/typeColors.feature b/__tests__/shared/features/typeColors.feature deleted file mode 100644 index 5e7b684..0000000 --- a/__tests__/shared/features/typeColors.feature +++ /dev/null @@ -1,12 +0,0 @@ -Feature: タイプカラー定義 - - Scenario: 全18タイプの色が定義されている - Given typeColorsが定義されている - When 全18タイプのキーを確認する - Then 全てのタイプに色が定義されている - And キーの数が18である - - Scenario: 各色が有効なHEXカラーコードである - Given typeColorsが定義されている - When 全ての色の値を確認する - Then 全てHEXカラーコード形式である diff --git a/__tests__/shared/features/useFavoritesStore.feature b/__tests__/shared/features/useFavoritesStore.feature deleted file mode 100644 index 364fb50..0000000 --- a/__tests__/shared/features/useFavoritesStore.feature +++ /dev/null @@ -1,54 +0,0 @@ -Feature: お気に入りストア - - Scenario: 初期状態ではお気に入りが空である - Given お気に入りストアが初期状態である - When useFavoritesフックを実行する - Then お気に入りリストが空である - - Scenario: toggleFavoriteでポケモンをお気に入りに追加できる - Given お気に入りストアが初期状態である - When useFavoritesフックを実行する - And ポケモンID 25 をトグルする - Then お気に入りリストに 25 が含まれる - - Scenario: toggleFavoriteで既にお気に入りのポケモンを削除できる - Given お気に入りストアが初期状態である - When useFavoritesフックを実行する - And ポケモンID 25 をトグルする - And ポケモンID 25 をトグルする - Then お気に入りリストが空である - - Scenario: isFavoriteがお気に入り登録済みのポケモンに対してtrueを返す - Given お気に入りストアが初期状態である - When useFavoritesフックを実行する - And ポケモンID 25 をトグルする - Then ポケモンID 25 がお気に入りである - - Scenario: isFavoriteが未登録のポケモンに対してfalseを返す - Given お気に入りストアが初期状態である - When useFavoritesフックを実行する - Then ポケモンID 25 がお気に入りでない - - Scenario: お気に入りが6匹に達している場合、追加できずアラートが表示される - Given お気に入りストアが初期状態である - When useFavoritesフックを実行する - And 6匹のポケモンをお気に入りに追加する - And ポケモンID 7 をトグルする - Then お気に入りの数が 6 である - And お気に入りリストに 7 が含まれない - And アラートが表示される - - Scenario: 上限に達していても既存のお気に入りは削除できる - Given お気に入りストアが初期状態である - When useFavoritesフックを実行する - And 6匹のポケモンをお気に入りに追加する - And ポケモンID 3 をトグルする - Then お気に入りの数が 5 である - And お気に入りリストに 3 が含まれない - - Scenario: isFullが上限到達時にtrueを返す - Given お気に入りストアが初期状態である - When useFavoritesフックを実行する - Then isFullがfalseである - When 6匹のポケモンをお気に入りに追加する - Then isFullがtrueである diff --git a/__tests__/shared/features/useLanguage.feature b/__tests__/shared/features/useLanguage.feature deleted file mode 100644 index 9f23739..0000000 --- a/__tests__/shared/features/useLanguage.feature +++ /dev/null @@ -1,18 +0,0 @@ -Feature: useLanguageフック - - Scenario: 現在の言語を返す - Given i18nが初期化されている - When useLanguageフックを実行する - Then 言語が "ja" である - - Scenario: 言語を変更するとi18nextの言語が更新される - Given i18nが初期化されている - When useLanguageフックを実行する - And 言語を "en" に変更する - Then 言語が "en" である - - Scenario: 言語変更がAsyncStorageに保存される - Given i18nが初期化されている - When useLanguageフックを実行する - And 言語を "en" に変更する - Then AsyncStorageに "en" が保存されている diff --git a/__tests__/shared/i18n/i18n.test.ts b/__tests__/shared/i18n/i18n.test.ts new file mode 100644 index 0000000..3ac5dc9 --- /dev/null +++ b/__tests__/shared/i18n/i18n.test.ts @@ -0,0 +1,54 @@ +jest.unmock("react-i18next"); + +import AsyncStorage from "@react-native-async-storage/async-storage"; +import i18n, { initI18n, SUPPORTED_LANGUAGES, STORAGE_KEY } from "@/src/shared/i18n/i18n"; + +describe("i18n", () => { + beforeEach(async () => { + await AsyncStorage.clear(); + if (i18n.isInitialized) { + await i18n.changeLanguage("ja"); + } + }); + + it("デフォルト言語が日本語で初期化される", async () => { + await initI18n(); + expect(i18n.language).toBe("ja"); + }); + + it("AsyncStorageに保存された言語で初期化される", async () => { + await AsyncStorage.setItem(STORAGE_KEY, "en"); + await initI18n(); + expect(i18n.language).toBe("en"); + }); + + it("不正な言語が保存されていた場合はデフォルトの日本語で初期化される", async () => { + await AsyncStorage.setItem(STORAGE_KEY, "fr"); + await initI18n(); + expect(i18n.language).toBe("ja"); + }); + + it("日本語の翻訳キーが正しく解決される", async () => { + await initI18n(); + expect(i18n.t("tabs.pokedex")).toBe("ポケモン図鑑"); + expect(i18n.t("favorites.empty")).toBe("お気に入りのポケモンはまだいません"); + }); + + it("英語に切り替えると英語の翻訳が返される", async () => { + await initI18n(); + await i18n.changeLanguage("en"); + expect(i18n.t("tabs.pokedex")).toBe("Pokédex"); + expect(i18n.t("favorites.empty")).toBe("No favorite Pokémon yet"); + }); + + it("日本語に戻すと日本語の翻訳が返される", async () => { + await initI18n(); + await i18n.changeLanguage("en"); + await i18n.changeLanguage("ja"); + expect(i18n.t("tabs.pokedex")).toBe("ポケモン図鑑"); + }); + + it("SUPPORTED_LANGUAGESに日本語と英語が含まれる", () => { + expect(SUPPORTED_LANGUAGES).toEqual(["ja", "en"]); + }); +}); diff --git a/__tests__/shared/i18n/useLanguage.test.ts b/__tests__/shared/i18n/useLanguage.test.ts new file mode 100644 index 0000000..740291e --- /dev/null +++ b/__tests__/shared/i18n/useLanguage.test.ts @@ -0,0 +1,41 @@ +jest.unmock("react-i18next"); + +import { renderHook, act } from "@testing-library/react-native"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import i18n, { initI18n, STORAGE_KEY } from "@/src/shared/i18n/i18n"; +import { useLanguage } from "@/src/shared/i18n/useLanguage"; +import type { SupportedLanguage } from "@/src/shared"; + +describe("useLanguage", () => { + beforeEach(async () => { + await AsyncStorage.clear(); + if (i18n.isInitialized) { + await i18n.changeLanguage("ja"); + } + }); + + it("現在の言語を返す", async () => { + await initI18n(); + const { result } = renderHook(() => useLanguage()); + expect(result.current.language).toBe("ja"); + }); + + it("言語を変更するとi18nextの言語が更新される", async () => { + await initI18n(); + const { result } = renderHook(() => useLanguage()); + await act(async () => { + await result.current.changeLanguage("en" as SupportedLanguage); + }); + expect(result.current.language).toBe("en"); + }); + + it("言語変更がAsyncStorageに保存される", async () => { + await initI18n(); + const { result } = renderHook(() => useLanguage()); + await act(async () => { + await result.current.changeLanguage("en" as SupportedLanguage); + }); + const saved = await AsyncStorage.getItem(STORAGE_KEY); + expect(saved).toBe("en"); + }); +}); diff --git a/__tests__/shared/repository/pokemonApi.test.ts b/__tests__/shared/repository/pokemonApi.test.ts new file mode 100644 index 0000000..f4d1689 --- /dev/null +++ b/__tests__/shared/repository/pokemonApi.test.ts @@ -0,0 +1,87 @@ +import { fetchPokemonById } from "@/src/shared"; + +const mockPokemonApiResponse = { + id: 25, + name: "pikachu", + types: [ + { + slot: 1, + type: { name: "electric", url: "https://pokeapi.co/api/v2/type/13/" }, + }, + ], +}; + +const mockMultiTypeResponse = { + id: 1, + name: "bulbasaur", + types: [ + { + slot: 1, + type: { name: "grass", url: "https://pokeapi.co/api/v2/type/12/" }, + }, + { + slot: 2, + type: { name: "poison", url: "https://pokeapi.co/api/v2/type/4/" }, + }, + ], +}; + +const originalFetch = globalThis.fetch; + +describe("pokemonApi", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("正しいURLでfetchを呼び出す", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockPokemonApiResponse), + }); + await fetchPokemonById(25); + expect(globalThis.fetch).toHaveBeenCalledWith("https://pokeapi.co/api/v2/pokemon/25"); + }); + + it("レスポンスをPokemon型に変換する", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockPokemonApiResponse), + }); + const result = await fetchPokemonById(25); + expect(result).toEqual({ + id: 25, + name: "Pikachu", + types: ["electric"], + }); + }); + + it("複数タイプを正しく変換する", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockMultiTypeResponse), + }); + const result = await fetchPokemonById(1); + expect(result).toEqual({ + id: 1, + name: "Bulbasaur", + types: ["grass", "poison"], + }); + }); + + it("空文字の名前が正しく処理される", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: 1, name: "", types: [] }), + }); + const result = await fetchPokemonById(1); + expect(result.name).toBe(""); + }); + + it("HTTPエラー時にエラーをスローする", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: false, + status: 404, + }); + await expect(fetchPokemonById(99999)).rejects.toThrow("Failed to fetch pokemon: 404"); + }); +}); diff --git a/__tests__/shared/steps/favoriteButton.steps.tsx b/__tests__/shared/steps/favoriteButton.steps.tsx deleted file mode 100644 index 3e2531d..0000000 --- a/__tests__/shared/steps/favoriteButton.steps.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { render, screen, fireEvent } from "@testing-library/react-native"; -import { FavoriteButton } from "@/src/shared"; - -const feature = loadFeature( - "__tests__/shared/features/favoriteButton.feature" -); - -defineFeature(feature, (test) => { - let onToggle: jest.Mock; - let rerender: ReturnType["rerender"]; - - test("Lottieアニメーションコンポーネントが描画される", ({ - given, - then, - }) => { - given("非お気に入り状態のFavoriteButtonを描画する", () => { - onToggle = jest.fn(); - render(); - }); - - then("Lottieアニメーションコンポーネントが存在する", () => { - expect(screen.getByTestId("favorite-lottie")).toBeTruthy(); - }); - }); - - test("autoPlayが無効になっている", ({ given, then }) => { - given("非お気に入り状態のFavoriteButtonを描画する", () => { - onToggle = jest.fn(); - render(); - }); - - then("autoPlayがfalseである", () => { - const lottie = screen.getByTestId("favorite-lottie"); - expect(lottie.props.autoPlay).toBe(false); - }); - }); - - test("ループが無効になっている", ({ given, then }) => { - given("非お気に入り状態のFavoriteButtonを描画する", () => { - onToggle = jest.fn(); - render(); - }); - - then("loopがfalseである", () => { - const lottie = screen.getByTestId("favorite-lottie"); - expect(lottie.props.loop).toBe(false); - }); - }); - - test("ボタン押下時にonToggleが即座に呼ばれる", ({ given, when, then }) => { - given("非お気に入り状態のFavoriteButtonを描画する", () => { - onToggle = jest.fn(); - render(); - }); - - when("お気に入りボタンを押す", () => { - fireEvent.press(screen.getByTestId("favorite-button")); - }); - - then("onToggleが1回呼ばれる", () => { - expect(onToggle).toHaveBeenCalledTimes(1); - }); - }); - - test("お気に入り状態の場合、ONの最終フレームで初期表示される", ({ - given, - then, - }) => { - given("お気に入り状態のFavoriteButtonを描画する", () => { - onToggle = jest.fn(); - render(); - }); - - then("progressがON最終フレームの値である", () => { - const lottie = screen.getByTestId("favorite-lottie"); - expect(lottie.props.progress).toBeCloseTo(90 / 181); - }); - }); - - test("非お気に入り状態の場合、progressが0で初期表示される", ({ - given, - then, - }) => { - given("非お気に入り状態のFavoriteButtonを描画する", () => { - onToggle = jest.fn(); - render(); - }); - - then("progressが0である", () => { - const lottie = screen.getByTestId("favorite-lottie"); - expect(lottie.props.progress).toBe(0); - }); - }); - - test("外部からisFavoriteが変更された場合、progressが再同期される", ({ - given, - then, - when, - }) => { - given("非お気に入り状態のFavoriteButtonを描画する", () => { - onToggle = jest.fn(); - const result = render( - - ); - rerender = result.rerender; - }); - - then("progressが0である", () => { - expect(screen.getByTestId("favorite-lottie").props.progress).toBe(0); - }); - - when("isFavoriteをtrueに変更する", () => { - rerender(); - }); - - then("progressがON最終フレームの値である", () => { - expect( - screen.getByTestId("favorite-lottie").props.progress - ).toBeCloseTo(90 / 181); - }); - }); - - test("お気に入り状態のアクセシビリティラベルが正しい", ({ - given, - then, - }) => { - given("お気に入り状態のFavoriteButtonを描画する", () => { - onToggle = jest.fn(); - render(); - }); - - then( - /^アクセシビリティラベルが "(.*)" である$/, - (label: string) => { - expect(screen.getByLabelText(label)).toBeTruthy(); - } - ); - }); - - test("非お気に入り状態のアクセシビリティラベルが正しい", ({ - given, - then, - }) => { - given("非お気に入り状態のFavoriteButtonを描画する", () => { - onToggle = jest.fn(); - render(); - }); - - then( - /^アクセシビリティラベルが "(.*)" である$/, - (label: string) => { - expect(screen.getByLabelText(label)).toBeTruthy(); - } - ); - }); -}); diff --git a/__tests__/shared/steps/i18n.steps.ts b/__tests__/shared/steps/i18n.steps.ts deleted file mode 100644 index 5f9a1d5..0000000 --- a/__tests__/shared/steps/i18n.steps.ts +++ /dev/null @@ -1,156 +0,0 @@ -jest.unmock("react-i18next"); - -import { defineFeature, loadFeature } from "jest-cucumber"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import i18n, { initI18n, SUPPORTED_LANGUAGES, STORAGE_KEY } from "@/src/shared/i18n/i18n"; - -const feature = loadFeature("__tests__/shared/features/i18n.feature"); - -defineFeature(feature, (test) => { - beforeEach(async () => { - await AsyncStorage.clear(); - if (i18n.isInitialized) { - await i18n.changeLanguage("ja"); - } - }); - - test("デフォルト言語が日本語で初期化される", ({ given, when, then }) => { - given("AsyncStorageが空である", () => { - // already cleared in beforeEach - }); - - when("i18nを初期化する", async () => { - await initI18n(); - }); - - then(/^言語が "(.*)" である$/, (lang: string) => { - expect(i18n.language).toBe(lang); - }); - }); - - test("AsyncStorageに保存された言語で初期化される", ({ - given, - when, - then, - }) => { - given( - /^AsyncStorageに言語 "(.*)" が保存されている$/, - async (lang: string) => { - await AsyncStorage.setItem(STORAGE_KEY, lang); - } - ); - - when("i18nを初期化する", async () => { - await initI18n(); - }); - - then(/^言語が "(.*)" である$/, (lang: string) => { - expect(i18n.language).toBe(lang); - }); - }); - - test("不正な言語が保存されていた場合はデフォルトの日本語で初期化される", ({ - given, - when, - then, - }) => { - given( - /^AsyncStorageに言語 "(.*)" が保存されている$/, - async (lang: string) => { - await AsyncStorage.setItem(STORAGE_KEY, lang); - } - ); - - when("i18nを初期化する", async () => { - await initI18n(); - }); - - then(/^言語が "(.*)" である$/, (lang: string) => { - expect(i18n.language).toBe(lang); - }); - }); - - test("日本語の翻訳キーが正しく解決される", ({ given, then, and }) => { - given("i18nが初期化されている", async () => { - await initI18n(); - }); - - then( - /^翻訳キー "(.*)" が "(.*)" に解決される$/, - (key: string, value: string) => { - expect(i18n.t(key)).toBe(value); - } - ); - - and( - /^翻訳キー "(.*)" が "(.*)" に解決される$/, - (key: string, value: string) => { - expect(i18n.t(key)).toBe(value); - } - ); - }); - - test("英語に切り替えると英語の翻訳が返される", ({ - given, - when, - then, - and, - }) => { - given("i18nが初期化されている", async () => { - await initI18n(); - }); - - when(/^言語を "(.*)" に切り替える$/, async (lang: string) => { - await i18n.changeLanguage(lang); - }); - - then( - /^翻訳キー "(.*)" が "(.*)" に解決される$/, - (key: string, value: string) => { - expect(i18n.t(key)).toBe(value); - } - ); - - and( - /^翻訳キー "(.*)" が "(.*)" に解決される$/, - (key: string, value: string) => { - expect(i18n.t(key)).toBe(value); - } - ); - }); - - test("日本語に戻すと日本語の翻訳が返される", ({ - given, - when, - then, - and, - }) => { - given("i18nが初期化されている", async () => { - await initI18n(); - }); - - when(/^言語を "(.*)" に切り替える$/, async (lang: string) => { - await i18n.changeLanguage(lang); - }); - - and(/^言語を "(.*)" に切り替える$/, async (lang: string) => { - await i18n.changeLanguage(lang); - }); - - then( - /^翻訳キー "(.*)" が "(.*)" に解決される$/, - (key: string, value: string) => { - expect(i18n.t(key)).toBe(value); - } - ); - }); - - test("SUPPORTED_LANGUAGESに日本語と英語が含まれる", ({ then }) => { - then( - /^SUPPORTED_LANGUAGESが "(.*)" と "(.*)" を含む$/, - (lang1: string, lang2: string) => { - expect(SUPPORTED_LANGUAGES).toEqual([lang1, lang2]); - } - ); - }); -}); diff --git a/__tests__/shared/steps/pokemonApi.steps.ts b/__tests__/shared/steps/pokemonApi.steps.ts deleted file mode 100644 index 564568d..0000000 --- a/__tests__/shared/steps/pokemonApi.steps.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { fetchPokemonById } from "@/src/shared/repository/pokemonApi"; - -const feature = loadFeature("__tests__/shared/features/pokemonApi.feature"); - -const mockApiResponse = { - id: 25, - name: "pikachu", - types: [ - { - slot: 1, - type: { name: "electric", url: "https://pokeapi.co/api/v2/type/13/" }, - }, - ], -}; - -const mockMultiTypeResponse = { - id: 1, - name: "bulbasaur", - types: [ - { - slot: 1, - type: { name: "grass", url: "https://pokeapi.co/api/v2/type/12/" }, - }, - { - slot: 2, - type: { name: "poison", url: "https://pokeapi.co/api/v2/type/4/" }, - }, - ], -}; - -const originalFetch = globalThis.fetch; - -defineFeature(feature, (test) => { - let result: Awaited>; - let error: Error; - - beforeEach(() => { - globalThis.fetch = jest.fn(); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - test("正しいURLでfetchを呼び出す", ({ given, when, then }) => { - given("fetchがモックされている", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockApiResponse), - }); - }); - - when(/^ポケモンID (\d+) で取得する$/, async (id: string) => { - await fetchPokemonById(Number(id)); - }); - - then(/^fetchが "(.*)" で呼ばれる$/, (url: string) => { - expect(globalThis.fetch).toHaveBeenCalledWith(url); - }); - }); - - test("レスポンスをPokemon型に変換する", ({ given, when, then }) => { - given("単一タイプのAPIレスポンスが返される", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockApiResponse), - }); - }); - - when(/^ポケモンID (\d+) で取得する$/, async (id: string) => { - result = await fetchPokemonById(Number(id)); - }); - - then( - /^IDが (\d+) で名前が "(.*)" でタイプが "(.*)" のPokemonが返される$/, - (id: string, name: string, types: string) => { - expect(result).toEqual({ - id: Number(id), - name, - types: types.split(","), - }); - } - ); - }); - - test("複数タイプを正しく変換する", ({ given, when, then }) => { - given("複数タイプのAPIレスポンスが返される", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockMultiTypeResponse), - }); - }); - - when(/^ポケモンID (\d+) で取得する$/, async (id: string) => { - result = await fetchPokemonById(Number(id)); - }); - - then( - /^IDが (\d+) で名前が "(.*)" でタイプが "(.*)" のPokemonが返される$/, - (id: string, name: string, types: string) => { - expect(result).toEqual({ - id: Number(id), - name, - types: types.split(","), - }); - } - ); - }); - - test("空文字の名前が正しく処理される", ({ given, when, then }) => { - given("空の名前のAPIレスポンスが返される", () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ id: 1, name: "", types: [] }), - }); - }); - - when(/^ポケモンID (\d+) で取得する$/, async (id: string) => { - result = await fetchPokemonById(Number(id)); - }); - - then("名前が空文字である", () => { - expect(result.name).toBe(""); - }); - }); - - test("HTTPエラー時にエラーをスローする", ({ given, when, then }) => { - given(/^HTTPエラー (\d+) が返される$/, (status: string) => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: Number(status), - }); - }); - - when(/^ポケモンID (\d+) で取得する$/, async (id: string) => { - try { - await fetchPokemonById(Number(id)); - } catch (e) { - error = e as Error; - } - }); - - then(/^"(.*)" エラーがスローされる$/, (message: string) => { - expect(error).toBeDefined(); - expect(error.message).toBe(message); - }); - }); -}); diff --git a/__tests__/shared/steps/pokemonCard.steps.tsx b/__tests__/shared/steps/pokemonCard.steps.tsx deleted file mode 100644 index f136d91..0000000 --- a/__tests__/shared/steps/pokemonCard.steps.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { render, screen, fireEvent } from "@testing-library/react-native"; -import { PokemonCard } from "@/src/shared"; -import type { PokemonSummary } from "@/src/shared"; - -const feature = loadFeature( - "__tests__/shared/features/pokemonCard.feature" -); - -const mockPokemon: PokemonSummary = { - id: 25, - name: "ピカチュウ", - types: ["electric"], -}; - -const mockMultiTypePokemon: PokemonSummary = { - id: 6, - name: "リザードン", - types: ["fire", "flying"], -}; - -defineFeature(feature, (test) => { - let pokemon: PokemonSummary; - let onPress: jest.Mock; - let onToggleFavorite: jest.Mock; - - test("ポケモンの名前が表示される", ({ given, when, then }) => { - given("ピカチュウのデータが用意されている", () => { - pokemon = mockPokemon; - }); - - when("PokemonCardを描画する", () => { - render(); - }); - - then(/^"(.*)" が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("ポケモンの画像が正しいURLで表示される", ({ given, when, then }) => { - given("ピカチュウのデータが用意されている", () => { - pokemon = mockPokemon; - }); - - when("PokemonCardを描画する", () => { - render(); - }); - - then(/^画像URLが "(.*)" である$/, (url: string) => { - const image = screen.getByTestId("pokemon-image"); - expect(image.props.source.uri).toBe(url); - }); - }); - - test("タイプバッジが翻訳されて表示される", ({ given, when, then }) => { - given("ピカチュウのデータが用意されている", () => { - pokemon = mockPokemon; - }); - - when("PokemonCardを描画する", () => { - render(); - }); - - then(/^"(.*)" が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("複数タイプの場合、全てのバッジが翻訳されて表示される", ({ - given, - when, - then, - and, - }) => { - given("リザードンのデータが用意されている", () => { - pokemon = mockMultiTypePokemon; - }); - - when("PokemonCardを描画する", () => { - render(); - }); - - then(/^"(.*)" が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - - and(/^"(.*)" が表示される$/, (text: string) => { - expect(screen.getByText(text)).toBeTruthy(); - }); - }); - - test("onPressコールバックが呼ばれる", ({ given, when, then, and }) => { - given("ピカチュウのデータが用意されている", () => { - pokemon = mockPokemon; - }); - - when("onPress付きでPokemonCardを描画する", () => { - onPress = jest.fn(); - render(); - }); - - and("カードを押す", () => { - fireEvent.press(screen.getByTestId("pokemon-card")); - }); - - then("onPressが1回呼ばれる", () => { - expect(onPress).toHaveBeenCalledTimes(1); - }); - }); - - test("onPressが未指定の場合でもエラーにならない", ({ - given, - when, - then, - }) => { - given("ピカチュウのデータが用意されている", () => { - pokemon = mockPokemon; - }); - - when("PokemonCardを描画する", () => { - render(); - }); - - then("カードを押してもエラーにならない", () => { - expect(() => { - fireEvent.press(screen.getByTestId("pokemon-card")); - }).not.toThrow(); - }); - }); - - test("isFavoriteとonToggleFavoriteが渡された場合、お気に入りボタンが表示される", ({ - given, - when, - then, - }) => { - given("ピカチュウのデータが用意されている", () => { - pokemon = mockPokemon; - }); - - when("お気に入り機能付きでPokemonCardを描画する", () => { - onToggleFavorite = jest.fn(); - render( - - ); - }); - - then("お気に入りボタンが表示される", () => { - expect(screen.getByTestId("favorite-button")).toBeTruthy(); - }); - }); - - test("isFavoriteがtrueの場合、Lottieアニメーションが表示される", ({ - given, - when, - then, - }) => { - given("ピカチュウのデータが用意されている", () => { - pokemon = mockPokemon; - }); - - when("お気に入り状態でPokemonCardを描画する", () => { - onToggleFavorite = jest.fn(); - render( - - ); - }); - - then("Lottieアニメーションが表示される", () => { - expect(screen.getByTestId("favorite-lottie")).toBeTruthy(); - }); - }); - - test("お気に入りボタン押下後アニメーション完了でonToggleFavoriteが呼ばれる", ({ - given, - when, - then, - and, - }) => { - given("ピカチュウのデータが用意されている", () => { - pokemon = mockPokemon; - }); - - when("お気に入り機能付きでPokemonCardを描画する", () => { - onToggleFavorite = jest.fn(); - render( - - ); - }); - - and("お気に入りボタンを押す", () => { - fireEvent.press(screen.getByTestId("favorite-button")); - }); - - then("onToggleFavoriteが1回呼ばれる", () => { - expect(onToggleFavorite).toHaveBeenCalledTimes(1); - }); - }); - - test("isFavoriteが未指定の場合、お気に入りボタンが表示されない", ({ - given, - when, - then, - }) => { - given("ピカチュウのデータが用意されている", () => { - pokemon = mockPokemon; - }); - - when("PokemonCardを描画する", () => { - render(); - }); - - then("お気に入りボタンが表示されない", () => { - expect(screen.queryByTestId("favorite-button")).toBeNull(); - }); - }); -}); diff --git a/__tests__/shared/steps/typeColors.steps.ts b/__tests__/shared/steps/typeColors.steps.ts deleted file mode 100644 index ebef295..0000000 --- a/__tests__/shared/steps/typeColors.steps.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { typeColors } from "@/src/shared"; -import type { PokemonType } from "@/src/shared"; - -const feature = loadFeature( - "__tests__/shared/features/typeColors.feature" -); - -const allTypes: PokemonType[] = [ - "normal", - "fire", - "water", - "electric", - "grass", - "ice", - "fighting", - "poison", - "ground", - "flying", - "psychic", - "bug", - "rock", - "ghost", - "dragon", - "dark", - "steel", - "fairy", -]; - -defineFeature(feature, (test) => { - test("全18タイプの色が定義されている", ({ given, when, then, and }) => { - given("typeColorsが定義されている", () => { - expect(typeColors).toBeDefined(); - }); - - when("全18タイプのキーを確認する", () => { - // verification happens in then - }); - - then("全てのタイプに色が定義されている", () => { - for (const type of allTypes) { - expect(typeColors[type]).toBeDefined(); - } - }); - - and("キーの数が18である", () => { - expect(Object.keys(typeColors)).toHaveLength(18); - }); - }); - - test("各色が有効なHEXカラーコードである", ({ given, when, then }) => { - given("typeColorsが定義されている", () => { - expect(typeColors).toBeDefined(); - }); - - when("全ての色の値を確認する", () => { - // verification happens in then - }); - - then("全てHEXカラーコード形式である", () => { - const hexPattern = /^#[0-9A-Fa-f]{6}$/; - for (const color of Object.values(typeColors)) { - expect(color).toMatch(hexPattern); - } - }); - }); -}); diff --git a/__tests__/shared/steps/useFavoritesStore.steps.tsx b/__tests__/shared/steps/useFavoritesStore.steps.tsx deleted file mode 100644 index a0d5d97..0000000 --- a/__tests__/shared/steps/useFavoritesStore.steps.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { renderHook, act } from "@testing-library/react-native"; -import { Alert } from "react-native"; -import { useFavorites } from "@/src/shared"; -import { useFavoritesStore } from "@/src/shared/stores/useFavoritesStore"; - -jest.mock("@/src/shared/i18n", () => ({ - i18n: { - t: (key: string) => key, - }, -})); - -jest.spyOn(Alert, "alert").mockImplementation(() => {}); - -const feature = loadFeature( - "__tests__/shared/features/useFavoritesStore.feature" -); - -defineFeature(feature, (test) => { - let result: ReturnType< - typeof renderHook, unknown> - >["result"]; - - beforeEach(() => { - useFavoritesStore.setState({ favoriteIds: [] }); - (Alert.alert as jest.Mock).mockClear(); - }); - - test("初期状態ではお気に入りが空である", ({ given, when, then }) => { - given("お気に入りストアが初期状態である", () => { - // reset in beforeEach - }); - - when("useFavoritesフックを実行する", () => { - const hook = renderHook(() => useFavorites()); - result = hook.result; - }); - - then("お気に入りリストが空である", () => { - expect(result.current.favoriteIds).toEqual([]); - }); - }); - - test("toggleFavoriteでポケモンをお気に入りに追加できる", ({ - given, - when, - then, - and, - }) => { - given("お気に入りストアが初期状態である", () => { - // reset in beforeEach - }); - - when("useFavoritesフックを実行する", () => { - const hook = renderHook(() => useFavorites()); - result = hook.result; - }); - - and(/^ポケモンID (\d+) をトグルする$/, (id: string) => { - act(() => { - result.current.toggleFavorite(Number(id)); - }); - }); - - then(/^お気に入りリストに (\d+) が含まれる$/, (id: string) => { - expect(result.current.favoriteIds).toEqual([Number(id)]); - }); - }); - - test("toggleFavoriteで既にお気に入りのポケモンを削除できる", ({ - given, - when, - then, - and, - }) => { - given("お気に入りストアが初期状態である", () => { - // reset in beforeEach - }); - - when("useFavoritesフックを実行する", () => { - const hook = renderHook(() => useFavorites()); - result = hook.result; - }); - - and(/^ポケモンID (\d+) をトグルする$/, (id: string) => { - act(() => { - result.current.toggleFavorite(Number(id)); - }); - }); - - and(/^ポケモンID (\d+) をトグルする$/, (id: string) => { - act(() => { - result.current.toggleFavorite(Number(id)); - }); - }); - - then("お気に入りリストが空である", () => { - expect(result.current.favoriteIds).toEqual([]); - }); - }); - - test("isFavoriteがお気に入り登録済みのポケモンに対してtrueを返す", ({ - given, - when, - then, - and, - }) => { - given("お気に入りストアが初期状態である", () => { - // reset in beforeEach - }); - - when("useFavoritesフックを実行する", () => { - const hook = renderHook(() => useFavorites()); - result = hook.result; - }); - - and(/^ポケモンID (\d+) をトグルする$/, (id: string) => { - act(() => { - result.current.toggleFavorite(Number(id)); - }); - }); - - then(/^ポケモンID (\d+) がお気に入りである$/, (id: string) => { - expect(result.current.isFavorite(Number(id))).toBe(true); - }); - }); - - test("isFavoriteが未登録のポケモンに対してfalseを返す", ({ - given, - when, - then, - }) => { - given("お気に入りストアが初期状態である", () => { - // reset in beforeEach - }); - - when("useFavoritesフックを実行する", () => { - const hook = renderHook(() => useFavorites()); - result = hook.result; - }); - - then(/^ポケモンID (\d+) がお気に入りでない$/, (id: string) => { - expect(result.current.isFavorite(Number(id))).toBe(false); - }); - }); - - test("お気に入りが6匹に達している場合、追加できずアラートが表示される", ({ - given, - when, - then, - and, - }) => { - given("お気に入りストアが初期状態である", () => { - // reset in beforeEach - }); - - when("useFavoritesフックを実行する", () => { - const hook = renderHook(() => useFavorites()); - result = hook.result; - }); - - and("6匹のポケモンをお気に入りに追加する", () => { - act(() => { - result.current.toggleFavorite(1); - result.current.toggleFavorite(2); - result.current.toggleFavorite(3); - result.current.toggleFavorite(4); - result.current.toggleFavorite(5); - result.current.toggleFavorite(6); - }); - }); - - and(/^ポケモンID (\d+) をトグルする$/, (id: string) => { - act(() => { - result.current.toggleFavorite(Number(id)); - }); - }); - - then(/^お気に入りの数が (\d+) である$/, (count: string) => { - expect(result.current.favoriteIds).toHaveLength(Number(count)); - }); - - and(/^お気に入りリストに (\d+) が含まれない$/, (id: string) => { - expect(result.current.favoriteIds).not.toContain(Number(id)); - }); - - and("アラートが表示される", () => { - expect(Alert.alert).toHaveBeenCalledWith( - "favorites.limitTitle", - "favorites.limitMessage" - ); - }); - }); - - test("上限に達していても既存のお気に入りは削除できる", ({ - given, - when, - then, - and, - }) => { - given("お気に入りストアが初期状態である", () => { - // reset in beforeEach - }); - - when("useFavoritesフックを実行する", () => { - const hook = renderHook(() => useFavorites()); - result = hook.result; - }); - - and("6匹のポケモンをお気に入りに追加する", () => { - act(() => { - result.current.toggleFavorite(1); - result.current.toggleFavorite(2); - result.current.toggleFavorite(3); - result.current.toggleFavorite(4); - result.current.toggleFavorite(5); - result.current.toggleFavorite(6); - }); - }); - - and(/^ポケモンID (\d+) をトグルする$/, (id: string) => { - act(() => { - result.current.toggleFavorite(Number(id)); - }); - }); - - then(/^お気に入りの数が (\d+) である$/, (count: string) => { - expect(result.current.favoriteIds).toHaveLength(Number(count)); - }); - - and(/^お気に入りリストに (\d+) が含まれない$/, (id: string) => { - expect(result.current.favoriteIds).not.toContain(Number(id)); - }); - }); - - test("isFullが上限到達時にtrueを返す", ({ given, when, then }) => { - given("お気に入りストアが初期状態である", () => { - // reset in beforeEach - }); - - when("useFavoritesフックを実行する", () => { - const hook = renderHook(() => useFavorites()); - result = hook.result; - }); - - then("isFullがfalseである", () => { - expect(result.current.isFull).toBe(false); - }); - - when("6匹のポケモンをお気に入りに追加する", () => { - act(() => { - result.current.toggleFavorite(1); - result.current.toggleFavorite(2); - result.current.toggleFavorite(3); - result.current.toggleFavorite(4); - result.current.toggleFavorite(5); - result.current.toggleFavorite(6); - }); - }); - - then("isFullがtrueである", () => { - expect(result.current.isFull).toBe(true); - }); - }); -}); diff --git a/__tests__/shared/steps/useLanguage.steps.ts b/__tests__/shared/steps/useLanguage.steps.ts deleted file mode 100644 index ca91b6d..0000000 --- a/__tests__/shared/steps/useLanguage.steps.ts +++ /dev/null @@ -1,87 +0,0 @@ -jest.unmock("react-i18next"); - -import { defineFeature, loadFeature } from "jest-cucumber"; -import { renderHook, act } from "@testing-library/react-native"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useLanguage } from "@/src/shared/i18n/useLanguage"; -import { initI18n, STORAGE_KEY } from "@/src/shared/i18n/i18n"; -import type { SupportedLanguage } from "@/src/shared"; - -const feature = loadFeature("__tests__/shared/features/useLanguage.feature"); - -defineFeature(feature, (test) => { - let result: ReturnType, unknown>>["result"]; - - beforeEach(async () => { - await AsyncStorage.clear(); - await initI18n(); - }); - - test("現在の言語を返す", ({ given, when, then }) => { - given("i18nが初期化されている", () => { - // done in beforeEach - }); - - when("useLanguageフックを実行する", () => { - const hook = renderHook(() => useLanguage()); - result = hook.result; - }); - - then(/^言語が "(.*)" である$/, (lang: string) => { - expect(result.current.language).toBe(lang); - }); - }); - - test("言語を変更するとi18nextの言語が更新される", ({ - given, - when, - then, - and, - }) => { - given("i18nが初期化されている", () => { - // done in beforeEach - }); - - when("useLanguageフックを実行する", () => { - const hook = renderHook(() => useLanguage()); - result = hook.result; - }); - - and(/^言語を "(.*)" に変更する$/, async (lang: string) => { - await act(async () => { - await result.current.changeLanguage(lang as SupportedLanguage); - }); - }); - - then(/^言語が "(.*)" である$/, (lang: string) => { - expect(result.current.language).toBe(lang); - }); - }); - - test("言語変更がAsyncStorageに保存される", ({ - given, - when, - then, - and, - }) => { - given("i18nが初期化されている", () => { - // done in beforeEach - }); - - when("useLanguageフックを実行する", () => { - const hook = renderHook(() => useLanguage()); - result = hook.result; - }); - - and(/^言語を "(.*)" に変更する$/, async (lang: string) => { - await act(async () => { - await result.current.changeLanguage(lang as SupportedLanguage); - }); - }); - - then(/^AsyncStorageに "(.*)" が保存されている$/, async (lang: string) => { - const saved = await AsyncStorage.getItem(STORAGE_KEY); - expect(saved).toBe(lang); - }); - }); -}); diff --git a/__tests__/shared/stores/useFavoritesStore.test.tsx b/__tests__/shared/stores/useFavoritesStore.test.tsx new file mode 100644 index 0000000..1bdf2a7 --- /dev/null +++ b/__tests__/shared/stores/useFavoritesStore.test.tsx @@ -0,0 +1,107 @@ +import { renderHook, act } from "@testing-library/react-native"; +import { Alert } from "react-native"; +import { useFavorites, useFavoritesStore } from "@/src/shared"; + +jest.mock("@/src/shared/i18n", () => ({ + i18n: { + t: (key: string) => key, + }, +})); + +jest.spyOn(Alert, "alert").mockImplementation(() => {}); + +describe("useFavoritesStore", () => { + beforeEach(() => { + useFavoritesStore.setState({ favoriteIds: [] }); + (Alert.alert as jest.Mock).mockClear(); + }); + + it("初期状態ではお気に入りが空である", () => { + const { result } = renderHook(() => useFavorites()); + expect(result.current.favoriteIds).toEqual([]); + }); + + it("toggleFavoriteでポケモンをお気に入りに追加できる", () => { + const { result } = renderHook(() => useFavorites()); + act(() => { + result.current.toggleFavorite(25); + }); + expect(result.current.favoriteIds).toEqual([25]); + }); + + it("toggleFavoriteで既にお気に入りのポケモンを削除できる", () => { + const { result } = renderHook(() => useFavorites()); + act(() => { + result.current.toggleFavorite(25); + }); + act(() => { + result.current.toggleFavorite(25); + }); + expect(result.current.favoriteIds).toEqual([]); + }); + + it("isFavoriteがお気に入り登録済みのポケモンに対してtrueを返す", () => { + const { result } = renderHook(() => useFavorites()); + act(() => { + result.current.toggleFavorite(25); + }); + expect(result.current.isFavorite(25)).toBe(true); + }); + + it("isFavoriteが未登録のポケモンに対してfalseを返す", () => { + const { result } = renderHook(() => useFavorites()); + expect(result.current.isFavorite(25)).toBe(false); + }); + + it("お気に入りが6匹に達している場合、追加できずアラートが表示される", () => { + const { result } = renderHook(() => useFavorites()); + act(() => { + result.current.toggleFavorite(1); + result.current.toggleFavorite(2); + result.current.toggleFavorite(3); + result.current.toggleFavorite(4); + result.current.toggleFavorite(5); + result.current.toggleFavorite(6); + }); + act(() => { + result.current.toggleFavorite(7); + }); + expect(result.current.favoriteIds).toHaveLength(6); + expect(result.current.favoriteIds).not.toContain(7); + expect(Alert.alert).toHaveBeenCalledWith( + "favorites.limitTitle", + "favorites.limitMessage" + ); + }); + + it("上限に達していても既存のお気に入りは削除できる", () => { + const { result } = renderHook(() => useFavorites()); + act(() => { + result.current.toggleFavorite(1); + result.current.toggleFavorite(2); + result.current.toggleFavorite(3); + result.current.toggleFavorite(4); + result.current.toggleFavorite(5); + result.current.toggleFavorite(6); + }); + act(() => { + result.current.toggleFavorite(3); + }); + expect(result.current.favoriteIds).toHaveLength(5); + expect(result.current.favoriteIds).not.toContain(3); + }); + + it("isFullが上限到達時にtrueを返す", () => { + const { result } = renderHook(() => useFavorites()); + expect(result.current.isFull).toBe(false); + act(() => { + result.current.toggleFavorite(1); + result.current.toggleFavorite(2); + result.current.toggleFavorite(3); + result.current.toggleFavorite(4); + result.current.toggleFavorite(5); + result.current.toggleFavorite(6); + }); + expect(result.current.isFull).toBe(true); + }); +}); diff --git a/__tests__/splash/features/useAnimatedSplash.feature b/__tests__/splash/features/useAnimatedSplash.feature deleted file mode 100644 index 51a6314..0000000 --- a/__tests__/splash/features/useAnimatedSplash.feature +++ /dev/null @@ -1,27 +0,0 @@ -Feature: スプラッシュアニメーションフック - - Scenario: マウント時にSplashScreen.hideAsyncが呼ばれる - Given onFinishコールバックが用意されている - When フックをレンダーする - Then SplashScreen.hideAsyncが1回呼ばれる - - Scenario: delay前にはonFinishが呼ばれない - Given onFinishコールバックとdelay 800ms が用意されている - When フックをレンダーして500ms経過する - Then onFinishは呼ばれていない - - Scenario: delay後にonFinishコールバックが呼ばれる - Given onFinishコールバックとdelay 800ms が用意されている - When フックをレンダーして800ms経過する - Then onFinishが1回呼ばれる - - Scenario: アンマウント時にタイマーがクリーンアップされる - Given onFinishコールバックとdelay 800ms が用意されている - When フックをレンダーしてアンマウントし1000ms経過する - Then onFinishは呼ばれていない - - Scenario: animatedStyleオブジェクトを返す - Given onFinishコールバックが用意されている - When フックをレンダーする - Then animatedStyleにopacityプロパティがある - And animatedStyleにtransformプロパティがある diff --git a/__tests__/splash/hooks/useAnimatedSplash.test.ts b/__tests__/splash/hooks/useAnimatedSplash.test.ts new file mode 100644 index 0000000..b7b8a63 --- /dev/null +++ b/__tests__/splash/hooks/useAnimatedSplash.test.ts @@ -0,0 +1,81 @@ +import { renderHook, act } from "@testing-library/react-native"; +import * as SplashScreen from "expo-splash-screen"; + +// useAnimatedSplashはバレルにエクスポートされていないため直接require +function getRealUseAnimatedSplash() { + const { useAnimatedSplash } = jest.requireActual< + typeof import("@/src/splash/hooks/useAnimatedSplash") + >("@/src/splash/hooks/useAnimatedSplash"); + return useAnimatedSplash; +} + +describe("useAnimatedSplash", () => { + it("マウント時にSplashScreen.hideAsyncが呼ばれる", () => { + const onFinish = jest.fn(); + jest.useFakeTimers(); + + const useAnimatedSplash = getRealUseAnimatedSplash(); + renderHook(() => useAnimatedSplash({ onFinish })); + + expect(SplashScreen.hideAsync).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + it("delay前にはonFinishが呼ばれない", () => { + const onFinish = jest.fn(); + jest.useFakeTimers(); + + const useAnimatedSplash = getRealUseAnimatedSplash(); + renderHook(() => useAnimatedSplash({ onFinish, delay: 800 })); + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(onFinish).not.toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it("delay後にonFinishコールバックが呼ばれる", () => { + const onFinish = jest.fn(); + jest.useFakeTimers(); + + const useAnimatedSplash = getRealUseAnimatedSplash(); + renderHook(() => useAnimatedSplash({ onFinish, delay: 800 })); + act(() => { + jest.advanceTimersByTime(800); + }); + + expect(onFinish).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + it("アンマウント時にタイマーがクリーンアップされる", () => { + const onFinish = jest.fn(); + jest.useFakeTimers(); + + const useAnimatedSplash = getRealUseAnimatedSplash(); + const { unmount } = renderHook(() => + useAnimatedSplash({ onFinish, delay: 800 }) + ); + unmount(); + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(onFinish).not.toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it("animatedStyleオブジェクトを返す", () => { + const onFinish = jest.fn(); + jest.useFakeTimers(); + + const useAnimatedSplash = getRealUseAnimatedSplash(); + const { result } = renderHook(() => useAnimatedSplash({ onFinish })); + + expect(result.current.animatedStyle).toBeDefined(); + expect(result.current.animatedStyle).toHaveProperty("opacity"); + expect(result.current.animatedStyle).toHaveProperty("transform"); + jest.useRealTimers(); + }); +}); diff --git a/__tests__/splash/features/animatedSplash.feature b/__tests__/splash/screens/splashScreen.feature similarity index 94% rename from __tests__/splash/features/animatedSplash.feature rename to __tests__/splash/screens/splashScreen.feature index c2e86ed..a60a14f 100644 --- a/__tests__/splash/features/animatedSplash.feature +++ b/__tests__/splash/screens/splashScreen.feature @@ -1,4 +1,4 @@ -Feature: アニメーション付きスプラッシュ画面 +Feature: スプラッシュ画面 Scenario: スプラッシュ画像が表示される Given スプラッシュコンポーネントに子要素がある diff --git a/__tests__/splash/steps/animatedSplash.steps.tsx b/__tests__/splash/screens/splashScreen.steps.tsx similarity index 95% rename from __tests__/splash/steps/animatedSplash.steps.tsx rename to __tests__/splash/screens/splashScreen.steps.tsx index 61424db..e1fb301 100644 --- a/__tests__/splash/steps/animatedSplash.steps.tsx +++ b/__tests__/splash/screens/splashScreen.steps.tsx @@ -6,19 +6,20 @@ import { AnimatedSplash } from "@/src/splash"; let mockOnFinish: (() => void) | undefined; jest.mock("@/src/splash/hooks/useAnimatedSplash", () => ({ - useAnimatedSplash: ({ onFinish }: { onFinish: () => void }) => { + useAnimatedSplash: jest.fn(({ onFinish }: { onFinish: () => void }) => { mockOnFinish = onFinish; return { animatedStyle: { opacity: 1, transform: [{ scale: 1 }] } }; - }, + }), })); const feature = loadFeature( - "__tests__/splash/features/animatedSplash.feature" + "__tests__/splash/screens/splashScreen.feature" ); defineFeature(feature, (test) => { beforeEach(() => { mockOnFinish = undefined; + jest.clearAllMocks(); }); test("スプラッシュ画像が表示される", ({ given, when, then }) => { diff --git a/__tests__/splash/steps/useAnimatedSplash.steps.ts b/__tests__/splash/steps/useAnimatedSplash.steps.ts deleted file mode 100644 index bdc9ded..0000000 --- a/__tests__/splash/steps/useAnimatedSplash.steps.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { defineFeature, loadFeature } from "jest-cucumber"; -import { renderHook, act } from "@testing-library/react-native"; -import * as SplashScreen from "expo-splash-screen"; -import { useAnimatedSplash } from "@/src/splash/hooks/useAnimatedSplash"; - -const feature = loadFeature( - "__tests__/splash/features/useAnimatedSplash.feature" -); - -defineFeature(feature, (test) => { - beforeEach(() => { - jest.useFakeTimers(); - jest.clearAllMocks(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - test("マウント時にSplashScreen.hideAsyncが呼ばれる", ({ given, when, then }) => { - let onFinish: jest.Mock; - - given("onFinishコールバックが用意されている", () => { - onFinish = jest.fn(); - }); - - when("フックをレンダーする", () => { - renderHook(() => useAnimatedSplash({ onFinish })); - }); - - then(/^SplashScreen\.hideAsyncが1回呼ばれる$/, () => { - expect(SplashScreen.hideAsync).toHaveBeenCalledTimes(1); - }); - }); - - test("delay前にはonFinishが呼ばれない", ({ given, when, then }) => { - let onFinish: jest.Mock; - - given(/^onFinishコールバックとdelay 800ms が用意されている$/, () => { - onFinish = jest.fn(); - }); - - when(/^フックをレンダーして500ms経過する$/, () => { - renderHook(() => useAnimatedSplash({ onFinish, delay: 800 })); - act(() => { - jest.advanceTimersByTime(500); - }); - }); - - then("onFinishは呼ばれていない", () => { - expect(onFinish).not.toHaveBeenCalled(); - }); - }); - - test("delay後にonFinishコールバックが呼ばれる", ({ given, when, then }) => { - let onFinish: jest.Mock; - - given(/^onFinishコールバックとdelay 800ms が用意されている$/, () => { - onFinish = jest.fn(); - }); - - when(/^フックをレンダーして800ms経過する$/, () => { - renderHook(() => useAnimatedSplash({ onFinish, delay: 800 })); - act(() => { - jest.advanceTimersByTime(800); - }); - }); - - then("onFinishが1回呼ばれる", () => { - expect(onFinish).toHaveBeenCalledTimes(1); - }); - }); - - test("アンマウント時にタイマーがクリーンアップされる", ({ given, when, then }) => { - let onFinish: jest.Mock; - - given(/^onFinishコールバックとdelay 800ms が用意されている$/, () => { - onFinish = jest.fn(); - }); - - when(/^フックをレンダーしてアンマウントし1000ms経過する$/, () => { - const { unmount } = renderHook(() => - useAnimatedSplash({ onFinish, delay: 800 }) - ); - unmount(); - act(() => { - jest.advanceTimersByTime(1000); - }); - }); - - then("onFinishは呼ばれていない", () => { - expect(onFinish).not.toHaveBeenCalled(); - }); - }); - - test("animatedStyleオブジェクトを返す", ({ given, when, then, and }) => { - let onFinish: jest.Mock; - let hookResult: { current: ReturnType }; - - given("onFinishコールバックが用意されている", () => { - onFinish = jest.fn(); - }); - - when("フックをレンダーする", () => { - const { result } = renderHook(() => useAnimatedSplash({ onFinish })); - hookResult = result; - }); - - then("animatedStyleにopacityプロパティがある", () => { - expect(hookResult.current.animatedStyle).toBeDefined(); - expect(hookResult.current.animatedStyle).toHaveProperty("opacity"); - }); - - and("animatedStyleにtransformプロパティがある", () => { - expect(hookResult.current.animatedStyle).toHaveProperty("transform"); - }); - }); -});