AngularJS application 테스트

AngularJS를 사용해서 구현한 Single Page Application(SPA)는 Unit Testing, End-to-end(E2E) Testing 2가지 방법을 이용해서 테스트가 가능하다.

  • Unit Tesing: karma 기반의 jasmine/mocha 테스트 프레임워크
  • E2E Testing: Selenium WebDriver로 브라우저 테스트 자동화, Protractor 프레임워크 기반으로 jasmine/mocha 테스트 프레임웍을 사용

karma, jasmine, mocha, protractor 모두 nodejs 기반으로 동작한다. E2E 테스트는 Protractor 기반으로 jasmine이나 mocha를 이용해서 테스트 코드를 작성하고, Selenium WebDriver가 브라우저를 자동 실행하여 화면 이벤트를 발생시킨다.

Unit Testing using Karma

카르마 설치하기

1
2
3
4
5
6
7
8
# Install Karma:
$ npm install karma --save-dev

# Install plugins that your project needs:
$ npm install karma-jasmine karma-chrome-launcher --save-dev

# Install command line for Window
$ npm install -g karma-cli

Karma 패키지를 설치한다. jasmine 기반으로 테스트 하기 위해서 karme-jasmine도 설치해준다. karma-chrome-launcher은 필요없다면 굳이 설치하지 않아도 상관없다. 윈도우에서 사용하려면 karma-cli를 설치하면 쉽게 사용할 수 있다고 한다. (근데 사용해보니 제대로 동작하지 않는 것 같음)

karma 설정하기

karma init 파일명.js 을 이용해서 카르마 설정 파일을 만들어 준다. 다음과 같이 커맨드 창에 입력하면, 문답형식으로 간단하게 설정 파일을 생성할 수 있다.

1
$ karma init karma.conf.js

생성된 karma.conf.js을 개발 환경에 맞춰 수정하였다. 결과는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
module.exports = function(config) {
'use strict';
config.set({
autoWatch: true,
basePath: '../',
frameworks: [
"jasmine"
],
files: [
'src/main/webapp/scripts/bower_components/angular/angular.js',
'src/main/webapp/scripts/bower_components/angular-mocks/angular-mocks.js',
'test/spec/myToDoAppTest/*.js'
],
exclude: [
],
port: 8080,
browsers: [
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe'
],
plugins: [
"karma-jasmine"
],
singleRun: false,
colors: true,
logLevel: config.LOG_INFO
});
};

karma start karma.conf.js를 입력해서 유닛 테스트를 실행한다. Karma v0.13.22 server started at http://localhost:8080이라는 로그가 뜨면 정상적으로 실행된 것이다. 브라우져로 크롬을 선언해주긴 했는데, 자동으로 실행되지 않는다. 아무 브라우저나 열고 http://localhost:8080으로 접속하면 페이지에 karma v0.13.22 - connected라고 떠있는걸 확인 할 수 있다. 추후에 테스트 코드를 작성하고 해당 페이지에 접속하면 로그 창에서 테스트의 성공/실패 여부를 확인 할 수 있다.

테스트 코드 작성하기

테스트를 진행하기 위해서 다음 3가지 파일을 생성한다. *Spec.js 파일은 테스트 코드이며, entpListTest.js는 angular module이다.

  • controllerSpec.js
  • serviceSpec.js
  • entpListTest.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// controllerSpec.js
describe('EntpListCtrl shoud ', function () {
var EntpListCtrl, scope, mockService;

beforeEach(function () {
module('entpListTest');

inject(function (_$rootScope_, _$controller_) {
scope = _$rootScope_.$new();
mockService = {
getEntpList: function (callback) {
callback.call(null, [{name: "test"}]);
}
};
EntpListCtrl = _$controller_('EntpListCtrl', {
$scope: scope,
EntpService: mockService
});
});
});

it('be defined.', function () {
expect(EntpListCtrl).toBeDefined();
});

it('declare entpList array.', function () {
expect(scope.entpList).toBeDefined();
});

it('have a method named searchEntps.', function () {
scope.searchEntps();
expect(scope.entpList.length).toEqual(1);
});
});

beforeEach() 메소드에서는 각 it 블록이 실행되기 전에 공통적으로 실행되어야 할 동작들을 선언한다. module을 로딩하며, _$rootScope__$controller_inject를 통해서 주입받아 UserListCtrl에 스코프 객체를 넘겨주어 it 블록에서 expect를 통해 EntpList을 검증한다. Angular에서는 service에서 자료구조와 비즈니스 로직을 생성하고 controller에서 service를 주입받아 사용한다.

EntpService에서는 매장 목록을 조회하고, 매장을 추가하고, 삭제할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// serviceSpec.js
describe('EntpService', function () {
beforeEach(function () {
module('entpListTest');
});

var EntpService;
beforeEach(inject(function(_EntpService_){
EntpService = _EntpService_;
}));

it('should be defined.', function(){
expect(EntpService).toBeDefined();
});

it('can remove all elements of entpList.', function() {
EntpService.removeAll();
expect(EntpService.getEntpList().length).toBe(0);
});

it('can get entps.', function() {
EntpService.removeAll();
EntpService.addEntp('KFC');

expect(EntpService.getEntpList().length).toBe(1);
});

it('can remove specific entp.', function() {
EntpService.removeAll();
EntpService.addEntp('KFC1');
EntpService.addEntp('KFC2');

EntpService.removeAt(0);

expect(EntpService.getEntpList().length).toBe(1);
});
});

다음은 entpListTest 애플리케이션 소스 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// entpListTest
angular.module('entpListTest', []).
controller('EntpListCtrl', ['$scope', 'EntpService', function ($scope, EntpService) {
$scope.entpList = [];

$scope.searchEntps = function () {
EntpService.getEntpList(function (data) {
$scope.entpList = data;
});
};
}]).
factory('EntpService', function () {
var entpList = [];

return {
getEntpList: function () {
return entpList;
},
addEntp: function (entp) {
entpList.push(entp);
},
removeAll: function () {
entpList = [];
},
removeAt: function (index) {
//console.log(index);
entpList.splice(index, 1);
//console.log(entpList);
}
};
});

*Spec.js 파일을 먼저 생성하여, controller와 service에 필요한 기능들을 정의한다. 테스트 코드에 맞게 애플리케이션을 수정할 때 마다, 자동으로 karma가 갱신되면서 테스트를 수행한다. 애플리케이션이 올바르게 작성될 때까지 로그에는 테스트가 실패했다는 에러 문구가 뜬다. success가 뜰때까지 하나씩 지워나가면 된다.

20160329_185203

E2E Testing using Protractor

예전부터 브라우저 기반으로 애플리케이션을 테으스트 하기 위한 시도가 있었고, 셀리넘과 웹드라이버가 따로 있었으나 현재는 셀레니엄 버전 2.0에서 셀레니엄과 웹드라이버의 각 장점을 취해 셀리니엄 웹드라이버라는 이름으로 발전했다. Angular Protractor는 셀레니엄 웹드라이버를 기반으로 애플리케이션 UI를 테스트 하기 위해 브라우저를 자동으로 기동해 테스트를 수행하는 E2E 테스트 프레임워크이다.

준비하기

ProtractorNode.js 프로그램이다. Protractor를 사용하기 위해서는 사전에 Node.js가 설치되어 있어야 하며, v0.10.0 이상의 Node.js를 필요로 한다. node --version을 통해서 현재 Node.js의 버전을 확인할 수 있다. 기본적으로 Protractor는 Jasmine을 사용하기 때문에, Jasmine 또한 기본적으로 설치 되어 있어야 한다.

Protractor 설치하기

-g 옵션을 줘서 글로벌로 protractor를 설치한다. local로 설치해봤는데 잘 안돌아가는데 이유는 잘 모르겠다.

1
2
3
4
5
6
# protractor와 webdriver-manager를 설치한다.
$ npm intstall protractor -g

# webdriver-manager를 업데이트 및 실행한다.
$ webdriver-manager update
$ webdriver-manager start

protractor를 설치하면 기본적으로 webdriver-manager를 자동으로 설치해주지만, 크롬 브라우저에서 자동화 테스트를 수행하는 ChromeDriver를 추가 설치 하기 위해서 webdriver-manager를 update 한다. 아래와 같은 오류가 발생한다면 JDK 버전을 확인하여 버전을 맞춰준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Exception in thread "main" java.lang.UnsupportedClassVersionError: org/openqa/grid/selenium/GridLauncher : Unsupported major.minor version 51.0
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(Unknown Source)
at java.security.SecureClassLoader.defineClass(Unknown Source)
at java.net.URLClassLoader.defineClass(Unknown Source)
at java.net.URLClassLoader.access$000(Unknown Source)
at java.net.URLClassLoader$1.run(Unknown Source)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(Unknown Source)
at java.lang.ClassLoader.loadClass(Unknown Source)
at sun.misc.Launcher$AppClassLoader.loadClass(Unknown Source)
at java.lang.ClassLoader.loadClass(Unknown Source)
at java.lang.ClassLoader.loadClassInternal(Unknown Source)
Could not find the main class: org.openqa.grid.selenium.GridLauncher. Program will exit.

webdirver-manager가 정상적으로 실행되면 RemoteWebDriver 인스턴스가 http://127.0.0.1:44/wd/hub와 연결되었고, Selenium Server가 구동 중이라는 로그를 확인 할 수 있다.

테스트 코드 작성하기

애플리케이션 화면 테스트에는 페이지 오브젝트 디자인 패턴(Page Object Design Pattern) 방식이 사용되곤 하는데, 이 방식을 사용하면 중복 코딩을 방지하고 페이지 오브젝트를 이용해 재사용 가능한 테스트를 수행할 수 있다. 또한 UI가 변경됐을 떄 테스트 코드는 그대로 두고 페이지 오브젝트만 변경하는 방식을 취할 수 있어 변화에 대한 유지보수를 수월하게 해준다. 먼저 화면에 대한 페이지 오브젝트를 정의한 후에 페이지 오브젝트를 사용해 테스트 코드를 정의해야 한다. Protractor는 페이지 오브젝트를 작성하기 위해서 몇가지 전역변수를 사용한다.

  • browser : 웹드라이버의 인스턴스로 페이지를 네비게이션한다.
  • element : 테스트하려는 페이지의 엘리먼트를 찾고 상호작용 해주는 도움 함수이다. ElementFinder 객체를 반환한다.
    • element(by.css('my-css'))$('my-css')와 동일한 의미라고 생각하면 된다.
  • by : 엘리먼트를 찾을 수 있는 로케이터의 컬렉션으로 CSS, ID, ng-bind 등을 찾을 수 있다.
    • <div ng-model="todo"/>by.model("todo")로 찾는다.
    • <div>{angular 표현식}</div>by.binding("todo")로 찾는다.
    • <div ng-repeat="todo in todos">by.repeater("todo in todos").row(1)로 두번재 todo 객체를 찾을 수 있다.

Step 0 - write a test

Protractor를 구동시키기 위해서는 기본적으로 spec fileconfiguration file이 필요하다. 테스팅을 위해서 Super Calculator를 사용할 것이며, 소스 코드는 별도로 정리해두었다.

1
2
3
4
5
6
7
8
// spec.js
describe('Protractor Demo App', function() {
it('should have a title', function() {
browser.get('http://juliemr.github.io/protractor-demo/');

expect(browser.getTitle()).toEqual('Super Calculator');
});
});

describeit syntax는 Jasmine에서 가져온 것이다.

1
2
3
4
5
6
// conf.js
exports.config = {
framework: 'jasmine',
seleniumAddress: 'http://localhost:4444/wd/hub',
specs: ['spec.js']
}

$ protractor conf.js 명령어를 통해서 테스트를 실시한다. 크롬브라우저가 잠깐 뜬 다음에 뭔가 쓱 지나간다. 다음과 같은 로그가 뜬다면 정상적으로 테스트가 진행된 것이다.

1
2
3
Started
.
1 spec, 0 failures

step 1 - interacting with elements

spec.js의 내용을 다음과 같이 추가해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// spec.js
describe('Protractor Demo App', function() {
it('should add one and two', function() {
browser.get('http://juliemr.github.io/protractor-demo/');
element(by.model('first')).sendKeys(1);
element(by.model('second')).sendKeys(2);

element(by.id('gobutton')).click();

expect(element(by.binding('latest')).getText()).
toEqual('5'); // This is wrong!
});
});

sendkey<input>에 값을 넣기 위해서 사용되었으며, click은 버튼을 클릭하기 위해서, getText는 해당 엘리먼트의 텍스트를 가져오기 위해서 사용했다. by.id는 id 값으로 엘리먼트를 사용하는데 사용된다. (by.model, by.binding은 앞서 설명했다) by.id('gobutton')<button id="gobutton">을 찾는다.

1
2
3
4
5
6
7
8
9
Started
F
.
Failures:
1) Protractor Demo App Should add one and two
Message:
Expected '3' to equeal '5'
.
.

위의 spec.js는 테스트를 통과하지 못한다. 소스 코드를 수정해서 테스트를 통과시키면 된다.

step 2 - writing multiple scenarios

앞선 spec.js는 테스트 케이스가 1개 만 존재한다. spec.js 파일을 수정해서 한번에 2개의 테스트를 수행해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// spec.js
describe('Protractor Demo App', function() {
var firstNumber = element(by.model('first'));
var secondNumber = element(by.model('second'));
var goButton = element(by.id('gobutton'));
var latestResult = element(by.binding('latest'));

beforeEach(function() {
browser.get('http://juliemr.github.io/protractor-demo/');
});

it('should have a title', function() {
expect(browser.getTitle()).toEqual('Super Calculator');
});

it('should add one and two', function() {
firstNumber.sendKeys(1);
secondNumber.sendKeys(2);

goButton.click();

expect(latestResult.getText()).toEqual('3');
});

it('should add four and six', function() {
// Fill this in.
expect(latestResult.getText()).toEqual('10');
});
});

beforeEach 함수가 추가되었다. 해당 함수는 모든 it 블록이 실행되기 전에 공통적으로 수행되어야 할 작업을 선언하는데 사용된다. 위의 spec.js를 테스트 하면 테스트는 통과되지 않는다. 수정해서 동작하게 만든다.

step 4 - lists of elements

list의 여러 엘리멘트를 다루기 위해서 element.all을 사용한다. element.allElementArrayFinder를 반환한다. spec.js는 아래와 같이 수정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// spec.js
describe('Protractor Demo App', function() {
var firstNumber = element(by.model('first'));
var secondNumber = element(by.model('second'));
var goButton = element(by.id('gobutton'));
var latestResult = element(by.binding('latest'));
var history = element.all(by.repeater('result in memory'));

function add(a, b) {
firstNumber.sendKeys(a);
secondNumber.sendKeys(b);
goButton.click();
}

beforeEach(function() {
browser.get('http://juliemr.github.io/protractor-demo/');
});

it('should have a history', function() {
add(1, 2);
add(3, 4);

expect(history.count()).toEqual(2);

add(5, 6);

expect(history.count()).toEqual(0); // This is wrong!
});
});

ElementArrayFinder를 얻기 위해서 by.repeaterelement.all을 함께 사용했다. history의 크기를 측정하기 위해서 count 메소드를 사용했다. ElementArrayFindercount이외에도 다양한 메소드를 제공한다. last는 Locator에 의해서 찾을 수 있는 엘리멘트의 마지막 값을 얻는데 사용할 수 있다. each, map, filter, reduce 등도 사용될 수 있다. 자세한 건 API 문서를 확인하면 된다.

1
2
3
4
5
6
7
it('should have a history', function() {
add(1, 2);
add(3, 4);

expect(history.last().getText()).toContain('1 + 2');
expect(history.first().getText()).toContain('foo'); // This is wrong!
});

틀린 부분을 통과 시키도록 구현하면 된다.

Reference

Webpack 시작하기 Integrate Meteor and React - 1

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×