3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Webアプリ構築カレンダーAdvent Calendar 2023

Day 12

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

Last updated at Posted at 2023-12-11

はじめに

スライド13.PNG


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

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

image.png

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

Web

Web.png

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

State

atoms/BlogsState
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: [],
})

Component

Blogs.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>
  )
}

Usecase

Usecase.png

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

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

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)
  })
})

次にUsecaseloadメソッドを呼び出します。

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

npm install --save-dev @testing-library/react
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を作成します。
ただし、実装はまだしません。あくまで、インポートのエラーを消すためです。

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

  return { load }
}

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

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

    未実装

Test Suites: 1 failed, 0 passed, 1 total

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

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

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の実装をしていきます。

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

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

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

テスト

BlogGateway.test.ts
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)
  })
})

実装

BlogGateway.ts
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))
        )
    )
  }
}

Driver

実装

BlogDriver.ts
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
  ) {}
}

Presenter

テスト

BlogPresenter.test.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)
  })
})

実装

BlogPresenter.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 }
}

Done

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

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

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

image.png

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

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

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

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?