안녕하세요 깍돌이 입니다. 오랜만에 인사드리네요 기존에 작업했던 내용들 ( UI ) 혹은 그 뒤에 작업했던 내용들이
사실 거의 다 대외비성이라 이게 .. 참 포스팅을 할수 없다는게 아쉽네요
이번에도 시작은 이렇게 하고 또 대외비로 빠질진 모르겠지만.. 나름의 큰 프로젝트를 하고있기도 하고 이번 UI는 있는거 에 한해서 이래저래 조합조합 해서 진행할 예정입니다.
PlayWright 입니다. 핫하기도하고 팀내에서 먼저 쓰고 있는 분들이 있기도하고 일단 간단하게 맛만 봤었을때 특이점이 하나 있어서 선택해보게 되었습니다.
물론 저는 셀레니움 버전 1 , 버전 2(RC타입) , 퍼펫티어도 어느정도 해봤으니 이래저래 팀내 프로젝트도 진행하고있고 여러 이유를 이용해서 이걸 시작해도 되지만 같이 해야 하는 인원이 있는 만큼 여러 가지 고민중 선택하게 된 계기는
러닝커브가 매우 낮다 입니다.
셀레니움에서 고민해야될 부분 들이 100가지가 있다면 실제로 playwright 에서는 없다고 봐도 무방합니다. (기본적인 트러블 슈팅이 아닌 근본적인 고민 부분에 대한 이야기입니다. )
대표적으로 Find Element 에 대한 모듈 분리 및 다른 전략들이 필요로 하는데 이중에 예외처리 해야되는 것들 중 자동화 하시는 분들이 어려워하는게 Element 를 찾지 못하는 경우 입니다. 해당 Text 값같은 걸 찾는 경우
Element에서 inner Text인지 textContents인지 value인지 data-property 인지 매번 예외처리를 해주었어야 했지만 playwright 에서는 해당 부분을 전부 자체 개발된 API 를 통해서 지원해주기 때문에 러닝커브가 매우 낮다고 판단하였습니다.
MPA에서 보시면 유의미하게 봐야 할 부분이 렌더링 및 페인트 ( 화면 그리기 ) 는 브라우저에서 발생합니다. 하지만
MPA는 Element ( 브라우저의 syntax tree 중 HTML Element ) 를 미리 서버에서 만들어 놓은 후 클라이언트에게 던져주는 것익오 SPA의 경우 Element를 서버에서 그려주는것이 아닌 클라이언트(브라우저) 에서 JS를 실행하여 만들고 그다음에 브라우저가 렌더링을 하는 방식입니다. ( syntax tree에서 렌더링을 하는 부분은 매우 복잡하고 내용이 길어지기 때문에 생략 합니다. )
요약을 하자면 MPA는 밑그림을 백엔드에서 그려서 던져주고 SPA는 밑그림 부터 내가 그려야 합니다.
그렇습니다. 화면은 변경되었지만 ? URL은 변경되지않았습니다. 리액트 홈페이지의 경우 React의 Router를 사용하여서 화면을 렌더링 해주기때문에 URL 이 변화가 생기면서 렌더링을 하고있지만 해당 홈페이지도 아마 확인시 새로운 networkidle을 받아 오지는 않을것으로 보입니다. ( 하지만 SSR을 적용하였다면 받아 올수 있습니다. )
그렇기 때문에 새로운 DCL 이나 L, FMP ( 아주 대중적으로 jquery 쓰시는 분들은 jquery.ready 도 있습니다. ) 등이 새롭게 이벤트가 캐치 되지 않기 때문에 waitForNavigation 는 매우 높은 확률로 timeout이 나고 이부분을 모르는 puppeteer 사용자는 의문을 표하게 되며 무조건 timeout을 스택오버플로우에 검색하게 될수 있습니다.
그럼 UI 자동화 툴을 이용하여서 페이지를 넘기거나 다른 작업이 시작전 정상적으로 로드 됨을 체크하기위해서 해당 API 를 썻던 경우 위의 화면과 같은 CSR의 경우 어떻게 확인을 하는것이 좋을까 에 대한 값은 아래의 API 가 있습니다.
isIntersectingViewport API
공식 홈페이지부터 보고 가시겠습니다~!
returns : 요소가 뷰 포트에 표시되면 true를 반환합니다.
여기서 뷰 포트는 현재 브라우저의 뷰 라고 할수 있습니다.
그럼 이걸로 페이지 가 정상 렌더 됐는지 어떻게 확인할까에 대해서 위의 서버 생성 페이지를 한번 보겠습니다.
상단 UL 부분
하단 페이지 렌더 부분
여기서 특이점을 찾으셨나요?
4개의 DIV가 있고 현재 보여주는 1개 제외하곤 display :none을 통해서 처리하고있습니다.
? 위에 5개가있던데 1개는 어디있을까 하고 확인해보니
마지막 생성시에 붙는점이 확인되었습니다. (의도는 잘 모르겠습니다.)
그럼 이제 이 페이지에서 내가 서버를 선택하고 -> 서버 생성 페이지로 정상적으로 들어왔는지를 체크해야합니다.
일단 2가지가 있는데 isIntersectingViewport 를 먼저 실행하진않습니다. 이유가 뭐냐면 저기 위에있는 html , div, ul 등을 HTML 태그라고 하면서 Element라고 표현하기도합니다. 그리고 이 요소들이 붙는 부분이 DOM 인데 이 Element들이 DOM 에 붙지 않았다면 당연히 브라우저는 렌더링을 해주지 않기 때문에
우선적으로 DOM 에 Element가 붙었는지를 체크합니다. 체크를 하는 Element를 선택하는 기준은 모두가 다르지만 저는 다음에 액션을 진행할 Element를 기준으로 잡습니다. DOM 체크는 간단합니다.
Puppeteer는 단일 요소는 $ 다중 요소는 $$ 로 찾게 되는데 해당으로 체크할시 null 이 아니라면 Element가 DOM 에 는 붙어있다는 의미가 됩니다.
그리고 그다음 isIntersectingViewport 를 호출하면 현재 보여지는 화면에 제가 선택한 Element 가 보여지는지 안보여지는지 확인이 가능합니다.
모든 페이지가 MPA로 구성되어있다고 해도 현재 브라우저의 뷰 포트에서 노출이 되고있느냐 아니냐로도 체크 되기도 하기 때문에 해당 API 는 지금 보여지는가 에 대한 사용으로 가능할거 같습니다.
기존에 준비하였던 내용중 일단 여기까지 하고 배포 릴리즈가 있기 때문에 .. 주말에 작업을 (또르르) 하고 다시 이어서 할 예정입니다.
품질은 제품이 잘 나가야 하는게 1순위니까요 이번 LNB Menu 선택 이후에 할 예정된 포스팅은
Selenium-Webdriver 에서 Puppeteer 로 작업중인 이유 (Puppeteer 선택의 이유 )
요즘 새로 핫한 playwright 에 대해 ( 퍼펫티어 팀이 나가서 MS에서 개발 중 )
MPA, SPA 페이지에 대한 waitForNavigationAPI , isIntersectingViewport API 사용
현재 진행 중 인 Console 자동화에서 Server 생성 (waitForNavigation API 먼저 포스팅 예정)
Server List에서 서버 상태 체크
등등이 있고 이제까지 업무하면서 개인적으로 공부 했던 부분들을 모두 포스팅으로 남겨 놓도록 하겠습니다.
LNB Menu 선택을 위한 함수 제작 입니다.
뭔가 앞에 로그인기능, 콘솔 이동, 파라미터 유효성 등 저렇게까지 해야하나 싶을정도의 예외 처리 등이 들어가게 되는데 이유는 공통적으로 사용되는 기능이라면 모듈화가 필수기 때문입니다.
3개의 Test Case 가 아래처럼 되어있다고 생각해보겠습니다.
각각의 로그인을 따로 구현, 콘솔이동도 따로 구현, 메뉴선택도 따로 구현 하였습니다.
이렇게 구현할 경우면 앞선 포스팅처럼 까다롭게 할필요도 없습니다. 단순히 그냥 지금 현상태로 로그인 Element가져와서 바로 입력하고 실행하게 하면 되는 것이지요 그럼 예외처리도 필요없고 파라미터도 필요 없고 하지만 대부분의 케이스들의 UI를 하기 위해선 콘솔로 이동을 하여야 하기 때문에 중복되는 케이스들이 발생합니다.
그래도 사용성에 문제가 없어서 잘 쓰고 있었다면 ? 네 이상이 없으면 상관이없을 수 있습니다.
하지만 이렇게 진행하다가 Automation Test Case가 100건까지 늘어났습니다. (이때까지 아무 이슈가 없다고 했을 경우)
그러는 와중에 개발팀에서 메뉴 Item의 class를 바꿨습니다. 그럼해야할 작업은
"모든 케이스의 직접 .class를 다시 맞춰서 찾아 주는 작업을 해줘야 합니다. 콘솔이동시에 URL체크로 하고있는데 이경우도 마찬가지입니다. 이동하는 URL의 변경 될경우 모두 바꿔 주어야 합니다. 그렇기 때문에 공통되는 작업들에 대해서는 일단 혼자 작업한다고 해도 모듈화를 우선적으로 하는게 건강에 좋기 때문에 위와같이 작성 되고있습니다.
그리고 모듈화에 앞서 UI Automation 에 서 보면 아래와 같은 질문들이 올라오는 경우가 있습니다.
"UI Automation 을 하면 개발자들의 자꾸 바꾸는 바람에 테스트가 오류가 납니다."
라는 이야기를 많이 듣기도했고 해보다 보니 그런거 같기도 하고 그래서 UI 는 답이없네 라고 할수도 있습니다.
하지만 실제로 경험을 해봤을 경우 웹사이트의 경우 그렇게 크게 바뀌지 않습니다. (대개편이 일어나지않는 이상)
특히 B2B 의 제품을 QA하고 계시다면 더더욱 그럴일은 더 적어지게 됩니다.
"그럼 QA분들이 말하는 게 다 허상이냐" 그건 또 아닙니다. 제가 느꼈던 짤막한 경험으로는 xpath 의 사용 혹은 고정 CSS Selector의 사용이였습니다. ( Element 선택에 대한 기본기가 부족하다고 할수 있습니다. )
예를 들면 요소 > 요소 > 요소 에서 최초에 3번째의 요소만 무조건 찾는 형태로 구성되어 있는 상태에서
개발자들의 hidden Element를 추가하게 될시 ( Tooltip 등을 Custom 으로 제작시등 많이 사용 합니다. - Z-index를 통한 툴팁 박스 )
요소 > 요소 > 히든 > 요소 의 구조가 가 되기때문에 기존에 지정해놓은 경로인 세번째 요소로는 찾아지지가 않는 것뿐입니다. xpath 가 나쁘다는 건 아니지만 CSS Selector를 유연하게 작업할수 있다면 그리고 공통적으로 써야하는 모듈을 제작하면서 예외처리를 하나씩 추가하게되면 화면을 통쨰로 갈아 엎지 않는 이상 자주 변경할 일은 발생하지않습니다.
그래서 결론적으로는 해당 공통케이스에 대한 모듈화 + CSS Selector를 위한 정확한 Element 선택 을 LNB Menu 에서 사용하고자 합니다. 같이 사용하게된다면 메뉴는 가장많이 사용되는 기능일수 있습니다.
우선 사용 호출 부 입니다.
해당 서버 밑의 서버 메뉴를 선택해야 하기 때문에 Server, Server를 파라미터로 받습니다. 만약에 3depth 까지 있을수 있기에 파라미터는 배열형태로 받게 되었습니다.
["Server","Storage"] 라면 Server를 열고 Storage를 선택하게 합니다.
지금은 완성이 아니지만 추가작업으로 Server가 ( 1depth ) Open 되어있는상태에서 Server, Snapshot이 들어오게된다면
1depth의 Server가 Expand 상태라면 바로 Snapshot을 선택할 수 있어야합니다.
다른 파라미터로는 testplatform 과 environment를 받게 됩니다. NCP의 콘솔의 경우 Classic 과 VPC를 선택할수 있기 때문이고 environment 는 환경 로깅 용 입니다.
보시면 해당 페이지의 개발자의 경우 모든 첫번째 메뉴들은 a태그와 id값을 사용 하였습니다.
따로 요청 하지않은 경우에 위와같이 id값을 직접 쓰게 되면 매우 좋은 케이스로 자체적으로도 유니크한 값을 쓰고있다고 보시고 #을 이용하여서 가져옵니다.
#인 경우는 따로처리할 필요가 없습니다. 다만유의해야 할 점이 있다면 li나 a 태그가 아니라 그 하단에 span 태그를 선택하는점 '#Server > span' 으로 클릭해주면 자연스럽게 열리게 됩니다.
DNS 메뉴를 선택하려고 해도
같은 방식으로 되어있기 때문에 첫번째 메뉴 선택까진 큰 무리가 없습니다.
두번째 서브 메뉴를 선택하려고 하면 메뉴상태에 따라서 여러가지 선택이 될수 있는 데 금일은 .active상태인걸 확인하고 (바로 1depth 의 메뉴가 열렸다는 가정) 선택을 할수 있도록 합니다.
지금 작성을 하려고 보니 예외처리나 여러가지 기능들이 잘 안되어있네요. 간단하게 설명드리자면
위의 케이스는 active된 케이스 ( Server ) 에 첫번째 Element를 가져와서 선택하게 되어있네요 (이부분은 수정해서 해당 포스팅에 그대로 작성하겠습니다.)
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist",
"baseUrl":".",
"paths":{
"*":[
"types/*",
"bin"
],
"dist/*":[
"../../dist/*"
]
},
"noEmitHelpers":false,
"importHelpers": false, // tslib 에서 방출된 헬퍼를 import 합니다. (예. __extends, __rest, 등..)
"sourceMap": false, //해당하는 .map 파일을 생성합니다.
"declaration": true, //해당하는 .d.ts 파일을 생성합니다.
"inlineSourceMap": false, //별도의 파일 대신 소스 맵으로 단일 파일을 내보냅니다
"noImplicitAny": false, // any 타입으로 암시한 표현식과 선언에 오류를 발생시킵니다.
"module": "commonjs",
"target": "es5",
"types": [
"node"
],
"lib": [
"ES5","ES6","DOM","DOM.Iterable"
],
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true, //동일 파일 참조에 대해 일관성 없는 대소문자를 비활성화합니다.
"noImplicitReturns": true, // 함수의 모든 코드 경로에 반환값이 없을 때 오류를 보고합니다.
"noImplicitThis": true, //any 타입으로 암시한 this 표현식에 오류를 보고합니다.
"strictNullChecks": true, // 엄격한 null 검사 모드에서는 null과 undefined 값이 모든 타입의 도메인에 있지 않고 그 자체와 any만 할당할 수 있습니다(한 가지 예외사항은 undefined 또한 void에 할당 가능하다는 것입니다).
"suppressImplicitAnyIndexErrors": true, //인덱스 시그니처가 없는 객체를 인덱싱하는 경우 --noImplicitAny 억제합니다. 오류를 시그니처 자세한 내용은 #1232 이슈를 참조하세요.
"allowSyntheticDefaultImports": true, //default export가 없는 모듈에서 default imports를 허용합니다. 코드 방출에는 영향을 주지 않으며, 타입 검사만 수행합니다.
"esModuleInterop": true,// 런타임 바벨 생태계 호환성을 위한 __importStar와 __importDefault 헬퍼를 내보내고 타입 시스템 호환성을 위해 --allowSyntheticDefaultImports를 활성화합니다.
"resolveJsonModule": true, // json 확장자로 import된 모듈을 포함합니다.
"emitDecoratorMetadata": true, // 소스에 데코레이터 선언에 대한 설계-타입 메타 데이터를 내보냅니다
"experimentalDecorators": true, // ES 데코레이터에 대한 실험적인 지원을 사용하도록 활성화
"skipLibCheck": true, // 모든 선언 파일 (*.d.ts)의 타입검사를 건너 뜁니다.
// "strict": true,
// "alwaysStrict": true,
"listEmittedFiles": true, // 컴파일의 일부로 생성된 파일의 이름을 출력
},
"include": [
"./src/**/*",
"./bin/**/*",
"./test/**/*"
],
"typeRoots": [
"./node_modules/@types"
]
}
const main = async ()=>{
try{
/** NSTP Test Runner Param Validation */
const TestConsole:string = paramValidator();
/*
Safety Navigate Console
*/
await LoginConsole();
switch(TestConsole.toUpperCase()){
case 'PUB_BETA':
await PUBLIC('beta');
break;
case 'PUB_REAL':
await PUBLIC('real');
break;
case 'FIN_REAL':
break;
case 'FIN_BETA':
break;
default:
Logger.info('Non Selected TestConsole');
return;
}
await Puppeteer.close();
}
catch(err){
Logger.error(err);
process.exit(1);
}
}
환경별 실행할수 있는 분기 처리문이 포함된 main 함수입니다. 오늘 포스팅에서는 paramValidator와LoginConsole을 알아 보도록 하겠습니다.
paramValidator
우선 해당 Test-Runner를 실행하기 위해서는 로컬에서 실행하는 목적이 아닌 Trigger서버를 통해서 실행 될 예정입니다.
Express 서버를 통해 실행 될 예정이고 실행은 아래와 같이 진행됩니다.
app.get('/uuid',async(req,res)=>{
try {
const uuid = createUUID();
res.send(uuid);
} catch (error) {
console.error(error);
res.send(error);
}
});
app.post('/run',async (req,res)=>{
const {id,pw,env,platform,uuid} = req.body;
console.log(`Test Call ${req.body}`);
const TaskObject = {
"account":id,
"password":pw,
"env":env,
"platform":platform,
"uuid":uuid
}
console.log(TaskObject);
runTestRunnerExec(TaskObject);
res.send(uuid);
})
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
});
// npm start account passwd pub_beta classic uuid
function runTestRunnerExec(TaskObject){
let command =`npm start ${TaskObject.account} ${TaskObject.password} ${TaskObject.env} ${TaskObject.platform} ${TaskObject.uuid}`;
exec(command,(err,out,stderr)=>{
console.log(out);
});
}
function createUUID() {
let d = new Date().getTime();
if (
typeof performance !== 'undefined' &&
typeof performance.now === 'function'
) {
d += performance.now(); // use high -precision timer if available
}
return 'task-xxxx-xxxx-4xxx-yxxx'.replace(/[xy]/g, function(c) {
var r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
})
}
코드의 일부를 가져왔으며 실행시 파라미터가 아래와 같습니다.
npm start account passwd env platform uuid
UUID의 Controller로 UUID를 발급을 받고 해당 Worker Job(UUID) 로 실행 요청을 하기 때문에 해당 의 형태를 가지고 있어 Test-Runner에서도 한번 유효성 검사를 하는 구간을 두자는 의미에서 작성하였습니다.
function platformValidate(param:string) : void {
const testPlatForm = {
"FIN_REAL":process.env.FIN_REAL,
"FIN_BETA":process.env.FIN_BETA,
"GOV_REAL":process.env.GOV_REAL,
"GOV_BETA":process.env.GOV_BETA,
"PUB_REAL":process.env.PUB_REAL,
"PUB_BETA":process.env.PUB_BETA
}
if(param==="undefined" || param===undefined || param===null){
Logger.error('Please Check your Runtime Parameter ');
Logger.error(`1. param is -> ${param}`);
process.exit(0);
}
let _param:string = param.toUpperCase();
const url = testPlatForm[_param];
if(url==="undefined" || url===undefined || url===null){
Logger.error(`undefined Get URL : ${url}`);
Logger.error('Check List');
Logger.error('1. Platform Param ');
Logger.error('2. Directory Root Check your .env File ');
process.exit(0);
}
process.env.url=url;
return url;
}
export const paramValidator = ()=>{
console.log(process.argv[6])
if(process.env.NODE_ENV==='development'){
Logger.info('Development Env');
}
else{
if(process.argv.length <= 5){
Logger.error('Parameter Validation Error');
Logger.error('Please set [ID], [PassWord], [TargetPlatForm] ');
Logger.error(`Current Param ID : ${process.argv[2]}`)
Logger.error(`Current Param Password : ${process.argv[3]}`)
Logger.error(`Current Param Test Console : ${process.argv[4]}`)
throw new Error('Param Validation Error');
}
else if(typeof process.argv[6]==="undefined"){
Logger.error('UUID is not Exist');
throw new Error('Required UUID Param');
}
let ID = process.argv[2];
var emailReg = /^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*.[a-zA-Z]{2,3}$/i;
if(!emailReg.test(ID)){
throw new Error(`ID Email Error is not Email ${ID}`);
}
Logger.info(`Automation URL ${platformValidate(process.argv[4])}`);
for(let i=2;i<process.argv.length;i++){
Logger.info(process.argv[i]);
}
// global UUID variable
process.env.nstpUUID = process.argv[6];
}
return process.argv[4];
}
간단하게 넘어가자면 5개의 파라미터를 기본으로 받아야하기 때문에
account - 로그인 할 계정
passwd - 로그인 할 계정의 비밀번호
env - 환경 ( 네이버 클라우드는 민간, 금융, 공공 3가지의 환경을 가지고 있습니다. )
platform - 플랫폼 ( Classic, VPC 의 2가지 플랫폼을 지원 합니다.)
uuid - UUID로 생성되어 발급된 UUID
(* 현재는 해외리전도 있는걸로 보아 해외리전 파라미터도 추가가 되어야겠네요 제대로 작업해서 만든다면요 )
1. 파라미터 개수 및 파라미터 확인
2. account가 email인지 확인
3. 파라미터에 따른 .env에서 URL을 정상적으로 가져왔는지 확인
etc. 환경등을 확인하는 조건문 등이 있긴한데 샘플 파일이라고 귀엽게 넘어 가주시면 좋겠습니다. ㅜ
파라미터 유효성 검사가 끝났다면 Console 로그인을 진행 하도록 하겠습니다.
LoginConsole
로그인 콘솔입니다. 사실 이렇게까지 해야되나 싶기도하지만 LoginConsole을 로그인 모듈로 빼서 여러 곳에서 호출해서 쓴다고 가정을 하고 작업을 하다보니 꽤 많은 양의 작업이 되었습니다.
우선 파라미터로 id,pw를 넘겼기 때문에 process.argv를 통해 파라미터 값을 가져옵니다. ( id,pw의 순서는 고정 )
getBrowser를 통해 퍼펫티어의 기본 세팅을 한후 브라우저를 실행합니다.
getBrowser는 API 가 아닙니다.
싱글톤 객체로 사용중인 자체 함수입니다. initialize 는 스킵하도록 하겠습니다. ( 원한다는 요청이 있으면 다시 재포스팅하겠습니다.)
그리고 나오는 isLoginPage 일단 해당 설명을 하기전에 NaverCloudPlatform의 페이지이동에 대한 히스토리부터 생각해보겠습니다.
보시면 바로 console.ncloud.com 으로 가게 되는데 로그인이 되어있지 않는 상태 의 세션 브라우저를 확인시 순서는 아래와같습니다.
1. 로그인창
2. 비밀번호 변경 정책에 어긋 났을 경우
3. 비밀번호 변경 정책에 어긋나지 않았을 경우 ( 콘솔 페이지로 이동 )
이동하였지만 Dimmed 처리에대한 다시 보지않기 처리가 PC에 세팅되어있지 않은 경우
4. Dimmed 처리에 다시보지않기 를 설정한 기록이있어 정상적으로 이동된 경우
여기까지가 로그인 을 하여 콘솔까지의 정상이동으로 볼수 있습니다. 이와중에 해결해야하는 부분은 아래와 같습니다.
1. 로그인 정상 처리
2. 로그인 정책 위반시 다음으로 넘길수있는 처리
3. Dimmed 처리 안되어있는 PC의 경우 Dimmed에 대한 처리
로그인 정상 처리
process.argv로 ID, PW를 받아온 상태에서 이제는 입력을 해야하고 입력하기 위한 Element를 찾아야 합니다. 모든 사람들이 Element를 찾는 방법은 수십 수백개라 저는 저만의 방식을 찾은 거를 포스팅하겠습니다.
그전에 현재 페이지가 login 페이지가 맞는지 먼저 체크합니다. ( explicitlyWait도 API 가 아닙니다. 자체 제작한 함수로 일단은 스킵하겠습니다. 해당 함수만 포스팅하려면 한페이지가 작성되어야해서 ;; )
핵심은 selector 부분입니다. 해당 css 셀렉터를 통해서 해당 페이지 유무를 확인합니다.
이미지시에 보이는 셀렉터를 기준으로 잡은 거였습니다. 로그인 페이지는 SPA Web FE 개발자에 잠시 빙의를 해보자면 Login Component를 따로 쓸걸로 예상되기 때문에 해당 페이지는 웬만하면 큰 변화가 없을 것을 기대합니다.
그렇다고 Chrome브라우저에 JS CSS 복사 기능을 쓰게 되면 아래와 같이 나타나는데
document.querySelector("#username")
위처럼 사용해도 될수 있지만 해당 페이지에서 유일하게 placeholder에 아이디* 로 들어가게되는 경우는 input box 뿐이라고 생각되어서 위의 CSS Selector를 사용하였습니다. ( 저는 xpath 는 사용안하고 CSS Selector를 선호합니다. 포스팅하려면 할수있는게 엄청많군요 )
이렇게 현재 페이지가 Login Page라는 점이 확인되면 Inputbox와 Password box를 찾아서 입력 후 로그인 버튼을 선택합니다. ( 이부분은 약식으로 하겠습니다. )
간단하게 입력 후 저는 Enter 키로 Navigate 를 진행했습니다.
page.waitForNavigation() -> 퍼펫티어의 API 인데 MPA에서는 문제가 되지 않지만 SPA ( Single Page Application ) 에서는 많은 문제가 됩니다. 현재 만들어진 페이지가 Vue, React, Angular 같은 SPA로 만들었는지 확인이 되어야합니다. 아니면 현재 페이지가 서버 라우팅인지, 클라이언트 라우팅인지 명확한 확인이 된 후에 해당 API 써야 합니다. 그렇지 않으면 Timeout이 나게 됩니다. - 이부분도 따로 포스팅을...
엔터까지 입력이 된다면 이제 해당 사이트의 로그인 정책에 위반되었을 케이스도 확인 하여야 합니다.
로그인 정책 위반시 다음으로 넘길수있는 처리
위에 보시면 이동후에 isDashBoard -> 네 맞습니다. 콘솔 대쉬보드로 이동이 됐는지 체크하는겁니다.
확인내용은 다음과 같습니다. (현재 URL , 이동할 URL) 을 받아와서 비교합니다.
이동하려는 페이지(대쉬보드 URL )가 아니라면 나올수있는 페이지는 비밀번호 정책 페이지 또는 비밀번호 오류 가 있을수 있습니다.
passwordAfterwardsCheck ( 비밀번호 변경 후 90일이 지났을 경우 에 대한 예외처리 )
async function passwordAfterwardsCheck(page:puppeteer.Page,currentURL,navigateURL){
const selector = '.loginSecure';
const loginSecureEle = await Puppeteer.explicitlyWait(page,selector);
/** Check passwordChange Request Page */
if(loginSecureEle!==false && typeof loginSecureEle !=="boolean"){
Logger.info(`🚧 The current page is the password change request page. `);
Logger.info('🚧 Proceed to change the default setting afterwards.');
let props = await Puppeteer.getProps(page,loginSecureEle,'innerText');
let regEx = /비밀번호변경 안내|90일마다/gi;
let changePasswordCheck = regEx.test(props);
/** Check passwordChange Request Button */
/** 30일 비밀번호 변경 버튼 유무 확인 */
if(changePasswordCheck){
Logger.info('[Check] nextChangeButton ');
const selector = '.loginSecure button';
const buttons = await Puppeteer.explicitlyWaits(page,selector);
let nextChangeBtn;
await Array.prototype.reduce.call(buttons,async(prev,curr)=>{
const nextItem = await prev;
let itemText = await Puppeteer.getProps(page,curr,'innerText');
let diffText = itemText.replace(/\r\n|\n| |\s/gi,"");
Logger.info(`[diffText] : ${diffText}`);
if(/다음에변경하기/gi.test(diffText)){
Logger.info('[Check-Success] nextChangeButton ');
nextChangeBtn = curr;
};
return nextItem;
},Promise.resolve());
if(nextChangeBtn==="undefined" ||
nextChangeBtn===undefined ||
nextChangeBtn ==="null" ||
nextChangeBtn===null){
//! nextChange Button is NULL
Logger.error('[Check-Failed] nextChange Button is not Exist');
return false;
}
else{
Logger.info(`[Click] passwordAfterwards Button`);
// * Console로 변경 될 때 까지 클릭
await Puppeteer.forcedClick(page,nextChangeBtn,"다음에 변경하기",async ()=>{
try {
let regExp = /[A-Za-z.-]+/g;
let currentURL:any = await page?.url();
let currentProtocolUrl = currentURL.match(regExp)[1];
let navigateProtocolUrl = navigateURL.match(regExp)[1];
Logger.info(`[Current] ${currentProtocolUrl} <----> [Target] ${navigateProtocolUrl}`)
return currentProtocolUrl===navigateProtocolUrl;
} catch (error) {
console.error(error);
return false;
}
});
//? passwordAfterwardsCheck Success Return
return true;
}
}
/** InValid ID or InValid Password */
else{
Logger.error('changePasswordCheck is not Exist');
return false;
}
}
else{
Logger.info('not Exist loginSecureEle ');
Logger.info('Login Actions Check');
const loginRootElement = await Puppeteer.explicitlyWait(page,'.center-wrap.mh-20');
let resultCheck;
if(loginRootElement!==false && typeof loginRootElement !=="boolean"){
resultCheck = await Puppeteer.getProps(page,loginRootElement,'innerHTML');
}
if(resultCheck.match(/아이디(메일)를 확인해 주세요/gi) ){
Logger.error('[Login Actions] ID/Mail Error');
}
else if(resultCheck.match(/패스워드 오류/gi) &&
resultCheck.match(/패스워드 오류 : 0회/gi)){
Logger.error(('[Login Actions] ID Error'));
}
else if(resultCheck.match(/패스워드 오류/gi)){
Logger.error(('[Login Actions] PassWord Error'));
}
else{
Logger.error('[Login Actions] UnKnown Login Error');
}
return false;
}
}
해당의 페이지도 마찬가지로 공통 Component 로 보여지는 페이지 이기때문에 정책변경에 대한 .loginSecure 를 찾습니다. 지금 보니까 article semantic tags를 사용하였기 때문에 article .loginsecure 로 찾는 게 조금 더 유니크 할수 있겠네요
forcedClick 같은것도 자체 제작 API 입니다. 해당 코드가 정상적으로 이동되면 이제 콘솔대쉬보드로 이동되면서
Dimmed 처리가 나타나게 됩니다.
Dimmed 처리의 핵심부터 말씀드리자면
.coach-mark 입니다. 해당 이 body 역할을 하고 있는 것으로 확인되며 해당 Dimmed 처리 역시 해당 Element Body 를 이용해서 찾은 후 변하지 않는 유니크 값을 찾습니다.
/환영합니다.|님,|다시 보지 않기/gi;
async function dimmedCloseActions(page:puppeteer.Page):Promise<boolean>{
const selector = '.coach-mark';
const dimmedEle = await Puppeteer.explicitlyWait(page,selector);
if(dimmedEle!==false && typeof dimmedEle !=="boolean"){
let diText = await Puppeteer.getProps(page,dimmedEle,"innerText");
let regex = /환영합니다.|님,|다시 보지 않기/gi;
await page.waitFor(500);
if(regex.test(diText)===true){
Logger.info("🚧 Dimmed is output and Close Window.");
let dimmedBtnArr = await Puppeteer.explicitlyWaits(dimmedEle,'.btn');
let dimmedCloseBtn;
//! dimmedBtnArr 이 undefined 거나 NULL일 경우에 대한 분기 처리 필요
await Array.prototype.reduce.call(dimmedBtnArr,async(prev,curr)=>{
let nextItem = await prev;
let btnText = await Puppeteer.getProps(dimmedEle,curr,'innerText');
let diffText = btnText.replace(/\r\n|\n| |\s/gi,"");
if(/닫기/gi.test(diffText)){
dimmedCloseBtn = curr;
}
return nextItem;
},Promise.resolve());
await Puppeteer.forcedClick(page,dimmedCloseBtn,"딤드 처리 제거",async ()=>{
try{
let afterDimmedEle = await Puppeteer.explicitlyWait(page,'.coach-mark',3,500);
if(afterDimmedEle===false){
return true;
}
else{
return false;
}
}
catch(err){
console.error(err);
return false;
}
})
return true;
}
else{
return false;
}
}
else{
Logger.info('🚧 Dimmed was not output .Process to the next Step .');
return true;
}
}
저는 여기까지 유니크한 값으로 비밀번호 정책 및 Dimmed처리를 진행하였습니다.
이후에 로그인 및 대쉬보드 이동 테스트를 500번정도 진행하였으나 현재까진 오류가 없었습니다.
var Key = {
CONTROL : '\uE009'
}
function sendKeys(...keys){
const actions = [];
for(const key of keys){
if(typeof key==='string'){
for(const symbol of key){
console.log(`symbol ${symbol}`);
}
}
else{
console.log(`not Symbol ${key}`);
}
}
}
sendKeys(Key.CONTROL,'automation');
보시면 CONTROL 부분은 심볼로 처리되고 그 외에 있는 문자열들은 따로따로 나눠서 되어있습니다.
미리 선언해놓은 심볼들은 내부적으로 다시 연결하여서 그에 맞는 조합키를 입력 합니다. (해당 내용은 이번 포스팅에서는 제외하고 하겠습니다.)
그리고 해당 값들을 통해서 KeyDown 과 KeyUp을 사용하여 진행합니다.
"Automation" 이 파라미터로 넘어간다면
A Key Down
A Key Up
u Key Down
u Key Up
.... 이 순서로 진행이 된다고 볼수 있습니다.
그래서 테스트 해 보았습니다.
// 첫번째 요소에 Shift 키와 함께
await targetElement.sendKeys(Key.SHIFT,'automation');
// Result : AUTOMATION
// 두번째 요소에 소문자 입력시
await subtargetElement.sendKeys('automation');
// Result : automation
위처럼 현재 Element 에 조합키 + 소문자 입력후 다른 Element 에 automation 만 입력시 어떻게 될지 확인해보았을 시
입력중인 위젯에 대해서는 Shift키 심볼로 인해 keyDOWN 상태가 유지되어 대문자로 나오지만
위의 sendKeys 마지막 부분 요소에 보면 insert하는 부분이 있게 되는데요 위의 코드는 Selenium 자체코드입니다.
Actions.SendKeys 의 경우 관련된 액션과 키 값들을 정리 후 한꺼번에 리턴합니다.
Actions의 경우 perform 이 호출될시 리턴된 액션들을 순차적으로 실행하는 형태이기 때문에 그때 순차적으로 실행됩니다.
WebElement의 SendKeys 의 경우
async sendKeys(...args) {
let keys = [];
(await Promise.all(args)).forEach(key => {
let type = typeof key;
if (type === 'number') {
key = String(key);
} else if (type !== 'string') {
throw TypeError('each key must be a number of string; got ' + type);
}
// The W3C protocol requires keys to be specified as an array where
// each element is a single key.
keys.push(...key.split(''));
});
if (!this.driver_.fileDetector_) {
return this.execute_(
new command.Command(command.Name.SEND_KEYS_TO_ELEMENT)
.setParameter('text', keys.join(''))
.setParameter('value', keys));
}
keys =
await this.driver_.fileDetector_.handleFile(
this.driver_, keys.join(''));
return this.execute_(
new command.Command(command.Name.SEND_KEYS_TO_ELEMENT)
.setParameter('text', keys)
.setParameter('value', keys.split('')));
}
위와 같은 형태를 띄고있는데요 Actions 과 유사한점이 있지만 다른점이 있다면 바로 Command 를 통해서 실행한다는점입니다.
눈에 띄는 것은 맨 첫줄에 Promise all 부분인데요 들어오는 키 형태중에 조합형태로 Promise 를 리턴하는 경우가 있을 경우에 대비해서 만들어 놓은 것으로 보입니다.
위에서 Shift를 눌러놓은 상태에서 automation 시 AUTOMATION 으로 나온후 타 요소에 갈땐 다시 SHIFT가 없는 상태였는데요
기존의 화면 (페이지가 만들어져 있는) 에 대한 자동화와 프론트 프레임워크 에 대한 자동화는 차이가있습니다.
화면 자동화의 경우 A페이지 (로그인 페이지) 일 때
아이디 창에 대한 E2E 테스트가 진행된다고 하면 개발할때 의 스펙에 맞게만 테스트가 진행되어야 합니다.
ex : 50자 최대 일 경우 (50자이상입력이 되는지 )
ex : id만 입력 후 로그인 시 "비밀번호를 입력해주세요" 메시지가 하단에 출력되는지 .
ex : 아이디 입력 후 "엔터" 키 입력 시 "비밀번호" 창으로 이동이 되는지
해당 화면은 "로그인페이지" 에만 존재하기 때문에 해당 케이스 또한 로그인 페이지에서만 유효한 케이스라고 볼수 있습니다.
하지만 "아이디 입력창" + "비밀번호 입력창" + "로그인 버튼" 이 한개의 컴포넌트라고 보게 됐을경우
<naverLogin-tag> 와 같은 형태로만들어지는 컴포넌트라면 이야기가 달라집니다.
get함수를 통해서 현재 id pw 가 {id: "",pw:""} 받을수도 있고 하나의 컴포넌트로서 기능적인 면이 들어가기 때문입니다.
컴포넌트를 테스트하는 것과 웹 페이지를 테스트 하는 것은 다르다
TOP에서는 이런 형태로 수십개의 컴포넌트를 제공하고 있습니다. 단 하나의 태그와 원하는 데이터 형태 로
사용자가 작성하는 html 태그가 최소화 되는 형태로 제공됩니다.
그렇기 때문에 QA 입장에서는 위젯, 레이아웃,컨테이너가 있을 경우 각각 의 모든 케이스가 TC(테스트케이스) 가 됩니다.
레이아웃은 위젯을 배치하는 컴포넌트이고위젯은 레이아웃에 배치되는 컴포넌트 컨테이너는 데이터를 표현하기
위한 컴포넌트 라고 봤을때
A레이아웃 + B 위젯 + C 컨테이너만 놓고 봐도 세개의 컴포넌트에서만 케이스가 무수하게 나옵니다.
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
A레이아웃 안에 C컨테이너를 배치시 화면에 렌더링 되는지
A레이아웃 안에 B위젯을 배치시 화면에 렌더링 되는지
A레이아웃 안에 B + C 컴포넌트가 같이 있을시 같이 렌더링이 되는지
.....
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
그런데 문제는 하나하나 다 컴포넌트 형태이기 때문에
각각이 제공하는 API 가있고 속성이 있습니다.
A레이아웃 - C 컨테이너로 테스트하는데
C컨테이너에 자체 속성이 20개 API 가 20개 일 경우 단순 무식하게 TC(테스트케이스) 를 만든다고 하면 이 숫자 또한 만만치 않게 됩니다.
그래서 원칙적으로는 페어와이즈 테스트 기법을 통해서 케이스를 추출 + 사용자들에 의해 발생하는 회귀테스트 를 우선 작성하고 이에대한 자동화를 진행하게 됩니다.
SeleniumWebDriver (Node.js) 를 사용하는 이유
Selenium Webdriver Node.js 를 사용한 이유는 우선적 으로 말씀드리자면 JavaScript를 좀 더 많이 쓰기 때문이였습니다.
하지만 단순히 이런 이유로 사용하기에는 자동화를 하기 위해 좀더 찾아 본 결과로는 SeleniumWebDriver 를 최초의 만들었던 제작자 가 기존의 JAVA 등의 버전에서 현재 js진영으로 넘어와서 새로 프로젝트를 하고 있기 때문이다.(링크를 하려 하였으나 아직 못찾아서 찾는대로 링크하겠습니다.)