【Day 12】ブログ一覧を取得し、表示する - Web実装

## はじめに

![スライド13.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985550/d956353b-516f-b171-27e4-ed53370fae86.png)

---

**2023年アドベントカレンダー12日目**です。

https://qiita.com/advent-calendar/2023/life-sync-app

現在「ユーザーはトップページでブログの一覧を見ることができる」を進めています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985550/272b7c0a-ca22-3db9-acc8-f52a2dd4a280.png)

前回はクリーンアーキテクチャに基づいてクラス設計を行いました。
今回はそれをもとに実装していきます。

## Web

![Web.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985550/56b7b0b5-fc17-82aa-fe2f-8b96ce610bff.png)

いったんはスタイルを無視して、E2Eテストが通る最低限を目指してみます。

### State

<details><summary>atoms/BlogsState</summary>

```ts:atoms/BlogsState
import { atom } from 'recoil'

export class BlogState {
  constructor(
    public title: string,
    public author: string,
    public body: string,
    public createdAt: string
  ) {}
}

export const blogsState = atom<BlogState[]>({
  key: 'blogsState',
  default: [],
})
```
</details>

### Component

<details><summary>Blogs.tsx</summary>

```tsx:Blogs.tsx
'use client'

import { blogsState } from '@/atoms/BlogsState'
import React, { useEffect } from 'react'
import { useRecoilValue } from 'recoil'
import { BsArrowRightCircle } from 'react-icons/bs'
import { useBlogUsecase } from '@/usecase/BlogUsecase'
import { BlogDriver } from '@/driver/BlogDriver'
import { BlogGateway } from '@/gateway/BlogGateway'
import { useBlogPresenter } from '@/presenter/BlogPresenter'

export const Blogs = () => {
  const blogs = useRecoilValue(blogsState)
  const blogDriver = new BlogDriver()
  const blogGateway = new BlogGateway(blogDriver)
  const blogPresenter = useBlogPresenter()

  const { load } = useBlogUsecase(blogGateway, blogPresenter)

  useEffect(() => {
    load()
  }, [])

  return (
    <div className='flex flex-col gap-4'>
      {blogs.blogs.map((blog, index) => (
        <div key={index} className='flex flex-col blog-card'>
          <div className='bg-stone-100 p-6 rounded-xl flex justify-between items-center'>
            {blog.title}
            <BsArrowRightCircle size={35} />
          </div>
          <div className='self-end'>{blog.createdAt}</div>
        </div>
      ))}
    </div>
  )
}
```
</details>

## Usecase

![Usecase.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985550/535a54ef-c7a3-a87c-2555-963b3eb6d59f.png)

**ユースケースのみ実装までの流れを詳細に載せていきます**

このUsecaseでは、`InputPort`から取得したBlogデータを`OutputPort`に渡すことをしたいので、
↓のようなアサーションになります。

```tsx
import { describe, test, expect } from '@jest/globals'

describe('Blog Usecase', () => {
  test('inputPortで受け取ったドメインオブジェクトを、outputPortに渡していること', async () => {
    const blogs = [
      new Blog(
        new BlogTitle(casual.title),
        new BlogAuthor(casual.name),
        new BlogBody(casual.sentences(3)),
        new BlogDate(new Date(casual.date('YYYY-MM-DDThh:mm:ssZ')))
      ),
    ]

    // inputPortのgetが呼ばれていること
    expect(mockBlogInputPort.get).toHaveBeenCalled()
    // outputPortのstoreがblogsを引数に呼ばれていること
    expect(mockBlogOutputPort.store).toHaveBeenCalledWith(blogs)
  })
})
```

次に`Usecase`の`load`メソッドを呼び出します。

今回は、`Usecase`をカスタムフックで作りたいと考えているので、
`@testing-library/react`の`renderHook`, `act`を使って、Jest内でカスタムフックを呼び出したいと思います。

```bash
npm install --save-dev @testing-library/react
```

```diff_tsx
import { act, renderHook } from '@testing-library/react'
import { useBlogUsecase } from '@/usecase/BlogUsecase'

describe('Blog Usecase', () => {
  test('inputPortで受け取ったドメインオブジェクトを、outputPortに渡していること', async () => {
    const blogs = [
      new Blog(
        new BlogTitle(casual.title),
        new BlogAuthor(casual.name),
        new BlogBody(casual.sentences(3)),
        new BlogDate(new Date(casual.date('YYYY-MM-DDThh:mm:ssZ')))
      ),
    ]

+   const blogUsecase = renderHook(
+     () => useBlogUsecase(),
+     {
+       wrapper: RecoilRoot,
+     }
+   )
+   await act(async () => {
+     await blogUsecase.result.current.load()
+   })
  
    expect(mockBlogInputPort.get).toHaveBeenCalled()
    expect(mockBlogOutputPort.store).toHaveBeenCalledWith(blogs)
  })
})
```

ここまできたら、インポートのエラーが出ているので、`useBlogUsecase`を作成します。
ただし、実装はまだしません。あくまで、インポートのエラーを消すためです。

```tsx:src/usecase/blogUsecase.tsx
export const useBlogUsecase = () => {
  const load = async () => {
    throw new Error('未実装')
  }

  return { load }
}
```

テストを実行してみます。

```bash
 FAIL  tests/usecase/BlogUsecase.test.tsx
  ● Blog Usecase › inputPortで受け取ったドメインオブジェクトを、outputPortに渡していること

    未実装

Test Suites: 1 failed, 0 passed, 1 total
```

未実装でテストが失敗しました。
**予定通りですね**

次にモックをテストに追加していきます。

```diff_tsx:tests/usecase/blogUsecase.test.tsx
import { describe, test, expect, jest } from '@jest/globals'
import { Blog, BlogAuthor, BlogBody, BlogDate, BlogTitle } from '@/domain/Blog'
import casual from 'casual'
import { BlogInputPort, BlogOutputPort } from '@/usecase/port/BlogUsecasePort'

describe('Blog Usecase', () => {
  test('inputPortで受け取ったドメインオブジェクトを、outputPortに渡していること', async () => {
    const blogs = [
      new Blog(
        new BlogTitle(casual.title),
        new BlogAuthor(casual.name),
        new BlogBody(casual.sentences(3)),
        new BlogDate(new Date(casual.date('YYYY-MM-DDThh:mm:ssZ')))
      ),
    ]
 
+   const mockBlogInputPort = {} as BlogInputPort
+   mockBlogInputPort.get = jest.fn(async () => blogs)
+
+   const mockBlogOutputPort = {} as BlogOutputPort
+   mockBlogOutputPort.store = jest.fn(async (blogs: Blog[]) => {})
+
    const blogUsecase = renderHook(
-     () => useBlogUsecase(),
+     () => useBlogUsecase(mockBlogInputPort, mockBlogOutputPort),
      {
        wrapper: RecoilRoot,
      }
    )
    await act(async () => {
      await blogUsecase.result.current.load()
    })

    expect(mockBlogInputPort.get).toHaveBeenCalled()
    expect(mockBlogOutputPort.store).toHaveBeenCalledWith(blogs)
  })
})
```

ここまで来たらUsecaseの実装をしていきます。

```diff_tsx:src/usecase/blogUsecase.tsx
export const useBlogUsecase = () => {
  const load = async () => {
-   throw new Error('未実装')
+   const blogs = await inputPort.get()
+   outputPort.store(blogs)
  }

  return { load }
}
```

## Gateway

![Gateway.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985550/702c9c00-5a61-6192-0eb5-28324cb3098a.png)

このGatewayでは、`blogsDriver`から取得したデータをドメインオブジェクトに変換し、`Usecase`に返すことを目指します。

このようにすることで、外の世界(Driver)から、中の世界(Usecase)を守ることができます。

### テスト

<details><summary>BlogGateway.test.ts</summary>

```tsx
import { Blog, BlogAuthor, BlogBody, BlogDate, BlogTitle } from '@/domain/Blog'
import { BlogBlogJson, BlogDriver, BlogJson } from '@/driver/BlogDriver'
import { BlogGateway } from '@/gateway/BlogGateway'
import { describe, test, expect, jest } from '@jest/globals'
import casual from 'casual'

describe('Blog Gateway', () => {
  test('blogs get', async () => {
    const titleValue = casual.word
    const authorValue = casual.name
    const bodyValue = casual.sentence
    const createdAtValue = casual.date('YYYY-MM-DD')
    const expected: Blog[] = [
      new Blog(
        new BlogTitle(titleValue),
        new BlogAuthor(authorValue),
        new BlogBody(bodyValue),
        new BlogDate(new Date(createdAtValue))
      ),
    ]

    const mockDriver = {} as BlogDriver
    mockDriver.get = jest.fn(
      async () =>
        new BlogJson([
          new BlogBlogJson(
            titleValue,
            authorValue,
            bodyValue,
            new Date(createdAtValue)
          ),
        ])
    )

    const target = new BlogGateway(mockDriver)
    const actual = await target.get()

    expect(actual).toStrictEqual(expected)
  })
})

```

</details>

### 実装

<details><summary>BlogGateway.ts</summary>

```tsx
import { Blog, BlogAuthor, BlogBody, BlogDate, BlogTitle } from '@/domain/Blog'
import { BlogDriver } from '@/driver/BlogDriver'
import { BlogInputPort } from '@/usecase/port/BlogUsecasePort'

export class BlogGateway implements BlogInputPort {
  constructor(private blogDriver: BlogDriver) {}

  async get(): Promise<Blog[]> {
    const blogJson = await this.blogDriver.get()
    return blogJson.blogs.map(
      ({ title, author, body, createdAt }) =>
        new Blog(
          new BlogTitle(title),
          new BlogAuthor(author),
          new BlogBody(body),
          new BlogDate(new Date(createdAt))
        )
    )
  }
}
```

</details>


## Driver

### 実装

<details><summary>BlogDriver.ts</summary>

```tsx
import { GetBlogsDocument, GetBlogsQuery } from '@/graphql/generated'
import { ApolloClient } from '@/lib/ApolloClient'

export class BlogDriver {
  async get(): Promise<BlogJson> {
    const client = ApolloClient.client()
    const response = await client.query<GetBlogsQuery>({
      query: GetBlogsDocument,
    })
    const blogs = response.data
    return new BlogJson(
      blogs.blogs.map(
        ({ title, author, body, createdAt }) =>
          new BlogBlogJson(title, author, body, new Date(createdAt))
      )
    )
  }
}

export class BlogJson {
  constructor(public blogs: BlogBlogJson[]) {}
}

export class BlogBlogJson {
  constructor(
    public title: string,
    public author: string,
    public body: string,
    public createdAt: Date
  ) {}
}
```

</details>

## Presenter

### テスト

<details><summary>BlogPresenter.test.tsx</summary>

```tsx
import { BlogState, BlogsState, blogsState } from '@/atoms/BlogsState'
import { Blog, BlogAuthor, BlogBody, BlogDate, BlogTitle } from '@/domain/Blog'
import { useBlogPresenter } from '@/presenter/BlogPresenter'
import { describe, test, expect, jest } from '@jest/globals'
import { act, renderHook } from '@testing-library/react'
import { ReactNode } from 'react'
import { RecoilRoot, useRecoilValue } from 'recoil'

describe('Blog Presenter', () => {
  test('store the parameter to the state', () => {
    const wrapper = ({ children }: { children: ReactNode }) => (
      <RecoilRoot>{children}</RecoilRoot>
    )
    const blogs = [
      new Blog(
        new BlogTitle('title'),
        new BlogAuthor('author'),
        new BlogBody('body'),
        new BlogDate(new Date('2021-12-01 09:00:00'))
      ),
    ]
    const { result } = renderHook(
      () => {
        const store = useBlogPresenter().store
        const blogsValue = useRecoilValue(blogsState)
        return { store, blogsValue }
      },
      { wrapper }
    )
    act(() => {
      result.current.store(blogs)
    })

    const expected = new BlogsState([
      new BlogState('title', 'author', 'body', '2021/12/01 09:00:00'),
    ])
    expect(result.current.blogsValue).toStrictEqual(expected)
  })
})
```

</details>

### 実装

<details><summary>BlogPresenter.tsx</summary>

```tsx
import { BlogState, BlogsState, blogsState } from '@/atoms/BlogsState'
import { Blog } from '@/domain/Blog'
import { BlogOutputPort } from '@/usecase/port/BlogUsecasePort'
import { useSetRecoilState } from 'recoil'

export const useBlogPresenter = (): BlogOutputPort => {
  const setBlogs = useSetRecoilState(blogsState)
  const store = (blogs: Blog[]) => {
    setBlogs(
      new BlogsState(
        blogs.map(
          (blog) =>
            new BlogState(
              blog.title.value,
              blog.author.value,
              blog.body.value,
              blog.createdAt.formatted
            )
        )
      )
    )
  }
  return { store }
}
```
</details>

## Done

全ての単体テストが通ったので、E2Eテストを通してみます
(モックサーバーとアプリを起動して)

```
  トップ画面の表示
    ✓ h1タグで"Life Sync"が表示されていること (573ms)
    ✓ h2タグで"ブログ一覧"の文字が表示されていること (505ms)
    ✓ "新規登作成"のボタンが表示されていること (452ms)
    ✓ ブログのタイトルが表示されていること (592ms)
    ✓ ブログの日付が表示されていること (496ms)
```

問題なくすべて通ったようです。
念のため画面でも確認しておきます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985550/e5779de2-1673-579a-ecec-fe3cf8e8ea0b.png)

画面も問題なさそうですね！

これでWebでの実装が終了しました。

次回からBffの環境構築＋実装に移ります