Test double

December 05, 2018 · 7 mins to read

1. Concept of test double

에서 테스트 더블의 필요성과 개념에 대해 간단한 설명만 했었다. 다시 읽어보기 귀찮은 사람들은 모든 테스트는 섬이어야 한다 라는 이유 때문이라는 것만 기억하면 좋을 것 같다. 불필요한 DB연결이나 API호출 등을 피하는 것이다. 만약 이런 과정이 없다면 단지 테스트만을 위해 테스트용 DB를 만들거나 따로 API서버를 구축해야하는 불편함이 발생한다.

테스트 더블은 결국 외부로 부터 독립적이어야하기에 필요한 대역(Stunt double)이라고 보면 될 것 같다. 아래에서 소개할 개념들과 예제는 Best Practices for Spies, Stubs and Mocks in Sinon.js (opens new window)라는 글을 참고하여 작성하였다.

2. Test double

이 예제는 기본적으로 mocha, chai, sinon, sinon-chai을 이용한다. 예제 코드를 작성하는데에는 아래 코드를 테스트 대상으로 하고자 한다.

class User {
  constructor() {
    this.db = new DB()
  }

  getUsers() {
    return this.db.get('users')
  }

  getUserById(id) {
    return this.db.getById('user', id)
  }

  saveUser(user, callback) {
    const userInfo = {
      name: user.name,
      lowerCaseName: user.name.toLowerCase(),
    }

    this.db.save(userInfo)

    if (callback) {
      callback()
    }
  }
}

1. Stub

Stub은 미리 정해져있는 임의값을 반환하는 짧은 코드이다. 원래의 복잡한 구현을 최대한 단순한 것으로 대체하는 역할을 한다고 생각하면 괜찮을 것 같다. 몇 가지 일반적인 용도를 소개하자면 다음과 같다.

  • 테스트가 어려운 코드 조각 대체
  • 오류 처리나 잘 호출되지않는 부분을 쉽게 호출
  • 비동기 코드를 쉽게 테스트

요약하자면, stub은 결국 문제가 되는 부분을 대신해주어 테스트를 좀 더 쉽게 만들어 주는 역할을 한다고 볼 수 있다.

// function
describe('Using stub', () => {
  before(function () {
    // set error
    this.error = new Error('TypeError')

    // create stub
    this.apiCallBackStub = sinon.stub()
    this.apiCallBackStub.withArgs(1).returns({
      user: 'test',
    })
    this.apiCallBackStub.withArgs().throws(this.error)
  })

  it('return user object', function () {
    expect(this.apiCallBackStub(1)).is.deep.equals({
      user: 'test',
    })
  })

  it('throw error', function () {
    expect(() => this.apiCallBackStub()).to.throw(this.error)
  })
})

apiCallbackStub이라는 가상의 callback 함수를 stub으로 만들어 보았다. 내부에서의 정확한 동작은 알 길이 없지만(사실은 알 필요도 없다) 함수 호출 시 1이라는 parameter를 받으면 object를 return하고, 아무것도 받지 않으면 error를 throw한다.

위에서 설명한 것 처럼 이런 모습의 stub을 만들면 fetch를 통한 비동기 액션이라든지, DB에 연결하는 등의 복잡한 부분을 건너뛰고 간단히 테스트할 수 있다.

이미 존재하는 class 내부에 존재하는 메소드도 stubbing이 가능하다.

describe('Test double', () => {
  before(function () {
    this.error = new Error('IdNotInput')
    this.user = new User()
    // Stubbing Db.getById in getUserById
    this.getById = stub(DB.prototype, 'getById')
    this.getById.withArgs('user', 1).returns([{ id: 1, name: 'test', lowerCaseName: 'test' }])
    this.getById.withArgs('user').throws(this.error)
  })

  after(function () {
    this.getById.restore()
  })

  it('should getUserById return user object when input user id', function () {
    expect(this.user.getUserById(1)).to.deep.equals([
      { id: 1, name: 'test', lowerCaseName: 'test' },
    ])
  })

  it('should getUserById to throw error when not input parameter', function () {
    expect(() => this.user.getUserById()).to.throw(this.error)
  })
})

정리하자면, stub은 다음 상황에서 사용이 가능하다.

  • 테스트를 어렵게 만드는 코드를 대체해야 할 때.
  • 오류를 발생시키기 어려울 때.
  • 비동기 코드를 단순화 시켜야할 때.

2. Spy

Spy는 함수호출에 대한 정보를 얻는 데 사용된다. 예를 들어, 함수가 몇 번 호출되었는지, 호출될 때 사용된 parameter는 무엇인지, 어떤 값을 return하는지, 어떤 에러를 throw를 하는지 등을 확인할 수 있다. 기억해야 할 점은 spying 대상 함수의 동작에는 영향을 미치지않기 때문에 대상 함수가 실제로 실행된다는 것이다.

간단한 예시로, User 클래스의 saveUser가 실행되었을 시 db.save() 메소드가 실행되었음을 알 수있는 방법은 다음과 같다.

describe('Test double', () => {
  it('save method spy', function () {
    const setUserSpy = sinon.spy(DB.prototype, 'save')
    const user = new User()
    user.saveUser({
      name: 'test',
    })

    expect(setUserSpy).calledOnce
  })
})

또한, db.save() 메소드가 실제로 우리가 넘겨준 user object를 이용한 값을 parameter로 받고있는지도 확인이 가능하다. callback 함수의 동작 여부 역시 확인할 수 있다.

describe('Test double', () => {
  it('save method spy', function () {
    const dbSaveSpy = sinon.spy(DB.prototype, 'save')
    const saveUserSpy = spy(User.prototype, 'saveUser')
    const callbackSpy = sinon.spy()

    const user = new User()

    const expectedUserInfo = {
      name: 'test',
      lowerCaseName: 'test',
    }

    user.saveUser(
      {
        name: 'test',
      },
      callbackSpy
    )

    expect(dbSaveSpy).calledOnce
    expect(dbSaveSpy).calledWith(expectedUserInfo)
    expect(callbackSpy).have.been.called
  })
})

Spy는 잘 사용하면 기존에 실행되는 코드에 크게 영향을 주지 않고, 원하는 동작을 하고 있는지 관찰이 가능하다. Stub과 spy를 사용할 시점을 잘 구분하여 사용한다면 보다 효율적으로 동작하는 테스트 코드를 짧은 시간 안에 작성할 수 있다.

3. Fake

sinon의 문서 (opens new window)에 의하면 fake는 stub과 spy의 개념을 합쳐놓은 개념이라고 쓰여있다. 하지만 직접 사용해 보고 느낀바로는 특정 object를 흉내낸다는 느낌이 더 강했던 것 같다. 조금 더 솔직히 말하자면, 다른 쓰임새는 확실히 와닿지 않았고 Fake server라는 high-level api가 정말 유용했던 것 같다.

  • 와닿지 않는, 억지로 작성한 예제 (잘 사용하는 방법이 있으면 꼭 알려주시길!)
describe('Test double', () => {
  before(function () {
    const fakeGetUserById = sinon.fake.returns(1)
    replace(User.prototype, 'getUserById', fakeGetUserById)

    this.callback = sinon.spy()
    const fakeSaveUser = sinon.fake(this.callback)
    replace(User.prototype, 'saveUser', fakeSaveUser)
    this.user = new User()
  })

  it('should return 1 getUserById', function () {
    expect(this.user.getUserById()).is.equals(1)
    this.user.saveUser()
    expect(this.callback).have.been.called
  })
})
  • sinon 문서에 존재하는 Fake server 예제 코드
{
  before(function () {
    this.server = sinon.createFakeServer()
  })

  after(function () {
    this.server.restore()
  })

  it('test should fetch comments from server', function () {
    this.server.respondWith('GET', '/some/article/comments.json', [
      200,
      { 'Content-Type': 'application/json' },
      '[{ "id": 12, "comment": "Hey there" }]',
    ])

    var callback = sinon.spy()
    myLib.getCommentsFor('/some/article', callback)
    this.server.respond()

    sinon.assert.calledWith(callback, [{ id: 12, comment: 'Hey there' }])

    assert(server.requests.length > 0)
  })
}

4. Mock

Mock은 Fake와 마찬가지로 stub과 spy의 개념이 합쳐져 있는, pre-programmed expectations 이라고 한다.

Stub과 spy가 할 수있는 모든 것을 할수 있기 때문에 무작정 사용하기 쉽지만, 테스트 코드를 지나치게 구체적으로 사용하게되어 깨지기 쉬운 테스트를 만들수도 있다는 것을 기억해야 한다(‘깨지기 쉬운 테스트’란 코드가 변경되었을 때 의도치 않게 깨지는 테스트 코드를 말한다).

Mock은 사용해야 할 때와 사용하지 말아야 할 때가 있다.

  • 사용해야 할 때

    • method under test를 하고자 할 때.
    • 하나의 테스트 코드에는 단 하나만의 유닛 테스트가 존재해야 한다.
    • 절대 개별적인 expectation을 하지 않는다.
  • 사용하지 말아야 할 때

    • 특정 함수를 개별적으로 assertion해야 할때. 이때는 stub을 사용한다.

Mock은 아직 구현이 되지 않았지만 추후에 필요한 기능들, 혹은 미리 구현된 기능들이 잘 동작하는지 일련의 시나리오를 테스트하는데 아주 유용하다. 예를들어 User.saveUser()를 호출하였을 때 DB의 동작을 좀 더 구체적으로 테스트할 수 있는 방법은 다음과 같다. 여기서 mocking의 대상은 User가 아니라 DB이다.

describe('Test double', () => {
  it('should save user object with correct values', function () {
    const user = new User()
    const userInfo = { name: 'Test' }
    const expectedUserInfo = {
      name: 'Test',
      lowerCaseName: 'test',
    }
    const db = mock(DB.prototype)

    db.expects('save').once().withArgs(expectedUserInfo).resolves(true)

    user.saveUser(userInfo)

    db.verify()
    db.restore()
  })
})

3. Conclusion

처음에 Effective Unit Testing이란 책을 읽으면서 테스트 더블에 대한 개념은 정말 어렴풋이 알고 있었다. 그리고 이번에 테스트 더블에 대해서 알아보고 예제코드를 직접 써보며, 에러를 해결하고 고민하는 과정을 거쳐 조금은 더 알게되었다고 생각한다.

테스트 코드도 결국 계속 관리해야하는 refactoring 대상이라는 것을 생각하면, 적절한 목적을 가진 적절한 테스트 더블을 사용해야 보다 쉽게 관리할 수 있다는 사실을 명심해야겠다.

끝으로, 바쁜사람들을 위해 테스트 더블의 사용에 대한 요약을 남긴다.

  • Stub

    • 간단하게 복잡한 코드를 대체하고자 할 때(비동기 처리, 테스트와 상관없는 복잡한 함수 등)
    • 오류를 일으키기 힘든 코드를 쉽게 테스트 할 수 있다.
    • Stubbing된 함수는 실행되지 않는다.
  • Spy

    • 함수의 호출에 관한 정보를 알 수 있다.
    • 대표적으로 호출 회수, parameter, return 하는 값, raising되는 에러 등
    • Spy 대상 함수는 실행된다. Spy는 말 그대로 엿보는 역할만 할 뿐.
  • Fake

    • Stub과 spy를 같이 사용하는게 귀찮게 느껴진다면 한번 쯤 고려해 보라.
  • Mock

    • 시나리오를 전체적으로 테스트하고자 할 때 사용하면 좋다.
    • Stub과 spy의 개념이 합쳐져있는 것은 fake과 같지만 목적은 분명히 다르다.
    • Mocking된 함수는 실행되지 않는다.
    • 하나의 테스트에 하나의 유닛 테스트만 작성한다.
    • 절대 개별적인 expectaion을 하지 않는다. 만약 그 과정이 필요하다면 stub을 사용한다.
    • 깨지기 쉬운 테스트를 만들기 쉬우니 잘 생각해 보고 결정한다. 대부분의 테스트는 stub과 spy로 해결될 때가 많다.

참고


© 2021, Built with Gatsby