프로트앤드 개발을 하면서 테스트는 어떻게 할 수 있는지 알아본다. 앵귤러에서는 테스트 관련 내용까지도 포함하고 있어서 개발 테스트의 편의성을 함께 제공하고 있다.
사전 준비
generator-angular를 설치하게 되면 앵귤러 프레임워크에 카르마(Karma) 테스트 프레임워크와 Mock 객체등이 포함되어 있다. 과거에는 Karma를 통해서 단위(Unit) 테스트와 E2E (End-To-End) 테스트 두가지를 하였으나, 최근에는 Karma로는 단위 테스트만을 수행하고 Protractor라는 새로운 테스트 프레임워크를 통해서 E2E 테스트를 수행한다
- karma.conf.js : Unit Test 환경파일. 내부적으로 Jasmine (http://pivotal.github.io/jasmine/) 프레임워크를 사용한다
- karma-e2e.conf.js : E2E Test 환경파일 (
- "grunt test" 를 통해 수행
Karma가 Jasmine 테스트 프레임워크를 사용하고, 필요한 files 목록을 정의하며 Chrome 브라우져에서 8080 포트를 사용하여 테스트함. angular.js 와 angular-mocks.js 파일은 필 첨부 (angular-mocks.js 안에 module, inject 펑션 존재)
it("contains spec with an expectation", function() {
expect(true).toBe(true);
});
});
describe로 시작하는 것을 Suite (슈트)라고 부르고, it 으로 시작하는 것을 spec (스펙)이라고 부른다. 슈트안에 여러개의 스펙이 있을 수 있다. beforeEach(), afterEach()를 통해서 스펙 수행시 마다 환경 초기값 셋업(Setup)이나 마무리 작업(TearDown)을 수행할 수 있다. 스펙 안에는 expect 기대값에 대한 다양한 메서드를 제공한다.
yo를 통하여 앵귤러 코드를 완성하게 되면 자동으로 테스트 코드까지 생성된다. SessionService에 대한 테스트 코드는 /test/spec/services 폴더 밑에 동일한 session-service.js 파일이 존재한다. module 호출 후에 inject 가 올 수 있으나 inject 후에 module 호출은 올 수 없다. _ (underscore)를 양쪽에 붙이는 것은 angular프레임워크에 테스트를 위하여 해당 서비스를 사용함을 알려준다.
describe('Service: SessionService', function () {
// load the service's module
beforeEach(module('meanstackApp'));
// instantiate service
var SessionService;
beforeEach(inject(function (_SessionService_) {
SessionService = _SessionService_;
}));
it('should do something', function () {
expect(!!SessionService).toBe(true);
});
});
grunt test 명령을 수행하면 에러가 발생할 것이다. 그것은 index.html에 필요에 의해 추가한 .js파일을 Karma 환경파일에 추가하지 않았기 때문이다. controller 쪽에서도 에러가 발생하는데 기본 테스트 코드에서 발생하므로 불필요한 코드를 삭제한다.
var request = msRequestFactory.createRequest('user', 'login', '');
expect(request).toEqual({
'area' : 'user',
'resource' : 'login',
'id' : '',
'request' : {}
});
});
});
msRestfulApi는 $resource를 통해 서버에 요청을 보낸다. 서버 요청에 대한 Mock을 만들어서 응답을 줄 수 있는 기능으로 앵귤러는 $httpBackend를 제공한다. 즉, 실서버가 없을 경우 XHR 또는 JSONP 요청없이도 실서버에 요청한 것과 같은 응답을 줄 수 있다. $httpBackend의 유형은 두가지 이다
- request expectation : 요청 JSON 데이터의 검증 원하는 결과가 왔는지 설정. expect().respond()
- backend definition : 가상의 응답정보를 설정. when().respond()
- Unit 과 Midway를 테스트시에는 테스트 스팩은 Mocha(모카)를 이용하고 Matcher/Assertion으로 Chai를 사용한다
- E2E 테스트시에는 Mocah, Chai를 사용할 수 없었다. 별도의 E2E 수행 문서를 제공한다
- 테스트 시에 AngularJS는 XHR 요청도 Interceptors를 통해서 가로챌 수 있다.
그외 route, controller, service, directive까지도 가로채서 테스트 코드를 넣을 수 있다.
- XHR 가로채기의 비용이 크면 angular-mock.js를 통하여 Mock을 사용할 수도 있다
1. Testing Module
- AngularJS에서 모듈은 directives, controllers, templates, services 그리고 resources 생성을 위한 컨테이너 오브젝트이다
- 따라서 테스트에 최초로 할 것이 해당 모듈이 있는지 체크하는 것이다
- Midway 테스트에서 수행한다
- midway/appSpec.js
//// test/midway/appSpec.js//describe("Midway: Testing Modules",function(){describe("App Module:",function(){varmodule;before(function(){module=angular.module("App");});it("should be registered",function(){expect(module).not.to.equal(null);});
// App이 의존하는 모듈들이 있는지 검증
describe("Dependencies:",function(){vardeps;varhasModule=function(m){returndeps.indexOf(m)>=0;};before(function(){deps=module.value('appName').requires;});//you can also test the module's dependenciesit("should have App.Controllers as a dependency",function(){expect(hasModule('App.Controllers')).to.equal(true);});it("should have App.Directives as a dependency",function(){expect(hasModule('App.Directives')).to.equal(true);});it("should have App.Filters as a dependency",function(){expect(hasModule('App.Filters')).to.equal(true);});it("should have App.Routes as a dependency",function(){expect(hasModule('App.Routes')).to.equal(true);});it("should have App.Services as a dependency",function(){expect(hasModule('App.Services')).to.equal(true);});});});
//// test/midway/routesSpec.js//describe("Testing Routes",function(){vartest;before(function(done){test=newngMidwayTester();test.register('App',done);});it("should have a videos_path",function(){expect(ROUTER.routeDefined('videos_path')).to.equal(true);varurl=ROUTER.routePath('videos_path');expect(url).to.equal('/videos');});it("the videos_path should goto the VideosCtrl controller",function(){varroute=ROUTER.getRoute('videos_path');route.params.controller.should.equal('VideosCtrl');});it("the home_path should be the same as the videos_path",function(){expect(ROUTER.routeDefined('home_path')).to.equal(true);varurl1=ROUTER.routePath('home_path');varurl2=ROUTER.routePath('videos_path');expect(url1).to.equal(url2);});
});
- E2E Testing
// test/e2e/routesSpec.js//describe("E2E: Testing Routes",function(){beforeEach(function(){browser().navigateTo('/');});it('should jump to the /videos path when / is accessed',function(){browser().navigateTo('#/');expect(browser().location().path()).toBe("/videos");});it('should have a working /videos route',function(){browser().navigateTo('#/videos');expect(browser().location().path()).toBe("/videos");});it('should have a working /wathced-videos route',function(){browser().navigateTo('#/watched-videos');expect(browser().location().path()).toBe("/watched-videos");});it('should have a working /videos/ID route',function(){browser().navigateTo('#/videos/10');expect(browser().location().path()).toBe("/videos/10");});});
3. Testing Controllers
- $scope를 통하여 template 페이지와 데이터 바인딩 되는 테스트
- Unit Testing
// test/unit/controllers/controllersSpec.js//describe("Unit: Testing Controllers",function(){beforeEach(angular.mock.module('App'));it('should have a VideosCtrl controller',function(){
// controller 모듈이 있는지 검증
expect(App.VideosCtrl).not.to.equal(null);});it('should have a VideoCtrl controller',function(){expect(App.VideoCtrl).not.to.equal(null);});it('should have a WatchedVideosCtrl controller',function(){expect(App.WatchedVideosCtrl).not.to.equal(null);});
// $httpBackend Mock 객체를 통해 단위 테스트 검증it('should have a properly working VideosCtrl controller',inject(function($rootScope,$controller,$httpBackend){
response.respond(null);var$scope=$rootScope.$new();varctrl=$controller('VideosCtrl',{$scope:$scope,$routeParams:{q:searchTestAtr}});}));it('should have a properly working VideoCtrl controller', inject(function($rootScope,$controller,$httpBackend){varsearchID='cars';varresponse=$httpBackend.expectJSONP('https://gdata.youtube.com/feeds/api/videos/' +searchID+'?v=2&alt=json&callback=JSON_CALLBACK');response.respond(null);var$scope=$rootScope.$new();varctrl=$controller('VideoCtrl',{$scope:$scope,$routeParams:{id:searchID}});}));it('should have a properly working WatchedVideosCtrl controller',inject(function($rootScope,$controller,$httpBackend){
var$scope=$rootScope.$new();//we're stubbing the onReady event$scope.onReady=function(){};varctrl=$controller('WatchedVideosCtrl',{$scope:$scope});}));
// 라투트통하여 페이지 변경 성공 이벤트 발생하면 onChange() 호출
before(function(){test.$rootScope.$on('$routeChangeSuccess',function(){if(onChange)onChange();});});beforeEach(function(done){test.reset(done);});it('should load the VideosCtrl controller properly when /videos route is accessed',function(){onChange=function(){test.path().should.eq('/videos');varcurrent=test.route().current;varcontroller=current.controller;varscope=current.scope;expect(controller).to.equal('VideosCtrl');};test.path('/videos'); // /videos 호출되면 자동으로 onChange() 호출되어 검증 });it('should load the WatchedVideosCtrl controller properly when /watched-videos route is accessed',function(done){onChange=function(){test.path().should.eq('/watched-videos');varcurrent=test.route().current;varcontroller=current.controller;varparams=current.params;varscope=current.scope;expect(controller).to.equal('WatchedVideosCtrl');done();};test.path('/watched-videos');});});
// 첫 페이지 있는지 검증
beforeEach(function(){browser().navigateTo('/');});
// videos의 html 화면에서 DIV id="ng-view"의 html 내역 안에 data-app-youtube-listings이 있는지 검증
it('should have a working videos page controller that applies the videos to the scope',function(){browser().navigateTo('#/');expect(browser().location().path()).toBe("/videos");expect(element('#ng-view').html()).toContain('data-app-youtube-listings');});it('should have a working video page controller that applies the video to the scope',function(){browser().navigateTo('#/videos/WuiHuZq_cg4');expect(browser().location().path()).toBe("/videos/WuiHuZq_cg4");expect(element('#ng-view').html()).toContain('app-youtube-embed');});
});
4. Testing Services/Factories
- 서비스는 테스트가 가장 쉬운 코들 블럭이다
- Service와 Factory에 대해서 테스트 하는 방법
- Unit Testing
// test/unit/services/servicesSpec.js//describe("Unit: Testing Controllers",function(){beforeEach(angular.mock.module('App'));it('should contain an $appStorage service',inject(function($appStorage){expect($appStorage).not.to.equal(null);}));
// 서비스 존재 유무 검증
it('should contain an $appYoutubeSearcher service',inject(function($appYoutubeSearcher){expect($appYoutubeSearcher).not.to.equal(null);}));it('should have a working $appYoutubeSearcher service',inject(['$appYoutubeSearcher',function($yt){expect($yt.prefixKey).not.to.equal(null);expect($yt.resize).not.to.equal(null);expect($yt.prepareImage).not.to.equal(null);expect($yt.getWatchedVideos).not.to.equal(null);}]));
// $appYoutubeSearcher Factory 검증 it('should have a working service that resizes dimensions',inject(['$appYoutubeSearcher',function($yt){varw=100;varh=100;varmw=50;varmh=50;varsizes=$yt.resize(w,h,mw,mh);expect(sizes.length).to.equal(2);expect(sizes[0]).to.equal(50);expect(sizes[1]).to.equal(50);}]));
// $appStorage Factory 검증 it('should store and save data properly',inject(['$appStorage',function($storage){varkey='key',value='value';$storage.enableCaching();$storage.put(key,value);expect($storage.isPresent(key)).to.equal(true);expect($storage.get(key)).to.equal(value);$storage.erase(key);expect($storage.isPresent(key)).to.equal(false);$storage.put(key,value);$storage.flush();expect($storage.isPresent(key)).to.equal(false);}]));
// 화면 변경이 있을 경우 onChange() 호출
before(function(){test.$rootScope.$on('$routeChangeSuccess',function(){if(onChange)onChange();});});before(function(){$injector=test.injector();});beforeEach(function(){//test.reset();});
// $appYoutubeSearcher 를 $injector로 부터 가져옴. findeVideo 호출함
it('should perform a JSONP operation to youtube and fetch data',function(done){var$yt=$injector.get('$appYoutubeSearcher');expect($yt).not.to.equal(null);//this is the first video ever uploaded on youtube//so I doubt it will be removed anytime soon//and should be a good testing itemvaryoutubeID='jNQXAC9IVRw';$yt.findVideo(youtubeID,false,function(q,data){expect(data).not.to.equal(null);expect(data.id).to.equal(youtubeID);done();});});
});
5. Testing Directives
- AngularJS입장에서 HTML 코드안에서 컴포넌트 역할이다
HTML상에 반복되며 격리될 수 있는 것을 Directive로 만든다. 따라서 HTML 코드 내역은 최소화 되고 불필요한 반복을 줄일 수 있다.
- Directive가 $scope와 DOM을 가지고 어떻게 작동할지 테스트 해야 한다
- XHR을 호출할 수도 있다. 따라서 Unit, Midway, E2E 전부분의 테스트를 수행한다
// 화면을 이동하였을 때 Directive가 표현하는 HTML 내역이 있는지 검증it('should have a working welcome directive apply it\'s logic to the page',function(){browser().navigateTo('#/videos');expect(browser().location().path()).toBe("/videos");expect(element('#app-welcome-text').html()).toContain('Welcome');});it('should have a working youtube listing directive that goes to the right page when clicked',function(){browser().navigateTo('#/videos');element('.app-youtube-listing').click();expect(browser().location().path()).toMatch(/\/videos\/.+/);});});
6. Testing Templates, Partials & Views
- 격리된 HTML 코드이다
- 앵귤러에서는 ngView 또는 ngInlcude 통하여 렌더링된다
- 뷰는 $templateCache 서비스 통해서 캐싱되어 제공된다.
테스트시 templateUrl을 주면 캐싱된 mock template을 얻어와 사용할 수 있다
// templateUrl을 test.path('XXX') 호출후 template 명칭이 맞는지 검증it("should load the template for the videos page properly",function(done){onChange=function(){setTimeout(function(){varcurrent=test.route().current;varcontroller=current.controller;vartemplate=current.templateUrl;expect(template).to.equal('templates/views/videos/index_tpl.html');onChange=nulldone();},1000);};test.path('/videos?123');});
// 각 templateUrl 이동시 화면 내역을 검증it('should redirect and setup the videos page template on root',function(){browser().navigateTo('#/');expect(element('#ng-view').html()).toContain('youtube_listing');});it('should load the watched videos template into view',function(){browser().navigateTo('#/watched-videos');expect(element('#ng-view').html()).toContain('youtube_listing');});it('should load the watched video template into view',function(){browser().navigateTo('#/videos/123');expect(element('#ng-view').html()).toContain('profile');});it('should redirect back to the index page if anything fails',function(){browser().navigateTo('#/something/else');expect(element('#ng-view').html()).toContain('youtube_listing');});
});
7. Testing Filters
- filter 테스트시에는 $injector 서비스가 필요하고, $injector로 부터 $filter를 얻어온다
- Unit Testing
// test/unit/filters/filtersSpec.js//describe("Unit: Testing Filters",function(){beforeEach(angular.mock.module('App'));it('should have a range filter',inject(function($filter){expect($filter('range')).not.to.equal(null);}));
// $filter 서비스를 받아서 range 필터를 얻는다
it('should have a range filter that produces an array of numbers',inject(function($filter){varrange=$filter('range')([],5);expect(range.length).to.equal(5);expect(range[0]).to.equal(0);expect(range[1]).to.equal(1);expect(range[2]).to.equal(2);expect(range[3]).to.equal(3);expect(range[4]).to.equal(4);}));it('should return null when nothing is set',inject(function($filter){varrange=$filter('range')();expect(range).to.equal(null);}));
// range filter의 input에 대한 검증it('should return the input when no number is set',inject(function($filter){varrange,input=[1];range=$filter('range')(input);expect(range).to.equal(input);range=$filter('range')(input,0);expect(range).to.equal(input);range=$filter('range')(input,-1);expect(range).to.equal(input);range=$filter('range')(input,'Abc');expect(range).to.equal(input);}));
// 최초 한번 미리 $filter 서비스를 얻어온다 before(function(done){ngMidwayTester.register('App',function(instance){test=instance;test.$rootScope.$on('$routeChangeSuccess',function(){if(onChange)onChange();});$filter=test.filter();done();});});beforeEach(function(){test.reset();});it('should have a working range filter',function(){expect($filter('range')).not.to.equal(null);});
// html 템플릿 DOM을 만들어서 append 하고, Directives에 사용한 range에 대해서 검증it('should have a working filter that updates the DOM',function(done){varid='temp-filter-test-element';varhtml='<div id="'+id+'"><div class="repeated" ng-repeat="n in [] | range:10">...</div></div>';varelement=angular.element(html);angular.element(document.body).append(html);test.directive(element,test.scope(),function(element){varelm=element[0];setTimeout(function(){varkids=elm.getElementsByTagName('div');expect(kids.length).to.equal(10);done();},1000);});});});
// range 필터가 사용된 화면으로 이동하였을 경우 화면이 정상적으로 표시 되는지 검증
it('should have a filter that expands the stars properly',function(){browser().navigateTo('#/videos/WuiHuZq_cg4');expect(repeater('#app-youtube-stars > .app-youtube-star').count()).toBeGreaterThan(0);});});