Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

Karma에서 $http 테스트를 작성할 때 $httpBackend로 목킹(mock)하기

웹 애플리케이션을 만들면 요즘 같은 경우 REST API를 사용하는 것이 일반적이고 특히 AngularJS를 사용하면 SPA(Single Page Applicaion)처럼 만드는 경우가 많으므로 REST API를 많이 사용하게 된다.

angular.module('MyApp', [])
  .factory('ApiService', function($http) {
    return {
      members: $http.get('http://express.dev/members/')
    };
  });

위와 같은 ApiService가 있다고 해보자. ApiService는 가상으로 만든 express.dev라는 서버에 회원목록을 조회하는 서비스다. 이 서비스를 Karma로 테스트하려면(Karma 테스트 러너 사용하기 참고) 다음과 같이 테스트를 작성할 것이다.

describe('MyApp', function() {
  'use strict';

  var $rootScope;

  beforeEach(module('MyApp'));
  beforeEach(inject(function(_$rootScope_) {
    $rootScope = _$rootScope_;
  }));

  describe('ApiService', function() {
    it('member\'s length should be 4', function(done) {
      inject(function(ApiService) {
        ApiService.members.success(function(data) {
          expect(data.members).to.have.length(4);
          done();
        });

        $rootScope.$digest();
      });
    });
 });
});

이 테스트의 결과를 보기 전에 먼저 설명을 해야 할 부분이 좀 있다. 이 테스트 코드에는 이전 글에는 없었던 $rootScope와 관련된 부분이 들어있다. 이 코드가 필요한 이유는 AngularJS의 메커니즘 때문에 그런데 내부 구조를 자세히는 몰라서 동작방식까지 설명하기는 어렵지만 AngularJS에서는 어떤 값이 처리되는 시점은 뷰에 표현될 때이다.(정확한 표현인지는 모르겠지만) 웹 브라우저에서 HTML이 있으므로 항상 잘 동작하지만, Karma에서 유닛 테스트를 수행할 때는 뷰가 존재하지 않으므로 뷰 표현 단계가 존재하지 않고 $http처럼 Promise 같은 것도 resolve단계에 들어가지 않는다. 좀 더 정확히 얘기하자면 스코프가 $digest되는 순간(혹은 $apply)에 실제 모든 처리가 이뤄지는 시점이다. 그래서 테스트코드에서 수동으로 해당 스코프를 $digest해주지 않으면 처리가 되지 않는다. 즉, 디렉티브로 DOM을 그린다면 컴파일만 해놓고 표현이 되지 않는다거나 위 예제 같은 경우는 HTTP 요청을 보내지 않거나 한다. 그래서 테스트 전인 beforeEach단계에서 $rootScope를 가져와서 테스트 마지막 부분에서 $rootScope.$digest();를 실행(19라인)시킨 것이다.

위 코드를 보면 루트 스코프를 주입할 때 _$rootScope_로 주입한 뒤에 이를 $rootScope 변수에 할당한 걸 볼 수 있는데 이는 여러 소스를 찾아본 결과 AngularJS 테스트에서 일반적으로 사용하는 테크닉(이라고 쓰고 꼼수라 읽는다)이다. 테스트 내에서 $rootScope를 계속 사용할 것이므로 $rootScope를 변수로 정의해서 계속 사용하고 싶은데 이럴 경우 주입하는 파라미터 이름과 겹쳐서 $rootScope = $rootScope;같은 형태가 돼서 동작하지 않으므로 주입을 _$rootScope_와 같이 한 것이다. 이렇게 하면 주입하는 파라미터 명은 (약간 이상해 보이는) _$rootScope_가 되지만 AngularJS가 알아서 _는 무시하고 루트 스코프를 잘 주입한다. 물론 _$rootScope_와 같이 사용하는 게 싫다면 $rootScope로 주입하고 로컬에서 사용하는 변수명을 $rootScope 대신 rootrs처럼 원하는 대로 작명해도 상관없다.

어쨌든 얘기가 다른 곳으로 좀 샜는데 위 테스트를 실행하면 다음과 같이 Error: Unexpected request: GET http://express.dev/members/라는 오류가 던져지면서 No more request expected라는 메시지가 나온다.

테스트에서 $http를 사용했을 때 No more request expected 오류

처음에 이 메시지를 보았을 때는 무척 당황스럽지만, 이는 AngularJS에서 의도적으로 막은 것으로 보인다. 유닛테스트에서는 $http를 사용해서 외부로 요청을 보낼 수 없다. 이상적인 유닛 테스트의 관점에서는 테스트가 외부 리소스에 의존성을 갖지 않는 것이 좋으므로 옳다고 느껴지지만, 너무 엄격하게 느껴지는 것도 사실이다.(다른 우회 방법이 있는지는 아직 잘 모르겠다.) 그래서 테스트에서는 $http도 목킹해서 테스트를 작성해야 하므로 Angular는 ngMock에서 $httpBackend를 제공하고 있다.

$httpBackend을 사용해서 테스트를 다음과 같이 다시 작성할 수 있다.

describe('MyApp', function() {
  'use strict';

  var $rootScope, $httpBackend;

  beforeEach(module('MyApp'));
  beforeEach(inject(function(_$rootScope_, _$httpBackend_) {
    $rootScope = _$rootScope_;
    $httpBackend = _$httpBackend_;

    $httpBackend.whenGET('http://express.dev/members/')
                .respond({
                  "members": ["zziuni", "nanha", "odyss", "danni"]
                });
  }));

  describe('ApiService', function() {
    it('member\'s length should be 4', function(done) {
      inject(function(ApiService) {

        ApiService.members.success(function(data) {
          expect(data.members).to.have.length(4);
          done();
        });

        $rootScope.$digest();
        $httpBackend.flush();
      });
    });
 });
});

$httpBackend$rootScope와 같은 방법으로 주입하고 $httpBackend에서 whenGet함수를 이용해서(여기선 GET 요청을 사용하므로) URL을 정의하고 respond함수로 응답을 지정한다. 이것에 하면 $http로 보내는 요청을 $httpBackend을 가로채고 지정한 URL 같은 경우 해당 응답을 대신 보내준다. (여기서 URL 정의는 파라미터까지 완전히 같아야 한다.) 마지막으로 테스트에서 $httpBackend.flush();를 실행하면 $httpBackend가 응답을 돌려준다.(flush()하지 않으면 응답을 보내지 않는다.) AngularJS 문서에 따르면 $digest이전에 $httpBackend.flush()를 실행하지 말라고 한다.

2014/01/24 21:58 2014/01/24 21:58