LoginSignup
6
2

More than 3 years have passed since last update.

JestでもRSpecしたいんじゃい!

Last updated at Posted at 2020-12-13

はじめに

なんで JestRSpec じゃないんじゃい!!!!!!!!!!!:japanese_goblin:
RSpec依存症の方であれば一度は思うのではないでしょうか?

そういった方は、一度かかりつけ医に相談されてください。

私は手遅れだったので、JestでもRSpecできるようにしました。
他にも手遅れの方がいたら是非参考にしてください!!

ライブラリを探してみる

まずはライブラリを探してみます。

モック周りまで実装しているものは見つかりませんでした。
仕方ないので作りましょう!  ... How hard can it be?

立ちはだかる壁

Jestを拡張するモジュールの実装なんて、そんな簡単に実装できるわけがなく :innocent:

遅延評価変数

遅延評価変数(let)の実装を考えます。

Jestは describe でテストグループ(Suite)を作れます。遅延評価変数(let)を作成するには、定義時や呼出時には現在のテストグループを参照できる必用があります。

しかし、Jestはそれらを外部から参照する方法がありません。

定義済みの全てのテストグループをツリー構造で参照する事はできたので、テスト定義中は最末端のSuiteを現在のコンテキスト、テスト実行中はテストを所有するテストグループを再帰処理で探すことで解決しました。

遅延評価マッチャー

RSpecの expect(...).to receive(...) マッチャーの実装を考えます。

このマッチャーは宣言して以降、テストが終了するまでに該当のモックが呼び出されることを検証します。

Jestのカスタムマッチャーは原則として、その場で結果を返さなくてはなりません。

Promiseを返すことで、評価を遅延させることはできますが await で処理完了を待たなければ UnhandledPromiseRejectionWarning が発生するだけでテスト結果が反映されません。

そこで多少強引ですが、expectラップしてjestのマッチャーに混ぜ込む形で、Jestのマッチャー風のJestのマッチャーではない何かである toReceive を実装しました。

メソッドチェイン

change マッチャーの実装を考えます。
RSpecはメソッドチェインでテスト内容を定義できます。

expect { subject }.to change(hoge, :count).from(1).to(2)

Jestでも同様の仕組みの実装を検討しましたが一筋縄で行きません。

チェインの終わり(テスト定義完了)を判定する方法がなく、テストを実行するタイミングの制御が難しいからです。

RSpec の場合は expect(...).to 以降を、 #to メソッドの引数とすることで解決しています。

expect { subject }.to change(hoge, :count).from(1).to(2)
#                     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#                           実はココは `#to` の引数

引数にチェインが渡ってきた時点でテスト定義完了とみなすことができます。

Jestで同じ仕組みを実装しようとすると関数呼び出しの カッコ を省略できないため、こんなダサダサ実装になってしまいます。

expect(subject).to(change(hoge, :count).from(1).to(2))

もう少し工夫してこんな感じの実装で動かすことができましたが

expect(subject)(to.change(hoge, :count).from(1).to(2))

少し過激な気がする上に可読性的にも疑問なので導入は見送りました。

他にもいくつかのポイントがありますが、そんなこんなで完成。

jest-rspec-style

jest-rspec-style です。

RSpecのコンテキストや遅延評価変数の素晴らしさは、様々な素晴らしい記事があるので本記事では触れません。ぜひそちらをご参考ください。

本ライブラリでは下記の機能が使えます。

  • lazy (let相当の機能)
  • context
  • sharedExamples
  • includeExamples
  • allow(...).toReceive(...)
  • allowAnyInstanceOf(...).toReceive(...)
  • expect(...).toChange(...)
  • expect(...).toReceive(...).with(...).times(...)
  • expectAnyInstanceOf(...).toReceive(...).with(...).times(...)

使い方

こんな感じの spec_helper.js を作り。

import JestRSpecStyle from 'jest-rspec-style'
JestRSpecStyle.setup()

各テストでimportしてください。

import './spec_helper'

describe('Hoge', () => {
  // ...
})

もう既に RSpecみ あると思いませんか?

lazy

RSpecのlet相当の機能です。ES6のletと干渉するためlazyにリネームしました。
テスト定義で使用し、テスト実行時に最も最後に定義された遅延評価変数を参照します。

//
// jest
//
price = undefined

beforeEach(() => {
  price = 100
})

it('寿司の値段が100円であること', () => {
  expect(price).toEqual(100)
})

//
// jest-rspec-style
//
lazy('price', () => 100)

it('寿司の値段が100円であること', () => {
  expect(lazy('price')).toEqual(100)
})

context

条件分岐したテストグループを作る際に使用します。

//
// jest
//
it('寿司の値段が100円の場合、税金が10円徴収されること')
it('寿司の値段が100円の場合、寿司を食べること')
it('寿司の値段が200円の場合、税金が20円徴収されること')
it('寿司の値段が200円の場合、寿司を食べないこと')

//
// jest-rspec-style
//
context('寿司の値段が100円の場合', () => {
  it('税金が10円徴収されること')
  it('寿司を食べること')
})

context('寿司の値段が200円の場合', () => {
  it('税金が20円徴収されること')
  it('寿司を食べないこと')
})

sharedExamples / includeExamples

共通のテスト項目をコンテキストとして挿入する機能です。

//
// jest
//
it('100円の寿司の場合、消費税が10円であること', () => {
  expect(Sushi.tax(100)).toEqual(10)
})

it('200円の寿司の場合、消費税が20円であること', () => {
  expect(Sushi.tax(200)).toEqual(20)
})

it('300円の寿司の場合、消費税が30円であること', () => {
  expect(Sushi.tax(300)).toEqual(30)
})

//
// jest-rspec-style
//
sharedExamples('寿司の消費税の検証', (price, tax) => {
  it(`${price}円の寿司の場合、消費税が${tax}円であること`, () => {
    expect(Sushi.tax(price)).toEqual(tax)
  })
})

includeExamples('寿司の消費税の検証', 100, 10)
includeExamples('寿司の消費税の検証', 200, 20)
includeExamples('寿司の消費税の検証', 300, 30)

toChange

メソッド実行前後で値の変化を検証します。

//
// jest
//
expect(sushi).toEqual(1)
sushi++;
expect(sushi).toEqual(2)

//
// jest-rspec-style
//
expect(() => sushi++).toChange(() => sushi, { from: 1, to: 2 })

allow / allowAnyInstanceOf

指定したオブジェクトのメソッドをモックします。

//
// jest
//
jest.spyOn(sushi, 'getName').mockReturnValue('サーモン')
jest.spyOn(Sushi.prototype, 'getName').mockReturnValue('サーモン')

var mock = new Sushi()
jest.spyOn(mock, 'getName').mockReturnValue('サーモン')
jest.spyOn(store, 'takeSushi').mockReturnValue(mock)

//
// jest-rspec-style
//
allow(sushi).toRceive('getName').andReturn('サーモン')
allowAnyInstanceOf(Sushi).toRceive('getName').andReturn('サーモン')

allow(store).toReceiveMessageChain('takeSushi', 'getName').andReturn('サーモン')

toReceive以降では次のチェインメソッドが利用可能です。

  • andCallOriginal
    • 元の実装の呼出
  • andReturn
    • 戻り値の指定
  • do
    • 呼出時のメソッド定義 (ブロックの概念がないため、Rspecではreceiveのブロック相当)

toReceive / expectAnyInstanceOf

指定したオブジェクトをモック化し、呼び出されることを検証します。

//
// jest
//
var mock = jest.spyOn(human, 'eat')
human.eat('マグロ')
human.eat('マグロ')
expect(mock).toHaveBeenCalledTimes(2)
expect(mock).toHaveBeenCalledWith('マグロ')

//
// jest-rspec-style
//
expect(human).toReceive('eat').with('マグロ').times(2)
human.eat('マグロ')
human.eat('マグロ')

// expect の代わりに expectAnyInstance を使うことで全てのインスタンスを対象にできます
expectAnyInstanceOf(Human).toReceive('eat)

toReceive以降ではallowのチェインメソッドに加えて、次のメソッドが利用可能です。

  • with
    • 呼出時の引数指定
  • times
    • 呼出回数の指定

最後に

こうして私はJestでもRspecすることができました。RSpec依存症の方に優しいパッケージを目指しているのでPRお待ちしております!!! :pray:

6
2
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
6
2