Search
▪️

Testing Node.js Applications

What is Testing?

이제까지는 작성한 Code를 Test하기 위해선 Manual하게 사이트에 들어가서 기능들을 Test해야 했었다. Manual하게 Test하는 장점은 실제로 사이트를 보면서 다른 사용자처럼 Application을 사용 해볼 수 있다는 점이다. 하지만 모든 부분에 대해서 Test하는 것을 잊을 수도 있고 Test 횟수가 적을 수도 있다는 단점이 있다.
위와 같이, 매 번 자잘한 기능을 Update할 때마다 실제 사이트에서, 구현된 기능들을 놓치지 않고 모두 일일이 Test하는 것은 용이하지 않기 때문에 Automated Code Testing을 이용하게 되는 것이다. Automated Code Testing은 우리가 작성한 Code를 Test할 Code를 작성하는 것이다. 즉, Test할 기능을 조금의 변화마다 자동으로 구동할 수 있게, 특정 Test Scenario를 부여하는 것이다.
이런 Automated Code Testing은 Deployment Process에 넣을 수도 있다. 이렇게 하면 Deploy 직전에 구동하여 Test가 가능하다. 이렇게 했을 때의 장점은, 매 Code 변화에 따라 자동으로 구동되므로 모든 Core Feature에 대해서 Test가 가능하여 기능에 대한 보장이 가능하다는 것이다. 단점이라면, 잘못된 Testing Code를 작성하면 Scenario 자체가 잘못된 것이므로 올바른 Testing 자체가 불가능 하다는 것이다. 또한 기능에 대한 Test만 가능하기 때문에 Interface에 대한 Test는 쉽지 않다는 점이다. (사용자가 보는 것을 놓칠 수도 있다는 것이다.)
따라서 Manual Testing Code와 Automated Testing Code 둘 중, 한 쪽으로 치우치는 것보다 적절히 조합하여 Test를 하는 것이 바람직하다.

Why & How ?

Automatic하게 Test Code를 수행하기 위해서는 몇 가지 도구가 필요하다.
우선 Test Code를 Run 시킬 수 있는 도구가 필요하다. 이 도구는 Test를 통과했는지 실패했는지 여부를 Return 한다. (Running the Tests ⇒ Executes the test code → Module Called 'Mocha')
그 다음으로 필요한 도구는 특정 Condition에 맞춰서 나타나야 할 결과를 정의할 수 있는 도구이다. (Assert Results ⇒ Validating the test outcome → Module Called 'Chai')
Test Code를 수행하고 이에 대해서 검증을 수행하는 Module외에도 Error Handling이나 Third Party Package 관리 등 프로젝트의 전반적인 Managing을 수행하는 Module이 필요하다. (Managing Side Effects / External Dependencies → Module Called 'Sinon')

Setup and Writing a First Test

Automatical한 Test Code는 SSR이나 REST API, GraphQL 등에 구애 받지 않고 설계할 수 있으며, Framework 역시 구애 받지 않는다.
sinon을 제외한 Third Party Package 들은 아래 명령어를 통해서 설치할 수 있다.
npm install —save mocha chai
Package를 설치했다면, package.json에서 test Script에 대한 명령어가 있는 것을 확인할 수 있는데 이 부분을 건드려줘야 한다. 그저 다른 것 필요 없이 mocha를 실행할 수 있도록 Value 값을 "mocha"로 주면 된다.
설정한 Value로 Test를 실행하기 위해서 npm test를 했다면, 별도의 Test Script가 없이는 Error를 던진다. 즉, 로직을 Test할 Script가 필요하고, 이 Script내에 Test 로직이 들어가게 된다. 일반적으로는 Root 폴더에 test 폴더를 두어야 Mocha가 Test임을 인식할 수 있고, 이 test 폴더 내에 Script를 두게 된다.
Script 내에서 로직을 작성할 때는 Mocha에서 지원하는 Method를 통해서, Method 내에 로직을 구현하게 된다. it()이라는 Method를 이용한다. it() Method는 두 가지 인자를 받는데, 첫 번째 인자는 Test Code를 Describe하는 설명이고, 두 번째 인자는 Test 기능을 수행할 Callback Function이다. 한 Script에는 여러 it() Method를 둘 수 있다.
Mocha가 지원하는 it() Method안에서 Test Code에 대한 Condition들로 검증을 하기 위해선 Chai Package를 사용하게 된다. 아래와 같은 Import가 필요하다.
const expect = require('chai').expect
Chai에는 크게 should(), expect(), assert() 이렇게 세 가지 Method가 존재한다. 사용법과 예제는 Official Document와 Code를 참고하자. expect() Method를 사용하면 Test하려는 Code의 결과 값이 기대치와 동일한 지의 여부 등을 따져서 Test를 Pass 시키거나 Fail 시킬 수 있다.

Testing the Auth Middleware

일반적으로 Automatic한 Test라고 함은 모든 Code들을 한 번에 Test하는 것이 아니라, Module 단위의 Unit Test를 지향해야 한다. 그래야 어느 부분에서 왜 Error가 생겼는지 확인하기 용이하기 때문이다.
이런 Unit Test들은 Route Path에 대한 검증도 가능할 뿐 아니라, Route Path에서 사용되는 Controller, Middleware에 대한 Test도 가능하다. 만일 이런 Middleware에 대한 Test가 필요하다면, Middleware로 들어오는 Request Object를 Manual하게 정의할 필요가 있다.
Request Object를 정의할 때 주의해야 할 점은, 실제 Code가 아니기 때문에 Test Method는 실제 Method처럼 특정 값이나 Object를 올바르게 Return 하지 않고, Test 수행 시 임의의 결과를 낼 수 있도록 만들어서 Expect하기만 하면 된다. (임의로 Error를 만들어 내도록 틀린 값을 Return 시키는 것도 하나의 방법이다. 즉, 임의의 Scenario를 만드는 것이다.)
Middleware에 대한 Test 진행 시, request는 위와 같이 정의하여 인자로 넘겨주고, 나머지 response와 next는 각각 {} Empty Object, () ⇒ {} Anonymous Arrorw Function으로 넘겨준다.
또한 이와 같이 Function에 대해서 Test를 진행한다면, chai Package에서 직접 Function을 실행하여 Test하기 때문에 ()를 써서 Function을 실행 시키지 말아야 하며, 그저 Reference만 넘겨주어 chai가 직접 Test 할 수 있도록 한다.
** 그렇다면 Reference만 넘기게 되면 인자는 어떻게 넘기는가? → Reference에 .bind()로 Chaining하여 인자를 넘기도록 한다. 단, bind()의 가장 첫 번째 인자는 this여야 한다.
** throw() Method 자체가 Exact String을 넘기면 해당 Error가 Throw된 것인지 확인할 수 있지만, 별도의 인자가 없다면 Error가 있었는지 없었는지를 확인할 수 있다.
이와 같이 Unit Test를 부분 부분마다 순차적으로 잘 쌓아서 작성해 나가면, 작성한 로직에 대해서 Well-Testing이 가능하다. (각 부분 마다의 Example을 조정하여 상황 변동에 대해서도 Testing이 쉽다.)

Organizing Mutiple Tests

Mocha는 단순히 it() Method만 사용할 수 있는 것이 아니라, 여러 Test들을 정리하여 수행할 수 있도록 describe() Method를 지원한다. descibe() Method는 Test Code들을 Grouping 할 수 있다. (여러 describe() Method들을 Nesting 할 수도 있다.)
따라서 여러 Test Code를 두었을 때, 어느 Test Code가 어느 Scenario에 해당하는지 용이하게 확인할 수 있게 된다. (가독성이 올라간다.)

What Not To Test!

일반적으로 Test를 하지 말아야 하는 Code들은 내가 작성하지 않은 Code들이다. 즉, Third Party Package에서 특정 Method들을 지원하여 사용한 Code에 대해서는 이 Method들이 올바르게 작동하는지 Test하지 않아야 한다는 것이다. (External Dependencies에 대한 Test를 방지해야 한다.)
물론 외부 Package에 대한 오류가 있다면 Test를 하고 검증을 한 뒤, 수정이 필요한 것은 사실이지만, 현재 수행하고 있는 TDD에는 적합하지 않은 일이다. 이런 부분에 대한 Test는 해당 Package에서의 Test가 필요한 것이다.
따라서 Test Code를 실행하기 위해서 expect로 Reference를 넘기지 않고, Manual하게 Code를 실행 시킨 뒤, 이 후 부분의 대해서 expect를 실행시킨다. (이것이 가능한 이유는 Third Party Package가 쓰이기 전까지는 이전 Test Code들로 Test와 Validating이 끝났고, 이것들이 Pass 했다면 Third Party Package의 Method가 어떤 것을 수행하든 신경 쓰지 않아도 되기 때문에, Manual하게 Code를 실행하고 뒷 부분에 대한 Test만 Expect하면 된다.)

Using Stubs

External Dependencies에 대해서 무시하고 Manual하게 Method를 수행하여 뒷 부분 로직에 대해서만 expect할 수 있었지만, 여전히 올바르지 않은 값에 대해서 생기는 Error가 거슬리기 때문에 넘기려는 External Dependencies의 Method를 Simple한 Method로 대체하는 방법을 추가한다.
배제하고자 하는 Third Party Package를 Test Script에서 Import한다. 그리고 무시하고자 하는 Method에 대해서 새로운 Method를 할당함으로써 Override한다.
Manual하게 Overriding한 Test Code를 조금 더 Elegant하게 활용할 수 있는 방법이 존재한다. Overriding한 Code가 들어있는 Test Module을 한 단계 이전 Test로 옮긴다. Overriding한 Function은 해당 Package에 대해서 Overriding 한 것이기 때문에, Global하게 Overriding 된 것인데 이를 활용한 것이다.
다만 Global하게 Overriding 된 Method에 대해서 Test가 끝나면 Restore해줘야 하는데 이를 Manual하게 진행하는 것이 아니라, Third Party Package의 도움을 받아 Restore 할 수 있다. sinon Package이다. (—save-dev로 설치한다.) 이를 위해선 Overriding 시 추가 구문과 약간의 변동이 필요하다.
sinon Package를 Import한다. Overriding 할 Method에 대해서는 sinon.stub() Method를 이용한다. Code를 참고한다. Test의 expect가 끝나고 난 뒤, 마지막에는 .restore() Method를 이용하여 원 Method를 복구한다.

Testing Controllers

Route 들에 대한 Test는 애초에 Express.js가 Application을 구동하면서 확인하기 때문에 쉽게 알 수 있다. 따라서 Test는 Controller에 대해서만 진행하면 된다.
이 때 고려해야 하는 것들은 Input Form으로부터 날라오는 데이터들도 있지만, Controller들은 데이터베이스와 통신을 하는 경우가 허다하기 때문에 이에 대한 Test Handling도 필요하다. 데이터베이스를 Test하는 방법은 두 가지가 존재한다.
1.
데이터베이스를 Access하는 부분들을 sinon Package의 stub()를 통해서 Overriding을 한다.
2.
Test 용도의 데이터베이스를 둔다.
Asynchronous Code를 Testing할 때는 Synchronous Code Testing보다 각별히 주의해야 한다. 단순히 Code를 Test Running하고 Expect를 하는 것이 아니라 Async Await의 Try Catch를 쓰든 then() catch()를 쓰든 비동기 Code에 대한 Handling이 필요하다. 이는 비동기 Code에 대한 처리일 뿐이고, 추가적으로 비동기 Code에 대한 expect의 Handling도 필요하다. expect시 Mocha에서 지원하는 done()이라는 Callback Function 인자를 받아서 expect의 끝에 호출해줌으로써 expect를 기다리도록 한다. (Mocha의 Test Method들은 Async Await가 불가능하다. 따라서 비동기 Code에 대한 expect 수행 시 done() Method를 활요하지 않으면 비동기로 처리되지 않고 모두 동기로 처리하기 때문에 원하지 않는 Test 결과가 나올 수 있다. 따라서 비동기에 대한 expect 시에는 done() Method를 사용해야 한다. 추가로, done인자를 넘겼는데 사용하지 않으면 Error가 발생한다.)
1번과 같은 방식으로 처리하면 직접 데이터베이스를 이용하기 때문에, Test 임에도 데이터베이스에 원하지 않는 값이 READ, WRITE 될 수 있다. 따라서 데이터베이스에 대해서 Testing이 이뤄질 때는 그리 바람직한 방법은 아니다. 따라서 2번과 같이 Testing을 위한 데이터베이스 설계가 필요한 것이다.

Setting up a Testing Database

매 Test마다 Test용의 데이터베이스를 Dedicating 하는 것이 조금 더 바람직하다고 했는데, 비록 이전의 Testing 보다도 시간이 조금 더 걸리겠지만 충분히 가치 있는 일이다.
Testing 데이터베이스에 연결 후 then() Method를 통해서 Test 로직을 구현하는 것이다.
비동기에 대해서 긴 시간이 필요하다면 package.json의 test Script 명령어에서 시간을 조정할 수 있다. —timeout 5000과 같이 주면 된다.

Cleaning Up

별도의 세팅 없이 Testing 데이터베이스와 연결하여 Test 진행 시, 한 번 Test 후 ctrl + c 키를 눌러야 하는 불필요한 동작들도 많고, ID 값을 Static하게 넣었기 때문에 두번 Test를 진행하기 위해서 데이터베이스에서 일일이 사용자를 지우거나 다른 Static ID 값을 넣어야 한다. (데이터베이스에 ID 값으로 중복된 값을 할당할 수가 없기 때문이다.)
이런 것들에 대해서 깔끔한 Test Code로 정리할 필요가 있다. 따라서 데이터베이스에서 Disconnect를 성공한 후에 Expect를 기다리는 done() Method를 호출하도록 바꾸고, Disconnect를 할 수 있도록 이전에 생길 수 있는 오류의 여지들 (ID 중복 값 등)에 대해서 처리해줘야 한다. (그렇지 않으면 Disconnect까지 도달하지 못하므로) ID 중복 값의 경우, Disconnect 전에 추가한 데이터를 삭제하도록 조치한다.
즉, 데이터 삭제 → Chaining → 데이터베이스 Disconnect → Chaining → done()이 되겠다.

Hooks

위와 같이 작성한 비동기 Code들에 대한 Testing은 가독성과 효율이 상당히 떨어지는 편이다. (여러 Testing에서 동일한 데이터베이스를 이용하는 경우 해당 Code를 매번 적어줘야 하고, 매 Test마다 데이터베이스를 재연결해야 하기 때문이다.) 따라서 Mocha에서는 Lifecycle Hook의 형태로 Cleaner Solution을 낼 수 있도록 지원한다.
예를 들어서, 한 describe() Method Block마다 before() Method를 통해서 Initializing Setting이 가능하다. 데이터베이스에 대한 연결을 before() Method에서 처리하면, describe() Block내의 매 Test에서 데이터베이스를 일일이 연결 요청을 하지 않아도 이미 연결이 되어 있는 상태로 이용이 가능하다. (굉장히 많은 시간을 단축할 수 있다.)
모든 Test들을 거치기 전에 Initialize를 했듯이, 모든 Test들이 마치고 나서 데이터베이스의 연결을 끊는 등의 Afterwork에 대한 작업도 처리해줘야 한다. (매 Test마다 데이터베이스의 연결을 해제하고 데이터를 삭제하면, before() Method에 Initializing을 해준 의미가 없다.) 따라서 이런 작업들은 after() Method에 정의하여 사용이 가능하다.
** before(), after() Method 모두 다 function을 인자로 가지며, function의 인자로 done() Method를 받는다. before(), after()내의 로직을 작성 후에는 반드시 done() Method를 호출한다.
** 자매품으로 beforeEach(), afterEach()라는 Method가 있다. 두 Method와 단순 before(), after()와 다른 점은, beforeEach()와 afterEach()는 describe() Block내에 있는 매 Test 전과 후로 실행되는 Method이다.

Wrap Up & Mastering Tests

물론 위에서 작업한 것들이 결코 적은 양은 아니지만 많이 부족하다.
File Access에 대한 Testing, Session과 Cookie에 대한 Testing 등을 다루지 못했다. 어떤 것을 어떻게 Test해야 하는지, 좋은 Test 방법은 무엇인지 등도 많이 다루지 못했다.
일단 명심해야 할 것은 알갱이 같이 조각 조각 작은 부분 별로 Test를 나누는 것이 좋다. (Granular Functions) 그래야 Testing이 쉬워진다.
또한 모르는 부분에 대해서는 많은 Googling과 Official Document를 통해서 해결해보자.
TDD도 많이 해본 사람이 잘한다. Testing이 잘 되지 않는다고 기죽지 말자. 실제 Code가 나쁜 것보다 Test가 잘못 되어서 올바른 결과가 나오지 않는 것일 수도 있다. 많은 Testing을 구현하면서, 폭을 넓히자.
경험을 쌓고, 많은 토론을 하며 구현하는 것이 많은 도움이 될 것이다.