[jstest] selenium + nodeJS #5

개요

여지껏 Driver의 WebElement중심으로 기능을 살펴봤습니다. 이제부터는 Driver자체의 기능을 하나씩 짚어보겠습니다. 브라우저별 드라이버가 반환하는 객체는 실은 WebDriver라는 클래스의 브라우저별 구현체로 실제 인터페이스는 전부 여기에 정의되어있습니다.
이번 포스팅에서는 드라이버 기능 중에 자주 언급되는 스크린샷찍기를 설명합니다. 또한 이를 실현하는 과정에서 reporter를 완전히 개조하여 http모듈기반으로 이사시키고, worker의 스크린샷 지원부분도 fs를 이용하여 구축하게 됩니다.

WebDriver의 객체관계도

이번에는 스크린샷 기능만 배우지만 우선 이쯤에서 WebDriver와 그 관련된 클래스구조를 살펴보는 시간을 갖겠습니다. 우선 전체적인 구조는 다음과 같습니다.

Screenshot_1

  1. WebDriver의 manage, navigate, switchTo는 가각 관련된 인스턴스를 생성해 반환하게 됩니다.
  2. 이 서브객체들은 저마다의 역할을 수행하는데 Options나 TargetLocater는 거기서 다시 서브객체를 만들게 됩니다.

사정이 이렇다보니 하고 싶은 일이 있으면 driver.manage().window().setPosition(10,10) 이런 식으로 길게 체이닝이 되는 경우가 있는데 이 경우도

  1. options = driver.manage() 이고
  2. window = options.window() 로 다시 서브객체가 나오고 최종적으로
  3. window.setPosition(10, 10) 을 호출한 셈입니다.

3개로 시작된 driver의 메소드에서 꼬리에 꼬리를 물어 총 6개의 클래스와 연결 짓게 됩니다.

  1. Logs – 브라우저 로그를 가져옮
  2. Timeouts – 타임아웃과 관련된 정책을 세움
  3. Window – 브라우저를 통제함
  4. Navigation – 브라우저 네비게이션을 통제함
  5. TargetLocator – 현재 문서와 관련된 다른 창을 연결함
  6. Alert – 다이얼로그창을 제어함

이제 객체 관계도에 따라 각 클래스에 기술된 메소드 전체 목록을 적어봤습니다.

  • driver.manage() – Options객체를 반환합니다. Options객체는 아래와 같은 메소드를 제공합니다.
    1. addCookie({name,value,domain,path,expiry,secure,httpOnly}) – 쿠키를 추가합니다.
    2. deleteAllCookies() – 모든 쿠키를 삭제합니다.
    3. deleteCookie(name) – 특정 쿠키를 삭제합니다.
    4. getCookies() – then의 v로 쿠키배열을 반환하는데 각 배열요소는 {name,value,domain,path,expiry}형태의 오브젝트입니다.
    5. getCookie(name) – 특정 쿠키를 얻어옵니다. 위와 같고 단일오브젝트를 반환합니다.
    6. logs() – Logs객체를 반환합니다. 이 객체는 브라우저에서 console.log 등에 쓴 값을 가져옵니다.
      • get(type) – 해당 type에 대한 로그를 가져옵니다. 한 번 가져오면 그 뒤로는 새것만 가져옵니다.
      • getAvailableLogTypes() – 해당 브라우저가 지원하는 로그 타입을 배열로 반환합니다.
    7. timeouts() – Timeouts 객체를 반환합니다. 묵시적 대기를 설명할때 등장한 적이 있습니다. 메소드는 다음과 같습니다.
      • implicitlyWait(ms) – 묵지적 대기를 설정합니다.
      • setScriptTimeout(ms) – 브라우저 내의 스크립트 타입아웃값을 조정할 수 있습니다. 0이하가 되면 타임아웃이 없어집니다.
      • pageLoadTimeout(ms) – 페이지로딩을 위해 대기할 시간을 정할 수 있습니다.
    8. window() – Window객체를 반환합니다. 현재 브라우저 그 자체입니다.
      • getPosition() – 브라우저의 윈도우상 위치를 {x, y} 로 반환합니다.
      • setPosition(x, y) – 브라우저의 위치를 옮깁니다.
      • getSize() – 브라우저 크기를 {width, height}로 얻습니다.
      • setSize(width, height) – 브라우저의 크기를 조정합니다.
      • maximize() – 브라우저를 최대화합니다.
  • driver.navigate() – Navigation객체를 반환합니다. 이 객체는 back(), forward()와 같이 브라우저에서 버튼 네비게이션이 하는 일을 시킬 수 있습니다.
    1. to(url) – 특정 url로 이동시킵니다.
    2. back() – 뒤로가기
    3. forward() – 앞으로 가기
    4. refresh() – 리로딩
  • driver.switchTo() – 브라우저 내에서 제어권을 옯겨주는 TargetLocator객체를 반환합니다. 이를 통해 다이얼로그, 아이프레임, 팝업창 등으로 이동해다니면서 작업할 수 있습니다.
    1. activeElement() – 해당 창의 루트(doc 또는 doc.body 또는 doc.documentElement)웹엘리먼트를 반환합니다.
    2. defaultContent() – 원래 driver가 가리키던 창으로 제어를 되돌립니다.
    3. frame(id)- 지정한 프레임으로 제어권을 옮깁니다.
    4. window(nameOrHandle) – 지정한 윈도우로 제어권을 옮깁니다. 이때 이름은 팝업을 띄울 때 윈도우의 이름으로 지정한 값입니다.
    5. alert() – alert, confirm, prompt 등의 다이얼로그로 제어권을 옮깁니다. Alert객체를 반환합니다.
      • getText() – 다이얼로그의 텍스트값을 읽습니다.
      • authenticateAs(username, password) – 기본인증 등 인증창인 경우 아이디, 비번을 입력합니다.
      • accept() – 확인버튼을 누릅니다.
      • dismiss() – 취소버튼을 누릅니다.
      • sendKeys(text) – prompt 등에 텍스트값을 타이핑해줍니다.

driver.takeScreenshot()

사실 객체 관계도는 이 후의 포스팅으로 가기 전에 예습의 의미이자 몇 가지 window의 창제어 기능을 사용할거라 미리 다뤘습니다. 이번 포스팅의 진짜 주인공은 스크린샷을 찍어주는 takeScreenShot() 메소드입니다. 이 메소드는 인자를 받지 않고 promise를 통해 resolve의 인자로 base64인코딩된 png를 넘겨줍니다.
이를 처리하기 위해 기존의 worker.js를 수정해야 할 것입니다. 간단히 driver의 메소드를 래핑하면 됩니다.

module.exports = class{[...]
	screenshot(path){
		return this[DRIVER].takeScreenshot();
	}
}

이제 외부에서 스크린샷을 찍을 수 있으니 이를 테스트해보죠.

const Worker = require('./worker.js');
const worker = new Worker('https://www.google.co.kr/search?newwindow=1&tbm=isch&q=%EC%84%A4%ED%98%84+%EB%A3%A8%EB%82%98%EC%9B%8C%EC%B9%98&spell=1&sa=X&ved=0ahUKEwjKws2IgsHNAhXLlJQKHcKtCzoQvwUIGSgA&dpr=1&biw=1360&bih=1153#imgrc=BSAW07RWQW7nyM%3A');

worker.waitUntil(2000, 'elementLocated', worker.by('#su-footer-links')).
then(el=>worker.screenshot()).
then(png=>/*...뭔가해야함...*/);

이런 느낌입이 됩니다. 위 주소는 구글에서 이미지검색한 결과인데 그 결과상의 html을 보면 저 아이디가 등장했을 때까지를 기준으로 기다리면 원하는 스크린샷을 얻을 수 있는 시점입니다. 이제 저 이미지를 받아왔으니 우선 로컬에 저장해보죠.

//저장하기 위해 fs를 로드함
const fs = require('fs');

worker.waitUntil(2000, 'elementLocated', worker.by('#su-footer-links')).
then(el=>worker.screenshot()).
then(png=>new Promise(resolve=>fs.writeFile(
	'screenshot.png',
	v.replace(/^data:image\/png;base64,/, ""), 
	'base64', 
	_=>resolve(path)
))).
then(path=>/*..저장완료된 시점..*/);
  1. 우선 저장하기 위해 fs를 가져왔습니다.
  2. 이제 png파일을 저장하기 위해 fs.writeFile을 사용하는데
  3. 이 메소드는 인자로 (파일경로, 데이터, 인코딩방법, 완료시콜백) 의 인자를 받습니다.
  4. screenshot.png란 이름으로 이미지를 저장할건데 젤 앞에 쓸데없는 마인타입같은게 기술되어있으니 떼어버립니다.
  5. 최종적으로 저장이 완료되면 콜백이 호출되지만 thenable로 되어있는 셀레늄의 특성상 불편함으로 Promise.reslove를 통해 promise로 반환했습니다.

여기까지 코드를 검토해보면 파일이름 부분 외엔 딱히 test.js에서 만들 이유가 없습니다. 따라서 여기까지의 코드를 worker.js의 screenshot()메소드로 이관합니다.

const fs = require('fs');
module.exports = class{[...]
	screenshot(path){
		return this[DRIVER].takeScreenshot().
			then(v=>new Promise(resolve=>fs.writeFile(
				path,
				v.replace(/^data:image\/png;base64,/, ""), 'base64', 
				_=>resolve(path)
			)));
	}
}

이제 test.js는 깔끔하게 변합니다.

worker.waitUntil(2000, 'elementLocated', worker.by('#su-footer-links')).
then(el=>worker.screenshot('screenshot.png')).
then(path=>/*..저장완료된 시점..*/);

이 저장된 이미지를 활용하기 위해 reporter.js를 대대적으로 손볼 시점이 되었습니다.

http모듈기반의 Reporter

기존의 Reporter는 테스트결과를 표시할 웹사이트를 외부에서 불러왔습니다. 하지만 nodeJS는 원래 웹서버의 기능을 손쉽게 구현할 수 있으므로 굳이 외부서버의 힘을 빌릴 필요가 없습니다.
대신 http.createServer()를 통해 간이 웹서버를 생성하고 여기서 단정문이나 레포트를 테스트할 수 있을 것입니다. nodeJS의 사용법을 사용하는 것은 이 포스팅의 목적을 크게 벗어나기 때문에 여기서는 nodeJS관련 코드에 대해서는 가볍게 언급하면서 가도록 하겠습니다.

우선 서버구현에 대한 가벼운 계획은 다음과 같습니다.

  1. http://127.0.0.1/ 로 요청이 온 경우는 미리 준비한 HTML로 응답해준다.
  2. .png로 끝나는 주소인 경우 로컬의 해당 이미지 파일을 읽어 응답해준다.

정말 간이서버라 이정도 기능밖에 필요없습니다. 위의 요건을 고려하여 미리 두 개의 필요요소를 작성합니다.

// 요청에 응답하는 함수
const res200 = (response, head, body)=>(
  response.writeHead(200, head), response. end(body)
);

//결과를 보여줄 문서의 정의
const BODY = `<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>Report</title>
</head>
<body>
  <ol id="report"></ol>
</body>
</html>`;

이제 외부의 URL에 의존할 필요가 없으니 Reporter클래스의 생성자는 다음과 같아질 것입니다.

const http = require('http');
const fs = require('fs');

const res200 = (response, head, body)=>(response.writeHead(200, head), response. end(body));
const BODY = `<html>...`;
module.exports = class{
	constructor(){
		this.server = http.createServer();
		this.server.on('request', (request, response)=>{
			const head = {'Content-Type': 'text/html'};
			res200(response, head, BODY);
		});
		this.server.listen(80);
		this[DRIVER] = new Driver();
		this[DRIVER].get('http://127.0.0.1/');
		Object.freeze(this);
	}

본인 내부에 http서버를 생성하고 이 서버에서 직접 위에서 정의했던 BODY를 출력해줍니다. 또한 driver도 외부 url을 참조하지 않고 직접 로컬서버를 가리키게 됩니다.
하지만 이렇게 단순한 request콜백으로는 worker에서 저장한 이미지를 가져올 수 없겠죠. 잊으면 안되는 것이 현재 Reporter를 대대적으로 수정하는 이유가

  1. 스크린샷을 레포팅하기 위해서라는 점인데
  2. 스크린샷은 worker에서 로컬에 파일로 저장하므로
  3. request가 .png로 오는 경우는 로컬의 이미지파일을 줘야한다

라는 점을 잊으면 안됩니다. 일단 이 관점에서 request콜백을 살짝 쿵 변경해봅니다.

const url = require('url');

module.exports = class{
	constructor(){
		this.server = http.createServer();
		this.server.on('request', (request, response)=>{
			const pathname = url.parse(request.url).pathname;
			const head = {'Content-Type': 'text/html'};
			if(pathname.indexOf('.png') > -1){ //png로 요청이 온 경우
				head['Content-Type'] = 'image/png';
				//pathname에서 파일이름을 추출한 뒤, 읽어드린 이미지파일로 출력
				fs.readFile(pathname.split('/').pop(), (e,v)=>res200(response, head, v));
			}else if(pathname == '/'){ //보통 http://127.0.0.1/ 로 온 경우는 BODY출력
				res200(response, head, BODY);
			}
		});
		this.server.listen(80);
		this[DRIVER] = new Driver();
		this[DRIVER].get('http://127.0.0.1/');
		Object.freeze(this);
	}
	report(value){
		return this[DRIVER].executeScript(
			`document.getElementById('report').innerHTML += '<li>' + '${value}' + '</li>';`
		);
	}
}

이제 요청이 http://127.0.0.1/test.png 같은걸로 오면 로컬에 이미지를 가져와 보내줄 수 있게 되었습니다. 어느 정도 worker.js와 코웍할 수 있는 기반이 되었으므로 test.js를 다음과 같이 완성시킬 수 있습니다.

worker.waitUntil(2000, 'elementLocated', worker.by('#irc_cc')).
then(el=>worker.screenshot('screenshot.png')).
then(path=>reporter.report(`<img src="http://127.0.0.1/${path}">`));

이제 레포트의 결과로 이미지를 보내서 표시할 수 있게 되었습니다. 간단히 실행하면 다음과 같은 결과가 나오게 됩니다.

Screenshot_2

특히 단순한 스크린샷이라기보다 해당 브라우저에서의 결과를 표현하기 때문에 다양한 driver를 이용하면 브라우저별로 스크린샷을 뜰 수 있을 것입니다.
여기까지의 코드는 다음에 있습니다.

https://github.com/hikaMaeng/seleniumNode/tree/master/5/1

worker, reporter의 생성자를 개선하기

편의상 가장 간단한 형태로 worker를 구축하여 사용해왔지만 실제 worker는 각 브라우저의 핵심기능을 테스트하기 때문에 최초 설정시에 커스터마이즈할 수 있는 몇가지 옵션이 필요합니다.
이 옵션들은 선택적으로 들어오기 때문에 인자해체를 사용하여 구현하도록 하죠.

  1. browser – 어떤 브라우저용 driver를 사용할지를 결정한다. 기본값 chrome
  2. x – 브라우저의 윈도우상의 x위치. 기본값 0
  3. y – 브라우저의 윈도우상의 y위치. 기본값 0
  4. width – 브라우저의 크기를 지정한다. 기본값은 1200
  5. height – 브라우저의 크기를 지정한다. 기본값은 740
  6. maximize – 브라우저를 최대 크기로 지정한다. 기본값은 false

이런 정도를 받아들이기로 하고, 이를 구현하기 위해서 최초에 설명해드렸던 manage().window()를 통한 브라우저 제어를 사용하게 됩니다.
간단히 생성자 시그니처를 수정하여 생성자를 새롭게 작성해보죠.

const BROWSERS = 'firefox,chrome,edge,ie,opera,phantomjs,safari';
module.exports = class{
	constructor(url, {
		browser = 'chrome', 
		x = 0, 
		y = 0, 
		width = 1200, 
		height = 740, 
		maximize = false
	} = {}){

		//지원하지 않는 브라우저에 대한 처리
		if(!BROWSERS.includes(browser)) throw `invalid browser:${browser}`;

		//해당 드라이버로 생성
		const Driver = require(`selenium-webdriver/${browser}`).Driver;
		this[DRIVER] = new Driver();

		if(maximize){ //최대화의 경우는 최대화한다.
			this[DRIVER].manage().window().maximize();

		}else{ //아니면 크기와 위치 조정
			this[DRIVER].manage().window().setPosition(x, y);
			this[DRIVER].manage().window().setSize(width, height);
		}
		this[DRIVER].get(url);
	}
}

이제 좀 편리하게 다음과 같이 사용할 수 있습니다.

const worker = new require('./worker.js')(url, {browser:'firefox', x:1000, y:200, width:960, height:500});

현데 다 만들고 나니 이 옵션은 reporter에게도 필요하다는 사실을 알게 됩니다. 따라서 driver.js를 만들고 이를 양쪽에서 재활용하도록 정리하죠. 역할 책임범위를 보건데 브라우저별 드라이버와 옵션의 처리까지를 전부 담당하는게 맞을 테니 대체할 수 없는 인자해체를 제외하면 대부분 driver.js로 옮겨가게 될 것입니다.

const BROWSERS = 'firefox,chrome,edge,ie,opera,phantomjs,safari';
module.exports = opt=>{
	let {browser = 'chrome', x = 0, y = 0, width = 1200, height = 740, maximize = false} = opt || {};
	if(!BROWSERS.includes(browser)) throw `invalid browser:${browser}`;
	const Driver = require(`selenium-webdriver/${browser}`).Driver;
	const driver = new Driver(), window = driver.manage().window();
	if(maximize){
		window .maximize();
	}else{
		window .setPosition(x, y);
		window .setSize(width, height);
	}
	return driver;
}

사실 driver.js입장에서는 해당객체를 알아서 해체해 쓰기 때문에 worker나 reporter는 굳이 해체할 필요가 없긴합니다만 가독성을 위해 해체코드를 남겨둘 수는 있습니다. 남겨둔 경우와 아닌 경우 둘다 worker생성자를 구현해보죠.

const getDriver = require('./driver.js');
module.exports = class{

	//해체될 키를 명시하기 위해 코드에 남겨둔 경우
	constructor(url, {browser, x, y, width, height, maximize}){
		this[DRIVER] = getDriver(arguments[1]);
		this[DRIVER].get(url);
	}

	//남겨두지 않은 경우
	constructor(url, driverOption){
		this[DRIVER] = getDriver(driverOption);
		this[DRIVER].get(url);
	}
}

코드를 보시면 아시겠지만 처음엔 해체로 사용될 키를 명시하는게 낫나 싶다가 이내 driverOption쪽이 더 낫다라는걸 알게 됩니다. 왜냐면 reporter도 사용할건데 driver.js의 인자스펙이 바뀔 때마다 양쪽을 수정할 수는 없기 때문입니다. 뿐만 아니라 해체한 인자를 사용할 일도 전혀 없습니다.
이를 그대로 reporter.js의 생성자에도 적용합니다.

const getDriver = require('./driver.js');
module.exports = class{
	constructor(driverOption){//옵션을 받고
		this.server = http.createServer();
		this.server.on('request', (request, response)=>{
			const pathname = url.parse(request.url).pathname;
			const head = {'Content-Type': 'text/html'};
			if(pathname.indexOf('.png') > -1){
				head['Content-Type'] = 'image/png';
				fs.readFile(pathname.split('/').pop(), (e,v)=>res200(response, head, v));
			}else if(pathname == '/'){
				res200(response, head, BODY);
			}
		});
		this.server.listen(80);
		
		//옵션으로부터 생성
		this[DRIVER] = getDriver(driverOption); 
		this[DRIVER].get('http://127.0.0.1/');
		
		Object.freeze(this);
	}
}

내부에서만 변경되었기 때문에 외부에서의 사용법은 여전히 동일합니다. 이를 전부 반영한 새로운 테스트코드는 다음과 같습니다.

const Reporter = require('./reporter.js');
const reporter = new Reporter({x:900, y:0, width:900, height:600});

const Worker = require('./worker.js');
const worker = new Worker(
	'https://www.google.co.kr/search?newwind...',
	{browser:'firefox', width:900, height:600}
);
worker.waitUntil(2000, 'elementLocated', worker.by('#irc_cc')).
then(v=>worker.screenshot('test.png')).
then(path=>reporter.report(`<img src="http://127.0.0.1/${path}">`));

reporter와 worker 생성 시 브라우저종류, 위치 등의 설정을 손쉽게 할 수 있는 구조가 되었습니다. 여기까지의 코드는 다음에 있습니다. (몇몇 코드 정리가 더 되어있습니다^^)

https://github.com/hikaMaeng/seleniumNode/tree/master/5/2

결론

이번에는 스크린샷을 찍으면서 동시에 driver의 manage().window() 쪽의 기능도 같이 공부했습니다. driver.js를 새롭게 제작하여 드라이버에 대한 상세한 설정을 할 수 있는 기반도 만들고 reporter와 worker도 점진적인 발전을 이뤘습니다. 다음 글에서는 driver의 자바스크립트 실행에 대한 상세한 부분을 공부해보겠습니다.

%d 블로거가 이것을 좋아합니다: