잊지 않겠습니다.

Webpack을 이용한 angularJS 1 application 개발

기존 grunt를 이용한 angularjs application을 webpack을 사용하도록 변경하고자 합니다. 일단 webpack을 사용하게 되면 다음과 같은 장점을 가질 수 있습니다.

  1. npm을 이용한 외부 liberary의 관리
  2. es6 문법의 사용
  3. import/require의 사용 가능
  4. nodejs의 server code와 client code의 완벽 호환 가능

위 장점 이외에, 최근의 웹 개발 양상은 module화입니다. 모든 liberary들이 CommonJS 방식으로 module화가 되어가고 있는 시점에서, 추세에 따라가지 못하면 도태가 되어버리는 것 같은 압박감을 느끼기도 하지요.

환경

사용되는 application의 주요 library들은 다음과 같습니다.

  • jquerey
  • angularjs
  • lodash
  • restangular
  • kendo ui Pro

구조

기본적으로 webpack의 폴더구조는 maven의 구조와 비슷하게 가는 경향들이 있습니다. 다음과 같은 folder구조를 가지고 갈 것입니다.

.
├── dist
├── gulpfile.js
├── libs
│   └── kendo
├── LICENSE
├── node_modules
├── package.json
├── README.md
├── src
│   ├── app
│   └── public
├── webpack.config.js
├── webpack-test.sublime-project
└── webpack-test.sublime-workspace
  • dist: build된 file들이 위치할 folder입니다.
  • libs: bower/npm이 지원되지 않는 외부 library들이 위치한 folder입니다. 상기 application에서는 kendo가 포함됩니다.
  • src: 소스가 위치합니다.
    • public: public에 배포될 css, image, html 들이 위치할 folder입니다.
    • app: angularJs application이 위치할 folder입니다. app.js를 시작접으로 application이 구동될 것입니다.

여기서 app folder의 경우 다음과 같은 구조로 구성됩니다. (기본적으로 yo angular와 동일한 구성을 가지고 갑니다.)

├── controllers
│   ├── home.js
│   └── user.js
├── directives
│   └── nameButton.js
├── services
│   ├── BaseService.js
│   └── CodeService.js
├── app.js
└── routes.js

사용자 directive, controller, service를 모두 사용합니다.

packages.js

webpack은 기본적으로 bower를 사용하지 않습니다. 모든 js library들은 npm을 통해 설치되며 관리됩니다.

basic angularJS application (STEP01)

가장 기본적인 angularjs application을 작성해보도록 합니다. 다음과 같은 특징을 갖습니다.

  • /home, /user 라는 두개의 route를 갖습니다.

추후에 npm을 이용해서 module을 관리할 예정이기 때문에 처음 시작은 모두 cdn을 이용해서 처리하도록 합니다.

public안에 다음과 같이 구성합니다.

├── js
│   ├── controllers
│   │   ├── home.js
│   │   └── user.js
│   └── app.js
├── home.html
├── index.html
└── user.html

그리고, /home, /user의 url router를 다음과 같이 구성합니다.

// app.js
'use strict';

angular
  .module('app', ['ui.router'])
  .config(function ($stateProvider, $urlRouterProvider) {
    $stateProvider.state('home', {
      url: '/home',
      templateUrl: 'home.html',
      controller: 'HomeController'
    })
    .state('user', {
      url: '/user',
      templateUrl: 'user.html',
      controller: 'UserController'
    });
  });

index.html

<!doctype html>
<html ng-app="app" lang="en">
<head>
  <meta charset="UTF-8">
  <title>Angular App</title>
</head>
<body>
  <button ui-sref="home">Home</button>
  <button ui-sref="user">User</button>
  <ui-view></ui-view>
</body>
  <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.9/angular.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/angular-ui-router/1.0.0-alpha.4/angular-ui-router.min.js"></script>
  <script src="/js/app.js"></script>
  <script src="/js/controllers/home.js"></script>
  <script src="/js/controllers/user.js"></script>
</html>

home.html

<p>this is home.html</p>

user.html

<p>This is user.html</p>

이제 public folder내에서 http-server를 실행해보면 간단한 angularjs webapp 이 생성됩니다.

basic angularJS application + webpack (STEP02)

이제 webpack을 이용해서 angularjs application을 구성해보도록 하겠습니다.
먼저, public/js 안에 있는 모든 js 파일들을 모두 app folder로 옮깁니다.

.
├── app
│   ├── controllers
│   │   ├── home.js
│   │   └── user.js
│   └── app.js
└── public
    ├── home.html
    ├── index.html
    └── user.html

이런 구성을 가지게 됩니다.
이제 webpack.config.js 파일을 생성합니다.

'use strict';

const config = {
  entry: './src/app/app.js',
  output: {
    path: __dirname + '/dist',
    filename: 'app.bundle.js'
  },
  plugins: []
};
module.exports = config;

매우 기초적인 config 파일입니다. app.js 파일을 entry point로 지정하고, compile된 folder를 지정합니다. 이제 bundle된 파일을 사용하도록 index.html을 다음과 같이 수정합니다.

<!doctype html>
<html ng-app="app" lang="en">
<head>
  <meta charset="UTF-8">
  <title>Angular App</title>
</head>
<body>
  <button ui-sref="home">Home</button>
  <button ui-sref="user">User</button>
  <ui-view></ui-view>
  <script src="/app.bundle.js"></script>
</body>
</html>

그리고, webpack에서 각 controller, angular-ui-router의 import를 위해 app.js를 다음과 같이 수정합니다.

var angular = require('angular');
require('angular-ui-router');

angular
  .module('app', [ 'ui.router' ])
  .config(function ($stateProvider, $urlRouterProvider) {
    $stateProvider.state('home', {
      url: '/home',
      templateUrl: 'home.html',
      controller: 'HomeController'
    })
    .state('user', {
      url: '/user',
      templateUrl: 'user.html',
      controller: 'UserController'
    });
  });

require('./controllers/home');
require('./controllers/user');

여기서 중요한 것은 require의 위치입니다. controller들의 등록은 최초 app의 등록이 마쳐진 이후에 등록되어야지 됩니다.

angularJS application (ES2015) + webpack (STEP03)

이제, angularJs application을 es2015 문법으로 구현해보도록 하겠습니다. 개인적으로 가장 욕심이 나는 부분입니다.

es2015 문법을 사용해서 compile 하기 위해서는 babel을 이용해야지 됩니다. babel loader와 babel 2015 loader를 npm을 이용해서 설치합니다.

npm install babel-loader --save-dev
npm install babel-preset-es2015 --save-dev

이제 webpack.config에 babel loader를 등록시켜줍니다. js파일은 모두 babel loader에 의해서 처리가 되도록 설정합니다.

'use strict';
const config = {
  entry: './src/app/app.js',
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'app.bundle.js'
  },
  module: {
    loaders: [{
      test: /\.js$/,
      loader: 'babel',
      query: {
        presets: ['es2015']
      },
      exclude: /node_modules/
    }]
  },
  devtool: 'inline-source-map',
  plugins: []
};
module.exports = config;

이제 app.js, home.js, user.js 파일을 es2015 문법에 맞게 수정을 합니다.

home.js

class HomeController {
  constructor() {
    this.name = 'HomeCtrl';
    console.log('HomeController.init');
  }

  getName() {
    return this.name;
  }

  changeName(name) {
    this.name = name;
    return name;
  }
}

module.exports = HomeController;

user.js

class UserController {
  constructor() {
    this.name = 'UserCtrl';
    console.log('UserController.init');
  }

  getName() {
    return this.name;
  }

  changeName(name) {
    this.name = name;
    return name;
  }
}

module.exports = UserController;

app.js

var angular = require('angular');
require('angular-ui-router');

angular
  .module('app', [ 'ui.router' ])
  .config(function ($stateProvider, $urlRouterProvider) {
    $stateProvider.state('home', {
      url: '/home',
      templateUrl: 'home.html',
      controller: 'HomeController'
    })
    .state('user', {
      url: '/user',
      templateUrl: 'user.html',
      controller: 'UserController'
    });
  })
  .controller('HomeController', require('./controllers/home'))
  .controller('UserController', require('./controllers/user'));

app.js에서 angular에 대한 dependency만을 갖고, 각 controller들은 dependency에서 angular에서 자유로워지는 것을 볼 수 있습니다. 이제 webpack 명령어를 실행해보면 다음과 같은 결과를 볼 수 있습니다.

Hash: f32a9fcb0a0a1f4a0d08
Version: webpack 1.12.14
Time: 1498ms
        Asset     Size  Chunks             Chunk Names
app.bundle.js  3.88 MB       0  [emitted]  main
    + 6 hidden modules

외부 Library 들의 연결 (STEP04)

angularJs application을 개발하면 당연히 기존 library 들과 같이 사용해야지 되는 경우가 많습니다. 먼저 angular-ui-router의 경우에도 마찬가지지요.

대표적인 것들이 일단 lodash와 같은 utility library들이 있을것이고, angularjs에서 rest api 를 call 할때 사용하는 restangular 역시 주로 사용되는 library 입니다.

먼저 loadash에 대해서 알아보도록 하겠습니다. 저는 lodash에 대한 의존이 매우 높은 관계로 전역으로 등록시켜서 사용하고자 합니다.

npm을 통해서 lodash를 설치합니다.

npm install lodash --save

lodash의 경우에는 다른 기타 library들의 dependency가 심한편입니다. 또한 모든 controller에서 전역으로 사용하고싶은 욕심이 있습니다. 그렇다면 webpack에서 미리 등록을 시켜두면 좀 더 사용하기 편할 것 같습니다. browser 환경의 전역 scope로 미리 등록시켜두기 위해서는 webpack에서 제공하는 ProvidePlugin을 이용해서 등록을 시킵니다.

webpack.config.js를 다음과 같이 수정합니다.

const webpack = require('webpack');

const config = {
  entry: './src/app/app.js',
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'app.bundle.js'
  },
  module: {
    loaders: [{
      test: /\.js$/,
      loader: 'babel',
      query: {
        presets: ['es2015']
      },
      exclude: /node_modules/
    }]
  },
  devtool: 'inline-source-map',
  plugins: [
    new webpack.ProvidePlugin({
      'window._': 'lodash',
      '_': 'lodash'
    })
  ]
};
module.exports = config;

lodashwindow 전역과 local 모두에 등록을 시켰습니다. 이제 controller에서는 lodashrequire필요 없이 사용 가능합니다.

home.js

class HomeController {
  constructor() {
    this.name = 'HomeCtrl';
    console.log('HomeController.init');
    console.log(_.random(0, 5));
  }

  getName() {
    return this.name;
  }

  changeName(name) {
    this.name = name;
    return name;
  }
}

module.exports = HomeController;

이제 restangular를 추가할 차례입니다. npm을 이용해서 restangular를 설치하는 일련의 과정은 lodash와 동일합니다. 다만, restangular의 경우에는 angularjs module에 등록을 시켜줘야지 됩니다. app.js는 다음과 같이 변경되어야지 됩니다.

app.js

var angular = require('angular');
require('angular-ui-router');
require('restangular');

angular
  .module('app', [ 'ui.router', 'restangular' ])
  .config(($stateProvider, $urlRouterProvider) => {
    $stateProvider.state('home', {
      url: '/home',
      templateUrl: 'home.html',
      controller: 'HomeController'
    })
    .state('user', {
      url: '/user',
      templateUrl: 'user.html',
      controller: 'UserController'
    });
  })
  .config(RestangularProvider => {
    console.log(RestangularProvider);
  })
  .controller('HomeController', require('./controllers/home'))
  .controller('UserController', require('./controllers/user'));

이제 controller에서는 생성자에서 Restangular를 inject해서 사용가능합니다.

home.js

class HomeController {
  constructor(Restangular) {
    this.Restangular = Restangular;
    this.name = 'HomeCtrl';
    console.log('HomeController.init');
    console.log(_.random(0, 5));

    this.Restangular.all('').get('data.json').then(result => {
      console.log(result);
    });
  }

  getName() {
    return this.name;
  }

  changeName(name) {
    this.name = name;
    return name;
  }
}

module.exports = HomeController;

Service, Directive, filter 추가 (Step 05)

angularJs의 주요 컨셉중 하나인 service, directive, filter의 경우, 기존 controller추가와 동일합니다. 다만 es2015 형식으로 코딩을 하는 경우, class를 이용해서 작성하는 것만 좀 유의해주면 될 것 같습니다.

userservice.js - service

class UserService {
  constructor(Restangular) {
    this.Restangular = Restangular;
  }

  getUserName() {
    return 'username';
  }

  getUserInfo() {
    this.Restangular.all('').get('info.json').then(info => {
      console.log(info);
    });
  }
}
module.exports = UserService;

userfilter.js - filter

function filteringUser () {
  return function (users) {
    return _.filter(users, function (user) {
      return user.name.indexOf('7') >= 0;
    });
  };
}
module.exports = filteringUser;

namedbutton.js - directive

function postLink(scope, element, attrs) {
  console.log(attrs);
  scope.ok = function () {
    alert(scope.name);
  };
}

function template(element, attrs) {
  console.log(attrs);
  return '<button ng-click="ok()">This is named button 확인</button>';
}

module.exports = function NameButtonDef() {
  return {
    restrict: 'E',
    scope: {
      name: '='
    },
    link: postLink,
    template: template
  };
};

app.js

var angular = require('angular');
require('angular-ui-router');
require('restangular');

angular
  .module('app', [ 'ui.router', 'restangular' ])
  .config(($stateProvider, $urlRouterProvider) => {
    $stateProvider.state('home', {
      url: '/home',
      templateUrl: 'home.html',
      controller: 'HomeController',
      controllerAs: 'model'
    })
    .state('user', {
      url: '/user',
      templateUrl: 'user.html',
      controller: 'UserController',
      controllerAs: 'model'
    });
  })
  .config(RestangularProvider => {
    console.log(RestangularProvider);
  })
  .controller('HomeController', require('./controllers/home'))
  .controller('UserController', require('./controllers/user'))
  .service('UserService', require('./services/userservice'))
  .filter('user', require('./filters/userfilter'))
  .directive('namedButton', require('./directives/namedbutton'));

controller와 별 차이가 없는 코드 패턴을 보입니다. 개인적으로는 최고의 장점이 service, directive, filter 코드 자체에 angular dependency가 없어지는 것이 장점인것 같습니다.

jqLite 대신 jquery 사용하기 (step06)

기본적으로 angular.element의 경우, jquery가 없는 경우에는 angularjs내부에 include 되어 있는 jqlite를 이용해서 element handling을 지원합니다. 그런데 angular.element의 경우 jquery selector보다 매우 기능이 빈약합니다. bootstrap도 사용할 가능성도 높고, 그냥 기본적으로 jquery를 포함해서 사용하게 되는 경우가 더 일반적입니다.

그럼 angular.element에서 jquery를 사용하도록 할려면 어떻게 해야지 될까요? 예전 방법대로라면 다음과 같이 해주면 됩니다.

<script src="jquery.js"></script>
<script src="angular.js"></script>

네. angular보다 jquery를 먼저 선언해주면 모든것이 해결되었습니다. 그럼 비슷한 구문으로 실행해보도록 하겠습니다.

    require('jquery');
    const angular = require('angular');
    console.log(angular.element('div'));

이러면 다음과 같은 에러가 발생합니다.

angular.js:13424 Error: [jqLite:nosel] Looking up elements via selectors is not supported by jqLite! See: http://docs.angularjs.org/api/angular.element
http://errors.angularjs.org/1.5.3/jqLite/nosel

네. jqLite를 사용하고 있지, jquery를 사용하고 있지 못하게 됩니다.

이런 경우에는 expose-loaderimport-loader를 이용해서 각 module에서 jquery등을 자동으로 내부내에서 import 시켜줘야지 됩니다.

npm을 이용해서 다음 module들을 설치합니다.

npm install imports-loader --dev-save
npm install expose-loader --dev-save

그리고 webpack.config.js의 module 부분을 다음과 같이 변경합니다.

  module: {
    loaders: [{
      test: /\/angular\.js$/,
      loader: 'imports?jQuery=jquery'
    }, {
      test: /\/jquery.js$/,
      loader: 'expose?jQuery'
    }, {
      test: /\.js$/,
      loader: 'babel',
      query: {
        presets: ['es2015']
      },
      exclude: /node_modules/
    }]
  },

코드 해석은 다음과 같습니다. jquery가 요청되는 경우 window.jQuery로 등록을 시키는 구문이 두번쩨 expose 구문입니다. 그리고 angular.js가 로드될 때, jQuery의 경우 jquery를 import 해서 사용하도록 강제하는 것입니다.

위의 두 구문은 매우 중요합니다. jquery 기반의 module들을 사용할 때, 안되는 이유의 대부분이 window.jQuery, window.$, window.jquery가 등록이 되지 않을 때 주로 나타나게 됩니다.

이제 저 구문을 넣어주고 webpack 을 실행해주면 다음과 같은 결과를 볼 수 있습니다.

[div#window-resizer-tooltip, div#feedly-mini, prevObject: jQuery.fn.init[1], context: document, selector: "div"]0: div#window-resizer-tooltip1: div#feedly-minicontext: documentlength: 2prevObject: jQuery.fn.init[1]selector: "div"__proto__: Object[0]

Summary

angularjs2가 나오는 이때에 유행에 어울리지 않는 angularjs와 최신 유행인 webpack간의 결합에 대해서 알아봤습니다.

위 소스는 모두 github에 올려두었고, step별로 tag를 달아두었습니다.
(https://github.com/xyzlast/webpack-angular1-step)

  1. angularjs1 application을 webpack으로 묶는 과정에 대해서 알아봤습니다.
  2. jquery 기반의 library들을 어떻게 결합할 수 있는지에 대해서 알아봤습니다.

css-loader등을 잘 설명하신 글이 있으니, 참고하셔서 보시면 좀 더 쉬울 것 같습니다.
(http://hyunseob.github.io/2016/04/03/webpack-practical-guide/index.html)


Posted by Y2K
,

Express에서의 Session 사용법

기초중의 기초이지만, 간혹 사용법이 헛갈릴때가 많아서 정리하였습니다.

요구사항

  1. ES6 문법을 기초로 이용
  2. Session을 이용한 로그인 관리
  3. Cluster를 이용가능 (pm2 등을 이용)
  4. login을 통해 얻어진 사용자 이름을 session에 저장.
  5. view는 모두 json을 이용

개발 환경 구성

yo generator중에 generator-express 설치

npm install -g generator-express

특정 directory에서 yo express 실행하면 기본적인 express application이 구성됩니다. 구성된 폴더안에서 gulp default를 실행하면 기본 port 3000번으로 application이 구동됩니다.

Application이 구동되지 않을때.

port 35729가 사용중일때 : gulp.liveload를 이용한 다른 application이 구동되고 있을 때, 발생되는 error. 두개의 application을 동시에 실행하고 싶은 경우에는 gulpfile.js에서 liveload method에 port 번호를 명시. livereload.listen(60000);

config.root undefined가 될때. config/config.js가 정상적으로 로드되지 않은 경우. NODE_ENVdevelopment, production 등으로 지정하지 않은 경우에 발생. 자신의 NODE_ENV를 확인후, NODE_ENV에 맞게 config.js를 수정

추가 npm - express-session, filestore

Session을 이용하기 위해서 express-session 설치합니다. 구 버젼을 기반으로한 책이나 blog에서는 이 과정이 나오지 않습니다. express의 middleware들은 대부분 npm module로 따로 분리가 되어 사용됩니다. express에 기본으로 들어가 있는 경우는 얼마 없습니다.

npm install express-session --save
babel-express-app@0.0.1 /home/ykyoon/dev/code/babel-express-app
└─┬ express-session@1.13.0 
  ├── crc@3.4.0 
  └─┬ uid-safe@2.0.0 
    └── base64-url@1.2.1 

npm WARN optional Skipping failed optional dependency /chokidar/fsevents:
npm WARN notsup Not compatible with your operating system or architecture: fsevents@1.0.7

express.js파일에 express-session을 middleware로 등록합니다.

const session = require('express-session');
app.use(session({
    secret: 'keyboard cat',
    resave: false,
    saveUninitialized: false,
    cookie: { secure: false }
}));

지정되는 parameter는 다음 의미를 갖습니다.

  • secret: session의 암호화에 사용되는 key값입니다.
  • resave: requrest가 요청되었을때, 기존의 session이 존재하는 경우 다시 저장할 필요가 있는지를 확인하는 option입니다. express-session에서 제공되는 touch를 구현하고 있는지를 확인해보면 됩니다. 기본적인 MemoryStore의 경우, touch가 구현되어 있기 때문에 설정할 필요가 없습니다.
  • saveUninitialized: 설정하지 않는 경우, Session이 저장되지 않습니다. 일반적인 로그인을 구성할 때, false로 지정해줘야지 됩니다. 기본값은 true이며 모든 초기화되지 않은 session은 저장되게 됩니다.
  • cookie.secure: https로 호출되는 경우에만 session cookie를 생성하는 option입니다. 실 production에 유용한 option으로 테스트나 개인 공부를 할 때는 false로 지정해주시면 됩니다.
  • cookie.maxAge: cookie가 만료되는 시간을 설정합니다.

login url 구현

DB를 사용하지 않는 최소한의 application이기 때문에 사용자가 입력한 username을 그대로 session의 username으로 넣어주는 code를 구성합니다.

'use strict';

const express = require('express'),
  router = express.Router();

router.get('/login', (req, res) => {
  const session = req.session;
  session.username = req.query.username;
  return res.json(session);
});

module.exports = (app) => {
  app.use('/user', router);
};

원래는 login의 경우, post로 구현해야지 되는 것이 원칙이지만, 테스트를 위한 application이고, browser에서 그대로 구현하기 위해서이니 그냥 get으로 구현하였습니다. 이제 저장된 username을 얻어내야지 됩니다. 이제 browser에서는 저장된 username이 계속 유지가 되어야지 됩니다.

router.get('/info', (req, res) => {
  return res.json(req.session);
});

이제 url을 두개를 연달아 browser에서 실행하면 다음과 같은 결과를 볼 수 있습니다.

{
    "cookie":{"originalMaxAge":null,"expires":null,"secure":false,"httpOnly":true,"path":"/"},
    "username":"abcdef"
}

Session store의 변경

고성능, 고가용성을 구현하기 위해서는 기본적으로 nodejs는 cpu의 core 숫자와 동일한 process숫자로 실행되어야지 됩니다. 이 사실은 지금 application의 경우 심각한 문제를 가지고 있는 것을 의미합니다. process간의 memory 공유는 원칙적으로 되지 않는거니까요. (hacking이나 memory editor의 경우 다르겠지만, web application의 기본 기능과는 거리가 있는 상황이니.)

그래서, nodejs application에서는 공용으로 사용되어야지 되는 정보는 file이나 redis, db와 같은 공용 IO에 저장해서 모든 process가 공유하는 형식으로 사용하게 됩니다. 또한, nodejs application은 application을 종료했다가 다시 올리는 경우가 왕왕 있습니다. express-session의 기본값인 MemoryStore는 기본적으로 production에 올릴수 없는 설정입니다.

성능은 안좋고, multiple instance에서는 절대로 사용할 수 없는 방법이지만, File에 session정보를 저장하기 위해서 FileStore를 이용해보도록 하겠습니다. FileStore와 Session간의 interface를 하는 session-file-store를 설치하면 FileStore가 같이 설치됩니다.

npm install session-file-store --save
babel-express-app@0.0.1 /home/ykyoon/dev/code/babel-express-app
├── filestore@0.1.3 
└─┬ session-file-store@0.0.24 
  ├── bagpipe@0.3.5 
  ├─┬ fs-extra@0.26.5 
  │ ├── jsonfile@2.2.3 
  │ ├── klaw@1.1.3 
  │ └─┬ rimraf@2.5.2 
  │   └── glob@7.0.0 
  └── retry@0.8.0

FileStore를 Session에 설정합니다. 코드는 다음과 같습니다.

const session = require('express-session');
const FileStore = require('session-file-store')(session);
app.use(session({
  secret: 'keyboard cat',
  resave: false,
  saveUninitialized: false,
  cookie: { secure: false },
  store: new FileStore()
}));

설치후, folder를 보면 session라는 folder가 생성되어 있는 것을 볼 수 있습니다. 이제 위에서 실행했던 URL을 다시 실행해보면 File이 하나 생성되어 있는 것을 볼 수 있습니다. 파일을 열어보면, session의 정보가 그대로 저장되어 있는 것을 볼 수 있습니다.

정리

express에서 session을 구성하는 방법에 대해서 간단히 알아봤습니다. 또한 MemoryStore가 아닌 다른 Store를 이용하는 방법에 대해서도 간단히 알아 보았습니다. 실무에서는 대부분 redis를 이용하는 경우가 일반적이긴 하지만, 개발단에서 편하게 알아보기 위해서, File을 사용하는 경우도 꼭 나쁜 것은 아닙니다.


Posted by Y2K
,

mocha를 이용한 express application test 방법

yo-express를 이용한 application을 test 하는 방법을 한번 알아보고자합니다.

npm module 설치

web에 대한 test는 supertest를 사용하는 것이 좋습니다. 직관적인 사용에도 좋고, mocha에서 사용하기에도 매우 편합니다.

npm install supertest --save-dev

root에 test folder 생성

app.test.js 파일 생성

express application을 먼저 구동하는 것이 필요합니다. 이는 원 app.js에서 구성되는 express start와 sequelize model의 초기화 과정에 대한 process를 실행해야지 되는것을 의미합니다. test라는 상대경로로 옮겨왔기 때문에 경로의 depth가 달라지는 것에 주의할 필요가 있습니다. 그리고 test의 시작전에 항시 실행되어야지 되는 global before이기 때문에 before로 구성되어야지 됩니다.

'use strict';

before(function () {
  var express = require('express'),
    config = require('../config/config'),
    db = require('../app/models');
  var agent = require('supertest');

  var app = express();

  require('../config/express')(app, config);

  db.sequelize
    .sync()
    .then(function () {
      app.listen(config.port, function () {
        console.log('Express server listening on port ' + config.port);
      });
    }).catch(function (e) {
      throw new Error(e);
    });
  console.log('before');
  global.app = app;
  global.agent = agent(app);
});

mocha로 test running

이제 mocha를 이용해서 다음 command로 실행이 가능합니다.

mocha test/app.test.js test/**/*.test.js

mocha.opts 설정

좀더 편한 test 환경을 만들기 위해서 mocha.optsapp.test.js를 넣어두면 편합니다.

--timeout 5000
--full-trace
test/app.test.js

이 때, mocha.opts파일의 위치는 다음과 같이 구성합니다.

└── test
    ├── app.test.js
    ├── controllers
    ├── mocha.opts
    └── models

controller test code 작성

controller test code는 다음과 같이 구성될 수 있습니다. app.test.js에서 supertest의 request를 global.agent로 지정했기 때문에, test에서는 언제나 agent를 접근 가능해서 test code를 작성하기 좀 더 편해집니다.

'use strict';

var assert = require('assert');

describe('func01', function () {
  it('test', function () {
    console.log('abc');
  });

  it('call /', function (done) {
    agent
      .get('/')
      .expect(200)
      .end(function (err, res) {
        // console.log(res);
        done(err);
      });
  });

  it('call / - 2', function (done) {
    agent
      .get('/')
      .expect(500)
      .end(function (err, res) {
        done();
      });
  });
});

Summary

nodejs는 compile 언어가 아니기 때문에, 실행되기 전까지 정상적인 code를 작성했는지를 헛갈릴 수 있습니다. 개인적으로는 원론적으로 test를 거치지 않으면 compile 언어 역시 마찬가지로 문제가 있다고 생각합니다만. test를 잘 만들어주는 것과 test를 잘 할 수 있도록 해주는 것은 자신 뿐 아니라 다른 개발자들에게도 매우 좋은 일들입니다. 이건 정말 잘 할 필요가 있다고 생각합니다.

mocha를 이용한 test는 매우 유용합니다. 만약에 nodejs를 이용한 개발을 하고 있다면, test를 어떻게 할지에 대한 고민을 꼭 해보시길 바랍니다.

Posted by Y2K
,

jenkins를 이용해서 nodejs project의 CI를 구성하는 방법은 다음과 같다.
기본적으로 mocha와 istanbul을 이용해서 xunit.xml과 codecoverage.xml 파일을 생성한다.

Test 결과의 저장 - xunit.xml 생성

junit의 결과값과 동일한 xml 파일을 생성한다. mocha에 다음과 같은 plugin을 설치한다.

npm install spec-xunit-file@0.0.1-3

위 plugin을 설치하면 mocha에서 xunit.xml 파일을 생성해줄 수 있다. unit test와 동시에 xunit.xml 생성을 위해서는 다음과 같은 command를 실행한다.

mocha test/**/*.test.js -R spec-xunit-file

이제 root directory에 xunit.xml 파일이 생성된 것을 볼 수 있다.

CodeCoverage 테스트 실행

istanbul을 먼저 설치한다.

npm install -g istanbul

istanbul에서 mocha를 이용한 test coverage를 해준다.

istanbul _mocha test/**/*.test.js -R node_modules/spec-xunit-file

위 command는 기본적으로 mocha를 실행시키는것이기 때문에, Code Coverage를 수행하는 경우에는 Test결과는 같이 생기게 된다. 주의점은 package dependency로 spec-xunit-file을 넣어주는 것이다. 이는 istanbul에서는 기본적으로 mocha의 기본적인 reporter만을 인식하게 되고 나머지는 직접 경로를 통해서 얻어야지 되는 단점이 있기 때문이다.

이제 생성된 결과를 보면 coverage라는 폴더가 생겨있다. 이 폴더를 지정해서 Cobertura xml을 생성해줘야지 된다. xml 생성은 다음 cmd를 통해서 생성가능하다.

istanbul report cobertura --root coverage --dir coverage

done이 나오면 cobertura-coverage.xml이 있는 것을 확인할 수 있다.

jenkins에 project의 등록

jenkins에 등록하기 위해서는 위 2가지 process를 순차적으로 실행시키면 된다. 그렇지만 문제가 두가지가 있다.

  1. spec-xunit-file를 상대경로를 사용할 수 없다. - jenkins에서 bash를 따로 실행시키는 것은 tomcat의 절대경로에 따라서 변경이 되게 된다. 따라서 tomcat의 경로를 넣어줘야지 되는 귀찮음이 생긴다.
  2. xunit.xml 파일의 절대경로를 얻어내야지 된다. - 역시 위와 같은 이유이다. spec-xunit-file의 경우 process path/xunit.xml파일로 생성되기 때문에 이에 대한 절대 경로를 얻어서 넣어줘야지 된다.

위와 같은 문제를 해결하는 npm package인 jenkins-mocha를 이용하는 것이 더 좋다고 생각된다.
jenkins-mochaistanbulmocha에 대한 wrapper기능만을 제공하지만, unit.xml파일과 coverage를 jenkins prorject folder에 생성해주는 역활을 담당하게 된다.

npm을 이용해서 설치후, package.json 파일에 다음 내용을 추가한다.

"scripts": {
    "test": "jenkins-mocha test/**/*.test.js"
},

등록 절차

freestyle project로 신규 project로 등록을 한다.

feeStyleProject

Build Process 등록

Execute Shell을 선택하고, 다음 Step들을 추가한다.

  1. npm install : npm module을 다운 받는 process
  2. npm test : istanbul, mocha를 이용해서 test를 구동하고 coverage를 계산하는 process
  3. istanbul report cobertura --root artifacts/coverage --dir artifacts/coverage : coverage.json을 cobertura.xml로 변경하는 process
Report file 등록

JUnit test report와 Code Coverage를 위한 Cobertura Coverage Report를 등록시키면 된다.
jenkins-mocha에서 생성되는 기본 report 파일들의 위치는 다음과 같다.

  1. xunit.xml : artifacts/xunit.xml
  2. Cobertura xml report: artifacts/coverage/cobertura-coverage.xml

위 두 파일을 Post Build Process에 다음과 같이 등록하면 된다.

Completed

이제 build를 하면 다음과 같은 결과를 볼 수 있다.

nodejs application은 test가 일반 java application보다 빠르게 끝나는 것이 특징이고, 무엇보다 TDD나 BDD를 사용하기가 좀 더 용의한 언어구조를 가지고 있다. 좀 더 즐거운 개발을 할 수 있지 않을까 생각된다.

Posted by Y2K
,

nodejs (express) + mongodb in Heroku

전부터 알고 있던 cloud base 개발 환경인 heroku를 한번 사용해보기로 했습니다. 일단 nodejs로 만들어진 application 이고, express와 mongodb를 사용하고 있는데. 이와 같은 환경을 무료로 제공해주는 곳 중에서 가장 유명하고, 한번 써보고자 하는 욕구가 강한 서비스여서 접근해보기로 했습니다.

먼저, 배포할 application의 구성입니다.

  • nodejs
  • express
  • mongodb
  • google OAuth 이용

nodejs + mongodb application deploy

Heroku service 가입

email을 등록시켜주면, 가입신청 확인 mail이 날라옵니다. 클릭후, password 설정만 하면 완료됩니다.
완료 후, https://dashboard-next.heroku.com/ 에서 Management Account -> Billing에서 신용카드 정보를 넣어두는 것이 좋습니다. 추후 설치할 addon에서 신용카드 정보가 없으면 진행할 수 없습니다.

Heroku toolbelt 설치

https://toolbelt.heroku.com/ 에서 환경에 맞는 toolbelt를 설치합니다. 저는 지금 linux를 설치하고 있기 때문에, linux 용 toolbelt를 설치한 기준으로 아래 글을 계속 이어가도록 하겠습니다.

설치후, heroku cmd를 사용할 수 있습니다.

Heroku toolbelt 인증 정보 + ssh public key 전송

heroku toolbelt를 설치하면 heroku cmd를 사용해서 인증정보를 입력합니다. heroku service에 가입한 email과 password를 넣어주면 됩니다. 그리고 ssh public key를 heroku에 등록시켜 이제는 heroku email/password가 아닌 ssh key인증을 이용해서 heroku cmd를 사용할 수 있습니다.

$> heroku login
Enter your Heroku credentials.
Email: 
Password:
Could not find an existing public key.
Would you like to generate one? [Yn]
Generating new SSH public key.
Uploading ssh public key /Users/adam/.ssh/id_rsa.pub

Application의 준비

기본적으로 heroku toolbelt를 이용한 application의 배포는 git를 이용합니다. local 또는 http://github.com 에서 제공되는 git repository에 project가 등록되어 있어야지 됩니다.

java의 pom.xml이나 build.gradle과 같은 기능을 하는 것이 package.json 입니다. 여기에 모든 dependency가 기록되어있어야지 됩니다.

그리고, scripts 항목의 start에 반드시 application의 실행 node command를 넣어줘야지 됩니다. 다음은 scripts 항목의 예시입니다.

  "scripts": {
    "start": "node app.js",
    "test": "grunt jasmine"
  }

개인적으로 이부분에서 실수를 한것이, 제 개발환경이 express가 global로 설치가 되어있습니다. 그래서 express가 package.json에 등록되어있지 않았습니다. 이렇게 되는 경우, dependency 문제로 인하여 application이 정상적으로 동작하지 않습니다. 반드시 package.json 정보만으로 application이 구동될 수 있어야지 됩니다.

git repository와 heroku repository 간의 연결

git repository directory안에서 다음 command를 실행합니다.

$>  heroku create --http-git
Creating sharp-rain-871... done, stack is cedar-14
http://sharp-rain-871.herokuapp.com/ | https://git.heroku.com/sharp-rain-871.git
Git remote heroku added

위 command의 결과, git는 remote repository를 하나 더 만들게 됩니다. 이 remote repository에 우리가 appliation을 배포하면 heroku에서 running되는 application을 구성하게 되는 것입니다.

위 console 창의 결과는 sharp-rain-871이라는 application의 이름을 갖게 됩니다. 이 이름은 unique 한 결과이며, 변경을 원하는 원하는 경우, heroku apps:rename 명령어를 통해 변경할 수 있습니다. 모든 action은 위 git repository directory 안에서 실행되어야지 됩니다.

git repository에서 heroku repository로 deploy

git repository directory안에서 다음 command를 실행합니다.

$> git push heroku master
Counting objects: 7, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 386 bytes | 0 bytes/s, done.
Total 3 (delta 2), reused 0 (delta 0)
remote: Compressing source files... done.
remote: Building source:
remote: 
remote: -----> Node.js app detected
remote: -----> Requested node range:  0.10.33
remote: -----> Resolved node version: 0.10.33
remote: -----> Downloading and installing node
remote: -----> Restoring node_modules directory from cache
remote: -----> Pruning cached dependencies not specified in package.json
remote: -----> Exporting config vars to environment
remote: -----> Installing dependencies
remote:        
remote:        > sadari-app@1.0.0 postinstall /tmp/build_efb71b073a7c6b8520ad4613bd4ca2a3

deploy가 마치면 heroku open command를 통해서 browser를 이용해서 application이 구동되고 있는 것을 확인할 수 있습니다.

heroku는 cloud platform입니다. instance의 갯수를 제어할 수 있습니다. instance를 1개 이상 사용하는 경우, 비용이 발생하기 때문에 주의가 필요합니다.

$> heroku ps:scale web=1

bower를 이용한 javascript module dependency 처리

bower를 이용하는 경우, javascript를 각각의 repository에서 다운 받아 처리하게 됩니다. 따라서 bower components의 경우, repository에 올라가지 않는 것이 원칙입니다. 따라서, heroku에 배포된 application은 bower components가 하나도 없게 됩니다.

개발환경에서 bower install을 통해서 bower components들을 얻어주는 것과 같이 heroku에서도 이와 같은 action이 필요하게 됩니다. heroku의 nodejs 환경에는 아무런 환경이 없다고 생각해야지 됩니다. 이를 해결하기 위해서 bower 자체를 dependency에 추가해야지 됩니다.

npm install bower --save

bower를 추가후, package.json 파일의 scripts 항목에 다음을 추가한 후, heroku에 배포를 다시 진행합니다.

  "scripts": {
    "start": "node app.js",
    "test": "grunt jasmine",
    "postinstall": "bower install"
  },

이제 application을 정상적으로 설치하는 과정이 모두 마쳐졌습니다.

mongodb 설치

제가 만든 application은 mongodb를 사용하고 있습니다. mongodb는 heroku에서 addon 형태로 제공하고 있습니다. 500M 까지는 무료로 사용할 수 있으니, 아주 작은 application의 경우에는 언제나 무료로 사용할 수 있습니다. compose mongoDb에 대한 소개는 다음 URL에서 확인 가능합니다. https://devcenter.heroku.com/articles/mongohq

mongodb addon의 설치는 다음과 같습니다.

$> heroku addons:add mongohq

여기서, billing information이 설정되어 있지 않으면 addon의 설치는 진행되지 않습니다. 주의가 필요합니다.

mongodb 연결

compose mongodb를 설치하면, ENV에서 db connection url을 확인할 수 있습니다. config 값의 확인은 다음과 같습니다.

$> heroku config
=== my-sadari Config Vars
MONGOHQ_URL: mongodb://heroku:Xucs9HmJrBKBr6bPkO6CM044_G6oVv5gV0bN0dxcXS-LYHWmKdWXaPnqcYwa7MQn1iVyOmbrP4BRT8QkdJcxlQ@dogen.mongohq.com:10058/app31973614

이제 이 값을 이용하는 nodejs code는 다음과 같습니다.

db: process.env.MONGOHQ_URL

mondodb data import/export

개발환경에서의 mongodb의 값을 heroku에 옮겨야지 되는 경우는 자주 발생합니다. 기본적인 key값이라던지, 초기값들을 넣어주는 작업들은 반드시 필요한 작업들입니다. 이러한 작업을 application에서 해주는 것도 좋지만, 개발중인 mongodb의 값을 eport 시킨 후, import 시켜주는 것이 보다 더 편합니다.

이를 위해서는 먼저 원 데이터의 export가 필요합니다.
기본적인 export command는 다음과 같습니다.

mongoexport --collection <collection> --out <collection.json>

각 collection 당 json 파일을 만들어야지 되는 것을 까먹으면 안됩니다.
이제 heroku mongodb로 값을 export 시켜야지 됩니다. heroku deshboard로 가면 설치된 addon들을 모두 볼 수 있습니다. (아래의 무료 Plan은 이제 없습니다. 기존에 사용하고 있던 사람들은 계속 사용가능합니다.)

click 하면 Compose MongoDB configuration 화면을 볼 수 있습니다.

여기서 Admin으로 들어가서 새로운 사용자를 하나 만들어줍니다.

이제 만들어진 사용자로, import를 진행할겁니다. 이 관리자 console에서 remote url을 확인 할 수 있습니다.

위 정보를 이용해서 mongodb import는 다음 command를 이용해서 처리가 가능합니다.

mongoimport --host dogen.mongohq.com --port 10058 --username <user> --password <pass> --collection <collection> --db app31973614 --file <file.json>

Summary

heroku에 nodejs를 이용한 appliation 배포에 대해서 알아봤습니다. 제가 속한 팀에서의 점심후의 사다리타기 결과를 모아둔 heroku app을 공개해뒀습니다. ㅋㅋ (http://my-sadari.herokuapp.com) heroku는 300M까지는 무료입니다. 이 점에서 nodejs의 가벼운 code의 강점이 보이는 것 같습니다. 아무리 가벼운 웹이라도 은근히 용량이 되는 경우가 많으니까요.

그럼 모두 Happy Coding!


Posted by Y2K
,

nodejs + express를 이용한 Google OAuth 연동

OAuth는 근간에 거의 모든 WebSite에서 사용되고 있는 인증방법입니다. 회원 가입절차를 빠르게 할 수 있으며, 개인정보에 대한 관리 이슈를 피할 수 있어, 많이들 사용되는 방법입니다. nodejs를 이용한 OAuth 인증 방법에 대해서 알아보도록 하겠습니다.

npm package 설치

npm install passport
npm install passport-google-oauth
npm install cookie-session
  • passport: OAuth를 지원하기 위한 base package 입니다. twitter, facebook, google에서 지원하고 있는 OAuth를 모두 지원합니다.
  • passport-google-oauth: passport를 기반으로 하는 google-oauth 지원 package입니다.
  • cookie-session: express에서 session을 지원하기 위한 package입니다. express-session으로 대체해서 사용 가능합니다.

google auth api 설정

https://console.developers.google.com 에서 Google에서 제공하는 API를 이용하는 project를 생성할 수 있습니다. 새로운 project를 생성한 후, API 및 인증 > 사용자인증정보에서 새클라이언트 ID 만들기를 통해서 새로운 API를 만들어줍니다. 여기서 중요한 설정은 다음과 같습니다.

  • 승인된 javascript 원본 : 인증을 요청할 url을 넣습니다. 개발자 환경인 http://localhost:3000 과 같은 표현 역시 가능합니다.
  • 승인된 redirection URL : 인증이 완료된 후, redirect될 url을 넣어줍니다.

만들어진 웹어플리케이션용 Client ID에서 이제 3개의 정보는 우리가 작성할 application에서 사용해야지 됩니다.

  • 클라이언트 ID
  • 클라이언트 보안 비밀
  • URI 리디렉션

Login uri handling

사용자가 OAuth를 통해서 인증될 URL을 설정합니다. express controller code에 해당되는 내용입니다.

module.exports = OAuthController;

function OAuthController(app) {
  var passport = require('passport');
  app.use(passport.initialize());
  app.use(passport.session());
  // Google OAuth를 사용하는데 필요한 객체를 선언합니다.
  var GoogleStrategy = require('passport-google-oauth').OAuth2Strategy;

  passport.serializeUser(function(user, done) {
    done(null, user);
  });
  passport.deserializeUser(function(obj, done) {
    done(null, obj);
  });

  passport.use(new GoogleStrategy({
    clientID: '클라이언트 ID',
    clientSecret: '클라이언트 보안 비밀',
    callbackURL: 'URI 리다렉션'
  },
  function(accessToken, refreshToken, profile, done) {
    //
    // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
    // req.session.passport 정보를 저장
    // done 메소드에 전달된 정보가 세션에 저장된다.
    // profile을 이용해서 사용자 정보를 DB에 넣는 등의 작업에 활용할 수 있다.
    // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
    //
    return done(null, profile);
  }));

  // 인증 URI : uri link를 이곳에 넣어주면 됩니다.
  app.get('/auth/google', passport.authenticate('google', {
    scope: [
      'https://www.googleapis.com/auth/userinfo.profile',
      'https://www.googleapis.com/auth/userinfo.email'
    ]
  }));

  app.get('/oauth2callback', passport.authenticate('google', {
    successRedirect: '/auth/successed',
    failureRedirect: '/auth/failure'
  }));

  app.get('/logout', function(req, res) {
    //
    // passport 에서 지원하는 logout 메소드이다.
    // req.session.passport 의 정보를 삭제한다.
    //
    req.logout();
    req.session = null;
    res.redirect('/');
  });
};

Summary

Passport를 통해서 Google OAuth에 접근하는 방법은 많은 예시 code들이 있고, 거의 유사한 형태를 갖습니다. 위 controller code를 그대로 copy & paste로만도 간단히 Google OAuth를 구현 가능합니다.


Posted by Y2K
,

jasmine-node를 이용한 nodejs test

설치

다음 npm package를 설치해서 처리한다.

sudo npm install -g jasmine-node

Application의 구성

기본적으로 yo에서 제공되는 application 구조를 그대로 가지고 가는 것이 좋다. (express application을 기준)

.
├── Gruntfile.js
├── app
│   └── lib
├── app.js
├── npm-debug.log
├── package.json
└── test
    └── calculator-spec.js

test code의 실행

일반적으로 root directory에서 다음 명령어를 이용해서 처리하면 된다.

jasmine-node --test-dir test --autotest --watch app test --color

simple test code

target이 되는 file의 위치가 app/lib/testservice.js 라고 할때, export되는 객체는 다음과 같이 구성될 수 있다.

exports.method1 = function() {
  return true;
};

exports.method2 = function() {
  return false;
};

javascript는 method 단위의 구성을 주목할 필요가 있다.

이때, 이 method1, method2를 test하는 code는 다음과 같이 작성가능하다.

var testservice = require('../app/lib/testservice.js');
describe('test testservice', function() {
  it('method1 called', function() {
    expect(testservice.method1()).toBe(true);
  });

  it('method2 called', function() {
    expect(testservice.method2()).toBe(false);
  });
});

simple test code 2

만약에 db를 접속해서 만들어지는 BL이 존재한다면 다음과 같이 구성한다. yo express-generator에서는 db connection이 app.js에서 구성되는데, 이를 따로 뽑아서 사용하면 db에 대한 service code test가 쉽다.
mongoDb에 접근하는 코드는 다음과 같다.

var mongoose = require('mongoose'),
    glob = require('glob'),
    path = require('path'),
    rootPath = path.normalize(__dirname + '/..');

var init = function() {
  mongoose.connect('mongodb://localhost/sadari');
  var db = mongoose.connection;
  db.on('error', function() {
    throw new Error('unable connect to database');
  });

  //glob.sync는 absolute directory를 기준으로 검색하기 때문에 반드시 path에 대한 정보가 필요하다.
  var models = glob.sync(rootPath + '/models/*.js');
  models.forEach(function(model) {
    require(model);
  });
};

exports.init = init;

위와 같은 db connection을 이용하는 간단한 CRUD service를 작성할 때, code는 다음과 같이 구성될 수 있다.

var mongoose = require('mongoose');
var Player = mongoose.model('Player');
var ObjectId = mongoose.Types.ObjectId;

var findAll = function(func) {
  return Player.find(function(err, players) {
    if(func) {
      func(players);
    }
  });
};

var save = function(name, defaultAmount) {
  var player = new Player({
    name: name,
    defaultAmount: defaultAmount
  });
  return player.save(function(err) {
    throw new Exception(err);
  });
};

var findOne = function(id, func) {
  var objectId = mongoose.Types.ObjectId(id);
  Player.findById(objectId, func);
};

var find = function(query, func) {
  Player.find(query, func);
};

var update = function(id, name, defaultAmount, func) {
  var objectId = mongoose.Types.ObjectId(id);
  Player.update(
    {_id: objectId},
    {
      $set: {
        name: name,
        defaultAmount: defaultAmount
      }
    }, { upsert: false, multi: true }, func);
};

exports.update = update;
exports.findOne = findOne;
exports.findAll = findAll;
exports.save = save;
exports.find = find;

이에 대한 test code는 다음과 같다.

var dbConnection = require('../app/lib/db-connect.js');
dbConnection.init();
var playerService = require('../app/lib/playerservice.js');

describe('mongoDb test', function() {
  it('playerService.findAll', function(done) {
    var conn = playerService.findAll(function(players) {
      expect(players.length).not.toBe(0);
      players.forEach(function(player) {
        expect(player.defaultAmount).not.toBe(0);
      });
      done();
    });
    expect(!!conn).toBe(true);
  });

  it('playerService.save', function() {
    var result = playerService.save('playerName', 10);
    expect(result).not.toBe(null);
  });

  it('playerService.find', function(done) {
    playerService.find({}, function(err, players) {
      expect(players.length).toBe(1);
      players.forEach(function(player) {
        console.log(player.name);
      });
      done();
    });
  });

  it('playerService.findOne', function(done) {
    var id = "5469751de83eaf6e34434c5e";
    playerService.findOne(id, function(err, player) {
      console.log(err);
      expect(!!err).toBe(false);
      expect(player).not.toBe(null);
      console.log(player);
      console.log(player.name);

      done();
    });
  });

  it('playerService.update', function(done) {
    var id = "5469751ae83eaf6e34434c55";
    playerService.update(id, "changedName2", 100, function(err, model) {
      expect(!!err).toBe(false);
      done();
    });
  });

  it('players list', function(done) {
    var mongoose = require('mongoose');
    var Player = mongoose.model('Player');
    var playersResult = null;
    Player.find(function(err, players) {
      expect(players.length).not.toBe(0);
      players.forEach(function(p) {
        expect(p.defaultAmount).not.toBe(0);
      });
      done();
    });
  });
});


Posted by Y2K
,

바이바이 마왕

시끌벅적 2014. 10. 28. 10:58

밤새 '민물장어의 꿈'을 계속 들으면서 출근을 했다.


중/고등학교때 음악을 많이 듣는 편은 아니였지만, 나를 힘들게 하던 여러 일들을 잠시 잊게 하고, 나아가는 힘을 주었던것들은 지금 생각해보면 책과 음악, 친구들이였던것 같다. 그 중에서 음악은 015B, NEXT 이정도가 가장 기억에 남는다. (전람회는 015B에서 나중에 파생되는 거나 다름 없으니까....)


그 중 하나가 갔다. 이렇게 갑자기. 그리고 이렇게 허무하게.


나이가 들면서, 그의 음악을 잘 안듣고 있던 것은 사실이지만...' 해에게서 소년에게'와 '먼 훗날 언젠가'는 노래방에서의 단골 주제가가 되었고, 힘들때 다시 한번씩 들어본던 음악이였는데...


잘가요 마왕. 고마웠어요 좋은 음악을 나에게 들려줘서.

그리고 앞으로도 고마울거에요. 당신이 이 음악들을 남겨줘서.


바이바이. R.I.P




Posted by Y2K
,
directive는 test하기에 가장 어려운 객체입니다. VIEW의 html element이 특정 HTML로 변경되는 process를 통과해야지 되며, 이 HTML에 event가 발생했을 때의 동작을 모두 테스트해야지 되기 때문입니다. 다음과 같은 directive code를 sample로 한번 알아보도록 하겠습니다.
angular.module('fmsmobilewebApp').directive('codeDropdown', function (ClassificationService, $timeout) {
  return {
    template: '<select class="ds-select" ng-model=ngModel ng-options="o.id as o.name for o in optionData" ></select> <button ng-click="load()">로드</button>',
    restrict: 'E',
    scope: {
      ngModel: '=',
      codeType: '@',
      parentCodeId: '@',
      styleValue: '@',
      emptyData:'@'
    },
    link: function postLink(scope, element, attrs) {
      scope.optionData = [];

      var init = function() {
        ClassificationService.getList(scope.codeType, scope.parentCodeId, listup);
      };

      scope.load = function() {
        init();
      };

      var listup = function(codes) {
        var items = [];
        if(scope.emptyData) {
          items.push({ text: scope.emptyData, value: ''});
        }
        for(var i = 0 ; i < codes.length ; i++) {
          items.push(codes[i]);
        }
        if(scope.ngModel == null || scope.ngModel == '') {
          scope.ngModel = items[0].value;
        }
        if(scope.styleValue != null && scope.styleValue != '') {
          $(element).find('select').attr('style', scope.styleValue);
        }
        scope.optionData = items;
        $timeout(function() {
          scope.$apply();
        });
      };
    }
  };
});

위 directive는 comboBox과 button을 표시하는 directive입니다. button을 click하면 load method가 호출되는 구조로 되어 있습니다.
scope의 형태를 한번 봐주시길 바랍니다.

이 directive는 다음과 같은 특징을 가지고 있습니다.

* Service에 대한 dependency를 갖는다.
* VIEW(html) 요소를 directive의 template으로 치환한다.
* controller의 scope의 value에 대한 bi-direction을 갖는 scope variable이 존재한다. (ng-model)
* button에 대한 외부 event가 존재한다.
  <button ng-click="load()">로드</button>

이와 같은 directive를 테스트 하기 위한 방법에 대해서 알아보도록 하겠습니다.

Service에 대한 dependency

directive는 생성되는 시점이 controller나 service와는 조금 다릅니다. controller는 URL의 호출시에 controller를 생성시키지만, directive의 경우에는 page의 loading이 다 마쳐진 이후에 $compile 과정을 통해서 directive가 생성되게 됩니다. 따라서, directive를 사용하기 위해서 필요한 html element, css의 선언이전에 dependency를 inject를 해야지 됩니다.

따라서, beforeEach에서 provider를 얻어내, service를 재정의해서 inject할 필요가 있습니다. 다음은 위 directive의 ClassificationService를 재정의하는 code입니다.

  beforeEach(module('fmsmobilewebApp', function($provide) {
    var classificationService = {
        getList: function(codeType, parentCodeId, func) {
          var codes = [];
          for(var i = 0 ; i < 20 ; i++) {
            codes.push({ id: 'id' + i.toString(), name: 'name-' + i.toString() });
          }
          if(func) {
            func(codes);
          }
          return true;
        }
      };
    $provide.value('ClassificationService', classificationService);
  }));

provider에 사용되는 ClassificationService를 setting해주는 형태입니다. 기본적으로 unit test는 whitebox test기법이기 때문에, 우리가 사용하는 service와 그 method에 대한 정보는 아는 것으로 가정하기 때문에 최소한으로 필요한 service code만을 재정의해서 만들어주는 것이 좋습니다.

VIEW(html)의 치환

지금까지 모든 angularJS의 unit test는 test 대상이 되는 객체를 얻어와서 test를 진행했습니다. 그렇지만 directive의 경우에는 좀 다릅니다. directive의 생성은 html이 있어야지 됩니다. html을 치환하는 과정을 compile이라고 합니다. 다음은 compile을 진행하는 과정을 test code에서 나타냅니다.

  it('code dropdown test code', inject(function ($compile) {
    element = angular.element('<code-dropdown ng-model="ngModel" code-type="system" parent-code-id="0801"></code-dropdown>');
    element = $compile(element)(scope);
    // display directive's compiled html
    console.log(element.html());
  });

눈으로 html을 보게 되면, 정상적인 test code라고 할 수 없습니다. 다음과 같이 만들어져야지 되는 html element의 숫자를 확인하는 것이 좋습니다.

    element = angular.element('<code-dropdown ng-model="ngModel" code-type="system" parent-code-id="0801"></code-dropdown>');
    element = $compile(element)(scope);
    //sync parent scope and directive scope
    scope.$digest();
    expect(directiveScope.optionData.length).not.toBe(0);
    expect(element.find('option').length).toBe(20);

scope의 binding

directive의 scope의 value는 크게 두가지 type이 있습니다. bi-direction으로 동작하는 scope의 property와 내부에서만 사용되는 isolateScope가 바로 그것입니다. 기본적으로 directive는 scope의 type이 =으로 지정된 경우를 제외하고 isolateScope를 생성하게 됩니다. directive내부의 scope를 접근하기 위해서는 기존 test code에서 사용한 scope로는 접근이 불가능합니다. 이 부분이 기존의 controller code와의 가장 큰 차이가 됩니다.

directive의 scope를 얻어내는 test code는 다음과 같습니다.

    element = angular.element('<code-dropdown ng-model="ngModel" code-type="system" parent-code-id="0801"></code-dropdown>');
    element = $compile(element)(scope);
    //sync parent scope and directive scope
    scope.$digest();

    //access directive scope
    expect(element.isolateScope).toBeDefined();
    expect(element.isolateScope()).not.toBeNull();

    var directiveScope = element.isolateScope();
    expect(directiveScope.ngModel).toBe('id0');

    //change parent scope value
    scope.ngModel = 'id2';
    scope.$digest();
    expect(directiveScope.ngModel).toBe('id2');

    directiveScope.ngModel = 'id1';
    directiveScope.$digest();
    expect(scope.ngModel).toBe('id1');

마지막 6 line을 주의깊게 볼 필요가 있습니다. parent scope value와 bi-direction 되어 있기 때문에 parent scope의 값을 변경하거나, directive의 scope 값을 변경하는 것 모두 양쪽에 영향을 주고 있는 것을 볼 수 있습니다.

button과 같은 event가 binding 되어 있는 경우

button과 같은 사용자의 event가 binding이 되어 있는 경우에는 event에 대한 처리를 code로 구성할 수 있습니다.
다음 test code를 보는 것이 설명이 더 빠릅니다.

    //Click Event
    element.find('button')[0].click();
    expect(directiveScope.optionData.length).not.toBe(0);
    expect(element.find('option').length).toBe(20);

element에서 event를 handling시킬 button을 찾아낸 후, click event를 전달할 수 있습니다.

추가: 외부 html을 template으로 사용하는 경우

directive를 작성할 때, template을 html 파일로 따로 관리하는 경우가 많습니다. directive의 크기가 너무 크거나, html로 관리하는 것이 관리 focus상 유리할 때 종종 이런 방법을 택하지요. 외부 html을 사용하는 경우, directive code는 다음과 같이 구성됩니다.

angular.module('fmsmobilewebApp').directive('loading', function () {
  return {
    templateUrl:'templates/loading.html',
    restrict: 'E',
    require: '^ngModel',
    transclude: true,
    scope: {
      ngModel: '='
    }

  };
});

여기서 문제가 되는 것은 templateUrl의 위치는 yo기준으로 app folder위치의 상대값입니다. 그렇지만 우리가 grunt test를 통해서 테스트를 실행하는 path는 ../app 이 됩니다. 따라서, 저 url은 절대로 로드할 수 없는 위치가 됩니다. 이 경우, 다음과 같은 karma.conf.js 수정이 필요합니다.

먼저, loading 해야지 되는 html을 files에 추가합니다.

    files: [
      // bower components
      'app/bower_components/jquery/dist/jquery.js',
      'app/bower_components/angular/angular.js',
      'app/bower_components/angular-mocks/angular-mocks.js',
      'app/bower_components/angular-resource/angular-resource.js',
      'app/bower_components/angular-cookies/angular-cookies.js',
      'app/bower_components/angular-sanitize/angular-sanitize.js',
      'app/bower_components/angular-route/angular-route.js',
      'app/bower_components/angular-touch/angular-touch.min.js',
      'app/bower_components/lodash/dist/lodash.min.js',
      'app/bower_components/mobile-angular-ui/dist/js/mobile-angular-ui.min.js',
      'app/bower_components/restangular/dist/restangular.min.js',
      'app/bower_components/angular-local-storage/angular-local-storage.min.js',
      // 3rd party library
      'app/library/kendo/js/kendo.all.min.js',
      // Test target
      'app/scripts/*.js',
      'app/scripts/**/*.js',
      // Test code
      'test/mock/**/*.js',
      'test/spec/**/*.js',
      // Template Files
      'app/templates/*.html'
    ],

다음에, preprocessor 단계에서 html로 로딩된 항목을 javascript로 변경시켜, cache에 저장합니다.

    preprocessors: {
      'app/templates/*.html': 'html2js'
    },

이제 저장된 html은 $templateCache를 통해서 얻어올 수 있게 됩니다. test code의 beforeEach에서 다음과 같이 처리해줍니다.

  beforeEach(inject(function($templateCache) {
    template = $templateCache.get('app/templates/loading.html');
    expect(template).not.toBeNull();
    $templateCache.put('templates/loading.html', template);
  }));

$templateCache에서 먼저 로딩된 template을 읽어주고, directive에서 사용하는 templateUrl 값에 그 값을 넣어주면, directive가 $compile될 때 templateCache값을 참고해서 처리하게 됩니다.

Summary

지금까지 angularJS를 이용한 web application의 unit test 방법에 대해서 알아봤습니다. 약간의 정리를 하자면, 다음 원칙을 지켜가면서 test code를 작성하면 좋습니다. 이는 개발 code 작성도 비슷한 느낌이 될 수 있을것입니다.

  1. filter, non HTTP service 부터 test 및 code를 작성합니다.
  2. HTTP service의 경우, 호출되는 URL과 parameter에 대한 test를 작성합니다.
  3. HTTP service의 경우, input이 json type인 경우에는 input data에 대한 validator를 따로 작성해서 test하는 것이 좋습니다.
  4. Controller는 사용자 scenario를 정해서 TDD(test driven development)로 작성하는 것이 좋습니다.

제일 중요한 것은 unit test는 white box test입니다. 개발자가 내용을 다 안다는 것을 가정하고 있습니다. 최종적인 UI에 대한 test는 반드시 하는 것이 좋습니다. unit test는 UI에 대한 통합 테스트를 진행하기 쉽게 좀더 신경쓸 내용들을 줄이는 용도로 사용하는 것이지, UI에 대한 통합 테스트를 대치할 수는 없습니다.

모두들 Happy coding 하세요.


Posted by Y2K
,
이번에는 Controller입니다. Controller는 view에서의 사용자의 action에 따른 scenario가 정해지게 됩니다. scenario에 따른 test code를 작성해주는 것이 가장 좋은 test code가 됩니다.
* page가 처음 load되면 list가 호출되어 기본 Data가 Loading된다. (AsService.list()가 호출)
* search button을 click시, list() method가 호출된다. (AsService.list()가 호출)
* startDate/endDate 값이 변경되면 list method가 호출된다.
* save button을 click시, confirm box가 표시되며, input값이 모두 정상적으로 입력되어 있지 않다면 error가 표시된다.

첫 case부터 시작해보도록 하겠습니다.

page가 처음 load되면 list() method가 호출된다.

page가 처음 load되는 것은 controller가 처음 생성되는 것을 의미합니다. 이는 beforeEach()에 대한 test code가 필요한 것을 의미합니다. list가 호출이 될 때, REST API를 호출하는 것을 가정하고 있기 때문에 REST API의 응답에 대한 mock response를 넣어주는 것이 필요합니다.

controller를 생성하기 위한 beforeEach와 createController 의 코드는 다음과 같이 구성될 수 있습니다.

  beforeEach(inject(function ($controller, $rootScope, _DateService_, $httpBackend) {
    httpBackend = $httpBackend;
    scope = $rootScope.$new();
    DateService = _DateService_;
    controller = $controller;
    OmAsListCtrl = createController();
  }));

  var createController = function() {
    var response = {"ok":true,"message":"api call completed","date":1413173458673,"data":[{"request":"김요청자","operators":"","requestPhoneNumber":"0212342341","receiptDate":"2014-08-27","name":"오피스를 더 좋게 해주세요.","receiptionPhoneNumber":"03121342134","location":"없음","id":"30221","receiption":"김접수자","departments":"관리팀,건축팀,관제팀,기계팀,소방팀","department":"-","status":"접수"},{"request":"박네임","requestPhoneNumber":"01015263748","departmentId":"0401","receiptDate":"2014-05-05","name":"13층 환기 안됨","receiptionPhoneNumber":"01056781234","location":"13층","id":"6","receiption":"이성명","departments":"관리팀,기계팀","department":"관리팀","status":"민원완료"}]};
    httpBackend.when('GET', /\/fms-api\/om\/as\/list?\W*/).respond(response);
    var ctrl = controller('OmAsListCtrl', {
      $scope: scope
    });
    scope.$digest();
    httpBackend.flush();
    expect(scope.items.length).not.toBe(0);
    return ctrl;
  };

scope.items의 갯수를 확인하고, 정확히 데이터가 matching되고 있는 것을 확인하면 load의 test code는 완료됩니다.

search button을 click시, list() method가 호출된다.

list button을 click하는 것은 $scope.list()가 호출되는 것을 의미합니다. 호출후에 items의 갯수를 확인해보는 것이 가장 쉬운 확인 방법입니다. test를 보다 쉽게 작성하기 위해서 list를 호출하기 전에 items를 초기화 시켜버린 후에 list를 호출한 후의 값을 비교해보면 확실히 알 수 있을것입니다.

  it('search button을 click시, list() method가 호출된다.', function() {
    scope.items = [];
    var response = {"ok":true,"message":"api call completed","date":1413173458673,"data":[{"request":"김요청자","operators":"","requestPhoneNumber":"0212342341","receiptDate":"2014-08-27","name":"오피스를 더 좋게 해주세요.","receiptionPhoneNumber":"03121342134","location":"없음","id":"30221","receiption":"김접수자","departments":"관리팀,건축팀,관제팀,기계팀,소방팀","department":"-","status":"접수"},{"request":"박네임","requestPhoneNumber":"01015263748","departmentId":"0401","receiptDate":"2014-05-05","name":"13층 환기 안됨","receiptionPhoneNumber":"01056781234","location":"13층","id":"6","receiption":"이성명","departments":"관리팀,기계팀","department":"관리팀","status":"민원완료"}]};
    httpBackend.when('GET', /\/fms-api\/om\/as\/list?\W*/).respond(response);
    scope.list();
    httpBackend.flush();
    expect(scope.items.length).not.toBe(0);
  });

startDate/endDate값이 변경되면 list method가 호출된다.

이는 spyOn을 이용하면 쉽게 처리가 가능합니다. 다음과 같은 test code를 작성할 수 있습니다.

  it('startDate가 변경되면, list가 호출되어야지 된다.', function() {
    spyOn(scope, 'list');
    scope.searchItem.startDate = '2015-05-01';
    scope.$digest();
    expect(scope.list).toHaveBeenCalled();
  });

  it('endDate가 변경되면, list가 호출되어야지 된다.', function() {
    spyOn(scope, 'list');
    scope.searchItem.endDate = '2015-05-01';
    scope.$digest();
    expect(scope.list).toHaveBeenCalled();
  });

save button을 click시, confirm이 표시되고, confirm true인 경우에 AsService.regist method가 호출된다.

confirm, alert은 browser가 제공하는 base dialog method입니다. 만약에 우리가 test code를 실행할때마다 dialog가 나오고, 그걸 수동으로 눌러줘야지 된다면 이는 매우 걸리적 거리는 일이 됩니다. 따라서, 이 method들을 override 시킬 필요가 있습니다. angularJS는 이를 위해서 $window를 제공하고 있습니다. 우리가 $window.confirm, $window.alert method를 override 시켜서 사용하면 confirm에서 항상 true만 선택하게 할 수 있습니다.

  beforeEach(inject(function ($controller, $rootScope, _$routeParams_, _$location_, _$window_) {
    routeParams = _$routeParams_;
    routeParams.id = '10';
    scope = $rootScope.$new();
    controller = $controller;
    window = _$window_;
    window.confirm = function(message) {
      console.log(message);
      return true;
    };
  }));

routeParam의 값에 따라 다른 동작을 하는 controller의 테스트

위 scenario에 없는 parameter값에 따라 다른 동작을 하는 controller를 test하는 방법입니다. 이는 기본적으로 $routeParam에 값을 설정시키고 나서 controller를 생성시켜주면 됩니다. 다음과 같은 test code pattern을 적용하면 됩니다.

  function createCtrl(ymd, type) {
    if(ymd) {
      routeParams.ymd = ymd;
    }
    if(type) {
      routeParams.type = type;
    }

    ctrl = controller('EmDailymeterListCtrl', {
      $scope: scope,
      $routeParams: routeParams,
      Dailymeterservice: service
    });
    if(ymd == null || type == 'list') {
      httpBackend.expectGET(/\/fms-api\/em\/dailymeter\/list?\W*/).respond(200, {});
    } else {
      httpBackend.expectGET(/\/fms-api\/em\/dailymeter\/new?\W*/).respond(200, {});
    }
    scope.$digest();
    httpBackend.flush();
    return ctrl;
  }


Posted by Y2K
,