Karma 테스트 프레임워크를 통하여 AngularJS기반 애플리케이션을 어떻게 테스트 하는지 알아보자
AngularJS의 두가지 테스트 타입
- Unit Testing : 작고 격리된 코드 유닛의 동작에 대한 검증 테스트
- E2E Testing : 애플리케이션 전영역에 대한 테스트. E2E 테스터는 로봇처럼 브라우져를 실행하여 페이지를 검증해 준다
- AngularJS에서 이야기하는 E2E테스트 시나리오 API : Angular Scenario Runner
+ describe
+ beforeEach
+ afterEach
+ it
describe('Buzz Client', function() {
it('should filter results', function() {
input('user').enter('jacksparrow');
element(':button').click();
expect(repeater('ul li').count()).toEqual(10);
input('filterText').enter('Bees');
expect(repeater('ul li').count()).toEqual(1);
});
});
- Testing의 유형을 나누어 본다 : Unit, Midway, E2E
+ Unit Testing
코드레벨 테스트
격리된 테스트
Mock & Stubbing 요구, 빠름
+ Midway Testing
애플리케이션/코드레벨 테스트
애플리케이션의 모든 부분 접근 가능
다른 애플리케이션 코드와 상호작용
뷰(html) 내부의 테스트는 불가능능
빠름, 단, XHR일 경우 느릴 수 있음
+ E2E Testing
웹레벨 테스트
웹서버가 필요
완벽한 통합 테스트
샘플코드 설치 및 수행
- 데모 페이지
- 소스 : GitHub Repository
- 설치 (참조)
$ git clone git://github.com/yearofmoo-articles/AngularJS-Testing-Article.git
Cloning into 'AngularJS-Testing-Article'...
remote: Counting objects: 536, done.
remote: Compressing objects: 100% (368/368), done.
remote: Total 536 (delta 130), reused 516 (delta 117)
Receiving objects: 100% (536/536), 1.28 MiB | 390.00 KiB/s, done.
Resolving deltas: 100% (130/130), done.
$ cd AngularJS-Testing-Article/ && npm install
// 만일 제대로 npm install시 에러가 발생하면 package.json의 버전을 바꾼다
$ vi package.json
"grunt-karma": "latest",
"karma": "latest",
- 수행 : http://localhost:8000 으로 호출한다
$ grunt server
Running "connect:server" (connect) task
Starting connect web server on localhost:8000.
Waiting forever...
- 디렉토리 구조
+ test 밑에 3가지의 karma 환경파일이 존재한다
+ grunt test 명령으로 test 프로그램을 수행할 수 있다 - grunt test, grunt karma:unit, grunt karma:midway, grunt test:e2e (참조)
* 테스트 안될 경우 주의
1) bower_components를 components 폴더로 명칭 변경
2) ngMidwayTester 못 찾으면 : comoponents/ngMidwayTest/src 에서 src를 Source 폴더로 명칭 변경
+ 각 파트별 수행 테스트 파일은 unit, midway, e2e 폴더로 나뉜다
- Karma안에서 사용한 Test Frameworks은 test/ 폴더 밑으로 karma-e2e/midway/unit/shared.conf.js 로 설정되어 있다
+ Mocha.js : Unit Test
+ Chai.js : matchers and assertions
+ Jasmine.js : End-To-End Test(E2E) Framework으로 Angular Scenario Runner에서 사용
* Midway Plugin에서 에러가 발생한다. 해결하면 500원
Mocha, Chai 그리고 Angular Scenario Runner
- 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() {
var module;
before(function() {
module = angular.module("App");
});
it("should be registered", function() {
expect(module).not.to.equal(null);
});
// App이 의존하는 모듈들이 있는지 검증
describe("Dependencies:", function() {
var deps;
var hasModule = function(m) {
return deps.indexOf(m) >= 0;
};
before(function() {
deps = module.value('appName').requires;
});
//you can also test the module's dependencies
it("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);
});
});
});
});
2. Testing Routes
- 애플리케이션의 페이지 라우팅이 제대로 되는지 테스트 한다
- Midway, E2E 테스트에서 수행한다
- Midway Testing (Midway Testser Plugin)
//
// test/midway/routesSpec.js
//
describe("Testing Routes", function() {
var test;
before(function(done) {
test = new ngMidwayTester();
test.register('App', done);
});
it("should have a videos_path", function() {
expect(ROUTER.routeDefined('videos_path')).to.equal(true);
var url = ROUTER.routePath('videos_path');
expect(url).to.equal('/videos');
});
it("the videos_path should goto the VideosCtrl controller", function() {
var route = 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);
var url1 = ROUTER.routePath('home_path');
var url2 = 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) {
var searchTestAtr = 'cars';
var response = $httpBackend.expectJSONP('https://gdata.youtube.com/feeds/api/videos?q='
+ searchTestAtr + '&v=2&alt=json&callback=JSON_CALLBACK');
response.respond(null);
var $scope = $rootScope.$new();
var ctrl = $controller('VideosCtrl', {
$scope : $scope,
$routeParams : {
q : searchTestAtr
}
});
}));
it('should have a properly working VideoCtrl controller',
inject(function($rootScope, $controller, $httpBackend) {
var searchID = 'cars';
var response = $httpBackend.expectJSONP('https://gdata.youtube.com/feeds/api/videos/'
+ searchID + '?v=2&alt=json&callback=JSON_CALLBACK');
response.respond(null);
var $scope = $rootScope.$new();
var ctrl = $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() { };
var ctrl = $controller('WatchedVideosCtrl', {
$scope : $scope
});
}));
});
- Midway Testing
// test/midway/controllers/controllersSpec.js
describe("Midway: Testing Controllers", function() {
var test, onChange;
// Async Test로 done 객체 파라미터 전달
before(function(done) {
ngMidwayTester.register('App', function(instance) {
test = instance;
done();
});
});
// 라투트통하여 페이지 변경 성공 이벤트 발생하면 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');
var current = test.route().current;
var controller = current.controller;
var scope = 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');
var current = test.route().current;
var controller = current.controller;
var params = current.params;
var scope = current.scope;
expect(controller).to.equal('WatchedVideosCtrl');
done();
};
test.path('/watched-videos');
});
});
- E2E Testing
// test/e2e/controllers/controllersSpec.js
//
describe("E2E: Testing Controllers", function() {
// 첫 페이지 있는지 검증
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) {
var w = 100;
var h = 100;
var mw = 50;
var mh = 50;
var sizes = $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) {
var key = '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);
}]));
});
- Midway Testing
// test/midway/services/servicesSpec.js
//
describe("Midway: Testing Services", function() {
var test, onChange, $injector;
before(function(done) {
ngMidwayTester.register('App', function(instance) {
test = instance;
done();
});
});
// 화면 변경이 있을 경우 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 item
var youtubeID = '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 전부분의 테스트를 수행한다
- Unit Testing
// test/unit/directives/directivesSpec.js
//
describe("Unit: Testing Directives", function() {
var $compile, $rootScope;
beforeEach(angular.mock.module('App'));
//
beforeEach(inject(
['$compile','$rootScope', function($c, $r) {
$compile = $c;
$rootScope = $r;
}]
));
// app-welcome Directive안에 Welcome 글자와 매치하는지 검증
it("should display the welcome text properly", function() {
var element = $compile('<div data-app-welcome>User</div>')($rootScope);
expect(element.html()).to.match(/Welcome/i);
})
});
- Midway Testing
// test/midway/directives/directivesSpec.js
//
describe("Midway: Testing Directives", function() {
var test, $injector;
// $injector 얻어오기
before(function(done) {
ngMidwayTester.register('App', function(instance) {
test = instance;
done();
});
});
before(function() {
$injector = test.$injector;
});
// $appYoutubeSearcher 통하여 응답을 받은후 app-youtube-listings Directive를 검증한다. XHR호출이므로 setTimeout 값으로 1초를 준다
it("should properly create the youtube listings with the directive in mind", function(done) {
var $youtube = $injector.get('$appYoutubeSearcher');
var html = '';
html += '<div data-app-youtube-listings id="app-youtube-listings">';
html += ' <div data-ng-include="\'templates/partials/youtube_listing_tpl.html\'" data-ng-repeat="video in videos"></div>';
html += '</div>';
var $scope = test.scope();
var element = angular.element(html);
$youtube.query('latest', false, function(q, videos) {
$scope.videos = videos;
test.directive(element, $scope, function(element) {
setTimeout(function() {
var klass = (element.attr('class') || '').toString();
var hasClass = /app-youtube-listings/.exec(klass);
expect(hasClass.length).to.equal(1);
var kids = element.children('.app-youtube-listing');
expect(kids.length > 0).to.equal(true);
done();
},1000);
});
});
});
});
- E2E Testing
// test/e2e/directives/directivesSpec.js
//
describe("E2E: Testing Directives", function() {
beforeEach(function() {
browser().navigateTo('/');
});
// 화면을 이동하였을 때 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을 얻어와 사용할 수 있다
- Midway Testing
// test/midway/templates/templatesSpec.js
//
describe("Midway: Testing Requests", function() {
var test, onChange;
var $location, $injector, $params;
// url path 변경시 onChange() 호출
before(function(done) {
ngMidwayTester.register('App', function(instance) {
test = instance;
test.$rootScope.$on('$routeChangeSuccess', function() {
if(onChange) onChange();
});
done();
});
});
before(function() {
$injector = test.$injector;
$location = test.$location;
$params = test.$routeParams;
});
beforeEach(function() {
onChange = null;
});
// templateUrl을 test.path('XXX') 호출후 template 명칭이 맞는지 검증
it("should load the template for the videos page properly", function(done) {
onChange = function() {
setTimeout(function() {
var current = test.route().current;
var controller = current.controller;
var template = current.templateUrl;
expect(template).to.equal('templates/views/videos/index_tpl.html');
onChange = null
done();
},1000);
};
test.path('/videos?123');
});
});
- E2E Testing
// test/e2e/templates/templatesSpec.js
//
describe("E2E: Testing Templates", function() {
beforeEach(function() {
browser().navigateTo('/');
});
// 각 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) {
var range = $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) {
var range = $filter('range')();
expect(range).to.equal(null);
}));
// range filter의 input에 대한 검증
it('should return the input when no number is set', inject(function($filter) {
var range, 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);
}));
});
- Mideway Testing
// test/midway/filters/filtersSpec.js
//
describe("Midway: Testing Filters", function() {
var test, onChange, $filter;
// 최초 한번 미리 $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) {
var id = 'temp-filter-test-element';
var html = '<div id="' + id + '"><div class="repeated" ng-repeat="n in [] | range:10">...</div></div>';
var element = angular.element(html);
angular.element(document.body).append(html);
test.directive(element, test.scope(), function(element) {
var elm = element[0];
setTimeout(function() {
var kids = elm.getElementsByTagName('div');
expect(kids.length).to.equal(10);
done();
},1000);
});
});
});
- E2E Testing
// test/e2e/filters/filtersSpec.js
//
describe("E2E: Testing Filters", function() {
beforeEach(function() {
browser().navigateTo('/');
});
// 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);
});
});
<참조>
- 원문 : Full Spectrum Test in AngularJS
- Service Test 전략 동영상
- AngularJS를 위한 새로운 E2E Test Framework "Protractor"
- Protractor로 E2E 테스트하기 : 기존 Karma E2E 보다 쉬워진 느낌이다. 30분 부터 보시라
'Testing, TDD > Test First' 카테고리의 다른 글
[AngularJS] 최적 테스트 환경 - 3 (0) | 2013.10.23 |
---|---|
[AngularJS] 최적 테스트 환경 - 1 (0) | 2013.10.20 |
[QUnit] 클라이언트 코드 테스트의 강자 (0) | 2013.04.03 |
[Backbone.js] 클라이언트 코드를 Mocha로 TDD 수행하기 (0) | 2013.03.20 |
[Casper.js] Backbone.js 테스트를 위한 선택 (0) | 2013.03.13 |