車輪の二度漬け

railsを触っていましたが、最近はreactに興味あります。

Jestとreact-testing-libraryを使ったTypeScript + ReactのテストをCircleCIで回す

ReactのテストをCircleCIで自動化したかったので、ネットで色々調べつつ簡単に環境を構築してみた。

やったこと

  • サンプルのテストを作成(jest + react-testing-library + TypeScript)
  • CircleCIで自動テスト
  • テスト結果をSlackに通知

やった内容

CircleCIの設定は公式のドキュメントを読みつつ、実際に動かすテストについてはこのガイドの通りにやってみた(一部改変したりしてはいる)。

.circleci/config.yml

version: 2.1
jobs:
  test:
    docker:
      - image: circleci/node:10.15.3
    steps:
      - checkout
      - run: yarn
      - run: yarn test
workflows:
  version: 2.
  test:
    jobs:
      - test

job1個だけだとworkflowにする意味ってあんまりなさそうだなと思いつつ、workflowにまとめてる。
今回は使ってないが、workflowにはこういうjobの依存関係を決められるようなオプションもあるらしくて便利そうだなと思った。

Slack通知設定

公式ドキュメントのここをみながらすればOK

テストコード

テスト対象のソース

import React from 'react'
import { render, fireEvent } from '@testing-library/react'

import LoginForm, { Props } from '../LoginForm'

const renderLoginForm = (props: Partial<Props> = {}) => {
  const defaultProps: Props = {
    onPasswordChange() {
      return
    },
    onRememberChange() {
      return
    },
    onUsernameChange() {
      return
    },
    onSubmit() {
      return
    },
    shouldRemember: true,
  }
  return render(<LoginForm {...defaultProps} {...props} />)
}

describe('<LoginForm />', () => {
  test('should display a blank login form, with remember me checked by default', async () => {
    const { findByTestId } = renderLoginForm()

    const loginForm = await findByTestId('login-form')

    expect(loginForm).toHaveFormValues({
      username: '',
      password: '',
      remember: true,
    })
  })

  test('should allow entering a username', async () => {
    const onUsernameChange = jest.fn()
    const { findByTestId } = renderLoginForm({ onUsernameChange })
    const username = await findByTestId('username')

    fireEvent.change(username, { target: { value: 'test' } })

    expect(onUsernameChange).toHaveBeenCalledWith('test')
  })

  test('should allow entering a password', async () => {
    const onPasswordChange = jest.fn()
    const { findByTestId } = renderLoginForm({ onPasswordChange })
    const username = await findByTestId('password')

    fireEvent.change(username, { target: { value: 'test' } })

    expect(onPasswordChange).toHaveBeenCalledWith('test')
  })

  test('should allow toggling remember me', async () => {
    const onRememberChange = jest.fn()
    const { findByTestId } = renderLoginForm({
      onRememberChange,
      shouldRemember: false,
    })
    const remember = await findByTestId('remember')

    fireEvent.click(remember)

    expect(onRememberChange).toHaveBeenCalledWith(true)

    fireEvent.click(remember)

    expect(onRememberChange).toHaveBeenLastCalledWith(false)
  })

  test('should submit the form with username, password and remember', async () => {
    const onSubmit = jest.fn()
    const { container, getByTestId } = renderLoginForm({
      onSubmit,
      shouldRemember: false,
    })

    // こうすれば、querySelectorを使える
    const username = container.querySelector('input[name="username"]') as Element
    const password = await getByTestId('password')
    const remember = await getByTestId('remember')
    const submit = await getByTestId('submit')

    fireEvent.change(username, { target: { value: 'test' } })
    fireEvent.change(password, { target: { value: 'password' } })
    fireEvent.click(remember)
    fireEvent.click(submit)

    expect(onSubmit).toHaveBeenCalledWith('test', 'password', true)
  })
})

テストコード

import React from 'react'
import { render, fireEvent } from '@testing-library/react'

import LoginForm, { Props } from '../LoginForm'

const renderLoginForm = (props: Partial<Props> = {}) => {
  const defaultProps: Props = {
    onPasswordChange() {
      return
    },
    onRememberChange() {
      return
    },
    onUsernameChange() {
      return
    },
    onSubmit() {
      return
    },
    shouldRemember: true,
  }
  return render(<LoginForm {...defaultProps} {...props} />)
}

describe('<LoginForm />', () => {
  test('should display a blank login form, with remember me checked by default', async () => {
    const { findByTestId } = renderLoginForm()

    const loginForm = await findByTestId('login-form')

    expect(loginForm).toHaveFormValues({
      username: '',
      password: '',
      remember: true,
    })
  })

  test('should allow entering a username', async () => {
    const onUsernameChange = jest.fn()
    const { findByTestId } = renderLoginForm({ onUsernameChange })
    const username = await findByTestId('username')

    fireEvent.change(username, { target: { value: 'test' } })

    expect(onUsernameChange).toHaveBeenCalledWith('test')
  })

  test('should allow entering a password', async () => {
    const onPasswordChange = jest.fn()
    const { findByTestId } = renderLoginForm({ onPasswordChange })
    const username = await findByTestId('password')

    fireEvent.change(username, { target: { value: 'test' } })

    expect(onPasswordChange).toHaveBeenCalledWith('test')
  })

  test('should allow toggling remember me', async () => {
    const onRememberChange = jest.fn()
    const { findByTestId } = renderLoginForm({
      onRememberChange,
      shouldRemember: false,
    })
    const remember = await findByTestId('remember')

    fireEvent.click(remember)

    expect(onRememberChange).toHaveBeenCalledWith(true)

    fireEvent.click(remember)

    expect(onRememberChange).toHaveBeenLastCalledWith(false)
  })

  test('should submit the form with username, password and remember', async () => {
    const onSubmit = jest.fn()
    const { container, getByTestId } = renderLoginForm({
      onSubmit,
      shouldRemember: false,
    })

    // こうすれば、querySelectorを使える
    const username = container.querySelector('input[name="username"]') as Element
    const password = await getByTestId('password')
    const remember = await getByTestId('remember')
    const submit = await getByTestId('submit')

    fireEvent.change(username, { target: { value: 'test' } })
    fireEvent.change(password, { target: { value: 'password' } })
    fireEvent.click(remember)
    fireEvent.click(submit)

    expect(onSubmit).toHaveBeenCalledWith('test', 'password', true)
  })
})

このガイドでは、各inputに埋め込んだdata-testidの値で各inputを特定しているが、個人的にはこれがすごくめんどくさかった。
コード書いてる時にテストのためだけの属性を書くのは絶対忘れる。
なので、代わりにcontainer を使って、各input要素のname属性をquerySelectorで引っ掛けられるようにした。
個人的にはこっちのほうが実装が楽になりそうな気がする。

所感

  • config.ymlの書き方はもっといい方法があるかもしれないのでこれから研究
  • フリープランでやってるから有料プランではもっと色々できるかもしれない
  • 以前は業務でEnzymeを使っていたが、react-testing-libraryも結構いいなあ。