Skip to main content
Version: 4.x

Our First Test

つまり Storybook がコンポーネントを作成/更新する最初のフェーズだとすれば、フェーズ2は機能するかをテストで確認する必要があります。 それでは、新機能である要約のテストを追加してみましょう。

もしあなたが今まで何らかのテストをしたことがないのであれば、これは少し難しいかもしれません。一般的なテストの概要を知りたい場合は all about testing という素晴らしいドキュメントがあります(哲学も含まれているので、興味のある方はご覧ください)。私たちは、これがどのように動作するかの謎を解くために、プレーンなJavascriptでイチから超シンプルなテストランナーを構築しています!

前のページのテストプロセスをまだ実行している場合は、 a を押すだけで全て( all)のテストを実行することができます。テストプロセスを停止した場合は、次のようにして再び開始することができます:

yarn rw test

このテストで何が壊れたかわかりますか?

image

このテストではブログ記事の全文を要求しましたが、ArticlesCell では Article にブログ記事の 要約 だけを表示させたことを思い出してください。このテストでは、もはやページ上に存在しないフルテキストと一致することを要求しています。

このテストを更新して、期待される振る舞いをチェックするようにしましょう。テストの最良の方法について書かれた本がたくさんあるので、このコードで何をテストするにせよ、私たちが間違ったやり方をしていれば誰かが教えてくれることでしょう。一例として、最も単純なテストは、出力されたものをコピーしてそのテキストをテストに使うことです:

web/src/components/ArticlesCell.test.js
test('Success renders successfully', async () => {
const articles = standard().articles
render(<Success articles={articles} />)

expect(screen.getByText(articles[0].title)).toBeInTheDocument()
expect(
screen.getByText(
'Neutra tacos hot chicken prism raw denim, put a bird on it enamel pin post-ironic vape cred DIY. Str...'
)
).toBeInTheDocument()
})

切り詰める文字数は後で変更することができるのですが、どのようにテストにカプセル化するのでしょうか?あるいはそうすべきでしょうか?切り詰める文字数は Article コンポーネントにハードコードされており、このコンポーネントが本当に気にする必要はありません:ブログ記事を表示するページが(スペースの問題やデザインの制約などに基づいて)表示する文字数を決定するべきだと思いませんか?たとえ truncate() 関数を共有の場所にリファクタリングして Article とこのテストの両方にインポートしたとしても、テストは Article についてあまりにも多くのことを知っていることになります -- なぜテストは Article の内部とこの truncate() 関数を使うことについて詳細な知識を持たなければならないのでしょうか?そんなことはないはずです!テスト理論の1つにテスト対象はブラックボックスであるべきというものがあります:テスト対象の内部を見ることはできず、あなたがテストできるのは、あるデータを入力したときに出力されたデータだけです。

妥協してみましょう -- この機能が "summary" という props を持つことから、テキストを短くするために 何か をしているのだと推測できます。そこで、今すぐに合理的に推測できる3つのことをテストしてみるのはどうでしょう:

  1. ブログ記事の本文の全文が 存在しない
  2. しかし、少なくともブログ記事の最初の数単語は存在する
  3. 表示されるテキストが "..." で終わっている

これにより、たとえば25ワードに切り捨てる場合、あるいは数百ワードに切り上げる場合のバッファが確保できます。しかし、これはブログ記事の本文が切り詰めの制限より短い場合を含み ません 。その場合は全文が表示されるので、 ... を追加しないように truncate() 関数を更新すべきかもしれません。この機能追加とテストケースは、自由時間に追加してくださいね ;)

Adding the Test

よし、やってみましょう:

web/src/components/ArticlesCell.test.js
import { render, screen, within } from '@redwoodjs/testing'

import { Loading, Empty, Failure, Success } from './ArticlesCell'
import { standard } from './ArticlesCell.mock'

describe('ArticlesCell', () => {
test('Loading renders successfully', () => {
expect(() => {
render(<Loading />)
}).not.toThrow()
})

test('Empty renders successfully', async () => {
expect(() => {
render(<Empty />)
}).not.toThrow()
})

test('Failure renders successfully', async () => {
expect(() => {
render(<Failure error={new Error('Oh no')} />)
}).not.toThrow()
})

test('Success renders successfully', async () => {
const articles = standard().articles
render(<Success articles={articles} />)

articles.forEach((article) => {
const truncatedBody = article.body.substring(0, 10)
const matchedBody = screen.getByText(truncatedBody, { exact: false })
const ellipsis = within(matchedBody).getByText('...', { exact: false })

expect(screen.getByText(article.title)).toBeInTheDocument()
expect(screen.queryByText(article.body)).not.toBeInTheDocument()
expect(matchedBody).toBeInTheDocument()
expect(ellipsis).toBeInTheDocument()
})
})
})

これは standard() モック内の各ブログ記事をループし、それぞれのブログ記事に対してテストを行います:

const truncatedBody = article.body.substring(0, 10)

ブログ記事本文の最初の10文字を格納した変数 truncatedBody を作成します。

const matchedBody = screen.getByText(truncatedBody, { exact: false })

画面上のレンダリングされたHTMLを検索して、切り捨てられた本文を含むHTML要素を見つけます(ここで { exact: false } に注意してください。通常は正確なテキストとそのテキストだけが存在する必要がありますが、この場合はおそらく10文字だけではありません)。

const ellipsis = within(matchedBody).getByText('...', { exact: false })

前の行で見つかったHTML要素の中で、 ... を探します。これも完全一致ではありません。

expect(screen.getByText(article.title)).toBeInTheDocument()

ページ内のブログ記事タイトルを検索します。

expect(screen.queryByText(article.body)).not.toBeInTheDocument()

ブログ記事本文の 全文 を探しても、存在 しない はずです。

expect(matchedBody).toBeInTheDocument()

切り詰められたテキストが存在することを確認します。

expect(ellipsis).toBeInTheDocument()

省略部分( ... )があることを確認します。

What's the difference between getByText() and queryByText()?

getByText() はテキストがドキュメント内に見つからなかった場合にエラーをスローしますが、 queryByText()null を返しテストを続行することができます(そして、これはあるテキストがページ上に存在 しない ことをテストする一つの方法です)。これらについては、DOM Testing Library Queries のドキュメントで詳しく説明されています。

テストファイルを保存するとすぐに、テストが実行され合格しているはずです!

他に何も壊れていないことを確認したい場合は、a を押してスイート全体を実行してください。 o を押すと、再び変更点のみのテスト( only)に戻ります(毎回テストスイートを全部実行するのは悪いことではありませんが、前回コードをコミットしたときから変更された点だけをテストするよりも時間がかかります)。

テストしている内容が正しいか確認するために、 ArticlesCell.js を開いて summary={true} propsを削除(または false に)すると、テストは失敗するはずです:ブログ記事本文の全文がページに 表示され 、テストの expect(screen.queryByText(article.body)).not.toBeInTheDocument() が失敗するはずです。なぜならブログ記事本文の全文がdocumentに存在 する からです。続ける前に summary={true} に戻すのをお忘れなく。

What's the Deal with Mocks?

私たちのテストでは、記事がどこから来るのか不思議に思いませんでしたか?開発用データベースでしょうか?いいえ:そのデータは モック から来ているのです。これは ArticlesCell.mock.js というファイルで、コンポーネント、テスト、ストーリーのファイルの隣にあります。モックは、Storybook のストーリーやテストにおいて、GraphQLによって通常返されるデータを定義したい場合に使用します。セルではGraphQLの呼び出し(ファイルの先頭の変数 QUERY で定義されたクエリ)が行われ Success コンポーネントに返します。Storybook やテストのためだけに、APIサイドのサーバを動かしてデータベースに実データが必要になるのは困るので、RedwoodはそれらのGraphQLコールをインターセプトして、代わりにモックからデータを返します。

If the server is being mocked, how do we test the api-side code?

次にブログに新しい機能をイチから実装するときに説明します!

モックにつけた名前は、テストやストーリーのファイルで使うことができます。 使いたいものをインポートするだけで(生成されたテストファイルでは standard がインポートされます)、スプレッド構文を使用して Success コンポーネントに渡すことができます。

モックがこんな感じだとします:

export const standard = () => ({
articles: [
{
id: 1,
title: 'First Post',
body: `Neutra tacos hot chicken prism raw denim...`,
createdAt: '2020-01-01T12:34:56Z',
},
{
id: 2,
title: 'Second Post',
body: `Master cleanse gentrify irony put a bird on it...`,
createdAt: '2020-01-01T12:34:56Z',
},
],
})

返されたオブジェクトの最初のキーは articles という名前です。これは、セルの Success に渡されるであろう props の名前でもあります:

export const Success = ({ articles }) => {
return (
{ articles.map((article) => <Article article={article} />) }
)
}

つまり、ストーリーやテストで Success コンポーネントを使う際に、 standard() の結果をスプレッド構文で展開すればすべてがうまくいくのです:

web/src/components/ArticlesCell/ArticlesCell.stories.js
import { Success } from './ArticlesCell'
import { standard } from './ArticlesCell.mock'

export const success = () => {
return Success ? <Success {...standard()} /> : null
}

export default { title: 'Cells/ArticlesCell' }

この構文が少し簡潔 過ぎる と感じる人もいて、であれば実際のコードと同じように <Success> コンポーネントが呼び出されるのを見たいと思うでしょう。もしそのような方がいれば、スプレッド構文を使わずに standard()articles プロパティを古い方法で呼び出してみてください:

web/src/components/ArticlesCell/ArticlesCell.stories.js
import { Success } from './ArticlesCell'
import { standard } from './ArticlesCell.mock'

export const success = () => {
return Success ? <Success articles={standard().articles} /> : null
}

export default { title: 'Cells/ArticlesCell' }

モックは好きなだけ用意できます。必要なモックの名前をインポートして、コンポーネントのpropsとして送信するだけです。

Testing Article

テストスイートは再びパス(成功)していますが、トリックがあります!私たちは Article コンポーネントに追加した実際の summary (要約)機能のためのテストを追加していませんでした。私たちは ArticlesCell のレンダー(最終的に Article を描画する)が要約を含むことをテストしましたが、要約を描画することは Article だけが持っている知識です。

アプリを作るとき、このような機能をテストするのを忘れがちです。 Winston Churchill は "a thorough test suite requires eternal vigilance"(徹底的なテストスイートを成すには永遠の警戒が必要だ)と言ったのではないでしょうか?Test Driven Development (TDD:テスト駆動開発) のようなテクニックは、この傾向に対抗するために確立されました:新しい機能を書くとき、まずテストを書き、それが失敗するのを確認します。そして、テストに合格するようにコードを書きます。そうすれば、あなたが書く実際のコードのすべての行が、テストによって裏付けられていることがわかります。私たちがやっていることは、親しみを込めて Development Driven Testing と呼ばれています。あなたはおそらく、この間のどこかに落ち着くと思いますが、ある格言は常に真実です:テストはないよりあったほうがいい。

Article 要約機能はとてもシンプルですが、テストするにはいくつかの方法があります:

  • truncate() 関数をエクスポートして、直接テストする
  • 最終的なレンダリングされた状態のコンポーネントをテストする

truncate()Article に "属する"(belongs to) 場合は、外部がこの関数について心配したり、その存在を知る必要はありません。 もし開発の途中で別のコンポーネントがテキストの切り詰めを必要とするようになったら、その時が、この関数を共有の場所に移動して、それを必要とする両方のコンポーネントにインポートする絶好の機会になるでしょう。そうすれば truncate() は専用のテストを持つことができます。しかし今は、関心事の分離を維持し、このコンポーネントについて "公開" されているもの -- レンダリング結果 -- をテストしましょう。

この場合、出力が厳密に文字列一致することをテストしましょう。要約の長さに関する知識は Article 自身に含まれているので、この時点では、この特定のコンポーネントのレンダリング結果に密結合なテストであっても問題ないでしょう( ArticlesCell 自身は切り詰める長さを知らないので、単に 何か がテキストを短くしているだけです)。コードを変更してもテストが壊れないよう完璧にリファクタリングし続けることもできますが、実際にそのレベルの柔軟性が必要でしょうか?それは常にトレードオフなのです!

テストにあるサンプルのブログ記事のデータを定数に移動して、既存のテスト( summary props を全く渡さないことで全文が描画されることをテストする) と、要約が描画されるバージョンをチェックする新しいテストの両方でそれを使用することにします:

web/src/components/Article/Article.test.js
import { render, screen } from '@redwoodjs/testing'

import Article from './Article'

const ARTICLE = {
id: 1,
title: 'First post',
body: `Neutra tacos hot chicken prism raw denim, put a bird on it enamel pin post-ironic vape cred DIY. Street art next level umami squid. Hammock hexagon glossier 8-bit banjo. Neutra la croix mixtape echo park four loko semiotics kitsch forage chambray. Semiotics salvia selfies jianbing hella shaman. Letterpress helvetica vaporware cronut, shaman butcher YOLO poke fixie hoodie gentrify woke heirloom.`,
createdAt: new Date().toISOString(),
}

describe('Article', () => {
it('renders a blog post', () => {
render(<Article article={ARTICLE} />)

expect(screen.getByText(ARTICLE.title)).toBeInTheDocument()
expect(screen.getByText(ARTICLE.body)).toBeInTheDocument()
})

it('renders a summary of a blog post', () => {
render(<Article article={ARTICLE} summary={true} />)

expect(screen.getByText(ARTICLE.title)).toBeInTheDocument()
expect(
screen.getByText(
'Neutra tacos hot chicken prism raw denim, put a bird on it enamel pin post-ironic vape cred DIY. Str...'
)
).toBeInTheDocument()
})
})

この変更を保存するとテストが実行され、ウチらが今もhappyだとわかります!

One Last Thing

summary propsが存在しない場合はデフォルトで false になるように設定したことを思い出してください。これは最初のテストケース( summary props を全く渡さない)でテストしました。しかし false を明示的に設定した場合にどうなるかをチェックするテストはありません。100% Code Coverage (カバレッジ100%)を望むなら、今すぐ自由に追加してください!