Rspect Style Guide

Jinsu Kim·2022년 3월 27일
2

Rails-Rspec

목록 보기
2/3

https://github.com/willnet/rspec-style-guide
Rspec에 대해 Style Guide가 잘 쓰여져있었기에 번역해보았습니다. Rspec을 공부하는 분들에게 조금이라도 도움이되기를 바랍니다!

📝 번역시작일: 2022-03-26 → 2022-04-09 → 2022-08-05(번역 완료)

RSpec 스타일 가이드

이 스타일 가이드는 무엇인가

가독성 높은 테스트 코드를 쓰기 위한 작법집이다.
모두의 의견을 수렴하여 좋은 가이드로 만들고 싶으니 아래의 해당하는 경우 계속 Github에 Issues나 Pull Request를 보내주세요!

  • 의문스러운 점이 있는 경우
  • 여기에 씌어 있지 않은 예문이 있는 경우.
  • 내용은 좋은데 표현이 이상한 경우
  • 더 좋은 샘플 코드가 있는 경우

日本語バージョン
English Version

전제

  • RSpec
  • FactoryBot

describe と context

describecontext 는 같은 메소드이지만, 아래와 같이 구분하여 무엇을 테스트하고 있는지를 알기 쉽게 할 수 있습니다.

  • describe 인수에는 테스트 대상을 적는다.
  • context 인수에는 테스트가 실행될 때 전제가 되는 조건이나 상태를 쓴다.

예시

RSpec.describe Stack, type: :model do
  let!(:stack) { Stack.new }

  describe '#push' do
    context '문자열을 push했을 경우' do
      it '반환값이 push한 값일 것' do
        expect(stack.push('value')).to eq 'value'
      end
    end

    context 'nil을 push했을 경우' do
      it 'Argument Error가 발생 할 것' do
        expect { stack.push(nil) }.to raise_error(ArgumentError)
      end
    end  
  end

  describe '#pop' do
    context 'stack 값이 없을 경우' do
      it '반환값이 nil일 것' do
        expect(stack.pop).to be_nil
      end
    end

    context 'stack 값이 있을 때' do
      before do
        stack.push 'value1'
        stack.push 'value2'
      end

      it '마지막 값을 pop 할 것' do
        expect(stack.pop).to eq 'value2'
      end
    end
  end
end

FactoryBot 기본값

FactoryBot을 이용한 경우 각 모델의 기본 컬럼 값을 설정하게 된다. 이 때 각 컬럼의 값이 모두 랜덤 값이 되도록 설정을 적으면 좋다. 그 후 필요한 값만을 테스트 중에 명시적으로 지정함으로써 '이 테스트에서 중요한 값이 무엇인가'를 알기 쉽게된다.

좋지 않은 예

계정이 활성화되어 있는지를 active 컬럼으로 관리하고 있다고 가정하자. 이런 유효/비유효를 나타내는 컬럼이 고정되어 있는 경우는 흔히 볼 수 있다.

FactoryBot.define do
  factory :user do
    name { 'willnet' }
    active { true }
  end
end
RSpec.describe User, type: :model do
  describe '#send_message' do
    let!(:sender) { create :user, name: 'maeshima' }
    let!(:receiver) { create :user, name: 'kamiya' }

    it '메시지가 옳바르게 전송되어지는 것' do
      expect { sender.send_message(receiver: receiver, body: 'hello!') }
        .to change { Message.count }.by(1)
    end
  end
end

이 테스트는 User#activetrue인 것이 암묵적인 조건이 되어 있다. sender.active #=> false일 때나 receiver.active #=> false일 때 어떻게 동작하고 있는지가 잘 전달되어지고 있지 않다.

게다가 이 테스트에서는 name을 명시적으로 지정했는데, 이것은 꼭 필요한 지정인가? 테스트를 읽는 사람에게 불필요한 자원을 소비시켜 버리는 불필요한 데이터 지정은 가급적 피하는 것이 바람직하다.

좋은 예

FactoryBot.define do
  factory :user do
    sequence(:name) { |i| "test#{i}" }
    active { [true, false].sample }
  end
end
RSpec.describe User, type: :model do
  describe '#send_message' do
    let!(:sender) { create :user }
    let!(:receiver) { create :user }

    it '메시지가 옳바르게 전송되어지는 것' do
      expect { sender.send_message(receiver: receiver, body: 'hello!') }
        .to change { Message.count }.by(1)
    end
  end
end

이 테스트에서는 「User#active의 반환값이 User#send_message의 동작에 영향을 주지 않는다」라고 하는 것이(암묵적이지만) 전해진다. 만약 User#active가 영향을 주는 수정이 추가되었을 경우, CI에서 테스트가 실패함으로써 테스트가 망가진 것을 알게 될 것이다.

중요한 것은 「테스트에 의존하고 있는 값을 모두 테스트 중에 명시하고 있다」라는 것이기 때문에 이 점이 지켜지고 있다면 FactoryBot의 디폴트 값을 고정으로 해도 무방하다.

랜덤 값이 원인으로 테스트가 실패했을 때의 재현 방법

「각 컬럼의 값을 랜덤 값으로 해 버리면, CI에서 테스트가 실패했을 때에 재현할 수 없는 것은 아닌가?」라고 하는 의견이 있지만, RSpec는 디폴트의 설정으로 다음과 같이 seed 값을 밖에서 설정 할 수 있게 되어 있다.

spec/spec_helper.rb

Kernel.srand config.seed

그래서, 다음과 같이 CI에서 실패했을 때의 seed값을 rspec 명령어의 --seed 옵션으로 지정함으로써 같은 상황을 재현할 수 있다.

rspec -- seed 1234

FactoryBot에서 belongs_to이외에 association을 디폴트로작성하지 않는다

FactoryBot에서 모델을 작성할 때, 관련된 모델도 같이 작성할 수 있다.

대상 관련성이 belongs_to이면 특별히 문제가 없지만 has_many관련을 다룰 경우에는 주의가 필요하다.

예를들어 User와 Post가 1:N였다고 하자. FactoryBot 에서 정의해 보겠다.

FactoryBot.define do
  factory :user do
    sequence(:name) { |i| "username#{i}" }

    after(:create) do |user, evaluator|
      create_list(:post, 2, user: user)
    end
  end
end

after(:create)를 사용하여 User가 작성되었을 때 관련 Post도 작성되도록 하였다. 이 정의를 이용해 User가 작성한 Post중 인기순으로 반환하는 User#posts_ordered_by_popularity의 테스트를 써 본다.

RSpec.describe User, type: :model do
  describe '#posts_ordered_by_popularity' do
    let!(:user) { create(:user) }
    let!(:post_popular) do
      post = user.posts[0]
      post.update(popularity: 5)
      post
    end
    let!(:post_not_popular) do
      post = user.posts[1]
      post.update(popularity: 1)
      post
    end

    it 'return posts ordered by populality' do
      expect(user.posts_ordered_by_popularity).to eq [post_popular, post_not_popular]
    end
  end
end

이해하기 어려운 테스트 코드가 되었다. 이 테스트는 User의 레코드를 작성했을 때 관련된 Post를 2개 작성하는 것에 의존하고 있다. 또 update를 이용해 데이터를 변경하고 있기 때문에, 최종적인 레코드 상태를 파악하기 어렵게 되어 있다.

이를 피하기 위해서는 우선 디폴트로 1:1의 관련 레코드를 작성하는 것을 중단해야 한다.

FactoryBot.define do
  factory :user do
    sequence(:name) { |i| "username#{i}" }

    trait(:with_posts) do
      after(:create) do |user, evaluator|
        create_list(:post, 2, user: user)
      end
    end
  end
end

trait을 이용하여 디폴트로는 Post를 작성하지 않도록 하였다. 어떤 값이라도 좋으므로 관련있는 곳의 Post를 원할 때에는 trait을 지정하여 User를 작성하면 된다.

관련있는 곳은 테스트 중에 명시적으로 작성하도록 한다.

RSpec.describe User, type: :model do
  describe '#posts_ordered_by_popularity' do
    let!(:user) { create(:user) }
    let!(:post_popular) { create :post, user: user, popularity: 5 }
    let!(:post_not_popular) { create :post, user: user, popularity: 1 }

    it 'return posts ordered by populality' do
      expect(user.posts_ordered_by_popularity).to eq [post_popular, post_not_popular]
    end
  end
end

이것으로 처음의 예보다 상당히 보기 쉽게 되었다.

일시를 다루는 테스트 작성

주의: 올바른 경계치를 분석하여 테스트 데이터를 생성하면 이하의 방법은 필요 없게 된다.이하의 기술은 보조적으로 사용하는 것이 좋다.

일시를 다루는 테스트를 쓸 경우, 절대 시간을 쓸 필요가 없는 경우라면 가급적 현재일때부터의 상대시간을 이용하는 것이 좋다. 그렇게 하는 것이 구현의 부적합을 눈치 챌 가능성이 증가하기 때문이다.

예로서 지난 달에 공개된 투고를 취득하는scope와 그 테스트를 절대 시간을 이용해 기술한다.

class Post < ApplicationRecord
  scope :last_month_published, -> { where(publish_at: (Time.zone.now - 31.days).all_month) }
end
require 'rails_helper'

RSpec.describe Post, type: :model do
  describe '.last_month_published' do
    let!(:april_1st) { create :post, publish_at: Time.zone.local(2017, 4, 1) }
    let!(:april_30th) { create :post, publish_at: Time.zone.local(2017, 4, 30) }

    before do
      create :post, publish_at: Time.zone.local(2017, 5, 1)
      create :post, publish_at: Time.zone.local(2017, 3, 31)
    end

    it 'return published posts in last month' do
      Timecop.travel(2017, 5, 6) do
        expect(Post.last_month_published).to contain_exactly(april_1st, april_30th)
      end
    end
  end
end

이 테스트는 항상 성공하지만, 구현에는 버그가 포함되어 있다.
테스트를 상대일시로 변경해 본다.

require 'rails_helper'

RSpec.describe Post, type: :model do
  describe '.last_month_published' do
    let!(:now) { Time.zone.now }
    let!(:last_beginning_of_month) { create :post, publish_at: 1.month.ago(now).beginning_of_month }
    let!(:last_end_of_month) { create :post, publish_at: 1.month.ago(now).end_of_month  }

    before do
      create :post, publish_at: now
      create :post, publish_at: 2.months.ago(now)
    end

    it 'return published posts in last month' do
      expect(Post.last_month_published).to contain_exactly(last_beginning_of_month, last_end_of_month)
    end
  end
end

이 테스트는 예를 들어 3월 1일에 실행하면 실패한다. 항상 버그를 검지할 수 있는 것은 아니지만, CI를 이용함으로써 계속 테스트가 통과하지 않는 가능성을 줄일 수 있을 것이다.

일시를 외부에서 주입한다

날짜를 외부에서 주입하다
time_cop이나travel_to등을 사용하여 현재 시각을 변경하고 테스트를 실행하는 것보다, 시각을 외부에서 입력할 수 있도록 하는 것이 견고한 코드가 된다. 상세한 내용은 다음 블로그 엔트리에 정리되어 있다.

'현재 시각'을 외부 입력으로 하는 설계와 그 구현을 말하는 것 - 쿡패드 개발자 블로그
블로그의 마지막 정리 부분만을 번역하였습니다.

현재 시각을 취득하 는 것 Time.now은 외부 입력뿐입니다. 현재 시각에 따라 분기하는 처리는 언뜻 간단해 보이지만 조금씩 어플리케이션을 복잡하게 만들고 있습니다.

그것을

  • 입력 부분과 판정 부분을 분리한다
  • 입력 또는 데이터를 취득하는 부분을 통일·국소화한다
  • 필요에 따라 외부 값으로 덮어쓸 수 있도록 한다

위의 3가지 처럼 리팩터링(refactoring) 해 나감으로써, 동작 확인이나 테스트로서 사용하기 쉽도록 되어 질 것입니다.

이러한 방법은 결코 특별하고 어려운 일을 하고 있는 것이 아닙니다. 어느 쪽인가 하면 오브젝트 지향적이거나 실제 어플리케이션에 적용하귀 위해서 필요한 작은 라이브러리를 만들었을 뿐입니다. 그래도 실제 서비스에서 자주 보는 괴로움을 줄일 수 있는 것은 아닐까요.

before와 let(let!)의 구분

테스트의 전제가 되는 오브젝트(or 레코드)를 생성할 경우, let(let!)나 before를 사용한다. 이때 생성 후 참조하는 것을 let(let!)로 생성하고 그 이외의 것을 before로 생성하면 가독성이 좋아집니다.

예를 들어 다음과 같은 scope를 갖는 User 모델이 있다고 해봅시다.

class User < Application Record
  scope:active, -> { where(deleted:false).where.not(confirmed_at: nil) }
end

이 테스트를 let!만을 이용해서 쓰면 다음과 같습니다.

require 'rails_helper'

RSpec.describe User, type: : model do
  describe '.active' do
    let!(:active) { create:user, deleted:false, confirmed_at:Time.zone.now }
    let!(:deleted_but_confirmed) { create:user, deleted:true, confirmed_at: Time.zone.now }
    let!(:deleted_and_not_confirmed) { create:user, deleted: true, confirmed_at: nil }
    let!(:not_deleted_but_not_confirmed) { create:user, deleted: false, confirmed_at: nil }

    it'return active users' do
      expect(User.active).to eq [active]
    end
  end
end

let!과 before를 함께 쓰면 다음과 같다.

require 'rails_helper'

RSpec.describe User, type: : model do
  describe '.active' do
    let!(:active) { create:user, deleted:false, confirmed_at:Time.zone.now }

    before do
      create:user, deleted:true, confirmed_at:Time.zone.now
      create:user, deleted:true, confirmed_at:nil
      create:user, deleted:false, confirmed_at:nil
    end

    it'return active users' do
      expect(User.active).to eq [active]
    end
  end
end

후자가 「메소드의 return값이 되는 오브젝트」 와 「그 외」를 구별하기 쉽고, 보기 쉬운 코드가 된다.

let!(:deleted_but_confirmed)처럼 이름을 붙임으로써 어떤 레코드인지 쉽게 이해할 수 있다고 느끼는 사람도 있을 것이다. 그러나 레코드의 이름 짓기가 필요하다면 단순히 코멘트로서 보충해 주면 될 것이다

let과let!을 구분하여 사용하자

기본적으로 let! 을 권장한다. let의 특성인 지연평가가 유효하게 작용하는 다음과 같은 경우에서는 let를 사용해도 된다.

let! (:user) { create:user, enabled:enabled }

context 'when user is enabled' do
  let(:enabled) { true }
  it { ... }
end

context 'when user is disable' do
  let(:enabled) { false }
  it { ... }
end

let에서 정의한 값은 불리지 않는 한 실행되지 않으므로 let!보다 비용이 낮다는 점을 이점이라고 말하는 사람도 있지만, 이를 그대로 받아들이면 「let에서 정의한 값이 테스트 케이스에 의해 사용되거나 사용되지 않는」 상태가 되어 버린다.이는 테스트의 전제조건을 이해하는 비용을 높이고 가독성을 떨어뜨린다.

소극적인DRY

DRY로 하는 행위는 항상 좋은 것 이라고 여겨질지 모르겠지만 항상 그렇지만은 않다. 예를 들어 코드의 중복을 하나로 정리하는 것은 처리를 추상화 하는 것으로, 상황이나 추상화 방법에 따라서는 DRY에 의해서 줄어든 코스트를 상회하는 코스트가 발생할 수도 있다.

shared_examplesはよく考えて使う

shared_examples를 이용하면 코드의 중복을 삭제할 수 있지만 글쓰기에 따라서는 오히려 가독성을 떨어뜨리는 경우가 있다.

예를들어, 인수로서 전달된 요일에 대응한 만큼 포인트를 늘려주는 메소드가 있다고 하자. Point#increase_by_day_of_the_week의 테스트를 shared_examples를 이용해서 쓰자. shard_example의 정의는 다른 파일에 쓰여져있다고 하고, 먼저 shared_examples를 이용하는 측만의 코드를 보자.

RSpec.describe Point, type: :model do
  describe '#increase_by_day_of_the_week' do
    let(:point) { create :point, point: 0 }

    it_behaves_like 'point increasing by day of the week', 100 do
      let(:wday) { 0 }
    end

    it_behaves_like 'point increasing by day of the week', 50 do
      let(:wday) { 1 }
    end

    it_behaves_like 'point increasing by day of the week', 30 do
      let(:wday) { 2 }
    end

    # ...
  end
end

어떤 전제조건의 결과로서도 무엇을 기대하고 있는 이것만 봐서는 잘 모르겠다.

정의는 다음과 같다.

RSpec.shared_examples 'point increasing by day of the week' do |expected_point|
  it "increase by #{expected_point}" do
    expect(point.point).to eq 0
    point.increase_by_day_of_the_week(wday)
    expect(point.point).to eq expected_point
  end
end

이 테스트가 읽기 어려운 것은 shared_examples를 성립시키기 위해 필요한 전제조건이 많다는 점을 들 수 있다.

테스트되는 주체인point
메서드에 전달되는 인수인wday
기대하는 결과인expected_point
또, 각각의 정의가 분산되어 있는 것을 들 수 있다.

바깥쪽 let(point)
it_behaves_like 블럭안의 let(wday)
it_behaves_like의 두 번째 인수(expected_point)
가독성을 높이려면 우선 적절하게 이름을 붙일 수 있을 것이다.

RSpec.shared_examples 'point increasing by day of the week' do |expected_point:|
  it "increase by #{expected_point}" do
    expect(point.point).to eq 0
    point.increase_by_day_of_the_week(wday)
    expect(point.point).to eq expected_point
  end
end

RSpec.describe Point, type: :model do
  describe '#increase_by_day_of_the_week' do
    let(:point) { create :point, point: 0 }

    context 'on sunday' do
      let(:wday) { 0 }
      it_behaves_like 'point increasing by day of the week', expected_point: 100
    end

    context 'on monday' do
      let(:wday) { 1 }
      it_behaves_like 'point increasing by day of the week', expected_point: 50
    end

    context 'on tuesday' do
      let(:wday) { 2 }
      it_behaves_like 'point increasing by day of the week', expected_point: 30
    end

    # ...
  end
end

새롭게 context를 만들고 wday에 대한 설명을 추가했다. 그리고, 기대하는 결과인 expected_point를 키워드 인수로서 사용함으로서, 인수 측에 이름이 붙고, 수치가 무엇을 나타내는지 곧바로 이해할 수 있게 되었다.

그러나, 원래 이것은 shared_examples를 이용해야 하는 케이스일까? shared_examples를 이용하지 않고 쓰면 다음과 같다.

RSpec.describe Point, type: :model do
  describe '#increase_by_day_of_the_week' do
    let(:point) { create :point, point: 0 }

    context 'on sunday' do
      let(:wday) { 0 }

      it "increase by 100" do
        expect(point.point).to eq 0
        point.increase_by_day_of_the_week(wday)
        expect(point.point).to eq 100
      end
    end

    context 'on monday' do
      let(:wday) { 1 }

      it "increase by 50" do
        expect(point.point).to eq 0
        point.increase_by_day_of_the_week(wday)
        expect(point.point).to eq 50
      end
    end

    context 'on tuesday' do
      let(:wday) { 2 }

      it "increase by 30" do
        expect(point.point).to eq 0
        point.increase_by_day_of_the_week(wday)
        expect(point.point).to eq 30
      end
    end

    # ...
  end
end

it_behaves_like에서의 전제조건과 인수가 많아질수록 복잡도가 증가한다. DRY로 만드는 장점이 복잡도를 넘어서는지에 대해서는 신중하게 생각해야 한다.

범위를 고려하다

describe 밖에 테스트 데이터를 두지 않도록 하자

예를 들어 다음과 같은 spec이 있다고 하자

describe 'sample specs' do
  context 'a' do
    # ...
  end

  context 'b' do
    let!(:need_in_b_and_c) { ... }
    # ...
  end

  context 'c' do
    let!(:need_in_b_and_c) { ... }
    # ...
  end
end

이 경우, b와 c에서 같은 전제조건을 이용하고 있기 때문에, 한 단계 위의 레벨로 이동시켜 DRY로 구현하려고 생각하는 사람도 있을지도 모른다.

describe 'sample specs' do
  let!(:need_in_b_and_c) { ... }

  context 'a' do
    # ...
  end

  context 'b' do
    # ...
  end

  context 'c' do
    # ...
  end
end

하지만 좋지 않는 생각일 수 있다. 'a'의 콘텍스트에, 필요 없는 전제 조건이 포함되어 버리기 때문이다. 이 예제뿐이라면 감이 잘 않올지도 모른다. 이러한 let!가 10, context가 30 있고, 어느 let!가 어느 context에 대응하는 전제 조건인지 모르는 상황을 상상해 보면 섣불리 전제 조건을 정리하는 두려움을 알 수 있을까.

물론, 모든 context에 있어서, 공통으로 사용하는 전제 조건이라면, 정리해 버리는 것은 문제가 없을 것이다.

각 블록에서의 전제조건 배치 규칙

  • 각 블록의 전제조건은 해당 부하의 모든 expectation에서 이용하는 것만 적음
  • 특정 expectation에서만 이용하는 것은 그 expectation에 쓴다.

이삭줍기

  • 각 블록에서의 전제조건 배치 규칙의 예외는 다음과 같은 케이스
  • 그러나 기본적으로는 각 블록 내에서 선언하는 것이 바람직하다.
let!(:user) { create :user, enabled: enabled }

context 'when user is enabled' do
  let(:enabled) { true }
  it { ... }
end

context 'when user is disabled' do
  let(:enabled) { false }
  it { ... }
end

향후 번역할 항목

필요없는 레코드는 만들지 않는다

퍼포먼스 관점에서 레코드를 만들지 않아도 되는 경우는 만들지 않도록 하고 싶다.

describe 'posts#index' do
  context 'when visit /posts' do
    let!(:posts) { create_list :post, 100 }

    before { visit posts_path }

    it 'display all post titles' do
      posts.each do |post|
        expect(page).to have_content post.title
      end
    end
  end
end

'100건의 기사(POST) 제목을 표시할 수 있는 것'을 테스트하고 싶은 경우는 별개지만, 단지 기사 제목을 표시할 수 있는지 체크하고 싶은 경우, 분명 쓸데없는 레코드를 만들고 있다.

이 경우의 최소한의 레코드 수는 1건이다.

describe 'posts#index' do
  context 'when visit /posts' do
    let!(:post) { create :post }

    before { visit posts_path }

    it 'display post title' do
      expect(page).to have_content post.title
    end
  end
end

모델의 유닛 테스트에서도 만들지 않아도 되는 레코드를 만들고 있는 경우는 자주 있다.

RSpec.describe User, type: :model do
  describe '#fullname' do
    let!(:user) { create :user, first_name: 'Shinichi', last_name: 'Maeshima' }

    it 'return full name' do
      expect(user.fullname).to eq 'Shinichi Maeshima'
    end
  end
end

User#fullname은 레코드가 저장되어 있는지 여부에 영향을 주지 않는 메서드이다. 이 경우는 create가 아닌 build(혹은 build_stubbed)를 사용한다.

RSpec.describe User, type: :model do
  describe '#fullname' do
    let!(:user) { build :user, first_name: 'Shinichi', last_name: 'Maeshima' }

    it 'return full name' do
      expect(user.fullname).to eq 'Shinichi Maeshima'
    end
  end
end

위의 예시처럼 간단한 예시는User.new를 사용해도 좋다

update데이터는 변경하지 않는다

Factory Bot에서 작성한 레코드 중의 컬럼을 update 메서드로 변경하면 최종 레코드의 상태를 알기 어렵고 테스트에 의존하고 있는 속성도 알기 어려워지므로 피한다.

RSpec.describe Post, type: :model do
  let!(:post) { create :post }

  describe '#published?' do
    subject { post.published? }

    context 'when the post has already published' do
      it { is_expected.to eq true }
    end

    context 'when the post has not published' do
      before { post.update(publish_at: nil) }

      it { is_expected.to eq false }
    end

    context 'when the post is closed' do
      before { post.update(status: :close) }

      it { is_expected.to eq false }
    end

    context 'when the title includes "[WIP]"' do
      before { post.update(title: '[WIP]hello world') }

      it { is_expected.to eq false }
    end
  end
end

Post#published 메소드에 의존하고 있는 속성을 바로 이해할 수 있을까?
update는 대개 Factory Bot의 기본값을 "데이터로서 많은 가장 많은 형태"로 설정하고 값을 변경하여 사용하기 위해 사용된다. update는 사용하지 않고 가장 처음 FactoryBot 기본값에 기재한 바와 같이 기본 값을 랜덤하게 유지하는 것이 좋다.

let을 덮어쓰지 않는다

let에서 정의한 파라미터를 안쪽의 context로 덮어쓰면 update에서 데이터를 변경하지 않는다Chapter에서 설명한 예와 마찬가지로 최종 레코드의 상태를 알기 어려워지므로 피한다.

RSpec.describe Post, type: :model do
  let!(:post) { create :post, title: title, status: status, publish_at: publish_at }
  let(:title) { 'hello world' }
  let(:status) { :open }
  let(:publish_at) { Time.zone.now }

  describe '#published?' do
    subject { post.published? }

    context 'when the post has already published' do
      it { is_expected.to eq true }
    end

    context 'when the post has not published' do
      let(:publish_at) { nil }

      it { is_expected.to eq false }
    end

    context 'when the post is closed' do
      let(:status) { :close }

      it { is_expected.to eq false }
    end

    context 'when the title includes "[WIP]"' do
      let(:title) { '[WIP]hello world'}

      it { is_expected.to eq false }
    end
  end
end

subject를 사용할 때의 주의사항

subjectis_expectedshould를 사용하여 한 줄로 expectation을 쓸 경우에는 편리하지만 반대로 가독성을 훼손하는 방식으로 사용될 수 있다.

describe 'ApiClient#save_record_from_api' do
  let!(:client) { ApiClient.new }
  subject { client.save_record_from_api(params) }

  #
  # ...많은expectationを생략했을 경우...
  #

  context 'when pass  { limit: 10 }' do
    let(:params) { { limit: 10} }

    it 'return ApiResponse object' do
      is_expected.to be_an_instance_of ApiResponse
    end

    it 'save 10 items' do
      expect { subject }.to change { Item.count }.by(10)
    end
  end
end

이럴 때 expect {subject}의 subject는 도대체 무엇을 실행하고 있는지 바로 판단할 수 없으며 파일 바로 위쪽에 있는 subject의 정의를 확인해야 한다.
원래 "subject"는 명사이며, 부작용을 기대하는 곳에서 정의하면 혼란을 초래한다.

is_expected를 이용하여 암묵적인 subject를 이용하는 곳과 직접 subject를 명시하는 곳이 혼재되어 있어 꼭 subject를 사용하고 싶다면 subject에 이름을 붙여 사용하면 된다.

describe 'ApiClient#save_record_from_api' do
  let!(:client) { ApiClient.new }
  subject(:execute_api_with_params) { client.save_record_from_api(params) }

  context 'when pass  { limit: 10 }' do
    let(:params) { { limit: 10} }

    it 'return ApiResponse object' do
      is_expected.to be_an_instance_of ApiResponse
    end

    it 'save 10 items' do
      expect { execute_api_with_params }.to change { Item.count }.by(10)
    end
  end
end

expect {subject}때보다는 이해하기 쉬워졌을 것이다.
is_expected를 이용하지 않을 경우에는 subject의 이용을 멈추고 client.save_record_from_api(params)를 각 expectation에 더덕더덕 쓰는 것이 좋다.

allow_any_instance_of 사용을 피하자

공식 문서에도 적혀 있지만, allow_any_instance_of(expect_any_instance_of)가 필요한 시점에서 테스트 대상의 설계가 이상할 수 있다.
예로서 다음과 같은 Statement #issue 테스트를 적어본다.

class Statement
  def issue(body)
    client = TwitterClient.new
    client.issue(body)
  end
end
RSpec.describe Statement do
  describe '#issue' do
    let!(:statement) { Statement.new }

    it 'call TwitterClient#issue' do
      expect_any_instance_of(TwitterClient).to receive(:issue).with('hello')
      statement.issue('hello')
    end
  end
end

expect_any_instance_of를 사용해 버린 것은 Statement 클래스와 TwitterClient 클래스가 밀접하게 결합하고 있는 것이 원인이다.
결합을 약화시켜 보자.

class Statement
  def initialize(client: TwitterClient.new)
    @client = client
  end

  def issue(body)
    client.issue(body)
  end

  private

  def client
    @client
  end
end
RSpec.describe Statement do
  describe '#issue' do
    let!(:client) { double('client') }
    let!(:statement) { Statement.new(client: client) }

    it 'call TwitterClient#issue' do
      expect(client).to receive(:issue).with('hello')
      statement.issue('hello')
    end
  end
end

issue 메서드를 가진 객체라면 어느 클래스에서나 client로 취급할 수 있도록 수정하였다.
외부로부터 Client를 지정할 수 있도록 함으로써, 장래적으로 FacebookClient 등 다른 클라이언트에도 대응할 수 있게 되었다.
결합이 약화되면서 단순한 Mock Object로 테스트를 기술할 수 있게 되었다.

profile
Ruby와 js로 첫 커리어를 시작하였고 3년차 엔진니어입니다! vim에 관심이 많습니다!

2개의 댓글

comment-user-thumbnail
2022년 4월 5일

좋은 글 감사합니다!!

1개의 답글