Testing Flow
테스트에는 Unit Testing, Integration Testing으로 2가지가 존재한다. (Integration Testing을 일종의 Test Pipeline으로 구현되어 있다.) 어플리케이션 내에서 Testing을 진행하는 것은 굉장히 중요하기 때문에 이에 대해서 알아보려고 한다.
jest는 mocha, jasmine과 같은 유명한 Test Runner이다.
Boot up a 'headless' version of Chromium 단계는 Backend 어플리케이션 내에 Virtual Browser를 켜서 테스트를 진행해 보는 것이다.
Chromium은 Chrome이나 Safari 처럼 일종의 Browser이다. 또한 Chromium은 Open Source Browser이다.
Headless라는 의미는 User Interface가 없다는 말이다. 즉, Headless Browser라고 하면 User Interface가 없는 Browser를 말하게 된다. Headless의 경우 GUI 없이 Command Line에서 동작시킬 수 있어서 더 빠르기 때문에 이용한다. (Window를 새로 띄울 필요도 없고, Render 시킬 필요도 없기 때문)
이 Test Flow를 진행하기 위해선 Chromium Browser의 설치가 필요하다.
Testing Challenges
1.
실행 자체는 그렇게 크게 어렵지 않고, Command를 통해서 해결 가능
2.
Assertion을 내는 것은 일종의 1번에서 Interaction과 비슷하다. 몇 줄의 코드만 작성하면 화면 내에 존재하는 각기 다른 Element들을 Virtual하게 클릭하여 Testing이 가능하다.
3.
OAuth를 따른다. Google Credential을 통해 Authorizing한다. (Virtual Browser로 Test하는게 꽤나 어렵..)
Commands Around Testing
npm run test라는 Command가 먹기 위해선 package.json에서 script에 Key값을 "test", Value 값을 "jest"로 준다. 차후에 jest라는 Test Runner에 대해서 추가적인 Option들을 부여한다면 Value에 준 "jest"가 수정될 것이다.
jest라는 Test Runner를 동작시키기 위해 jest라는 Dependency가 필요하고, Test로 사용할 Headless Browser인 Chromium Instance를 실행하고 상호작용하는데 puppeteer를 사용하므로 puppeteer라는 Dependency도 요구된다.
필요한 라이브러리들을 설치했다면, npm run test를 수행해보자. 프로젝트 내에 Test File들이 없다고 Error가 발생할 것이다. 따라서 프로젝트 내에 Test File들을 저장할 수 있는 Directory를 두고 여기에 Test File들을 작성할 것이다.
First Jest Test
어플리케이션 최상단에 test라는 Directory를 생성하고, Test File을 생성한다.
Test File 이름은 'element이름.test.js'와 같은 관례로 작성된다. 예를 들어서 header를 Test 하고 싶다면, header.test.js가 된다. (jest라는 Test Runner는 확장자 명으로 .test.js를 찾기 때문에 .test.js 앞의 이름이 File의 이름이 된다고 보면 된다. 이 때 File의 이름을 Test 하고자 하는 부분의 이름으로 두는 것이 일반적이다.)
Test를 진행할 스크립트 내부는 다음과 같이 작성한다.
// Test하고자 하는 것에 대해 test 함수를 작성한다.
// test 함수의 인자로 Test의 Description을 작성한다.
// 두 번째 인자로 주는 익명 함수가 실제로 Test 구동 시 실행될 함수이다.
test('Add two numbers', () => {
const sum = 1 + 2;
// expect를 통해 해당 함수 내에서 생성된 특정 Value에 대해서 Verify할 수 있다.
expect(sum).toEqual(3);
});
JavaScript
복사
위와 같이 작성했을 때, npm run test로 다시 Test를 진행해보면 아래와 같이 굉장히 쉽게 Test 결과가 나오는 것을 볼 수 있다. (개인적으로는 mocha, chai, sinon을 사용했던 때보다 조금 더 쉽게 와닿는다... 저번에 너무 어렵게 했어서 그런가...)
** mocha - test runner, chai - value comparison으로 사용 했는데, jest에서는 test runner로서 이용 가능하고 jest 내에 value comparison 함수가 이미 존재한다. (jest 하나로 저 2개를 퉁칠 수 있는 것 같다.)
** jest의 test 함수는 mocha의 describe, it 함수와 동일한 역할을 수행한다.
** jest의 expect 함수 chai의 expect 함수와 동일한 역할을 수행한다.
Launching Chromium Instances
Puppeteer 이용은 위 링크를 통해서 도움을 받을 수 있다. doc 내에서 puppeteer의 인터페이스에 대해서 찾아볼 수 있다.
Puppeteer 라이브러리를 사용하면, Browser를 만들 수 있고 이를 나타내는 JavaScript Object를 리턴한다. 리턴 받은 JavaScript Object를 Browser Object라고 부른다.
Browser Object 내에는 구동 중인 Browser를 제어할 수 있는 많은 함수들이 내장되어 있다. 그 중에서도 새로운 Tab을 생성할 수 있는 함수가 있는데, 해당 함수를 사용하면 Page Object를 리턴한다.
Page Object 역시 일종의 JavaScript Object이며, Browser 내부에서 동작하고 있는 Tab을 나타내는 Object이다.
Browser를 실행시키나, 상호작용을 한다거나, Assertion을 내거나, Page에서 정보를 취득하는 등의 Puppeteer를 이용하는 거의 모든 작업들은 비동기 작업들이다.
실행한 test라는 프로세스가 운영체제에 도달하여 Browser Window로 작업을 해야하기 때문에 대부분 비동기로 실행된다. 따라서 await 키워드가 꼭 필요하다.
// Chromium Browser 제어를 위해 Puppeteer 라이브러리를 사용한다.
// 오타가 자주나므로 재차 확인한다.
const puppeteer = require('puppeteer');
test('Launch Chromium Browswer', async () => {
// 1. launch 함수 내에 Empty Object를 잊지 말 것
// (Browser 생성에 이용할 인자를 추후에 추가함)
// 2. 오타 내지 말 것
// 생성된 browser는 Running Browser이며, Browser Object이다.
const browser = await puppeteer.launch({
// headless 값이 true일 경우, GUI에 대한 제공을 하지 않는다.
headless: false,
});
// Browser Object의 newPage 함수를 통해 새로운 Page를 생성할 수 있다.
// 생성된 page는 Running Tab이며, Page Object이다.
// Page에서의 특정 요소를 클릭하게 할 수 있다.
// Page를 다른 Page로 Navigate 시킬 수 있다.
const page = await browser.newPage();
});
JavaScript
복사
Chromium Navigation
Test를 진행할 때, puppeteer를 통해서 Browser를 Launch 하게 되면 Test를 마치고 Browser를 닫아주는 것이 일반적이다. puppeteer를 통해서 생성한 Browser는 Test를 마친다고 자동으로 종료되지 않기 때문에 Browser를 닫는 코드도 작성해줘야 한다.
일단 Browser를 닫는 작업을 작성하기 전에, 어플리케이션 내부에서 의미 있는 테스트를 위해서 실행되는 작업들을 살펴보자.
제시된 플로들 중에서 Navigate to app에 해당하는 코드는 아래와 같다.
await page.goto('localhost:3000');
JavaScript
복사
Page 이동을 했다면, Page 내의 HTML의 Element를 선택하여 올바른 값인지 확인하는 코드는 아래와 같다.
const text = await page.$eval('a.brand-logo', (el) => el.innerHTML);
expect(text).toEqual('Blogster');
JavaScript
복사
만일 Test가 종료되었다면 Chromium Browser를 닫으면 된다. 코드는 다음과 같다.
await browser.close();
JavaScript
복사
Puppeteer - Behind the Scenes
왜 Puppeteer를 통해 HTML Element를 고를 때는 getContent와 같은 간단하고 편한 구문이 아닌, $eval과 같은 기괴한 구문을 사용하고 내부적으로는 Callback Function을 사용하는 것일까? 이유는 Puppeteer의 동작 과정에 있다.
Jest를 통해서 Test 환경을 만들어 놓고 Puppeteer를 이용하는 프로세스는 1개의 Node.js 프로세스이다. 하지만 Puppeteer를 이용하여 실행한 Chromium Instance는 Node.js 프로세스 내부의 것이 아니라 별도의 프로세스이다. 아예 서로 다른 프로세스인 것이다.
다시 말하면, Test 환경 내에서 아무리 코드를 실행해도 이를 처리하는 프로세스는 Node.js일 뿐 Chromium이 아니라는 소리이다. 즉, 내부적으로 도는 코드를 아무리 실행해도 Chromium에 도달할 수 없고 혹여나 도달하더라도 Chromium이 이해하는 코드는 아닌 것이다. 이와 같이 Chromium의 Page 내에 변수를 Node.js에서 직접적으로 이용할 수 있는 것이 아니기 때문에 Puppeteer를 이용하는 것이다. 이 때, Puppeteer는 우리가 작성한 코드를 실행하는 것이 아니라 Text로 Serialize하여 Chromium으로 전달하게 된다. 이렇게 전달된 Text는 Parsing되어 Browser에서 제 기능을 수행할 수 있도록 만든다.
조금 더 자세하게 말하자면 위에서 작성한 $eval의 Callback 함수를 보면, 해당 Callback 함수는 Chromium으로 전달되어 실행될 것 같지만 사실은 그렇지 않다. (별도의 프로세스므로 Callback 함수 호출에 대해서 어려움이 있다. A라는 프로세스에서 A 내에 작업을 마치고 A의 Callback 함수 호출은 가능하더라도, A라는 프로세스에서 B 프로세스의 작업을 마치고 B에서 A의 Callback 함수를 호출하는 것은 무리다 있다.) Puppeteer 내부적으로 이를 Text로 변환하여 Chromium으로 전달하게 되고, Chromium은 전달받은 Text를 다시 실제 함수로 변환하여 (Deserialize) Chromium에서 실행할 수 있게 되면 이를 실행한다. 그리고 나온 결과를 다시 Text로 Serialize하고 (어떤 결과가 나오더라도) Node.js Runtime으로 보내게 된다.
DRY Test
Do not Repeat Yourself 라는 원칙에 따라 매 테스트 마다 브라우저 열고, 페이지 열고, 특정 페이지 이동하는 코드를 수행하는 것은 좋지 않다.
Test를 수행하기 전에 특정 작업을 실행하라는 의미로 beforeEach라는 함수를 이용할 수 있다. (mocha의 before 구문과 동일한 기능)
예를 들어 아래와 같은 코드가 반복된다고 하면,
const browser = await puppeteer.launch({
// headless 값이 true일 경우, GUI에 대한 제공을 하지 않는다.
headless: false,
});
const page = await browser.newPage();
await page.goto('localhost:3000');
JavaScript
복사
이를 아래 코드로 묶어서 매 Test 실행 전에 수행될 수 있도록 만들 수 있다.
beforeEach(async () => {
const browser = await puppeteer.launch({
// headless 값이 true일 경우, GUI에 대한 제공을 하지 않는다.
headless: false,
});
const page = await browser.newPage();
await page.goto('localhost:3000');
});
JavaScript
복사
그렇다면 browser, page는 beforeEach의 Callback 함수의 Scope내에 존재하는데, Test를 수행하는 구문에서 browser와 page는 어떻게 이용할 수 있을까?
어렵게 생각할 필요 없이 browser와 page를 외부에 전역으로 빼둔다.
따라서 지금까지의 코드를 고치면 아래와 같다.
// Chromium Browser 제어를 위해 Puppeteer 라이브러리를 사용한다.
// 오타가 자주나므로 재차 확인한다.
const puppeteer = require('puppeteer');
let browser, page;
beforeEach(async () => {
// 1. launch 함수 내에 Empty Object를 잊지 말 것
// (Browser 생성에 이용할 인자를 추후에 추가함)
// 2. 오타 내지 말 것
// 생성된 browser는 Running Browser이며, Browser Object이다.
browser = await puppeteer.launch({
// headless 값이 true일 경우, GUI에 대한 제공을 하지 않는다.
headless: false,
});
// Browser Object의 newPage 함수를 통해 새로운 Page를 생성할 수 있다.
// 생성된 page는 Running Tab이며, Page Object이다.
// Page에서의 특정 요소를 클릭하게 할 수 있다.
// Page를 다른 Page로 Navigate 시킬 수 있다.
page = await browser.newPage();
await page.goto('localhost:3000');
});
test('Launch Chromium Browswer', async () => {
const text = await page.$eval('a.brand-logo', (el) => el.innerHTML);
expect(text).toEqual('Blogster');
await browser.close();
});
JavaScript
복사
beforeEach라는 함수가 있었듯이 afterEach라는 함수도 있다. beforeEach와는 반대로 Test를 수행 후에 실행된다. (mocha의 after와 동일하다.) afterEach에는 위에서 작성한 Browser를 닫는 작업을 넣으면 이전보다 코드가 많이 깔끔해진다.
Issues with OAuth
puppeteer를 이용하여 Virtual Browser를 통한 OAuth Test는 확인하는 플로우가 존재한다. 본격적인 OAuth에 대한 검증을 수행하기 전에는 로그인 버튼을 누르고, 그 결과로 전환된 화면의 Domain이 올바른지 확인을 해준다.
test('Click Login & Start OAuth Flow', async () => {
// 1. 로그인 버튼을 누른다.
await page.click('.right a');
// 2. 화면 전환이 되어 올바른 Domain을 갖고 있는지 URL 비교를 통해 확인
// Jest Document를 통해서 비슷한 기능을 수행하는 함수가 있는지 확인
const url = await page.url();
expect(url).toMatch(/accounts\.google\.com/);
});
JavaScript
복사
이렇게 기본적인 검증이 끝났다면 본격적으로 OAuth에 대해서 Test를 수행하면 된다.
로그인 하고자 하는 email을 포함하고 있는 부분을 찾아 클릭시키고 로그인을 하면 된다. 이를 수행하는데 있어서 한 가지 문제 되는 점이 있다. 기존 이용하고 있던 기기에서는 별 문제 없이 로그인할 수 있지만, 낯선 환경에서 Test를 진행시키려고 하다보면 처음 로그인하는 환경이므로 구글이 이를 수상히 여기고 추가 인증을 요구하도록 Block 시킬 수 있다. 이에 대해서 처리할 수 있어야 한다.
이 문제가 해결 되지 않는다면 Local Machine에서 아무리 OAuth를 Test할 수 있었더라도, 다른 Integration Server에서 Test를 진행하는데 무리가 있다.
따라서 OAuth에 대한 검증은 다른 방법이 필요하다. 어떻게 인증을 하면 좋을까?
Solving Authentication Issues with Automation Testing
코드를 수정하거나 Authentication을 거치지 않는 것은 좋지 않다. 그렇다면 코드를 수정하지도 않으면서 Authentication을 거치는 식으로 하는 방법은 무엇이 있을까?
그저 이전과 똑같이 로그인을 하면서 Authentication을 검증하되, 실제로 로그인하는 것이 아니라 로그인하는 것처럼 하는 것이다. (Server는 똑같이 로그인 하듯이 버튼 눌러서 검증한 것으로 알고 있지만, 실제로는 그렇지 않은 것이다.)
The Google OAuth Flow
실제 OAuth를 통한 사용자 검증 절차는 위와 같다. 하지만 까다로운 인증으로 검증을 못할 가능성도 존재하기 때문에 Test를 일반화 하여 어느 환경에서도 Test가 가능하도록 해야 한다.
여러 SNS 로그인을 사용하면서 낯선 환경 로그인으로 추가 인증을 요구하는 경우, 유동적으로 대처할 수 없기 때문에, OAuth를 이용해야 하는 타사의 검증 절차를 생략하고 이를 대체할 수 있는 정보를 만들어서 로그인이 되는지 직접 확인한다. 이로써 사용자 정보를 얻고 검증을 하는 것처럼 보이지만 실제로는 사용자 정보를 OAuth를 통해 얻지 않으면서 Server의 Authentication 로직이 정상 작동하는지 확인할 수 있다.
조금 더 부연설명하자면, 사용자를 검증하는 것은 Server에서 만든 Token인데 (위의 경우는 Server에서 만든 Session ID를 담고 있는 Cookie이다.) 이를 생성할 때 기반으로 하는 것은 타사의 검증을 통해 얻은 Token으로 받아온 사용자 정보들이다. 즉, 까다로운 타사의 검증은 어차피 알아서 될 것이니 타사 검증의 도메인은 생략하고, 우리 쪽에 필요한 정보를 직접 만들어 Server의 문제가 될 수 있는 부분들만 검증하는 것이다.
Inner Workings of Sessions
JWT와 달리 Cookie는 Session ID 등을 저장할 때, 별도의 암호화 없이 간단한 Encoding 후에 저장하게 된다.
utf-8기준 base64로 encoding하기 때문에 Cookie에 저장된 Session ID를 String으로 가져오면 쉽게 decoding 할 수 있다.
아래의 코드를 참고하여 직접 돌려보면 decoding 되는 것을 볼 수 있다.
const session = '${something long string}';
const Buffer = require('safe-buffer').Buffer;
const decodedValue = Buffer
.from(session, 'base64')
.toString('utf8');
console.log(decodedValue);
JavaScript
복사
실제로 저렇게 decoding된 값이 Server에서 어떻게 사용되는지 확인해보자.
이와 같이 Session을 통해서 사용자 Authentication을 마치게 되는데, 이런 Session은 OAuth를 통해서 얻은 정보들로 만들어진다. 따라서 위와 같은 로직을 OAuth 없이 정상적으로 수행할 수 있도록, Session에 사용되는 값들을 만들어내고 이를 Server에서 실제로 사용할 수 있도록 해야 한다.
Session From Another Angle
Session Signatures
실제 Web Browser에서 어떤 플랫폼에 로그인하여 Session이 생기면 Cookie 내에 Session과 Session Signature가 생긴 것을 볼 수 있다.
이 두가지는 매 Request마다 헤더에 Cookie 값이 담겨, Server에서 Session을 통해 사용자 검증을 거치게 된다.
이 때 Signature는 Session의 변조 여부를 체크하는데 사용되는 값이다.
아래와 같은 예로 Session 부터 다시 살펴보자.
이런 값을 유지하고 있는 Session이 있을 때, 악성 사용자가 이를 습득하여 (Cookie 값에 담겨 있는 값이니 쉽게 취득할 수 있기 때문) 이를 활용하면 어떻게 될까? 악성 사용자는 정당한 권한이 없는데도 불구하고, 정당한 사용자의 권한을 뺏어서 서비스를 이용할 수 있게 된다. (심지어 Cookie 내의 Session 값은 별도의 Encryption이 적용되지 않은 base64 encoding이기 때문에 해독도 쉽게 할 수 있다.) 따라서 Session Signature의 필요성이 여기서 대두된다.
Session Signature는 그 값 자체를 이용해서 Session 값을 바꾸는데 이용되는 일종의 Key가 아니라, 아래 그림처럼 base64로 encoding된 값에 Signature의 Key값을 이용하여 새로운 Session Signature를 생성하게 된다
Server에서는 Session과 Session Signautre의 짝이 맞지 않으면 Malicious한 사용자라고 인식할 수 있게 된다.
Cookie내에 Session, Session Signature값이 있는 채로 Request를 타고 들어오면 위와 같은 플로우로 처리가 되는데, 여기서 keygrip이라는 모듈을 통해서 처리를 하게 된다.
Session Signature 생성 및 Session & Session Signature를 통한 검증은 아래와 같다.
const session = 'some long string';
const Keygrip = require('keygrip');
const keygrip = new Keygrip(['a secret key value']);
const session-signature = keygrip.sign('session=' + session);
const result = keygrip.verify(
'session=' + session,
session-signature
);
console.log(result); // true or false
JavaScript
복사
따라서 이렇게 Session과 Session Signature 생성 방법을 Server내에서 사용하고 있는 Keygrip을 통해서 할 수 있다는 것을 알았으니, 이를 통해서 Test Automation에 적용하면 사용자 검증에 대한 Test도 일반화 할 수 있다.
(Session도 JWT와 마찬가지로 Session과 Session Signautre 두 개를 갈취 당하면, Session의 Expiration에 도달하기 전까지는 악성 사용자가 이를 자유롭게 사용할 수 있다. 그렇다면 JWT와의 차이는 무엇인가? 바로 JWT는 웹 뿐만 아니라 Mobile, Desktop 등 다양한 Application에서 사용할 수 있다는 점이다. Session 정보가 들어간 Cookie는 Web Browser 한정이다. 따라서 PWA Application을 사용하길 바란다면 JWT를 사용하는 것이 좋다. JWT를 이용한 검증은 아래 코드와 같다.)
const JwtStrategy = require('passport-jwt').Strategy
const ExtractJwt = require('passport-jwt').ExtractJwt
const mongoose = require('mongoose')
const Admin = mongoose.model('admins')
const keys = require('../config/keys').keys
const opts = {}
opts.jwtFromRequest = ExtractJwt.fromAuthHeaderAsBearerToken()
opts.secretOrKey = keys.jwt.secret
module.exports = passport => {
passport.use(
new JwtStrategy(opts, (jwt_payload, done) => {
Admin.findById(jwt_payload.id)
.then(admin => {
if (admin) { return done(null, admin) }
return done(null, false)
})
.catch(err => console.log(err))
})
)
}
JavaScript
복사
jwt.sign(
payload,
keys.jwt.secret,
{ expiresIn: 7000 },
(err, token) => {
res.json({
success: true,
token: 'Bearer ' + token
})
})
JavaScript
복사
** 별도로 Fake Session을 이용하여 Test를 진행하려고 하면, 데이터 베이스에 늘 접근해서 새로운 계정을 만들고 Session, Session Signature 값을 만들어서 진행해도 되고, 이것이 불편하다면 이미 Test Account를 하나 만들어두어 해당 정보를 이용하여 Session, Session Signature 값을 만들어 진행할 수 있도록 한다. 하지만 TDD는 일반적으로 하나의 기능만 Test하는 것이 아니라 여러 Test들을 진행하기 때문에 동일한 사용자를 이용하게 되면 의도치 않은 문제가 발생할 수 있기 때문에 각 Test 모듈 별로 사용자를 만들어 해당 정보를 통해 Session과 Session Signature를 만들게 된다.
Generating Sessions and Signatures
Session과 Session Signature를 생성하는 새로운 Test부분 코드이다.
test('When Signed In , Show Logout Button', async () => {
const id = '5f560533c554e2b971e8bc82';
const Buffer = require('safe-buffer').Buffer;
const sessionObject = {
passport: {
user: id,
},
};
const sessionString = Buffer.from(JSON.stringify(sessionObject)).toString(
'base64'
);
const Keygrip = require('keygrip');
const keys = require('../config/keys');
const keygrip = new Keygrip([keys.cookieKey]);
// sessions=를 붙이는 이유는 Cookie Library가 이런식으로 이용하기 때문이다.
// 꼭 Technical한 이유는 아니다.
const sessionSignature = keygrip.sign('session=' + sessionString);
console.log(sessionString);
console.log(sessionSignature);
});
JavaScript
복사
Assembling the Pieces
위에서 얻은 Session과 Session Signature 값은 실제로 Page Object가 로그인 버튼을 눌렀을 때, Cookie에 담겨서 오는 값들이므로 Page Object의 함수를 이용한다.
Page Object는 setCookie라는 함수가 있다. 이를 이용한다. (puppeteer의 page에 대한 Official Document를 확인한다.) setCookie로 들어오는 많은 인자들이 있으니 확인해보자.
** Login 버튼을 눌러 OAuth 인증 거치는 부분을 생략하고, 그 절차에 해당하는 데이터를 임의로 생성하여 진행하기 때문에 Session, Session Signature를 생성하여 Cookie에 넣어 두었다면 Refresh만해주면 된다. 따라서 처음에 제시한 것처럼 Server 코드를 바꾸는 것도 아니고, 사용자 검증을 생략하는 것도 아닌 채로 사용자 검증이 잘 작동하는지 확인할 수 있는 것이다.
위의 Test Module에서 Session, Session Signature를 얻은 부분 아래에 추가해준다.
await page.setCookie({ name: 'session', value: sessionString });
await page.setCookie({ name: 'session.sig', value: sessionSignature });
await page.goto('localhost:3000');
JavaScript
복사
정상적으로 작동하는 것을 볼 수 있다.
Wait Statement
위의 Test Module을 통해 Logout 버튼에 대한 Text에 대해서 Test를 해보자. 아래 코드대로 수행하면 오류가 발생한다. 이유는 주석에 제시된 것과 같다.
test('When Signed In , Show Logout Button', async () => {
const id = '5f560533c554e2b971e8bc82';
const Buffer = require('safe-buffer').Buffer;
const sessionObject = {
passport: {
user: id,
},
};
const sessionString = Buffer.from(JSON.stringify(sessionObject)).toString(
'base64'
);
const Keygrip = require('keygrip');
const keys = require('../config/keys');
const keygrip = new Keygrip([keys.cookieKey]);
// sessions=를 붙이는 이유는 Cookie Library가 이런식으로 이용하기 때문이다.
// 꼭 Technical한 이유는 아니다.
const sessionSignature = keygrip.sign('session=' + sessionString);
// result value of generating for authentication
console.log(sessionString);
console.log(sessionSignature);
await page.setCookie({ name: 'session', value: sessionString });
await page.setCookie({ name: 'session.sig', value: sessionSignature });
await page.goto('localhost:3000');
// await 구문을 통해 실제로 Page가 로딩된 후에 작동할 것처럼 보이지만,
// 실제 Page는 이를 아주 빠르게 처리하려고 노력한다.
// 즉, localhost:3000으로 이동은 했지만 아직 Render가 완료된 상태가 아님에도,
// localhost:3000으로 이동은 된 상태기 때문에 eval을 시도한다.
// 따라서 해당 element가 존재하지 않음에도 eval을 시도하여 오류가 발생한다.
// Page에 대해 waitFor 함수가 필요하다.
const text = await page.$eval(
'a[href="/auth/logout"]',
(el) => el.innerHTML
);
expect(text).toEqual('Logout');
});
JavaScript
복사
따라서 goto 이후 Render될 때까지 기다리는 구문이 필요하다.
await page.waitFor('a[href="/auth/logout"]');
JavaScript
복사
위 코드를 $eval 전에 추가를 해주면 되겠다. 하지만 알아둬야 할 것이 있다. 만일 저 Anchor Tag가 존재하지 않는다면? 실제로 우리는 주어진 Tag가 Render 될 때까지 기다리는 것이지만, Anchor Tag가 존재하지 않으면 $eval, expect 구문 도달하기 전에 Test가 Fail이 되는 것을 확인할 수 있다. Expect하기 전에 Test하고자 하는 대상에 대해 결과를 미리 알 수 있어 조금 이상한 느낌이 들긴 하지만 어쨌든 정상적으로 Test가 수행되는 것을 알 수 있다. (계속해서 waitFor로 기다리게 되면 jest의 Default Timeout에 의해 Fail되는 것을 알 수 있다.)
Factory Functions
Jest를 이용한 Browser 띄우기, 상호작용하기, OAuth 문제가 해결 되었으므로 본격적으로 다른 기능들을 Test 하면 된다.
이 때 사용자 검증을 마쳐야만 이용할 수 있는 기능들이 있을텐데, 해당 Test를 수행할 때마다 반복되는 코드를 작성하는 것은 좋지 않으므로 사용자 검증 부분을 따로 빼낼 필요가 있다.
Session Factory는 새로운 Session과 Session Signature를 생성한다.
User Factory는 Test에 필요한 새로운 사용자를 만들어 Session Factory 등을 이용할 수 있도록 한다.
이런 Factory 들의 구조는 test Directory 내부에 factories를 두어 그 안에 저장한다.
Session Factory 코드는 아래와 같다.
const Buffer = require('safe-buffer').Buffer;
const Keygrip = require('keygrip');
const keys = require('../../config/keys');
const keygrip = new Keygrip([keys.cookieKery]);
// Mongoose의 User를 인자로 받는다.
module.exports = (user) => {
const sessionObject = {
passport: {
// Mongoose의 id는 _id로 되어 있는데 이는 String이 아니다.
// 따라서 객체 내에 정의 된 toString을 통해 id를 문자열로 만든다.
user: user._id.toString(),
},
};
const session = Buffer.from(JSON.stringify(sessionObject)).toString(
'base64'
);
const sig = keygrip.sign('session=' + sessionString);
return {
session,
sig,
};
};
JavaScript
복사
User Factory 코드는 다음과 같다.
const mongoose = require('mongoose');
const User = mongoose.model('User');
module.exports = async () => {
return await new User({}).save();
};
JavaScript
복사
이 때, User Factory 부분을 Test에 사용하면 Mongoose에 대해서 Error가 발생한 것을 볼 수 있다. jest를 통해 실행한 Node.js 환경은 일반 Server의 코드와 별개로 구분 된다. (jest는 .test.js만 이해할 수 있었다.) 즉, Server에 작성한 Mongoose의 Client와 Connection은 Server를 위한 것이지 Test 환경에서는 이용할 수 없는 부분인 것이다. 따라서 추가 작업이 필요하다.
Test 진행을 위해 Server를 켜두긴 했으나, 이는 Test 진행의 로직이 실행 되면서 Server 코드를 이용하는 것이지 Test 내부적으로 Server를 킨 것이 아니다. 비슷한 맥락으로 Mongoose가 돌아가는 것도 Server에서 돌아가고 있는 것이지 이를 Test에서 이용하려고 하면 오류가 나는 것이다. (별도의 환경이다. 만일 Test가 Server 코드를 이용하면서 Mongoose를 건드리면 오류가 안 나겠지만, 위 Factory 코드는 Test 환경 내에서 Direct하게 Mongoose를 건드리는 행위이다.)
따라서 Test 코드를 진행하면서 Server가 사용하고 있는 DB에 접근하여 새로운 사용자를 만들기 위해선 Test 환경에서도 MongoDB Connection을 열어주어야 한다. 이런 Test 환경에서 사용해야 하는 것들에 대해서 Global Setup이 필요하다. test Directory 내부에 Globl Setup용 파일을 별도로 생성한다.
// Server Code is totally separated from Test Code on jest
// Thus, need to run MongoDB Connection to use DB directly
require('../models/User');
const mongoose = require('mongoose');
const keys = require('../config/keys');
// mongoose does not want to use built in promise
// tell mongoose to use promise of global object
mongoose.Promise = global.Promise;
mongoose.connect(keys.mongoURI, { useMongoClient: true });
JavaScript
복사
이렇게 생성한 Global Setup 파일은 package.json내에 설정해줌으로써 jest 실행과 동시에 설정하게 만들 수 있다. package.json의 script 부분 위에 다음을 추가한다.
"jest": {
"setupTestFrameworkScriptFile": "./test/setup.js"
},
JSON
복사
이와 같이 설정하면 npm run test로 jest가 실행될 때, 해당 파일을 먼저 실행하게 된다.
npm run test를 통해 실행되는 jest의 코드는 다음과 같다.
// Chromium Browser 제어를 위해 Puppeteer 라이브러리를 사용한다.
// 오타가 자주나므로 재차 확인한다.
const puppeteer = require('puppeteer');
const userFactory = require('./factories/userFactory');
const sessionFactory = require('./factories/sessionFactory');
let browser, page;
beforeEach(async () => {
// 1. launch 함수 내에 Empty Object를 잊지 말 것
// (Browser 생성에 이용할 인자를 추후에 추가함)
// 2. 오타 내지 말 것
// 생성된 browser는 Running Browser이며, Browser Object이다.
browser = await puppeteer.launch({
// headless 값이 true일 경우, GUI에 대한 제공을 하지 않는다.
headless: false,
});
// Browser Object의 newPage 함수를 통해 새로운 Page를 생성할 수 있다.
// 생성된 page는 Running Tab이며, Page Object이다.
// Page에서의 특정 요소를 클릭하게 할 수 있다.
// Page를 다른 Page로 Navigate 시킬 수 있다.
page = await browser.newPage();
await page.goto('localhost:3000');
});
afterEach(async () => {
await browser.close();
});
test('Header Correct Text', async () => {
const text = await page.$eval('a.brand-logo', (el) => el.innerHTML);
expect(text).toEqual('Blogster');
});
test('Click Login & Start OAuth Flow', async () => {
// 1. 로그인 버튼을 누른다.
await page.click('.right a');
// 2. 화면 전환이 되어 올바른 Domain을 갖고 있는지 URL 비교를 통해 확인
// Jest Document를 통해서 비슷한 기능을 수행하는 함수가 있는지 확인
const url = await page.url();
expect(url).toMatch(/accounts\.google\.com/);
});
test('When Signed In , Show Logout Button', async () => {
// User Factory Refactored
// const id = '5f560533c554e2b971e8bc82';
const user = await userFactory();
// Session Factory Refactored
// const Buffer = require('safe-buffer').Buffer;
// const sessionObject = {
// passport: {
// user: id,
// },
// };
// const sessionString = Buffer.from(JSON.stringify(sessionObject)).toString(
// 'base64'
// );
// const Keygrip = require('keygrip');
// const keys = require('../config/keys');
// const keygrip = new Keygrip([keys.cookieKey]);
// // sessions=를 붙이는 이유는 Cookie Library가 이런식으로 이용하기 때문이다.
// // 꼭 Technical한 이유는 아니다.
// const sessionSignature = keygrip.sign('session=' + sessionString);
// // result value of generating for authentication
// console.log(sessionString);
// console.log(sessionSignature);
const { session, sig } = sessionFactory(user);
await page.setCookie({
name: 'session',
// value: sessionString
value: session,
});
await page.setCookie({
name: 'session.sig',
// value: sessionSignature
value: sig,
});
await page.goto('localhost:3000');
await page.waitFor('a[href="/auth/logout"]');
// await 구문을 통해 실제로 Page가 로딩된 후에 작동할 것처럼 보이지만,
// 실제 Page는 이를 아주 빠르게 처리하려고 노력한다.
// 즉, localhost:3000으로 이동은 했지만 아직 Render가 완료된 상태가 아님에도,
// localhost:3000으로 이동은 된 상태기 때문에 eval을 시도한다.
// 따라서 해당 element가 존재하지 않음에도 eval을 시도하여 오류가 발생한다.
// Page에 대해 waitFor 함수가 필요하다.
const text = await page.$eval(
'a[href="/auth/logout"]',
(el) => el.innerHTML
);
expect(text).toEqual('Logout');
});
JavaScript
복사
위와 같이 작성된 코드에서도 사용자를 생성하고 이에 따른 Session, Session Signature를 만드는 과정이 일종의 Login으로 볼 수 있는데, 매 Test마다 반복되는 저 코드들을 사용하기에는 불필요한 것들이 많으니 함수로 Refactor 시킬 수 있다. 이 함수를 단순히 생성하는 것이 아니라 편리하게 사용하기 위해 Page 객체 내에 넣으려고 한다. 이전에 Caching 부분에서 Monkey Patch를 했었는데, 이 방법이 아니라 ES6부터 지원되는 기능을 통해 Login Method를 Page Object내에 추가해보자.
Extending Page
Monkey Patch는 어쨌든 라이브러리의 원 함수를 손보는 것이기 때문에 (코드 자체를 바꾸는 것은 아니더라도 어쨌든 기능 자체가 수정되기 때문에) 의도하지 않은 버그가 발생할 수 있다. 이는 다른 프로그래밍의 라이브러리를 수정하는 행위에도 해당될 수 있다. 이런 것들은 상속의 Overriding을 통해 해결할 수 있다.
하지만 Page를 상속 받은 CustomPage를 두더라도 결국에 Test에서 사용하는 것은 Puppeteer의 조작을 통해서 진행하는데, Puppeteer 조작을 통해 CustomPage를 원활히 이용할 수 없다. (Puppeteer는 Page 밖에 모르기 때문) 즉, Puppeteer에거 Page말고 CustomPage를 이용하라고 하는 것은 어렵다.
어떻게 해결해야할까? 정말 Monkey Patch 밖에 없을까?
CustomPage를 두되 Page를 상속받는 것이 아니라, Page 타입의 멤버 변수를 두고 생성자 함수를 통해 초기화 하여 사용한다면? 여전히 Page에 대한 조작 자체는 Puppeteer를 통해 이용하게 되고, CustomPage에서는 자체 로그인을 구현하되 Page에 필요한 정보들은 생성자 함수를 통해 초기화 된 Puppeteer의 Page를 그대로 이용할 수 있다.
이 둘의 차이가 명확히 와닿지 않을 수도 있는데 이렇게 이해하면 되겠다. Page를 상속 받은 CustomPage의 경우 Page에 대한 생성이 CustomPage의 생성으로 이뤄지고 (부모 클래스기 때문에), 생성자 함수를 이용하는 CustomPage의 경우 Page에 대한 생성이 Puppeteer에 의해 이뤄지는 차이가 있다.
즉, Puppeteer의 조작을 통해 Test를 진행하기 때문에 전자의 경우 Puppeteer 이용에 무리가 있고, 후자는 Puppeteer를 통해 생성된 Page이므로 Test 진행에 지장이 없다.
(Puppeteer ≠ Page = CustomPage / Puppeteer = Page = CustomPage 이런 느낌이다. Page의 제어 주체가 다르다. 느낌이 전혀 안 올까봐 조금 더 덧붙이자면, 전자는 new Page()를 통해 생성되어 Puppeteer의 제어를 받지 않지만, 후자는 browser.launch()를 통해 생성되어 Puppeteer의 제어를 받는다는 차이가 있는 것이다.)
따라서 Login Method Refactoring에 있어서 상속도 좋은 해결 방안일 수 있지만, 여기서는 아니며 Puppeteer를 통해 먼저 생성한 Page를 생성자 함수의 인자로 주어 해결한다.
(다음 것과 내용 이어진다...)
Introduction to Proxies
위와 같이 클래스를 이용하면 CustomPage에 대한 이용은 하나의 Dot Chain으로 가능하지만, 내부에 존재하는 멤버 변수 Page를 이용할 때는 customPage.page.goto과 같이 2개의 Dot Chain으로 이용하게 된다. 이런 것들은 굉장히 귀찮은 작업일 수 있다. 어떻게 해결할 수 있을까?
애초에 클래스에 대한 기본 설정 자체를 바꿔 버릴 수 없을까? (예를 들면, page.goto를 CustomPage에서 이용할 때는 custompage.page.goto가 아니라 애초에 custompage.goto로 인식할 수 있게끔 말이다.)
가장 간단한 방법은 CustomPage 객체 내에 멤버 함수를 추가하여 객체 내의 goto 함수에 this.page.goto()를 작성하여, custompage.goto가 가능하도록 하는 것이다. 하지만 이는 존재하는 모든 멤버 변수에 대해서 다 작성을 해줘야 하기 때문에 따지고 보면 이 역시도 굉장히 비효율적인 작업 중 하나이다.
JavaScript에는 조금 특별한 Tool이 있는데 이를 통해서 CustomPage 객체에게 CustomPage의 멤버 함수를 호출한 것인지, CustomPage의 멤버 변수 Page의 멤버 함수를 호출한 것인지 결정 시킬 수 있다.
바로 Proxy라는 것을 이용한다. Proxy는 JavaScript ES6의 특징으로, Target Object나 Multiple Target Object에 대해서 접근 권한을 얻는다. Proxy로 여러 Target을 묶은 뒤, Target에 직접 접근하는 것이 아니라 Proxy를 통해서 접근하면 Proxy가 어떤 Target에 접근할지 결정하여 작업을 수행하게 된다.
Proxy 생성은 new 키워드를 통해 생성하며, 첫 번째 인자는 Target을 주고 두 번째 인자는 Target을 처리하는 Handler를 준다. Handler는 Object이며, Object 내부에는 Target을 접근하려고 할 때 호출되는 함수들이 정의되어 있다. 함수 형태는 아래와 같다.
const allTargets = new Proxy(firstTarget, {
get: function(target, property) {
console.log(property);
}
});
// 두 함수는 () 붙지 않았기 때문에 Callable은 아니다.
allTargets.handle;
allTargets.eventThatDoesNotExist;
JavaScript
복사
Target의 Handler로 주어지는 get 함수는 Proxy를 통해 접근하려는 property를 확인하고 Target에 해당하는 property를 호출 할 수 있게 해준다.
위의 allTargets.handle을 호출할 경우 get 함수에 의해 target에는 firstTarget이 들어가고, property로는 호출한 handle이 들어가면서 console.log가 실행된다. 따라서 결과로 "handle"이라는 결과가 수행된다. 즉 allTargets.handle을 호출하면 Target이 갖고 있는 handle이 호출되는 것이 아니라, allTargets의 get 함수가 호출되면서 처리된다.
다시 한 번! Proxy의 property를 호출하면 Target의 property가 호출되는 것이 아니라, Proxy의 get 함수가 호출되면서 Target을 처리할 수 있도록 돕게 된다. allTargets.handle 아래의 함수를 호출하게 되면, Target의 eventThatDoesNotExist를 호출하는 것이 아니라 Proxy의 get 함수가 호출되면서 eventThatDoesNotExist가 console.log를 통해 나타난다. (Proxy의 property 호출 시, property의 직접 실행이 아닌 get 함수가 Intercept하여 실행된다.)
따라서 Proxy를 이용하여 Original Target의 Property를 호출하려면 Target에 해당하는 Property를 리턴하여 호출하면 된다.
const allTargets = new Proxy(firstTarget, {
get: function(target, property) {
// target의 property에 해당하는 함수를 리턴
// allTargets.property()로 함수가 수행됨
return target[property];
// target의 property에 해당하는 함수를 수행
// allTargets.property 만으로 함수가 수행됨
// return target[property]();
}
})
// 두 함수는 () Callable이다.
allTargets.handle(); // firstTarget에 존재하므로 수행
allTargets.eventThatDoesNotExist(); // firstTarget에 존재하지 않으므로 오류
JavaScript
복사
위 처럼 생성해둔 Proxy에서 만일 Proxy가 Target으로 삼고 있는 객체에 property가 없다면 다른 Target에서 property를 수행하도록 하려면 어떻게 할까?
const allTargets = new Proxy(firstTarget, {
get: function(target, property) {
return target[property] || secondTarget[propert];
}
});
allTargets.handle(); // firstTarget에 존재하므로 수행
allTargets.eventThatDoesNotExist(); // firstTarget에 존재하지 않지만 secondTarget에 존재하므로 수행
JavaScript
복사
따라서! 결과적으로! 마지막으로!
CustomPage에 Page를 멤버 변수로 두면서, Proxy를 이용하여 각 함수들을 여러 번의 Dot Chain 없이 편리하게 호출할 수 있게 된다. (ES6 사랑해요 ~)
Combining the Page and Browser
실제 원래 코드를 Refactor 해보자. 이 때, CustomPage와 Page를 통해 Proxy를 만들게 되는데 매 Page 생성 마다 Proxy를 만들 수는 없으니, Proxy를 리턴하는 함수로 묶는다. 또한 해당 함수는 Proxy를 리턴하도록 되어 있는 와중에 CustomPage 없이는 생성되지 않으므로, Proxy를 만드는 함수를 CustomPage에 넣도록 한다. 주의할 것은 어차피 해당 함수 내부적으로 CustomPage를 생성하도록 되어 있기 때문에, CustomPage 인스턴스에 Dot Chain으로 해당 함수를 호출할 필요 없이 CustomPage 클래스에 Dot Chain 하도록 만들면 된다. (실제 객체 없이 클래스 만으로 호출할 수 있도록) static으로 정의한다. 이에 유의하여 Refactor 하면 된다. (심지어 Page와 Browser까지 합칠 수 있다.)
이 때 해당 클래스는 test Directory 아래에 helpers Directory에 생성하도록 한다. Browser를 통해 Page를 만드는 것을 static 함수 내에서 처리하고, Proxy를 두었기 때문에 모든 작업은 Proxy 위에서 하게 된다. Page에 대해 새로운 스크립트를 만든 것 뿐 아니라 기존 코드 역시 모드 Refactor 된다.
아래는 test/helpers/page.js로 CustomPage를 작성한 것이다.
const puppeteer = require('puppeteer');
const userFactory = require('../factories/userFactory');
const sessionFactory = require('../factories/sessionFactory');
class CustomPage {
static async build() {
const browser = await puppeteer.launch({
headless: false,
});
const page = await browser.newPage();
const customPage = new CustomPage(page, browser);
return new Proxy(customPage, {
get: function (target, property) {
return target[property] || page[property] || browser[property];
},
});
}
constructor(page, browser) {
this.page = page;
this.browser = browser;
}
async login() {
const user = await userFactory();
const { session, sig } = sessionFactory(user);
await this.page.setCookie({ name: 'session', value: session });
await this.page.setCookie({ name: 'session.sig', value: sig });
await this.page.goto('localhost:3000');
}
}
module.exports = CustomPage;
JavaScript
복사
이전에 sessionFactory와 userFactory가 들어갔던 코드 및 beforeEach, afterEach의 모든 코드들은 정말 간단하게 대체 되었다.
const Page = require('./helpers/page');
let page;
beforeEach(async () => {
page = await Page.build();
await page.goto('localhost:3000');
});
afterEach(async () => {
await page.close();
});
test('Header Correct Text', async () => {
const text = await page.$eval('a.brand-logo', (el) => el.innerHTML);
expect(text).toEqual('Blogster');
});
test('Click Login & Start OAuth Flow', async () => {
await page.click('.right a');
const url = await page.url();
expect(url).toMatch(/accounts\.google\.com/);
});
test('When Signed In, Show Logout Button and Click', async () => {
await page.login();
await page.waitFor('a[href="/auth/logout"]');
const textLogout = await page.$eval(
'a[href="/auth/logout"]',
(el) => el.innerHTML
);
expect(textLogout).toEqual('Logout');
await page.click('a[href="/auth/logout"]');
await page.waitFor('a[href="/auth/google"]');
const textLogin = await page.$eval(
'a[href="/auth/google"]',
(el) => el.innerHTML
);
expect(textLogin).toEqual('Login With Google');
});
JavaScript
복사
여기서 Browser와 Page, CustomPage를 모두 묶었기 때문에 page.close가 Browser의 close가 아닌 Page에 대한 close로 동작하는 것을 볼 수 있다. 이것만 해결 해주면 된다. Function Lookup Priority에 대해서 잘 생각해보면 해결책을 찾을 수 있다.
여기까지의 중간 요약:
Browser 실행 문제 및 상호 작용 문제
→ Puppeteer를 통해 해결
OAuth 문제
→ User Factory, Session Factory를 통해 해결
→ DB Connection 문제를 package.json을 통해 해결
OAuth를 통한 반복되는 Login
→ Page 객체 Extending 하여 함수 별도 정의
Function Lookup Priority
애초에 CustomPage 클래스의 build 함수에서 Proxy는 Page에 대한 함수가 없으면 Browser를 탐색하기 때문에, close의 경우 Page에 있는 함수므로 Page에 대한 close를 수행하게 된다. (Puppeteer Official Document를 보면 확실히 알 수 있다.)
이에 따른 해결 방법은 2가지가 있다.
방법1. CustomPage 내에 close 함수를 새로 만드는 것이다. (Page보다도 CustomPage에 대한 함수를 찾는 우선 순위가 더 높으므로) 이 때 close에는 browser를 사용해야 하므로 CustomPage 내에 page 멤버 변수 뿐 아니라 browser 멤버 변수도 둔 것이다.
다음과 같은 코드를 CustomPage 클래스에 추가한다.
// 해당 함수를 만들기 싫다면, Proxy의 get 함수에서 browser와 page의 순서를 바꾼다.
close() {
this.browser.close();
}
JavaScript
복사
방법2. Proxy 내부의 get 함수에서 page[property]와 browser[property]의 순서를 바꾼다.
** 참고로 puppeteer.$eval 함수와 같이 JavaScript 부분에서 호출되는 함수가 아니라 Browser로 Serialize되어 전달되어 그 환경에서 실행되는 특이한 이름의 함수는 JavaScript 코드로 Wrapping하여 사용하면 조금 더 가독성이 좋아진다. Browser의 Element에 접근할 수 있는 puppeteer.$eval함수를 CustomPage 내 getContentsOf로 Refactor 시키는 것도 하나의 방법이다.
** header.test.js 뿐 아니라 blog.test.js를 추가하여 두 Test 파일을 동시에 돌려보면, mocha처럼 병렬 처리가 안 되는 것이 아닌 두 Test 파일이 동시에 Chromium Instance를 실행하면서 Test 하는 병렬 처리를 볼 수 있다. 이런 병렬 처리 가능 여부 때문에 jest를 사용하는 것이다. (병렬 처리가 무조건 좋은 것은 아니다. 때때론 병렬 처리가 되어선 안 되는 것들도 있다. 그런 경우 jest가 아닌 기존 mocha를 이용한 Test를 수행하도록 한다.)
Test Timeout
jest Test 수행에 있어서 수행 제한 시간이 있는데 기본 값은 5000ms이다. 이는 Test를 수행하는데 있어서 적절한 제한 시간이 아닐 수 있으므로 제한 시간에 대한 수정이 요구될 수도 있다. (모든 Test를 수행하는데 있어서 5000ms가 아니라 비동기 작업 1개의 Timeout이 5000ms이라는 소리다.)
DB 연결할 때 사용했던 setup.js에서 설정을 해준다. 최상단에 아래 코드를 추가한다.
// 비동기 작업에 대해서 완료 될 때까지 30초까지 기다린다.
jset.setTimeout(30000);
JavaScript
복사
Common Test Setup
Test 작업을 위한 전반적인 세팅이 위에서 살핀 예시와 같이 끝이 났다. 그렇다면 실제로 Test 로직은 어떻게 구성하며, 어떻게 작성해야 할 까?
위와 같은 Test 로직은 2가지로 나뉜다.
파란색은 조건절이 붙는 것들이고, 초록색은 그 조건에 대한 결과들을 의미한다. (Terminal Node들은 초록색이라고 생각하면 편하다.) 이 때, 최하단 최좌측 2개의 Test를 수행하기 위해선 각 Test마다 파란색 조건절 2개를 타고 와야하는데 코드 중복 문제는 어떻게 처리 할 것인가? 이와 같이 공통적으로 수행해야 하는 부분들에 대해서 1번만 작성해도 되도록 할 수 있다. Nested Describes for Structure에 대해 알아보자.
Nested Describes for Structure
파란색은 Describe로 초록색은 Test로 처리한다.
Describe 구문은 그 속에 Test 구문을 넣든가, 또 다른 Describe 구문을 넣을 수 있다. 이를 통해 같은 작업이 필요하더라도 로직을 반복하여 기입할 필요가 없어진다. (이렇게 할 수 있는 이유는, Test 구문 실행 전에 BeforeEach AfterEach를 둘 수 있었던 것을 생각하면 조금 더 쉽게 와닿을 것이다. 이전처럼 두었던 BeforeEach, AfterEach 구문을 Describe구문 내부에도 추가로 두어 Describe 내부에서만 동작하는 BeforeEach, AfterEach를 둘 수 있기 때문이다.)
또한 가정 중요한 점은, 이를 통해 새로운 프로젝트를 하러 온 사람에게 특정 작업이 수행되기까지 말로 일일이 설명하지 않고도 쉽게 이해시킬 수 있다는 것이다. (실제로 내가 새로운 회사에서 새로운 일을 맡았을 때, Test 코드가 존재한다면 Test 코드를 읽는 것이 전반적인 프로젝트 수행 과정을 더 빠르게 이해하는데 도움이 된다.)
이전에 작성했던 blog.test.js가 아래와 같다면,
const Page = require('./helpers/page');
let page;
beforeEach(async () => {
page = await Page.build();
await page.goto('localhost:3000');
});
afterEach(async () => {
await page.close();
});
test('When logged in, can see blog create form', async () => {
await page.login();
await page.click('a.btn-floating');
const label = await page.getContentsOf('form label');
expect(label).toEqual('Blog Title');
});
JavaScript
복사
기존의 Test 로직을 모두 수용할 수 있도록 바꾸어 낸 위의 내용은, 아래와 같다.
const Page = require('./helpers/page');
let page;
beforeEach(async () => {
page = await Page.build();
await page.goto('localhost:3000');
});
afterEach(async () => {
await page.close();
});
// All test statement changed into describe statement
// test('When logged in, can see blog create form', async () => {
// await page.login();
// await page.click('a.btn-floating');
// const label = await page.getContentsOf('form label');
// expect(label).toEqual('Blog Title');
// });
describe('When logged in', async () => {
// beforeEach statement affect all of the descibe and test in root describe
beforeEach(async () => {
await page.login();
await page.click('a.btn-floating');
});
test('Can see blog create form', async () => {
const label = await page.getContentsOf('form label');
expect(label).toEqual('Blog Title');
});
});
JavaScript
복사
그렇다면 그림으로 주어진 Test Logic 중 좌측 Brach를 Describe, BeforeEach, AfterEach, Test 구문으로 나타내면 어떤 코드가 나타날까? (Describe와 Test의 Callback 함수 전에 나와 있는 Text를 통해서 쫓아가면 편하게 쫓아갈 수 있다. 따라서 실제 서비스 동작을 추적할 때 코드보다 Test를 보는 것이 더 빠를 수도 있다는 것이다. 일일이 Text로 설명이 되어 있으니!)
const Page = require('./helpers/page');
let page;
beforeEach(async () => {
page = await Page.build();
await page.goto('localhost:3000');
});
afterEach(async () => {
await page.close();
});
// All test statement changed into describe statement
// test('When logged in, can see blog create form', async () => {
// await page.login();
// await page.click('a.btn-floating');
// const label = await page.getContentsOf('form label');
// expect(label).toEqual('Blog Title');
// });
describe('When logged in', async () => {
// beforeEach statement affect all of the descibe and test in root describe
beforeEach(async () => {
await page.login();
await page.click('a.btn-floating');
});
test('Can see blog create form', async () => {
const label = await page.getContentsOf('form label');
expect(label).toEqual('Blog Title');
});
describe('When using valid inputs', async () => {
beforeEach(async () => {
await page.type('.title input', 'mytitle');
await page.type('.content input', 'mycontent');
await page.click('form button');
});
test('Submitting takes user to a review screen', async () => {
const reviewText = await page.getContentsOf('h5');
expect(reviewText).toEqual('Please confirm your entries');
});
test('Submitting then saving adds blog to "Blog Index" page', async () => {
await page.click('button.green');
// Need to wait until the post being created
await page.waitFor('.card');
const title = await page.getContentsOf('.card-title');
const content = await page.getContentsOf('p');
expect(title).toEqual('mytitle');
expect(content).toEqual('mycontent');
});
});
describe('When using invalid inputs', async () => {
beforeEach(async () => {
await page.click('form button');
});
test('Submitting shows error messages', async () => {
const titleError = await page.getContentsOf('.title .red-text');
const contentError = await page.getContentsOf('.content .red-text');
expect(titleError).toEqual('You must provide a value');
expect(contentError).toEqual('You must provide a value');
});
});
});
JavaScript
복사
Direct API Requests
Test Logic에서 좌측 Branch만 코드로 먼저 작성한 것은, Client Side에서 그 요소들을 확인할 수 있었기 때문이다. 우측 Branch의 Clienet Side에서 DOM 요소들을 쉽게 확인할 수 없기 때문에, 조금은 다른 처리를 통해 Test 해야 한다. (로그인 하지 않은 상태에서는 보이지 않는 DOM 요소들이므로, DOM 요소를 클릭하거나 DOM 요소의 검증을 통해서는 Test가 불가능하다.)
이 Test를 통해 검증하려는 시나리오는 Client Side에서 DOM 요소가 보이지 않아야 하는데, 버그로 인해 접근할 수 있게 되어 Server로 Request를 보낼 수 있게 된 경우이다. 따라서 Test 시에도 DOM 요소를 이용하여 Test하는 것이 아니라(어차피 안 보이고, 안 보여야 하고, 안 보이니까), 직접적으로 Request를 날려서 작업이 수행되지 않는다는 것을 보이면 된다.
DOM 요소를 통해 Blog 생성 페이지나 Blog 리스트를 보는 것 자체가 안 되어야 하는 Test는 React 쪽에서 Test하면 되고 (Frontend), 위 Test의 경우 Request를 보냈을 때 작업이 처리되지 않는다는 것을 보인다 (Backend). React 쪽에서의 Test만으로 끝이 아니라 Request를 통해서 해당 작업이 처리되지 않는다는 것을 꼭 보여야 하는 이유는, URL을 통해서 어쨌든 Blog 생성 페이지나 Blog 리스트에 대해서 접근을 시도할 수 있기 때문이다. 만일 Request 자체가 먹히지 않는다면, 이와 같은 접근 시도에도 작업이 처리되지 않을 것이다.
직접적인 Request를 보낼 때 ajax나 axios를 통해서 보낼 수 있지만, 명심해야 할 것은 이 Request를 보내는 주체가 Node.js가 아닌 Chromium Browser이다. 즉, Request를 보내는 Method를 Chromium의 것으로 요청해야 한다. fetch를 사용한다.
fetch는 추가 인자로 Object를 주는데, method, credential, headers, body와 같은 key에 value를 매칭시켜 작성한다. fetch는 기본적으로 Cookie를 Request에 집어넣지 않기 때문에 직접 기입해줘야 한다. 이 Test의 경우 Cookie를 없이 보내는 것이 아니라, Request를 보내더라도 Cookie내의 값이 이미 만료된 상황을 가정하기 때문에 기존에 사용하던 Cookie를 집어넣어 작동하지 않음을 보여줄 것이다.
fetch('/api/blogs', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'mytitle',
content: 'mycontent',
})
});
JavaScript
복사
이와 같이 Direct 요청을 보냈을 때, Error에 대한 결과를 받아서 처리하면 되는 Test인 것이다. (로그인이 되어 있는 상태면 위의 fetch가 작동할 것이고, 로그인이 되어 있지 않으면 fetch가 작동하지 않을 것이다.)
위와 같은 fetch 요청을 puppeteer를 통해서 만드는 방법은 evaluate를 이용한다. puppeteer의 document를 찾아보면 알겠지만, evaluate 시에 실행하고자 하는 함수를 그대로 넣는 것이 아니라 Arrow Function으로 감싸서 익명 함수로 넘겨아 한다. Arrow Function과 같은 익명 함수가 아니라, Named Function으로 넘기게 되면 정의되지 않은 함수로 evaluate이 처리할 수 없다고 Error를 만들어 내기 때문이다. 따라서 익명함수로 넘겨야하며, Arrow Function으로 된 함수는 Serialize를 통해 그대로 Chromium으로 넘어가서 Deserialize 후에 Arrow Function 내부의 함수들이 실행된다. (evaluate 함수의 인자로 넣은 Arrow Function내의 리턴 값은 evaluate 함수가 Promise로 받게끔 만들어 준다.)
또 다시 이 때 중요하게 봐야할 것은, fetch의 리턴 타입이다. fetch는 raw data의 stream을 리턴한다. 따라서 raw data를 json으로 바꾸어 받아야 한다.
이렇게 완성된 최종 코드는 아래와 같다.
const Page = require('./helpers/page');
let page;
beforeEach(async () => {
page = await Page.build();
await page.goto('localhost:3000');
});
afterEach(async () => {
await page.close();
});
// All test statement changed into describe statement
// test('When logged in, can see blog create form', async () => {
// await page.login();
// await page.click('a.btn-floating');
// const label = await page.getContentsOf('form label');
// expect(label).toEqual('Blog Title');
// });
describe('When logged in', async () => {
// beforeEach statement affect all of the descibe and test in root describe
beforeEach(async () => {
await page.login();
await page.click('a.btn-floating');
});
test('Can see blog create form', async () => {
const label = await page.getContentsOf('form label');
expect(label).toEqual('Blog Title');
});
describe('When using valid inputs', async () => {
beforeEach(async () => {
await page.type('.title input', 'mytitle');
await page.type('.content input', 'mycontent');
await page.click('form button');
});
test('Submitting takes user to a review screen', async () => {
const reviewText = await page.getContentsOf('h5');
expect(reviewText).toEqual('Please confirm your entries');
});
test('Submitting then saving adds blog to "Blog Index" page', async () => {
await page.click('button.green');
// Need to wait until the post being created
await page.waitFor('.card');
const title = await page.getContentsOf('.card-title');
const content = await page.getContentsOf('p');
expect(title).toEqual('mytitle');
expect(content).toEqual('mycontent');
});
});
describe('When using invalid inputs', async () => {
beforeEach(async () => {
await page.click('form button');
});
test('Submitting shows error messages', async () => {
const titleError = await page.getContentsOf('.title .red-text');
const contentError = await page.getContentsOf('.content .red-text');
expect(titleError).toEqual('You must provide a value');
expect(contentError).toEqual('You must provide a value');
});
});
});
describe('When not logged in', async () => {
const actions = [
{
method: 'get',
path: '/api/blogs',
},
{
method: 'post',
path: '/api/blogs',
data: {
title: 'T',
content: 'C',
},
},
];
test('Blog related actions are prohibited', async () => {
const results = await page.execRequests(actions);
for (let result of results) {
expect(result).toEqual({ error: 'You must log in!' });
}
});
});
JavaScript
복사
** 참고할 것이 있는데, 마지막 로그인이 안 된 부분에 대한 Test에서 Blog 글을 갖고 오는 것과 Blog 글을 생성하는데 있어서 로직이 동일하여 Refactor 하였다. 그 과정에서 Promise.all을 통해서 두 Promise를 동시에 처리하고자 하였고, 이 부분을 CustomPage에 추가하였다. 또한 CustomPage 내에서 Promise.all을 문제 없이 수행하고자 인자를 정해진 Array로 넘겨 받았다. 수정된 CustomPage의 코드는 아래와 같다.
get, post, execRequests 부분이 추가되었다.
const puppeteer = require('puppeteer');
const userFactory = require('../factories/userFactory');
const sessionFactory = require('../factories/sessionFactory');
class CustomPage {
static async build() {
const browser = await puppeteer.launch({
headless: false,
});
const page = await browser.newPage();
const customPage = new CustomPage(page, browser);
return new Proxy(customPage, {
get: function (target, property) {
return target[property] || page[property] || browser[property];
},
});
}
constructor(page, browser) {
this.page = page;
this.browser = browser;
}
async login() {
const user = await userFactory();
const { session, sig } = sessionFactory(user);
await this.page.setCookie({ name: 'session', value: session });
await this.page.setCookie({ name: 'session.sig', value: sig });
await this.page.goto('localhost:3000/blogs');
}
getContentsOf(selector) {
return this.page.$eval(selector, (el) => el.innerHTML);
}
get(path) {
return this.page.evaluate(async (_path) => {
const response = await fetch(_path, {
method: 'GET',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
});
return response.json();
}, path);
}
post(path, data) {
return this.page.evaluate(
async (_path, _data) => {
const response = await fetch(_path, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(_data),
});
return response.json();
},
path,
data
);
}
async execRequests(actions) {
return Promise.all(
actions.map(({ method, path, data }) => {
return this[method](path, data);
})
);
}
// 해당 함수를 만들기 싫다면, Proxy의 get 함수에서 browser와 page의 순서를 바꾼다.
close() {
this.browser.close();
}
}
module.exports = CustomPage;
JavaScript
복사