TDD 003: 유닛테스트 작성하기 - 클래스

Raymond Yoo·2022년 1월 15일
0

TDD

목록 보기
3/6
post-thumbnail

이번에는 클래스에 대한 유닛테스트를 작성해보자.

소스코드 링크


프로젝트 설정 (추가)

클래스에서 사용하는 라이브러리, 테스트 중에 사용하는 라이브러리를 설치한다.

npm i axios
npm i -D sinon sinon-chai

axios 는 User 클래스에서 사용하는 네트워크 통신 라이브러리이다.
sinon 은 유닛테스트를 용이하게 하기 위해서 가짜 객체를 만들 때 사용하는 라이브러리이다.


클래스 생성

src/user.js 파일을 생성하고 다음과 같은 내용으로 User 클래스를 작성한다.

// src/user.js
const axios = require("axios");

class User {
  constructor(userName, viewRepos = false) {
    this.userName = userName;
    this.canViewRepos = viewRepos;
  }

  getUserId() {
    return axios
      .get(`https://api.github.com/users/${this.userName}`)
      .then((response) => response.data.id);
  }

  getUserRepo(repoIndex) {
    if (this.canViewRepos) {
      return axios
        .get(`https://api.github.com/users/${this.userName}/repos`)
        .then((response) => response.data[repoIndex]);
    }
    return Promise.reject("cannot view repos");
  }
}

module.exports = User;

테스트 코드 작성 시작

// test/user.js
const User = require("../src/user");
const axios = require("axios");
const chai = require("chai");
const sinon = require("sinon");
const sinonChai = require("sinon-chai");

const expect = chai.expect;

// sinon 이라는 수도 객체(pseudo object) 생성 라이브러리를
// chai 와 연계해서 사용하려면 반드시 필요한 코드이다.
chai.use(sinonChai);

getUserId() 메소드 테스트 코드

User.getUserId() 함수는 'https://api.github.com/users/${this.userName}' URL 로 요청을 보내서
해당 계정 사용자의 정보를 불러온다.
다음은 요청 응답 예시이다.

// 20220115235641
// https://api.github.com/users/OptimistLabyrinth

{
  "login": "OptimistLabyrinth",
  "id": 16411622,
  "node_id": "MDQ6VXNlcjE2NDExNjIy",
  "avatar_url": "https://avatars.githubusercontent.com/u/16411622?v=4",
  "gravatar_id": "",
  "url": "https://api.github.com/users/OptimistLabyrinth",
  "html_url": "https://github.com/OptimistLabyrinth",
  "followers_url": "https://api.github.com/users/OptimistLabyrinth/followers",
  "following_url": "https://api.github.com/users/OptimistLabyrinth/following{/other_user}",
  "gists_url": "https://api.github.com/users/OptimistLabyrinth/gists{/gist_id}",
  "starred_url": "https://api.github.com/users/OptimistLabyrinth/starred{/owner}{/repo}",
  "subscriptions_url": "https://api.github.com/users/OptimistLabyrinth/subscriptions",
  "organizations_url": "https://api.github.com/users/OptimistLabyrinth/orgs",
  "repos_url": "https://api.github.com/users/OptimistLabyrinth/repos",
  "events_url": "https://api.github.com/users/OptimistLabyrinth/events{/privacy}",
  "received_events_url": "https://api.github.com/users/OptimistLabyrinth/received_events",
  "type": "User",
  "site_admin": false,
  "name": null,
  "company": null,
  "blog": "",
  "location": null,
  "email": null,
  "hireable": null,
  "bio": null,
  "twitter_username": null,
  "public_repos": 31,
  "public_gists": 0,
  "followers": 3,
  "following": 9,
  "created_at": "2015-12-23T08:34:41Z",
  "updated_at": "2021-12-13T07:30:25Z"
}

이 메소드에 대한 테스트 코드를 작성해보자.

// test/user.js
...
describe("the User class", function () {
  it("should get the user id", function (done) {
    const userName = "OptimistLabyrinth";
    
    // 새로운 User 객체를 생성한다.
    const user = new User(userName);
    user
      .getUserId()
      .then(function (result) {
        expect(result).to.be.a("number");
        expect(result).to.be.eq(16411622);
      
      	// 테스트 케이스에서 promise 기반의 함수를 사용하면 
      	// 마지막에 반드시 done 함수를 호출해야 한다.
        done();
      })
      // 메소드 내에서 오류가 발생했을 때에도
      // done 함수를 호출하면서 종료해야 한다.
      .catch(done);
  });
});

결과는 다음과 같다.

테스트 실행은 성공했지만 현재 테스트 코드에는 문제점이 몇 가지 있다.

  1. 테스트 케이스 하나를 실행하는데 313ms 가 걸렸다.
    지금은 테스트 케이스가 하나 뿐이라서 큰 문제는 없는 것처럼 보이지만
    테스트 케이스가 아주 많아졌을 때는 실제 테스트에 아주 오랜 시간이 걸린다.
  2. getUserId() 메소드의 결과값은 User 객체 생성 시 입력한 userName 의 id 값이다.
    이 id 값은 API 서버의 내부 구현에 따라서 언제든 달라질 가능성이 있고,
    객체를 생성할 때 다른 userName 을 입력하면 id 값이 달라질 것이다.
    결국 테스트를 실행할 때 오류가 발생하기 너무나 쉬운 방식으로 테스트 코드가 작성되어 있다.

현재 테스트 대상이 되는 getUserId 메소드를 살펴보면
실제 값보다는 일정한 인풋에 대한 일정한 형식을 갖춘 아웃풋이 나오는 것을 확인하는 것이
핵심이다.
그러므로 stub 객체를 활용해서 테스트를 지금보다 덜 불편하게 만들 수 있다.

경우에 따라서는 클래스와 메소드의 인풋과 아웃풋의 형식 뿐만 아니라 값까지 정확하게 확인하는 것이 중요할 수도 있다.
이 경우에는 stub 없이 진짜 리턴값으로 테스트하면 될 것 같다.

테스트 코드를 수정해보자.

// test/user.js
...
describe("the User class", function () {
  it("should get the user id", function (done) {
    const userName = "OptimistLabyrinth";
    const user = new User(userName);
    
    // axios 라이브러리의 동작을 흉내내는 stub 객체를 생성한다.
    // axios 라이브러리는 요청 성공 시 값을 data 필드에 담아서 객체로 응답한다.
    const getStub = sinon.stub(axios, "get").resolves({ data: { id: 1234 } });
    user
      .getUserId()
      .then(function (result) {
        expect(result).to.be.a("number");
        expect(result).to.be.eq(1234);
      
      	// getStub 가 성공적으로 생성되었는지 확인하고
      	// getStub 의 실행 컨텍스트를 확인한다.
        expect(getStub).to.have.been.calledOnce;
        expect(getStub).to.have.been.calledWith(
          `https://api.github.com/users/${userName}`
        );
        done();
      })
      .catch(done);
  });
});

이제 다시 테스트 스크립트를 실행해보자.

실제로 axios 요청을 보내는 대신에 stub 객체를 사용하므로서
테스트 코드 실행이 굉장히 빠르게 완료되었음을 확인할 수 있다.


getUserRepo() 메소드 테스트 코드

User 클래스의 또 다른 메소드인 getUserRepo를 테스트하는 코드를 작성해보자.

// test/user.js
...
describe("the User class", function () {
  ...
  it("should return a repository if the user can view repos", function (done) {
    const userName = "OptimistLabyrinth";
    const user = new User(userName);
    const getStub = sinon
      .stub(axios, "get")
      .resolves({ data: ["repo1", "repo2", "repo3"] });
    user
      .getUserRepo(2)
      .then(function (response) {
        expect(response).to.be.eq("repo3");
        expect(getStub).to.have.been.calledOnceWith(
          `https://api.github.com/users/${userName}/repos`
        );
        done();
      })
      .catch(done);
  });
});

위에서 본 테스트 케이스와 동일하게 User 객체, stub 객체 각각을 만들어서 테스트 하는 코드이다.

이를 실제로 실행해보면 다음과 같은 오류로 실패한다.

지금과 같은 방식으로는 테스트하는 중에 stub 객체를 하나 밖에 만들지 못한다.
그러나 테스트 중에
axios.get 으로 메소드마다 각각 다른 URL 로 요청을 보내야 하고
URL 마다 돌아오는 응답도 형식이 달라진다.
즉, 테스트 케이스마다 새로운 stub 를 만들 수 있어야 한다.

이를 위해서 test/user.js 파일을 다음과 같이 수정한다.


아래의 테스트 코드에 등장하는 sinon.stub와 sandbox 개념은 아직 확실하게 이해하지 못한 상태이다.

이해하기로는
sinon.stub() 를 호출하면
어떤 오브젝트를 래핑(wrapping)해서
이 오브젝트의 프로퍼티를 가로채서 대신 실행하는, 말하자면 delegate 기능을 사용하게 되는 것 같다.

그러나 sandbox 를 생성하면 네임스페이스가 생기는 것처럼 동작해서
개별 테스트 케이스 시작할 때 stub 를 생성하고
테스트 케이스 종료할 때 sandbox.restore(); 실행해서 stub 를 소멸시키는 것처럼
동작하도록 테스트를 작성할 수 있는 것 같다.

// test/user.js
...
describe("the User class", function () {
  const userName = "OptimistLabyrinth";
  
  // stub 객체를 테스트 케이스마다 제어하기 위해서 sandbox 를 사용한다.
  const sandbox = sinon.createSandbox();
  
  // 모든 테스트 케이스에서 User 객체를 생성하기 때문에
  // beforeEach 로 빼서 중복을 줄였다.
  let user;

  // 각각의 테스트 케이스를 실행하기 직전에 반드시 실행해야 하는 작업을 지정한다.
  beforeEach(() => {
    user = new User(userName);
  });

  // 각각의 테스트 케이스를 실행한 직후에 반드시 실행해야 하는 작업을 지정한다.
  // sandbox 를 테스트 시작하기 직전 상태로 restore 한다.
  // "const sandbox = sinon.createSandbox();" 를 실행한 상태로 돌아간다.
  afterEach(() => {
    sandbox.restore();
  });

  it("should get the user id", function (done) {
    const getStub = sandbox.stub(axios, "get").resolves({ data: { id: 1234 } });
    user
      .getUserId()
      .then(function (result) {
        expect(result).to.be.a("number");
        expect(result).to.be.eq(1234);
        expect(getStub).to.have.been.calledOnce;
        expect(getStub).to.have.been.calledWith(
          `https://api.github.com/users/${userName}`
        );
        done();
      })
      .catch(done);
  });

  it("should return a repository if the user can view repos", function (done) {
    // beforeEach 에서 만든 User 객체를 제어할 수 있다.
    sandbox.stub(user, "canViewRepos").value(true);
    const getStub = sandbox
      .stub(axios, "get")
      .resolves({ data: ["repo1", "repo2", "repo3"] });
    user
      .getUserRepo(2)
      .then(function (response) {
        expect(response).to.be.eq("repo3");
        expect(getStub).to.have.been.calledOnceWith(
          `https://api.github.com/users/${userName}/repos`
        );
        done();
      })
      .catch(done);
  });
});

이제 테스트는 오류없이 성공한다.

getUserRepo 메소드 요청이 실패하는 경우에 대한 테스트도 작성해보자.

// test/user.js
...
describe("the User class", function () {
  it("should return an error if the user cannot view repos", function (done) {
    const getStub = sandbox.stub(axios, "get");
    user.getUserRepo(2).catch(function (error) {
      expect(error).to.be.eq("cannot view repos");
      expect(getStub).to.have.not.been.called;
      done();
    });
  });
});

테스트 스크립트는 별다른 문제 없이 성공한다.

profile
세상에 도움이 되고, 동료에게 도움이 되고, 나에게 도움이 되는 코드를 만들고 싶습니다.

0개의 댓글