はじめに
2023年アドベントカレンダー12日目です。
現在「ユーザーはトップページでブログの一覧を見ることができる」を進めています。
前回はクリーンアーキテクチャに基づいてクラス設計を行いました。
今回はそれをもとに実装していきます。
Web
いったんはスタイルを無視して、E2Eテストが通る最低限を目指してみます。
State
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
'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では、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)
})
})
次にUsecase
のload
メソッドを呼び出します。
今回は、Usecase
をカスタムフックで作りたいと考えているので、
@testing-library/react
のrenderHook
, 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
を作成します。
ただし、実装はまだしません。あくまで、インポートのエラーを消すためです。
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
未実装でテストが失敗しました。
予定通りですね
次にモックをテストに追加していきます。
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の実装をしていきます。
export const useBlogUsecase = () => {
const load = async () => {
- throw new Error('未実装')
+ const blogs = await inputPort.get()
+ outputPort.store(blogs)
}
return { load }
}
Gateway
この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)
問題なくすべて通ったようです。
念のため画面でも確認しておきます
画面も問題なさそうですね!
これでWebでの実装が終了しました。
次回からBffの環境構築+実装に移ります