잊지 않겠습니다.

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
,

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
,
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
,

service (non-HTTP)

angularJS의 service code는 일반적으로 REST API에 대한 호출을 하고, 그 결과를 받는 것을 담당하지만, 값의 변경이나 특정값들의 display 형식으로의 변경등을 service로 만들어서, 코드의 응집력을 높일 수 있습니다. 섭씨온도를 화씨온도로 바꾸는 등의 계산 로직도 역시 이 영역에 들어가게 됩니다.

이러한 service code의 테스트는 다음 항목을 확인하면 됩니다.

input 값에 대하여 output 값이 예상대로 나왔는지. 
> toBe(value) 구문을 이용합니다.

기본적으로 matcher를 사용하는 것이 point입니다. 예상한 값을 기준으로 원하는 output이 정상적으로 나오는지를 확인하는 것이 좋습니다.

service (with HTTP)

REST API를 사용하는 service code의 테스트 방법에 대해서 알아보도록 하겠습니다. 일단 REST API를 호출한다면 외부의 HTTP resource를 이용하게 됩니다. 외부의 자원을 이용한다는 것은 그 외부의 자원에 대한 통제권은 우리가 개발한 application에는 없다는 말과 동일합니다. 따라서, 우리가 REST API를 이용하는 service에서 확인할 내용은 다음 두가지입니다.

* 원하는 URL로 정상적으로 호출하고 있는지?
* 전달하는 parameter가 API 호출 규약에 맞는지?

여기서 두가지의 역활이 나뉘게 됩니다. 먼저 원하는 URL로 호출하는 것은 당연히 service가 하는 일입니다. 그런데, 전달하는 parameter가 호출규약에 맞는지는 어떻게 확인하는 것이 좋을까요. 우리가 Spring을 이용한 개발을 진행하였을때를 생각해보면 쉽게 결론이 나옵니다. Validator객체를 만들어서 사용하는 것입니다.

따라서, REST API를 호출하는 service의 코드는 두개의 객체로 나뉘어지면 테스트 및 개발이 원활하게 됩니다. 이와 같은 형태는 마치 Spring MVC에서의 Controller 객체와 Validator 객체를 따로 개발하는 것과 동일한 패턴을 가지고 오게 됩니다. 객체지향이라는 것이 궁극적으로는 서로간에 할 일을 명확히 구분지어주고 그 분류대로 일을 진행하는 것이니까요.

먼저, 간단한 validator code는 다음과 같습니다.

'use strict';

/**
 * @ngdoc service
 * @name fmsmobilewebApp.ResultRegisterValidatorService
 * @description
 * # ResultRegisterValidatorService
 * Service in the fmsmobilewebApp.
 */
angular.module('fmsmobilewebApp').service('ResultRegisterValidatorService', function ResultRegisterValidatorService() {
  var self = this;

  self.validateStatus = function(data) {
    return angular.isDefined(data.status);
  };

  self.validateDepartment = function(data) {
    return angular.isDefined(data.departmentId);
  };

  self.validateOpResult = function(data) {
    var checked = angular.isDefined(data.opResult) &&
                  angular.isDefined(data.opResult.content) &&
                  angular.isDefined(data.opResult.startDate) &&
                  angular.isDefined(data.opResult.endDate);
    return checked;
  };
});

그리고 이에 대한 테스트 코드는 다음과 같습니다.

'use strict';

describe('Service: ResultRegisterValidatorService', function () {

  // load the service's module
  beforeEach(module('fmsmobilewebApp'));

  // instantiate service
  var service;
  beforeEach(inject(function (_ResultRegisterValidatorService_) {
    service = _ResultRegisterValidatorService_;
  }));

  it('validate for opResult', function() {
    var data = {};
    expect(service.validateOpResult(data)).toBe(false);
    data.opResult = {};
    expect(service.validateOpResult(data)).toBe(false);
    data.opResult.content = 'Content';
    expect(service.validateOpResult(data)).toBe(false);
    data.opResult.startDate = '2014-04-05 12:00';
    expect(service.validateOpResult(data)).toBe(false);
    data.opResult.endDate = '';
    expect(service.validateOpResult(data)).toBe(true);
  });

  it('validate for status', function() {
    var data = {};
    expect(service.validateStatus(data)).toBe(false);
    data.status = '0701';
    expect(service.validateStatus(data)).toBe(true);
  });

  it('validate for department', function() {
    var data = {};
    expect(service.validateDepartment(data)).toBe(false);
    data.departmentId = '0401';
    expect(service.validateDepartment(data)).toBe(true);
  });

});

Validator의 test code의 경우에는 기본적인 service code와 동일합니다. input에 따른 결과가 어떻게 나오는지만 확인하면 됩니다.

이제 가장 중요한 http에 대한 서비스 코드입니다. 우리는 test code를 작성하기 전에 REST API를 호출하는 행위에 대한 정의가 우선 필요합니다.
REST API를 호출하는 것은 다음과 같습니다.

  1. URL에 대한 HTTP request
  2. method에 따른 HTTP request
  3. HTTP response에 대한 결과값에 대한 parsing

결국은 우리가 확인할 것은 위 3가지입니다. 그리고 중점을 줘야지 되는 것을 따지자면 1, 2에 대한 확인입니다. 3에 대한 내용은 REST API의 호출 특성상, API 서버측에서 보내는 결과이기 때문에 그 결과값이 json format인 경우, 우리가 테스트를 해서 확인할 내용이 아닙니다. 우리는 그 결과값을 온전히 받아서 사용하는 client측에서만 생각하면 되기 때문에 1, 2번에 대한 내용만을 테스트한다면 우리가 원하는 test code를 작성할 수 있습니다.

angularJS는 angular-mock을 통해 $httpBackend 객체에 우리가 호출되는 URL, method를 확인할 수 있는 방법을 제공합니다. 일단 다음 상황을 가정해보도록 합니다.

GET /api/as/list

그리고, 이러한 url을 호출하는 API Service를 작성합니다.

function AsService(Restangular, AsServiceValidator) {
  var self = this;
  self.getList = function(status, department, func) {
    if(AsServiceValidator.checkStatus(status) && AsService.checkDepartment(department)) {
      Restangular.all('/api/as/list').get().then(function(jsonResult) {
        if(jsonResult.ok && func) {
          func(jsonResult);
        }
      });
      return true;
    } 
    return false;
  };
}

위 코드는 그 전에 이야기한 validator에 대한 체크 후, Restangular를 이용한 http call을 하고 있습니다. service에 대한 test code를 작성할 때, 반드시 $httpBackend에 대한 expect를 설정해야지 됩니다. 다음은 test code의 일부분입니다.

var httpBackend, service
beforeEach(inject(function($httpBackend, AsService) {
  httpBackend = $httpBackend;
  service = AsService;
}));

위 코드패턴을 이용해서 우리는 테스트의 대상이 되는 service와 $httpBackend를 얻어올 수 있습니다. 이제 우리가 원하는 API를 정상적으로 호출하고 있는지를 확인해보도록 하겠습니다.

it('as list REST API 호출', function() {
  httpBackend.expectGET(/\/api\/as\/list/\W*).respond('{ ok: true, data: null, message: data }');
  service.getList(status, department, function(jsonResult) {
    console.log(jsonResult);
  });
  httpBackend.flush();
});

REST API를 호출하는 service의 test code pattern은 다음과 같습니다.

  1. expectGET/expectPOST/expectDELETE/expectPUT method를 이용해서 request가 전달될 URL을 예상합니다.
  2. service를 호출합니다.
  3. httpBackend.flush() method를 호출합니다.

여기서 주의할점은 httpBackend.flush() 입니다. flush가 호출되게 되면, httpBackend는 httpRequest에 대한 response를 발생시킵니다. 만약에 expect에 정의되지 않은 http request가 발생되게 되면 exception을 발생하게 됩니다. REST API의 response를 명확히 알고 있다면, 그 결과값에 대한 처리 로직도 테스트가 가능합니다.

REST API service에 대한 test code작성방법에 대해서 다시 한번 정의해보도록 하겠습니다.

  1. input에 대한 validator의 분리가 필요 > validator 테스트 코드
  2. $httpBackend.expectXXX method를 이용한 REST API 호출 확인

이 두가지에 중점을 주면 이제 service code 역시 테스트가 가능하게 됩니다.


Posted by Y2K
,
filter의 경우에는 배열에 대한 조건을 처리하고 있기 때문에, 최대한 가벼울 필요가 있는 객체이기도 합니다. 또한, non-HTTP service의 경우에는 값의 계산이나 validation에 대한 처리가 일반적입니다.

filter의 테스트는 다음을 확인하면 됩니다.

input array data의 filtering이 정상적으로 되었는지
> count를 이용하는 것이 일반적입니다.

다음은 간단한 filter code입니다.

angular.module('fmsmobilewebApp').filter('planFilter', function () {
  return function (plans, status) {
    if(status == '' || status == '0') {
      return plans;
    } else {
      var result = [];
      angular.forEach(plans, function(plan) {
        if(plan.statusId == status) {
          result.push(plan);
        }
      });
      return result;
    }
  };
});

위와 같은 filter code의 경우, filter의 input 값을 개발자가 제어를 해서 처리하면 간단히 해결됩니다. 다음과 같은 방법으로요.

describe('Filter: planFilter', function () {
  beforeEach(module('fmsmobilewebApp'));
  var planFilter;
  var plans;

  beforeEach(inject(function ($filter) {
    planFilter = $filter('planFilter');
    plans = [{"section":"정기","sectionId":"1801","type":"일반","mainBody":"대성","classification":"보고관리","priority":"1일이내","planDate":"2014-09-02 16:00","statusId":"0805","name":"A 보고관리 (주 2회)","id":"33913","department":"관리팀","orderDate":"2014-09-11","status":"완료"},{"section":"정기","sectionId":"1801","type":"일반","mainBody":"대성","classification":"보고관리","priority":"1일이내","planDate":"2014-09-05 16:00","statusId":"0805","name":"A 보고관리 (주 2회)","id":"33914","department":"관리팀","orderDate":"2014-09-11","status":"완료"}];
  }));

  it('not filtered (status is empty)', function() {
    var count = plans.length;
    expect(planFilter(plans, '').length).toBe(count);
    expect(planFilter(plans, '0').length).toBe(count);
  });

  it('filtered plans', function() {
    var count = 0;
    var status = '0805';
    angular.forEach(plans, function(plan) {
      if(plan.statusId == status) {
        count++;
      }
    });
    expect(count).toBeGreaterThan(0);
    expect(planFilter(plans, status).length).toBe(count);
  });

});


Posted by Y2K
,
yo의 angular-generator를 이용한 angularJS web application을 테스트 하는 방법을 한번 정리해보도록 하겠습니다.

먼저, 테스트의 대상은 다음과 같습니다.

  • filter, service (non-HTTP)
  • service (HTTP)
  • directive
  • controller

angularJS로 만들어지는 객체들은 모두 test의 대상이 될 수 있습니다. 그렇지만, 저 순서가 조금 중요한 것이 기본적으로 service의 테스트 없이, controller의 테스트는 어렵습니다. 그리고 directive 역시 내부에서 service를 사용하고 있다면, service의 test가 우선이 되어야지 됩니다. 따라서, 위 순서와 비슷하게 dependency에 따른 test의 순서를 결정해보는 것이 좋습니다.

xUnit를 이용한 test와 마찬가지로, unit이라는 범위안에서 dependency가 외부 library(jquery, restangular etc..)등에만 의존이 되어 있는 객체들 부터 테스트를 진행하고, dependency가 내부 객체들에 의존되어 있다면, 그 객체들의 테스트가 우선되어야지 됩니다. 이러한 조건을 먼저 생각하는 것부터 테스트를 시작할 수 있습니다. 그리고, 이 순서는 일반적으로 위에 적어놓은 객체들의 순서로 진행되는 것이 일반적입니다.

환경 설정

karma-jasmine을 먼저 설치해야지 됩니다. 기본으로 설정되어 있는 jasmine test framework를 karma를 통해 실행하기 위한 기본 library입니다.

npm install karam-jasmine

설치후, 사용할 browser에 따른 launcher를 설치해야지 됩니다. 자신의 개발 환경에 맞는 launcher를 설치해주면 됩니다. window, linux, mac에 모두 설치가 가능한 web browser를 선택해주는 것이 개발환경을 구성하는 데 편합니다.

npm install karma-chrome-launcher
npm install karma-firefox-launcher
npm install karma-ie-launcher
npm install karma-safari-launcher

launcher의 경우, karam.conf.js 파일에 사용할 browser의 설정이 필요합니다.

browsers: ['Chrome']

karma로 테스트를 진행하기 위해서는 karma.conf.js 파일을 주의깊게 봐야지 됩니다. 테스트가 초기에 진행되지 않는 모든 이유는 바로 이 파일안에 있습니다. 먼저 확인해봐야지 될 것이 우리가 bower 또는 외부 상용 library들에 의존되는 application을 작성하고 있다는 것을 확인해야지 됩니다. 이는 모든 library들이 load 될 때에, 기본적인 library들이 먼저 loading 되어야지 되는 것을 의미합니다. 기본적인 library들을 loading하는 것을 karma에서는 files로 정의하고 있습니다. 또한, files에는 test의 target이 되는 library들과 unit test code역시 정의되어 있어야지 됩니다. 따라서, 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'
    ],

특히 jquery의 경우에는 사용하고 있는 경우, 최상단에 넣어주는 것이 좋습니다. 많은 library들이 jquery에 의존적이며, angularJS의 경우에는 내장된 jqLite가 jquery가 있는 경우 jquery를 사용하도록 되어 있기 때문입니다.

이제 karma를 구동할 grunt에 대한 설정을 해보도록 하겠습니다. yo를 이용한 web application을 구성하는 경우, 다음 3가지의 application이 모든 동작을 제어하게 됩니다.

  • yeoman : code에 대한 scafolding 제공, code의 기본적인 구조 제공. template을 이용한 code generater라고 이해하면 쉽습니다.
  • bower : gradle, maven과 같은 library dependency 관리 툴입니다. 이제 jquery를 얻기 위해서 home page에 들어가서 다운 받을 필요가 없습니다.
  • grunt : build tool입니다. test의 진행 또는 webserver의 시작등을 모두 grunt를 통해서 진행합니다.

grunt는 Gruntfile.js 파일을 기반으로 동작합니다. 여기서 karma에 관련된 설정만 처리하면 됩니다. 큰 변경은 필요없고, 개발과 test를 동시에 자동으로 처리하도록 설정하면 개발이 매우 편합니다.

    // Test settings
    karma: {
      unit: {
        configFile: 'karma.conf.js',
        singleRun: false,
        authWatch: true
      }
    }
  • singleRun(default : true) : test가 한번만 진행됩니다. 테스트를 연속적으로 계속 진행할 수 있도록 true로 수정해주면 좋습니다.
  • authWatch(default : false) : 파일의 변경이 있는 경우, test를 다시 진행합니다. 충분히 빠릅니다. 약 90여개의 test code의 경우, 1sec내로 테스트를 진행할 수 있습니다.

이제 테스트를 진행할 수 있는 환경 구성이 모두 마쳐졌습니다. 먼저 jasmine에 대한 소개를 간략하게 하고, 각 객체의 test를 하는 방법에 대하여 알아보도록 하겠습니다.

jasmine

jasmine은 javascript의 test framework로서, xUnit의 역활을 맡게 됩니다.
jasmine의 소개는 다음 url에서 확인해보실 수 있습니다. http://jasmine.github.io/2.0/introduction.html

기본적으로 jasmine은 suites를 기반으로 동작합니다. suites는 descirbe로 선언이 되며, 이 test code가 어떤 test code인지를 서술하는 기능을 갖습니다.

describe('sample test code suit', function() {

});

xUnit에서 사용되는 @Test/[Test]는 it으로 정의됩니다.

describe('sample test code suit', function() {
  it('sample test code unit', function() {
      // Sample Test code
  });
});

xUnit의 @SetUp/[SetUp]은 beforeEach로 정의 됩니다.

describe('sample test code suit', function() {
  beforeEach('pre-run codes each test codes', function() {
      // SetUp code
  });
  it('sample test code unit', function() {
      // Sample Test code
  });
});

xUnit의 @TearDown/[TearDown]은 afterEach로 정의됩니다.

describe('sample test code suit', function() {
  beforeEach('pre-run codes each test codes', function() {
      // SetUp code
  });
  it('sample test code unit', function() {
      // Sample Test code
  });
  afterEach('after-run codes each test codes', function() {

  });
});

beforeEach, afterEach의 경우 여러개가 있어도 상관 없으며, 여러 코드 블럭이 있는 경우, 위에서부터 아래로 code가 실행되게 됩니다.

기본적으로 xUnit에서 사용되는 Test code의 개념을 그대로 들고 왔기 때문에, 사용하기가 매우 편한것을 알 수 있습니다. jasmine은 이제 test code의 기계적인 확인을 위한 여러 matcher 들을 제공합니다. 다음은 matcher들의 종류입니다.

matcher description
expect(target).toBe(value) value와 같은지를 확인합니다.
expect(target).toEqual(value) value와 같은지, 그리고 type이 동일한지를 비교합니다.
expect(target).toBeGreaterThan(value) value보다 값이 큰지를 비교합니다.
expect(target).toBeNull() target이 null인지 확인합니다.
expect(target).toBeDefined() target이 define되어 있는지 확인합니다.
expect(target).toBeTruthly() target이 true인지 확인합니다. 이는 if(target) 의 결과와 동일합니다.
expect(target).toBeFalsy() target이 false인지 확인합니다. 이는 if(!target) 의 결과와 동일합니다.
모든 matcher는 not을 가질 수 있습니다.
> expect(target).not.toBe(value);
> expect(target).not.toBeNull();

jasmine은 method에 대한 call 확인을 지원합니다. 하나의 method가 내부의 특정 method를 호출을 했는지, 호출의 input parameters들을 확인할 수 있습니다. 다음은 예시코드입니다.

  var obj = {
    method1 : function() {
      console.info('call method1');
      this.method2('test value');
    },
    method2 : function(value) {
      console.info(value);
    }
  };

  describe('obj test', function() {
    it('obj method2 called Test', function() {
      spyOn(obj, 'method2');
      obj.method1();
      expect(obj.method2).toHaveBeenCalled();
      expect(obj.method2).toHaveBeenCalledWith('test value');
    });
  });

spyOn 을 이용해서, 감시할 대상을 선정하고, method의 호출을 확인할 수 있습니다. spyOn을 이용한 method의 호출에 대한 matcher는 다음과 같습니다.

matcher description
expect(obj.method).toHaveBeenCalled() method가 호출이 되었는지 확인합니다.
expect(obj.method).toHaveBeenCalledWith(“args”) method가 특정 argument들이 전달되었는지 확인합니다.

Posted by Y2K
,

spock를 이용한 spring test

Java 2013. 12. 11. 03:17

드디어 JUnit의 시대가 끝이 나는것인가.... 라는 생각이 드는 멋진 테스트 Framework인 Spock를 소개합니다.


groovy 언어로 구성을 해야지 되는 언어적 제약사항이 있지만, 기존의 JUnit Test에 비해서 매우 간결한 문법과 TDD를 위한 구성을 제공하고 있습니다. 


1. 설정


gradle 기준으로 spock는 다음과 같이 설정됩니다. 


    repositories {
        mavenCentral()
        maven { url "http://oss.sonatype.org/content/repositories/snapshots/" }
     }
    dependencies {
        groovy "org.codehaus.groovy:groovy-all:2.1.5"
        testCompile "org.spockframework:spock-core:1.0-groovy-2.0-SNAPSHOT"
    }
   


기본적으로 JUnit을 사용하고 있는 test project입니다. 


2. 구성


기본적으로 JUnit과 거의 유사한 기본 설정을 갖습니다.

SpockJUnit
SpecificationTest class
setup()@Before
cleanup()@After
setupSpec()@BeforeClass
cleanupSpec()@AfterClass
FeatureTest
Parameterized featureTheory
ConditionAssertion
Exception condition@Test(expected=...)
@FailsWith@Test(expected=...)
InteractionMock expectation (EasyMock, JMock, ...)



3. 테스트 코드의 종류에 따른 구현


우리가 일반적으로 테스트 코드를 짤때, 다음과 테스트 코드가 주로 작성되게 됩니다. 


1) 특정한 method를 실행후, 객체의 상태가 예측대로인지 확인

2) 특정한 method의 input을 선정하고, output값이 예측대로 나왔는지 확인


이 두가지 case는 약간은 미묘하게 다릅니다. 1)의 경우는 response를 확인을 하는 것이고, 2)의 결과는 예측하는 것으로 약간의 차이를 가지고 오게 됩니다. 

이를 spock에서는 여러 block으로 지정하여, case에 대한 내용을 명확하게 하고 있습니다. 


spock에서 지원하는 block은 다음과 같습니다.  또한 이 block뒤에는 자유롭게 문자열을 넣어서, block안의 내용을 서술할 수 있습니다.


setup : 객체에 대한 설정

when / then : when block이 있으면 반드시 then block이 존재합니다. when에서 객체의 response를 얻어내고, then을 통해 response를 확인합니다. condition을 설정한다고 생각하시면 편합니다.

expect / where : 예측되는 값과 예측값 list를 정의합니다. spock에서 가장 놀라운 기능중 하나입니다. 


위 block을 적용한 code example은 다음과 같습니다. 


    def "건물이름 길이가 작아 exception이 발생"() {
        def buildingName = "가"
        when:
        service.searchByBuilding(buildingName)
        then:
        def e = thrown(IllegalArgumentException)
        e.message == AddressSearchServiceImpl.BUILDING_NAME_IS_TOO_SHORT
    }

    def "서울내의 레미안만 얻어오기"() {
        def buildingName = "레미안"
        when:
        def searchResults = service.searchByBuilding(SidoEnum.SEOUL.getStringValue(), buildingName, 0, 10)
        then:
        searchResults.every { r ->
            r.buildingName.contains(buildingName)
            r.siGunGu.sido.sidoNumber.equals(SidoEnum.SEOUL.getStringValue()) == true
        }
    }

    def "검색문자열 분리 테스트"() {
        expect:
        def matcher = SearchTextRegex.extractSearchText(input)
        matcher.find() == true
        matcher.group("mainText").equals(mainText) == true
        matcher.group("mainNumber").equals(mainNumber) == true

        where:
        input | mainText | mainNumber
        "구로동 245 번지" | "구로동" | "245"
        "서초1동" | "서초1동" | ""
        "잠실동 624" | "잠실동" | "624"
        "지봉로 1" | "지봉로" | "1"
    }

너무나 멋지지 않나요? 지금까지 구성하던 test code의 길이가 절반 이하로 줄어들뿐 아니라, 매우 직관적이고, TDD에 적합한 테스트 코드 형태로 구성이 되고 있는 것을 알 수 있습니다. 


개인적으로 하고 있는 project들을 모두 spock로 변경하는 작업을 한번 해봐야지 될 것 같습니다. 이런 것을 보면 왠지 희열이 느껴지지 않나요? ^^;;

모두들 Happy Coding~ 



Posted by Y2K
,

Spring MVC를 이용한 개발에서 가장 멋진 일은 MockMvc를 이용한 테스트다. 특히 Controller의 사용자 Scenario를 짜고, 그 Sceneario의 결과를 테스트 해보는것은 너무 재미있는 일이다. 그런데, Spring MVC에 Spring Security를 적용한 후에 인증에 대한 테스트를 하기 위해서는 다음의 간단한 절차를 거쳐야지 된다. 


1. MockMvc에 Spring Security Filter를 적용해야지 된다.

2. Login 절차를 통과한 사용자를 만들어내야지 된다. - MockHttpSession을 이용한다. 


이와 같은 과정을 조금 단순화하기 위해서 간단한 TestSupport 객체를 만들어봤다. Utility 객체이기 때문에, 하는 일들은 매우 단순하다. 

1. MockMvc에 Spring Security Filter를 적용한 후, Return시킨다.

2. Digest 인증키값을 만들어낼 수 있다.

3. Basic 인증키값을 만들어낼 수 있다.

4. Form 인증이 반영된 MockHttpSession 값을 만들어낼 수 있다.


Helper class 코드는 다음과 같다.


/**
 * User: ykyoon
 * Date: 11/18/13
 * Time: 7:13 PM
 * Spring Security Filter 적용 및 인증 지원을 위한 Test Helper Class
 */
public class AuthorizedControllerHelper {

    /**
     * MockMvc 생성 코드
     * @param context WebApplicationContext
     * @return Spring Security Filter가 적용된 MockMvc 객체
     * @throws Exception
     */
    public static MockMvc getSecurityAppliedMockMvc(WebApplicationContext context) throws Exception {
        DelegatingFilterProxy delegateProxyFilter = new DelegatingFilterProxy();
        MockFilterConfig secFilterConfig = new MockFilterConfig(context.getServletContext(),
                BeanIds.SPRING_SECURITY_FILTER_CHAIN);
        delegateProxyFilter.init(secFilterConfig);

        return MockMvcBuilders.webAppContextSetup(context).addFilter(delegateProxyFilter).build();
    }

    public static final String AUTH_HEADER = "Authorization";

    /**
     * Basic 인증 문자열 생성
     * @param username 사용자 이름
     * @param password 비밀번호
     * @return Basic XXXX 형태의 Authorization 문자열
     * @throws Exception
     */
    public static final String buildBasicAuthHeaderValue(String username, String password) throws Exception {
        String authHeaderFormat = "Basic ";
        String encodingRawData = String.format("%s:%s", username, password);
        String encodingData = authHeaderFormat + new String(Base64.encode(encodingRawData.getBytes("utf-8")));
        return encodingData;
    }

    /**
     * Digest 인증 문자열 생성
     * @param mvc MockMvc. Digest의 경우, 한번의 Request를 통해 서버의 nonce값을 얻어내야지 된다.
     *            Spring Security의 EntryEndPoint의 설정이 DigestAuthenticationFilter로 되어있어야지 된다.
     * @param username 사용자 이름
     * @param password 비밀번호
     * @param uri 호출할 URI
     * @param method HttpRequestMethod : GET, POST, PUT, DELETE
     * @return Digest 인증 문자열
     * @throws Exception
     */
    public static String buildDigestAuthenticateion(MockMvc mvc, String username,
                                                    String password,
                                                    String uri, String method) throws Exception {
        MvcResult mvcResult = null;
        if(method.equals("GET")) {
            mvcResult = mvc.perform(get(uri)).andDo(print()).andReturn();
        } else if(method.equals("POST")) {
            mvcResult = mvc.perform(post(uri)).andDo(print()).andReturn();
        } else if(method.equals("PUT")) {
            mvcResult = mvc.perform(put(uri)).andDo(print()).andReturn();
        } else if(method.equals("DELETE")) {
            mvcResult = mvc.perform(delete(uri)).andDo(print()).andReturn();
        }
        String authHeader = mvcResult.getResponse().getHeader("WWW-Authenticate");
        String[] authHeaderItemStrings = authHeader.split(",\\s");
        Map<String, String> authItems = new HashMap<>();
        Pattern keyAndItemPattern = Pattern.compile("(Digest\\s)?(?<key>[^=]+)=\"(?<value>[^\"]+)\"");
        for(int i = 0 ; i < authHeaderItemStrings.length; i++) {
            Matcher matcher = keyAndItemPattern.matcher(authHeaderItemStrings[i]);
            assertThat(matcher.find(), is(true));
            String key = matcher.group("key");
            String value = matcher.group("value");
            authItems.put(key, value);
        }
        Assert.assertNotNull(authItems.get("realm"));
        Assert.assertNotNull(authItems.get("nonce"));
        Assert.assertNotNull(authItems.get("qop"));

        String ha1 = DigestUtils.md5DigestAsHex(String.format("%s:%s:%s", username, authItems.get("realm"), password).getBytes("UTF-8"));
        String ha2 = DigestUtils.md5DigestAsHex(String.format("%s:%s", method, uri).getBytes("UTF-8"));
        String cnonce = calculateNonce();
        String totalString = String.format("%s:%s:00000001:%s:%s:%s",
                ha1, authItems.get("nonce"), cnonce, authItems.get("qop"), ha2);
        String response = DigestUtils.md5DigestAsHex(totalString.getBytes("UTF-8"));

        String clientRequest = String.format("Digest username=\"%s\",", username) +
                String.format("realm=\"%s\",", authItems.get("realm")) +
                String.format("nonce=\"%s\",", authItems.get("nonce")) +
                String.format("uri=\"%s\",", uri) +
                String.format("qop=%s,", authItems.get("qop")) +
                "nc=00000001," +
                String.format("cnonce=\"%s\",", cnonce) +
                String.format("response=\"%s\"", response);

        return clientRequest;
    }

    /**
     * Form인증을 위한 MockHttpSession 반환 함수
     * @param context WebApplicationContext
     * @param username 사용자 이름
     * @return Spring Security Attribute가 적용된 MockHttpSession 값
     * @throws Exception
     */
    public static MockHttpSession buildSecuritySession(WebApplicationContext context, String username) throws Exception {
        MockHttpSession session = new MockHttpSession();
        SecurityContext securityContext = buildFormAuthentication(context, username);
        session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, securityContext);
        return session;
    }

    /**
     * Spring Security Context 얻어내는 내부 함수
     * @param context WebApplicationContext
     * @param username 사용자 이름
     * @return SecurityContext
     * @throws Exception
     */
    private static SecurityContext buildFormAuthentication(WebApplicationContext context, String username) throws Exception {
        UserDetailsService userDetailsService = (UserDetailsService) context
                .getBean(BeanIds.USER_DETAILS_SERVICE);
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                userDetails,
                userDetails.getPassword(),
                userDetails.getAuthorities());
        SecurityContext securityContext = new SecurityContext() {
            private static final long serialVersionUID = 8611087650974958658L;
            private Authentication authentication;

            @Override
            public void setAuthentication(Authentication authentication) {
                this.authentication = authentication;
            }

            @Override
            public Authentication getAuthentication() {
                return this.authentication;
            }
        };
        securityContext.setAuthentication(authToken);
        return securityContext;
    }

    /**
     * Digest인증시에 사용되는 cnonce 값을 생성하는 내부 함수
     * @return
     * @throws UnsupportedEncodingException
     */
    private static String calculateNonce() throws UnsupportedEncodingException {
        Date d = new Date();
        SimpleDateFormat f = new SimpleDateFormat("yyyy:MM:dd:hh:mm:ss");
        String fmtDate = f.format(d);
        Random rand = new Random(100000);
        Integer randomInt = rand.nextInt();
        return DigestUtils.md5DigestAsHex((fmtDate + randomInt.toString()).getBytes("UTF-8"));
    }
}

Spring Security가 반드시 적용된 Test에서만 사용가능하다. Helper를 이용한 Test code는 다음과 같다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { DomainConfiguration.class, SecurityConfiguration.class, ControllerConfiguration.class })
@WebAppConfiguration
public class AdminNoticeControllerTest {
    @Autowired
    private WebApplicationContext context;
    private MockMvc mvc;
    @Autowired
    private ObjectMapper objectMapper;
    @Autowired
    private NoticeService noticeService;

    @Before
    public void setUp() throws Exception {
        assertThat(context, is(not(nullValue())));
        mvc = AuthorizedControllerHelper.getSecurityAppliedMockMvc(context);
    }

    @Test
    public void getAllNotices() throws Exception {
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(get(AdminNoticeController.API_ADMIN_NOTICES)
                .param("pageIndex", "0")
                .param("pageSize", "10").session(session))
                .andExpect(status().isOk())
                .andReturn();
        checkResultData(result);
    }

    @Test
    public void hideNotice() throws Exception {
        final Page<Notice> notices = noticeService.getNotices(0, 10, false);
        Notice notice = notices.getContent().get(0);
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(post(AdminNoticeController.API_ADMIN_NOTICE_HIDE)
                    .param("noticeId", notice.getId().toString())
                    .session(session))
                .andExpect(status().isOk())
                .andDo(print())
                .andReturn();
        checkResultData(result);
    }

    @Test
    public void editNotice() throws Exception {

    }

    @Test
    public void showNotice() throws Exception {
        final Page<Notice> notices = noticeService.getNotices(0, 10, true);
        Notice notice = notices.getContent().get(0);
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(post(AdminNoticeController.API_ADMIN_NOTICE_SHOW)
                .param("noticeId", notice.getId().toString())
                .session(session))
                .andExpect(status().isOk())
                .andDo(print())
                .andReturn();
        checkResultData(result);
    }

    private void checkResultData(MvcResult result) throws Exception {
        ResultData r = objectMapper.readValue(result.getResponse().getContentAsString(), ResultData.class);
        assertThat(r.isOk(), is(true));
    }
}


Posted by Y2K
,

20. Controller - Spring 2.x

Java 2013. 9. 12. 10:53

* 사내 강의용으로 사용한 자료를 Blog에 공유합니다. Spring을 이용한 Web 개발에 대한 전반적인 내용에 대해서 다루고 있습니다.


maven을 이용한 기본 web application을 구성하는 방법에 대해서 알아봤습니다. 
servlet을 이용한 web page 개발은 Model 1 또는 Model 2라고 하는 Model에 의해서 구성되게 됩니다. 먼저 Model 1에 대해서 알아보도록 하겠습니다. 

Model 1은 JSP Page에서 JavaBean을 이용해서 직접 DB에 또는 DataSource에 접근하는 방식입니다. Client에서 WebServer로 호출이 되게 되면 SErver에서 요청한 File을 분석하게 됩니다. 이는 JSP 또는 Html 형태로 구성이 되게 되며, JSP의 경우 Servlet Container에서 JavaBean을 이용해서 DB에 접속하고 처리 결과를 조합하여 JSP 호출부에 전송하여 Html 형태로 Client에 response를 보내게 되는 방식입니다.


이러한 방식을 Model 1 이라고 합니다.


이러한 방법의 장/단은 다음과 같습니다.

장점
1. 개발 속도가 빠르다.
2. 개발자의 스킬이 낮아도 배우기 쉬워 빠르게 적용할 수 있다.

단점
1. JSP페이지에서 프리젠테이션 로직과 비즈니스 로직을 모두 포함하기 때문에 JSP페이지가 너무 복잡해 진다.
2. 프리젠테이션 로직과 비즈니스 로직이 혼재되어 있기 때문에 개발자와 디자이너의 분리된 작업이 어려워진다.
3. JSP페이지의 코드가 복작해 짐으로 인해 유지보수 하기 어려워진다.
4. 정교한 Presentation 레이어를 구현하기 힘들다.(유효성 체크, 에러 처리등)

점점 고도화 되어가는 Web Application의 구성에 있어서 Model 1의 코드의 복잡도에 의하여 유지보수를 하는 것이 불가능해졌습니다. 그래서, 기존의 Model 1을 버리고 Model 2 방식으로 변경이 되기 시작했습니다.
참고로, asp .net webform도 Model 1 방식이라고 할 수 있습니다. 


다음은 Model 2입니다.



Servlet에서 Request를 받아 Controller에서 비지니스 로직을 처리하고, View에서 표현할 데이터를 객체로 전달하고, View에서는 그 데이터를 어떻게 표현을 할지를 결정하는 방식입니다. 이때 View는 Presentation Layer라고 지칭이 됩니다. 이러한 방식을 Model 2 라고 합니다. Model 2의 장/단점은 다음과 같습니다.

장점
1. Presenation에서 명확한 역할 분담이 된다.
2. UI 레이어를 단순화 시킴으로서 디자이너도 작업하는 것이 가능하게 된다. - 단지 Display용으로만 사용된다.
3. Presentation 레이어의 정교한 개발이 가능하다. 유효성 체크, 에러 처리와 같은 기능들은 Spring 프레임워크에서 제공한다.
4. Dependency Pull 없이 Dependency Injection만을 이용해서 애플리케이션을 개발하는 것이 가능하다.
5. UI 레이어가 단순해 짐으로서 유지보수가 쉽다.

단점
1. 새로운 기술을 익혀야하는 부담감이 있다.
2. 프로젝트 초반에 개발속도의 저하를 가져올 수 있다.

Spring MVC를 사용하지 않을 때, 거의 모든 Web에서 사용되던 Struct 1과 Struct 2가 Model 2를 가장 잘 지원하는 Web Framework로서 인기를 끌었습니다. 


Web 개발에 들어가기 전에......

지금부터 모든 글(chapter 마지막까지) 은 Servlet 3.0 기반으로 적혀있습니다. servlet 3.0을 사용하기 위해서는 다음 jar가 반드시 pom.xml에 존재해야지 됩니다. scope를 반드시 provide로 해서 넣어주세요.

    <dependency>
      <groupId>org.apache.tomcat</groupId>
      <artifactId>tomcat-servlet-api</artifactId>
      <version>7.0.37</version>
      <scope>provide</scope>
    </dependency>


Spring 2.x 에서의 Model 2 Web 개발

spring web은 Model2에서 사용할 Controller interface를 제공합니다. Controller interface는 다음과 같이 선언됩니다.

public interface Controller {
    ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception;
}


매우 단순한 View 형태입니다. Controller interface를 이용해서 Hello World Web Application을 작성해보도록 하겠습니다.
바로 전에 이야기드린 것 처럼 maven project를 하나 만들어 줍니다.
그리고, 이제 spring mvc에 대한 maven 설정들을 모두 추가합니다.
정의된 maven bean들은 다음과 같습니다.

  <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.11</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-web</artifactId>
      <version>3.2.1.RELEASE</version>
    </dependency>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-context-support</artifactId>
      <version>3.2.1.RELEASE</version>
    </dependency>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-webmvc</artifactId>
      <version>3.2.1.RELEASE</version>
    </dependency>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-context</artifactId>
      <version>3.2.1.RELEASE</version>
    </dependency>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-test</artifactId>
      <version>3.2.1.RELEASE</version>
    </dependency>

구성된 Folder중 src/main/webapp/WEB-INF/web.xml 파일을 다음과 같이 수정합니다. 

<?xml version="1.0" encoding="UTF-8"?><web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://java.sun.com/xml/ns/javaee" 
         xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" 
         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd" id="WebApp_ID" version="3.0">
  <display-name>mvc.spring01</display-name>
  <welcome-file-list>
    <welcome-file>index.html</welcome-file>
    <welcome-file>index.htm</welcome-file>
    <welcome-file>index.jsp</welcome-file>
    <welcome-file>default.html</welcome-file>
    <welcome-file>default.htm</welcome-file>
    <welcome-file>default.jsp</welcome-file>
  </welcome-file-list>
  <listener>
    <display-name>ContextLoader</display-name>
    <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
  </listener>
  <servlet>
    <servlet-name>spring</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <load-on-startup>1</load-on-startup>
  </servlet>
  <servlet-mapping>
    <servlet-name>spring</servlet-name>
    <url-pattern>/app/*</url-pattern>
  </servlet-mapping>
</web-app>


web.xml에 대해서 알아보도록 하겠습니다. 

먼저, listener입니다. web application이 실행될 때, 기본 bean들을 load 하기 위한 listener를 등록합니다. 기본적으로 main/webapp/WEB-INF/applicationContext.xml 파일을 읽어 bean들을 load 시켜줍니다. 

다음은 servlet 설정입니다. front controller로 spring 이라는 이름으로 DispatcherServlet을 로드하고 있는 것을 볼 수 있습니다. 

마지막으로 servlet-mapping입니다. Servlet이 사용될 url path를 설정합니다. 위 xml에서는 /app/* path에서 DispatcherServlet이 실행됨을 지정하고 있습니다. 그리고, DispatcherServlet가 로드될때, 자동으로 spring-servlet.xml bean 정의를 로드합니다. 이는 DispatcherServlet 내부에서 사용할 child appliation context입니다. 파일 이름은 servlet 이름 + "-servlet.xml" 로 자동 결정됩니다. 

따라서, 위 설정의 경우 src/main/webapp 폴더는 다음과 같이 구성되게 됩니다. 


지금보시면 applicationContext가 2개가 등록되게 됩니다. 전에 applicationContext에 대해서 이야기드릴때, root context와 child context가 존재한다는 이야기를 드린적이 있습니다. 이 부분에 대한 내용이 바로 이것입니다. root context가 applicationContext.xml이 되고, child context가 spring-servlet.xml이 되게 되는 것입니다. 이런 root-child context 구조는 기존에는 spring application context를 사용하지만, web 기술은 spring @MVC를 하지 않고 structure 와 같은 다른 web framework를 사용하는 경우를 지원하기 위해서 만들어진 구조입니다.  xml을 사용하게 되면 application context class는 기본으로 지정된 XmlConfigWebApplicationContext를 사용하게 됩니다. 

그런데 왜 이렇게 2개의 application context를 가지도록 설계가 되어 있을까요? 그 이유는 Spring은 먼저 ApplicationContext를 제공하는 Spring-core와 Spring-Bean으로 시작했기 때문입니다. spring-mvc의 경우에는 후에 추가가 된 것이고, 초기에는 spring-core, spring-bean을 이용한 DI와 AOP만을 이용하고, web기술은 struct 와 같은 다른 web framework를 이용했기 때문입니다. 그래서 두개의 설정이 나뉘게 되었고, 그게 지금까지 고정되어 사용되고 있는 것입니다.

@Configuration을 이용한 개발의 경우, Spring 3.x대의 개발에서 좀더 알아보도록 하겠습니다. 기본적으로 Spring 2.x에서 3.0까지는 @Configuration을 이용한 설정을 지원하지 않습니다. 

이제 Controller와 Controller에서 사용될 객체인 HelloString을 만들어보도록 하겠습니다. 

public class HelloSpring {
    public String sayHello(String name) {
        return "Hello " + name;
    }
}

public class HelloController implements Controller {
    @Autowired
    private HelloSpring helloSpring;

    @Override
    public ModelAndView handleRequest(HttpServletRequest request,
            HttpServletResponse response) throws Exception {
        String name = request.getParameter("name");
        String message = helloSpring.sayHello(name);
        Map<String, Object> model = new HashMap<String, Object>();
        model.put("message", message);
        return new ModelAndView("/WEB-INF/view/hello.jsp", model);
    }
}

그리고, HelloSpring는 applicationContext.xml에 HelloController는 spring-servlet.xml에 등록을 합니다.

<?xml version="1.0" encoding="UTF-8"?><beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
    <bean id="helloSpring" class="com.xyzlast.mvc.spring01.entities.HelloSpring"/>
</beans>

<?xml version="1.0" encoding="UTF-8"?><beans xmlns="http://www.springframework.org/schema/beans"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:context="http://www.springframework.org/schema/context"
  xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.2.xsd">
    <bean name="/hello" class="com.xyzlast.mvc.spring01.controllers.HelloController"/>
</beans>


Controller의 등록시, id가 아닌 name으로 /hello로 등록된 것을 주의해주세요. 

이제 이 Controller의 결과를 보여줄 view를 구성하도록 하겠습니다. view 파일의 이름은 webapp/WEB-INF/view/hello.jsp 입니다. 

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>Insert title here</title>
</head>
<body>
    <p>${message}</p>
</body>
</html>

그리고 command 창에서 mvn jetty:run 을 실행시키면 이제 웹서버가 구동된겁니다. browser를 통해서 http://localhost:8080/hello/app/hello?name=ykyoon 를 실행시키면 다음 화면을 볼 수 있습니다.

지금까지 만들어지는 과정을 보면 기존 Application을 만드는 과정과는 조금 다른 과정을 보게 됩니다. 먼저, bean을 name으로 등록하게 됩니다. 이건 Controller를 url에 mapping하는 spring만의 방법입니다. 이때는 id가 아닌 name으로 url에 mapping 하게 됩니다. 

그리고 하나 더 주목할 것이 있습니다. ModelAndView객체가 바로 그것입니다. 

ModelAndView는 최종적으로 Controller가 return 해주는 값입니다. ModelAndView에서는 표현될 view의 파일 이름과 view 에서 사용될 Model을 Map형태로 return 시켜주게 됩니다. 이 형태는 jsp를 이용할 때의 형식이고, pdf 또는 파일이 return 될때는 좀 다른 형식을 사용합니다. 이 부분에 대해서는 좀 더 나중에 알아보도록 하겠습니다. 지금은 web page를 보여줄 때는 ModelAndView를 return 한다는 것을 기억해주시면 되겠습니다. 

그리고, 이와같은 web application의 테스트는 어떻게 해야지 될까요? 지금까지 web을 만들고 테스트 하는 것은 web server를 실행시키고, web server의 url을 직접 타이핑을 하던가, 아니면 link를 click-click 해가면서 손으로 테스트를 했습니다. 그런데 이런 테스트 방법은 매우 시간이 많이 걸리고, 개발자들을 불편하게 합니다. 
이제 다시 한번 테스트를 어떤것을 해야지 되는지 확인해보도록 합시다. 먼저, 지금 HelloController는 /hello url로 접근이 가능한지 확인해야지 됩니다. 그리고 Model에 message가 들어있는지 확인되어야지 됩니다. 마지막으로 message가 원하는 값인 Hello + input 임을 확인할 수 있어야지 됩니다. 

정리해보겠습니다.  우리가 Controller에서 테스트할 내용은 다음과 같습니다. 

1. 원하는 url로 접근이 가능한지.
2. return되는 Model에 필요한 값이 존재하는지.
3. return된 Model의 값이 원하는 값으로 나오는지 확인

이 3가지가 되면 Controller는 테스트가 가능합니다. 그리고 View는 Controller에 의해서 나온 값을 보여주는 영역이기 때문에 특별히 테스트를 하지 않습니다. 최종적으로 View영역도 HTML의 테스트를 하는 것이 필요하지만, 이 부분은 지금 영역을 넘어가기 때문에 다루지 않도록 하겠습니다. 
이러한 테스트 방법으로 spring은 멋진 솔루션을 제공합니다. Controller에서 Ctrl+j를 눌러 Controller에 대한 단위 테스트 코드를 작성하도록 하겠습니다. 

@SuppressWarnings("unused")
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration({ "file:src/main/webapp/WEB-INF/applicationContext.xml",  "file:src/main/webapp/WEB-INF/spring-servlet.xml"})
@WebAppConfiguration
public class HelloControllerTest {
    private MockMvc mvc;
    @Autowired
    WebApplicationContext context;

    @Before
    public void setUp() throws Exception{
        mvc = webAppContextSetup(context).build();
    }

    @Test
    public void getHello() throws Exception {
        MvcResult result = mvc.perform(get("/hello").param("name", "ykyoon"))
           .andExpect(status().isOk())
           .andReturn();
        String message = (String) result.getModelAndView().getModel().get("message");
        assertThat(message.contains("ykyoon"), is(true));
        assertThat(message, is(not(nullValue())));
        System.out.println(message);
    }
}

import를 엄청나게 많이 합니다.위 구성을 eclipse template에 등록하시면 좀 더 편하게 테스트 코드를 작성할 수 있습니다. 
그리고 테스트를 실행하면 너무나 깔끔하게 처리가 되는 것을 알 수 있습니다. 

test code 살펴보면 다음과 같습니다. 

먼저 MockMvc라는 servlet container를 가상으로 Spring 내부에서 만들어줍니다. 만들어지는 객체는 tomcat이나 jetty와 동일하게 움직입니다. 

mvc.perform method를 통해서 url을 기반으로 접근이 됩니다. 그리고, status값이 200이 나오는지 확인을 하는 구조로 만들어집니다. 
마지막으로 andReturn을 통해서 Model 과 View값을 얻어내 접근이 가능합니다. 이런 테스트 코드를 이용해서 우리가 만든 Controller가 정상적으로 움직이는지 확인이 가능합니다. 

다만, 이 테스트 코드는 Spring 3.2 이상에서만 가능합니다.; 그리고, web aplication에서의 테스트 코드는 우리가 실제로 사용할 aplicationContext.xml 을 그대로 사용하는 형식으로 테스트를 행해야지 됩니다. 
Controller의 테스트 코드는 매우 중요합니다. 지금은 단순하게 model의 값을 확인하는 것이지만, 후에 나올 Server 측 validation과 cookie의 만료 부분에서 이러한 테스트의 통과는 매우 중요한 역활을 갖게 됩니다.

지금까지 만든 코드는 Spring 2.x 대에서 Controller를 만드는 방법입니다. 그렇지만 이 부분은 약간의 논란이 있습니다. Spring의 장점인 특정 환경, 특정 기술에 종속되지 않는 코드의 기반이 깨지게 됩니다. Spring에서 제공하는 Controller interface를 반드시 상속을 해야지 되는 문제가 같이 발생하게 됩니다. 그리고, 코드와 URL간의 mapping이 xml에 있기 때문에 코드를 한번에 파악하기도 힘들게 됩니다. 그리고 가장 큰 문제는 전체 Url 1개 당 하나씩 객체가 만들어지고, 객체의 갯수는 기하급수적으로 계속해서 증가하게 되는 문제가 발생하게 됩니다.

그래서, 이 부분 문제를 해결하기 위해서 Spring 3.x에서는 다른 방법의 URL mapping을 지원하게 됩니다. 다음 장에서 Spring 3.x대에서의 URL mapping의 방법을 알아보도록 하겠습니다. 


web.xml 없는 spring mvc 설정 

web.xml 을 소개할 때 나왔던 web.xml이 없는 개발 방법에 대해서 알아보도록 하겠습니다. 
spring은 WebApplicationInitializer을 제공하고 있습니다. 이 interface는 javax.servlet.ServletContainerInitializer를 구현하고 있으며, Spring jar가 로드되면서 자동으로 web.xml 대신에 사용되도록 servlet container에 로드가 되게 됩니다. 
다음 코드를 먼저 확인해보도록 하겠습니다. 
먼저,  ControllerConfiguration을 한번 알아보도록 하겠습니다. 

@Configuration
public class Spring2WebConfiguration extends WebMvcConfigurerAdapter {
    @Bean
    public HelloSpring helloSpring() {
        return new HelloSpring();
    }

    @Bean(name="/hello")
    public HelloController helloController() {
        HelloController helloController = new HelloController();
        helloController.setHelloSpring(helloSpring());
        return helloController;
    }
}

그리고, WebAplicationInitializer를 구현한 Spring2WebApplicationInitializer 코드는 다음과 같습니다. 

public class Spring2WebApplicationInitializer implements WebApplicationInitializer {

    @Override
    public void onStartup(final ServletContext servletContext) throws ServletException {
        registerDispatcherServlet(servletContext);
    }

    private void registerDispatcherServlet(final ServletContext servletContext) {
        WebApplicationContext dispatcherContext = createContext(Spring2WebConfiguration.class);
        DispatcherServlet dispatcherServlet = new DispatcherServlet(dispatcherContext);
        ServletRegistration.Dynamic dispatcher = servletContext.addServlet("dispatcher", dispatcherServlet);
        dispatcher.setLoadOnStartup(1);
        dispatcher.addMapping("/app/*");
    }

    private WebApplicationContext createContext(final Class<?>... annotationClasses) {
        AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
        context.register(annotationClasses);
        return context;
    }
}

이제 이 두 객체를 package에 넣고, web.xml을 없애거나 파일 이름을 변경시키면 자동으로 Spring2WebApplicationInitializer가 실행되게 됩니다. 

코드의 내용을 보시면, web.xml의 내용을 그대로 코드에 옮겨온것을 알 수 있습니다. dispatcherServlet을 등록시켜주고, dispatcherServlet에 Spring의 applicationContext bean들을 적용시켜주는 것으로 매우 쉽게 개발이 가능합니다. 
한번 jetty를 이용해서 /hello를 실행시켜보시길 바랍니다. 그리고 테스트 코드 역시 @ContextConfiguration에 classes 에 Spring2WebConfiguration.class만 등록시켜주시면 동일하게 사용 가능합니다. test나 개발에 있어서는 확실히 code base configuration이 좀 더 우위인것 같습니다.;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = Spring2WebConfiguration.class)
@WebAppConfiguration
public class HelloControllerTest {
    private MockMvc mvc;
    @Autowired
    WebApplicationContext context;



Posted by Y2K
,