자스민으로 프론트엔드 테스트 코드 작성하기

노드에서는 모카로 테스트 코드를 작성했다. 문서도 깔끔하고 함수 사용하는 것도 간단해서 어렵지 않았다.

한편 프론트엔드에서는 테스트 러너로 자스민(Jasmine) 을 사용하는데 이게 좀 까다로웠다. 문서가 정확하지 않는 것인지, 아니면 잘못 이해하고 사용한 것인지 아직도 모르겠으나 문서보다는 스택오버플로우에서 동작 가능한 사용법을 찾는 경우가 많았다.

jasmine logo

테스트 환경 설정

자스민은 테스트 결과 리포트를 HTML이다. 하지만 테스트 자동화 툴인 카르마(Karma) 를 이용하면 콘솔에서 그 결과를 확인할 수 있다. 내가 구성한 테스트 환경은 자스민, 카르마, NPM 조합이다.

jasmine logo

먼저 노드와 NPM을 설치한다. npm init 으로 package.json 파일을 생성한 뒤 npm i karma --save-dev 명령어로 카르마를 다운로드 한다.

곧 이어 node_modules/.bin/karma init 명령어로 카르마 설정파일을 생성한다. 이때 어떤 테스트 프레임웍을 사용할 건지 물어보는데 기본값인 'Jasmine'를 선택하고 진행한다.

✔︎ ~ node_modules/.bin/karma init

Which testing framework do you want to use ?
Press tab to list possible options. Enter to move to the next question.
> jasmine

Do you want to use Require.js ?
This will add Require.js plugin.
Press tab to list possible options. Enter to move to the next question.
> no

Do you want to capture any browsers automatically ?
Press tab to list possible options. Enter empty string to move to the next question.
> Chrome
>

What is the location of your source and test files ?
You can use glob patterns, eg. "js/*.js" or "test/**/*Spec.js".
Enter empty string to move to the next question.
>

Should any of the files included by the previous patterns be excluded ?
You can use glob patterns, eg. "**/*.swp".
Enter empty string to move to the next question.
>

Do you want Karma to watch all the files and run the tests on change ?
Press tab to list possible options.
> yes


Config file generated at "karma.conf.js".

모든 질문에 답변하고 나면 프로젝트 루트 경로에 karma.conf.js 파일이 생성된다. 설명을 위해 필요한 부분만 발췌했다.

karma.conf.js:

module.exports = function (config) {
  config.set({
    frameworks: ["jasmine"],
    files: ["./src/hello.js", "./test/hello.spec.js"],
    autoWatch: true,
    browsers: ["Chrome"],
  })
}

frameworks는 테스트 프레임웍 이름을 설정하는 부분인데 'jasmine' 문자열로 자스민 프레임웍을 사용한다고 설정했다. 어떤 프로젝트는 모카와 함께 사용하는 경우도 있었다.

files에는 테스트와 테스트 대상이 되는 파일, 그리고 필요하면 제이쿼리같은 써드파티 라이브러리등 테스트에 필요한 파일 목록을 나열한다. 나는 src 폴더에는 소스코드, test 폴더는 테스트 코드로 분류했다.

autoWatch는 테스트 종료후 계속 파일 변화를 감지하면서 테스트를 자동으로 재실행하는 옵션이다. 모카의 --watch 옵션과 같은 기능이다.

browsers는 테스트할 브라우져를 설정하는 옵션이다. 팬텀js를 사용하고 싶었으나 [아직 es6 미지원]( [Support ES6/ES2015 Features · Issue #14506 · ariya/phantomjs · GitHub]https://github.com/ariya/phantomjs/issues/14506))이라 크롬으로 설정했다.

생성한 카르마 컴피그 파일로 카르마를 실행하려면 세 가지 카르마 플러그인을 추가 다운로드 해야한다.

  • karma-jasmine: 카르마에서 자스민을 사용할 수 있다.
  • jasmine-core: karma-jasmine은 카르마가 자스민을 사용할수 있도록 하는 어답터 역할만 한다. 자스민 코드가 있는 jasmine-core를 추가해야 한다.
  • karma-chrome-launcher: 테스트 브라우져로 크롬을 선택했으면 이 플러그인을 설치해서 카르마가 크롬을 사용할 수 있도록 해야한다. 이것도 어답터라고 보면 되겠다.

아래 명령어로 한번에 설치한다.

npm i karma-jasmine jasmine-core karma-chrome-launcher --save-dev

다음은 소스 코드와 이를 테스트하는 테스트 코드를 작성하겠다.

기본 테스트

src/hello.js 파일을 만들어 Hello 모듈을 만들었다.

src/hello.js:

var Hello = {
  message: "Hello world",
  greeting() {
    return this.message
  },
}

그리고 test/hello.spec.js 파일을 만들어 Hello 모듈의 테스트 코드를 작성한다.

test/hello.spec.js:

describe("Hello", () => {
  describe("greeting", () => {
    it("인사 문자열을 반환한다", () => {
      const expectedStr = Hello.message,
        actualStr = Hello.greeting()

      expect(actualStr).toBe(expectedStr)
    })
  })
})

Hello 모듈의 greeting 메서드를 테스트하는데 반환값을 검사하는 코드다. expectedStr에 이 메서드가 리턴하길 기대하는 문자열을 저장하고 actualStr에는 실제 리턴한 값을 저장했다. 그리고나서 자스민의 expected 함수와 toBe 매처로 두 값이 같은지 검증했다.

이제 첫번째 테스트를 돌려보자. 계속해서 카르마 명령어로 실행한다.

node_modules/.bin/karma start
Chrome 57.0.2987 (Mac OS X 10.12.3): Executed 1 of 1 SUCCESS (0.01 secs / 0.003 secs)

실제로 크롬이 열리면서 터미널에는 테스트 성공 결과가 출력되었다.

jasmine logo

karma-chrome-launcher가 카르마를 실행할 때 크롬브라워져를 열었고 이 위에서 방금 작성한 테스트 코드를 실행한 것이다. 브라우져에 따라 karma-firefox-laucher, karma-safari-launcher를 사용할 수 있다.

한편 팬텀js 라는 것이 있는데

PhantomJS is a headless WebKit scriptable with a JavaScript API

크롬, 사파리 등에서 사용하는 레이아웃 엔진이 웹킷(WebKit)인데 이 웹킷으로 만든 브라우져다. 단 헤더가 없기(Headless) 때문에 렌더링한 웹 페이지를 화면에 출력하지는 않는다. 대신 터미널에서 렌더링 결과에 DOM api로 접근할 수 있어서 자동화된 테스트에서 빠른 성능으로 사용할 수 있다. 아쉽게도 현재 버전의 팬텀js는 ES6 미지원 상태라서 크롬으로 대신한것 뿐이다.

앞으로 카르마 명령어는 package.json 파일에 테스트 스크립트로 등록해 사용하겠다.

package.json

{
  "scripts": {
    "test": "karma start"
  }
}

스파이 테스트

greeting 메서드에 로직을 추가해 보자. 지금은 단순히 'Hello world' 문자열을 리턴하도록 했지만 'world' 문자열은 getName이라는 다른 메서드의 반환값을 사용하겠다.

코드 구현이 변경되면서 테스트도 변경 되어하는데 greeting이 getName을 호출하는지를 테스트하고 싶다. 자스민에는 spyOn 함수가 있는데 이것을 이용하면 함수 호출 여부를 검증할 수 있다.

참고로 스파이는 테스트 더블 중 하나의 개념인데 아래 다섯 가지를 통칭하여 테스트 더블이라고 부른다. (자바스크립트 패턴과 테스트 74쪽 참고)

  1. 더미(dummy): 파라매터로 사용되며 전달만하고 실제 사용하지는 않는다.
  2. 스텁(stub): 더미를 좀 더 구현하여 아직 개발하지 않은 메서드가 실제 작동하는 것처럼 보이게 만든 객체이다.
  3. 스파이(spy): 스텁과 비슷하지만 내부적으로 기록을 남긴다는 점이 다르다. 특정 메서드가 호출되었는지 등의 상황을 감시(spying)한다.
  4. 모의체(fake): 스텝에서 좀 더 발전하여 실제로 간단히 구현된 코드를 갖고 있지만, 운영 환경에서 사용할 수는 없는 객체다
  5. 모형(mock): 더미, 스텁, 스파이를 혼합한 형태와 비슷하나 행위를 검증하는 용도로 주로 사용된다.

greeting 함수가 getName 함수를 호출하는지 테스트한다.

test/hello.spec.js:

describe("Hello모듈의", () => {
  describe("greeting함수는", () => {
    it("getName 함수을 호출한다", () => {
      spyOn(Hello, "getName")
      Hello.greeting()
      expect(Hello.getName).toHaveBeenCalled()
    })
  })
})

spyOn 메서드로 Hello 모듈에 getName 메서드에 스파이를 심었다. 그리고 모듈의 greeting 메서드를 실행한 뒤 스파이를 심은 getName 메서드의 호출을 확인하는 toHaveBeenCalled 매처로 검사했다.

테스트 코드를 작성하고 npm t 명령어로 테스트를 실행하면 실패가 떨어진다. 이제 이 테스트를 통과할수 있을만큼만 Hello 모듈을 수정하겠다.

src/hello.js

var Hello = {
  message: "Hello",
  greeting() {
    return `this.message ${this.getName()}`
  },
  getName() {
    return "World"
  },
}

greeting에서 getName을 호출하고 getName은 undefined 값반 반환하도록 했다. 정말 테스트만 통과할 뿐 항상 'World' 문자열만 리턴하는 메서드다. 다시 테스트를 돌리면 성공한다.

AJAX 테스트

getName 메서드의 기능을 이렇게 정의해 보자.

Ajax를 이용해 외부 API 결과를 이름으로 반환한다.

자스민에서는 ajax 테스트를 위한 jasmin-ajax 라이브러리를 제공한다. 나는 카르마로 자스민을 돌리고 있기 때문에 karma-jasmine-ajax 프러그인을 사용할 거다. npm i karma-jasmine-ajax --save-dev 으로 플러그인을 다운로드 하고 karma.conf.js 파일에 관련한 설정 코드를 추가한다.

frameworks: ['jasmine-ajax', 'jasmine'],

frameworks 배열의 문자열 순서가 중요하다. 만약 ['jasmine', 'jasmine-ajax'] 순서로 설정하고 테스트를 돌리면 다음과 같이 에러가 발생한다.

Uncaught ReferenceError: getJasmineRequireObj is not defined

Ajax 요청을 보냈는지 검사하자

이제 getName 메서드가 ajax 요청을 보내는지 테스트하는 코드를 작성해 보겠다.

test/hello.spec.js

describe("Hello", () => {
  describe("getName함수는", () => {
    let request

    beforeEach(() => {
      jasmine.Ajax.install()
      Hello.getName()
      request = jasmine.Ajax.requests.mostRecent()
    })

    afterEach(() => jasmine.Ajax.uninstall())

    it("HTTP 요청을 보낸다", () => {
      const expectUrl = "http://name"
      expect(request.url).toBe(expectUrl)
    })
  })
})

테스트 케이스는 실행 전에 적절한 테스트 환경을 구성할 필요가 있는데, 자스민의 beforeEach가 그런 역할을 한다. 반대로 afterEach는 테스트 케이스 종료 후 정리 작업에 사용하는 함수다. 테스트는 beforeEach -> it -> afterEach 순으로 실행된다.

Ajax 요청을 테스트하기 위해서 jasmine-ajax 라이브러리의 install 함수를 먼저 실행해야 하는데, ajax 요청을 캡쳐하기 위한 목(mock)을 만드는 역할이다. 그리고 나서 getName을 호출하는데 이는 내부적으로 ajax 요청을 하는 메서드다. 따라서 요청 결과를 저장하기 위해 jasmine.Ajax.request.mostRecent() 반환값을 request 변수에 저장해 놨다.

다음으로 테스트 케이스를 작성한 it 함수로 가보자. 이름 조회 API인 'http://name' 이 request.url의 주소와 같은지 검사하는 코드다.

테스트가 종료되면, 즉 afterEach에서는 다시 원상복구 하기위해 jasmine.Ajax.uninstall() 함수를 호출한다.

테스트를 돌려보면 실패하고 request 값이 undefined라는 것을 알 수 있다. getName 메서드에서 아직 ajax로 요청하는 코드가 없기 때문이다. 다시 소스코드를 수정해 보겠다.

src/hello.js

var Hello = {
  getName() {
    const req = new XMLHttpRequest()
    req.open("GET", "http://name", true)
    req.send(null)
    return "World"
  },
}

XMLHttpRequest 객체를 만들어 send만 호출하고 리턴값은 기존과 동일하다. 테스트를 돌리면 성공한다.

Ajax 응답 후 콜백함수가 동작했는지 확인하자

getName이 Ajax 요청을 하면 응답이 올 것인데 이 값을 반환하도록 하겠다. Ajax는 비동기로 응답되기 때문에 getName의 ajax 요청후 응답 시점에 리터값을 반환해야 한다. 간단히 콜백함수를 getName의 파라매터로 넘겨 ajax 응답시 콜백함수를 통해 데이터를 전달 받도록 하겠다. 먼저 콜백함수가 실행되었는지만 테스트 할건데 이 경우 스파이를 심는게 딱이다.

describe('Hello', ()=> {
  describe('getName함수는', ()=>{
    let request,
        callbackSpy;

    beforeEach(()=> {
      jasmine.Ajax.install();
      callbackSpy = jasmine.createSpy('callback');
      Hello.getName(callbackSpy);
      request = jasmine.Ajax.requests.mostRecent();
    });

    afterEach(()=> {...});

    it('HTTP 요청을 보낸다', () => { ... });
    it('http 응답이 오면 콜백함수를 실행한다', ()=> {
      expect(callbackSpy).toHaveBeenCalled();
    });
  });
});

beforeEach 부분에 콜백함수 역할을 하는 스파이를 만들어 getName에 파라매터로 넘겼다. 그리고 나서 스파이를 체크하는 테스트 케이스를 추가했다. 테스트를 돌리면 실패한다. 그럼 다시 소스 코드를 수정해 보겠다.

src/hello.js

var Hello = {
  getName(cb) {
    const req = new XMLHttpRequest()
    req.open("GET", "http://name", true)
    req.onreadystatechange = cb
    req.send(null)

    return "World"
  },
}

getName 메서드에 콜백함수를 cb 변수로 받았다. Ajax 요청이 완료되어 응답이 오면 콜백함수를 실행하도록 했다. 다시 테스트 코드를 돌리면 테스트에 통과 한다.

콜백함수가 ajax 응답 결과를 반환하는지 체크하자

마지막으로 콜백함수를 통해 오는 데이터를 검증해 보고 싶다. jasmine-ajax의 responseWith 함수를 이용하면 ajax 응답을 흉내낼 수 있다.

test/hello.spec.js

beforeEach(() => {
  jasmine.Ajax.install()
  callbackSpy = jasmine.createSpy("callback")
  Hello.getName(callbackSpy)
  request = jasmine.Ajax.requests.mostRecent()

  response = {
    status: 200,
    responseText: "Chris",
  }
  request.respondWith(response)
})

it("콜백함수 파라매터로 이름을 반환한다", () => {
  expect(callbackSpy).toHaveBeenCalledWith(response.responseText)
})

response 객체를 만들어 응답할 데이터를 만들었다. 그리고 요청객체의 respondWith 메서드의 인자로 넘겨주면 테스트에서 발생하는 ajax 요청에 대한 응답을 흉내낼수 있다.

그리고 나서 응답 결과를 테스트 하는 코드를 작성한다. 자스민의 toHaveBeenCalledWith 매처는 스파이 함수가 실행될 때 어떤 인자로 실행되었는지 검사하는 역할을 한다. Ajax 응답 더미의 responseText와 같은지 확인했다.

테스트를 돌리면 실패한다. 소스코드를 개선해보자. Ajax 응답 핸들러 부분이다.

req.onreadystatechange = function () {
  if (req.readyState == 4) {
    if (req.status == 200) {
      cb(req.responseText)
    } else {
      cb("ajax error")
    }
  }
}

Ajax 응답 데이터중 responseText 값을 콜백함수의 인자로 전달했다. 테스트를 돌리면 성공이다.

DOM 요소 테스트

Hello 모듈에는 ajax로 데이터를 가져오는 getName과 이를 이용해 인사말 문자열을 반환하는 greeting 메서드가 있다. 이번에는 이 문자열을 돔에 출력하는 print 메서드를 추가해 보자.

항상 테스트 코드가 먼저다.

예외를 던지는지 확인하자

test/hello.spec.js:

describe("print 함수는", () => {
  it("파라매터로 돔 요소가 없으면 에러를 던진다", () => {
    expect(() => Hello.print()).toThrowError()
  })
})

print 메서드는 돔 요소를 파라매터로 받고 여기에 greeting의 반환값을 출력하는 녀석이다. 전달인자가 돔 엘레맨트가 아닐경우 에러를 던지는지 확인하는 코드인데 자스민의 toThrowError 매처가 그 역할을 한다. 예외 상황을 감지하기 위해서는 예외 던지는 함수를 실행하도록 한번 더 함수로 감싸서 expect 인자로 전달한다.

테스트는 실패다. 당연하다. 모듈에 print 메서드를 추가해 보겠다.

src/hello.js:

const Hello = {
  /* ... */

  print(el) {
    if (!el instanceof jQuery) {
      throw new Error("파라매터는 제이쿼리 객체이어야 합니다")
    }
  },
}

돔 처리를 위해 제이쿼리를 사용했다. 들어온 파라매터가 제이쿼리 인스턴스인지 검사해서 아니면 예외를 던지도록 했다.

카르마에서 제이쿼리를 사용하려면 npm i jquery --save-dev 로 제이쿼리를 설치한 뒤, 카르마 설정파일인 karma.conf.js에 제이쿼리 라이브러리 경로를 추가해야한다.

karma.conf.js:

files: [
  './node_modules/jquery/dist/jquery.js',
  './src/hello.js',
  './test/hello.spec.js'
],

테스트 성공.

돔을 변경했는지 검사하자

돔 요소를 제대로 전달 받았으면 여기에 greeting 메서드 결과값을 출력했는지 체크하는 일만 남았다. 그 전에 자스민에서 제이쿼리 매쳐를 제공하는 라이브러이인 jasmine-jquery를 사용하겠다. 카르마 환경에서 사용할 것이기 때문에  npm i karma-jasmine-jquery --save-dev 명령어로 karma-jasmine-ajax 플러그인을 다운받은 뒤 테스트 케이스를 작성한다.

test/hello.spec.js:

describe('print 함수는', ()=> {
  let el;
  beforeEach(()=> {
    el = $('<h1></h1>');
    $('body').append(el);
  });

  afterEach(() => el.remove();

it('파라매터로 받은 돔에 인삿말을 출력한다', ()=> {
  Hello.print(el);
  expect(el).toHaveText('Hello World')
});

테스트 케이스 실행 전에 문자열을 출력할 돔을 미리 만들어야 하는데 익숙한 제이쿼리 함수로 돔을 만들어 el 변수에 저장해 놨다. 테스트 케이스가 종료하면 el.remove() 메서드에 의해 돔을 제거한다.

테스트 케이스에는 print에 el을 전달하여 실행한다. 이어서 expect로 el을 검사하는데 jasmine-ajax 라이브러리의 toHaveText 매처 함수로 돔에 담긴 문자열을 검사한다.

테스트를 돌리면 실패가 떨어진다. 소스코드를 수정하겠다.

src/hello.js:

print(el) {
 if (!el instanceof jQuery) {
    throw new Error('파라매터는 제이쿼리 객체이어야 합니다');
  }
  el.text(this.greeting());

제이쿼리 text 메서드로 greeting() 함수 결과 값을 돔에 출력했다. 다시 테스트를 돌리면 성공이다!

정리

자스민은 자바스크립트 테스트 러너인데 웹 프론트엔드의 대표적인 프레임웍이다. 모카는 테스트러너 역할만 하기 때문에 검증 라이브러리로 should나 chai를 사용했는데 자스민은 검증 매처들도 포함하는 점이 다르다. 그리고 필요에 따라 jasmine-ajax, jasamine-jquery 등의 자스민 플러그인도 추가할 수 있다.

자스민으로 작성한 파일을 html에 로딩하여 테스트 결과를 브라우져에서 확인하는 방법이 있지만, 카르마를 이용하면 콘솔에서 확인하고 감시(watch) 옵션 등의 기능을 사용할수 있다. 테스트 자동화를 위한 툴이라고 보면 되겠다.

전체 테스트 코드와 샘플은 여기서 확인할 수 있다. GitHub - jeonghwan-kim/jasmine-sample