Preview
저희 회사는 개발 이후 테스트를 진행할 때 QA엔지니어가 없기 때문에 1차적으로 개발자가 테스트하고 2차로 현업직무에 종사하는 임직원이 테스트하고 배포를 진행합니다. 개발자 인력도 부족해 부랴부랴 개발하기에도 시간이 벅차고 그렇다고 현업직원은 테스트가 본인 업무도 아니기 때문에 제대로 테스트를 진행해주리라 기대하기도 어렵습니다.
최근 운영하는 과정에서 개발자의 실수로 발생하는 단순 오류들을 수정해가는 과정에서 테스트의 중요성에 대해 뼈저리게 느끼게 되었고 API 테스트 툴, 테스팅 라이브러리 적용, 모바일 앱 테스트 자동화 등 어떻게 하면 개발자가 테스트에 시간을 많이 쏟지 않고 효율적으로 테스트를 진행해 배포까지 이어질 수 있을까 고민하며 자료를 찾아봤습니다.
현재 프로젝트 개발을 마치고 운영단계에 있는 소스에 테스팅을 적용하기란 쉽지 않은 작업입니다. 모든 소스가 테스트를 위한 소스가 아니다 보니 함수나 클래스에 테스팅 소스를 적용하려면 본래 함수나 클래스를 수정해야 하는 경우도 있는데 이는 운영중인 소스파일을 수정해야 하기 때문에 리스크 하기도 하죠.
그래서 최대한 소스의 수정없이 필수적인 기능(금융앱이니 이체, 주문 등)의 테스트를 진행할 수 있는 방법이 뭐가 있을까? 하다 찾아보니 appium 이란 테스팅 툴을 알게 되었습니다.
이번 글에서는 appium 설치 과정이나 초기 프로젝트 설정 방법 등에 대한 소개를 하려고 합니다. 현재 초기 단계에 있어 추후 소스에 변경도 있을 것이고 안드로이드 환경에 한정되어 테스트를 진행하고 있기 때문에 IOS에 대한 내용은 없으니 참고를 바랍니다!
Methodology
appium은 nodejs로 개발된 크로스플랫폼 애플리케이션 테스팅 툴입니다. 현재 국내에 여러 기업에서도 사용되고 있으며 QA Engineer 직군에서 꽤나 유용하게 쓰이는 것 같습니다.
Appium is an open source automation tool for running scripts and testing native applications, mobile-web applications and hybrid applications on Android or iOS using a webdriver.
설치하는 과정은 아래와 같습니다.
- 안드로이드 스튜디오나 IOS 라면 XCode가 설치되어 있어야 합니다.
- NodeJS 설치
Nodejs 기반으로 개발되었기 때문에 node 가 설치되어 있어야 합니다. 다만 조금 높은 버전의 node가 필요합니다.
Node.js version in the SemVer range ^14.17.0 || ^16.13.0 || >=18.0.0
- appium 설치
appium은 global로 설치하는게 좋습니다. 물론 local location에 설치해 npx appium을 해도 좋지만 저는 npx 붙이는 게 좀 번거롭게 느껴져서 그냥 global로 설치했습니다.
npm i -g appium
appium gui 프로그램을 설치하도록 안내하는 사람도 있는데 서버를 직접 설치도 해보고 GUI 프로그램도 설치해봤지만 그냥 터미널에서 설치해서 사용하는 게 더 편합니다. 어차피 서버만 구동하는 역할을 하기 때문에 굳이 프로그램을 설치할 필욘 없다고 생각합니다.
appium을 설치한 다음엔 terminal에 appium만 입력해도 실행이 됩니다.
다만 준비작업을 마치지 않았으니 오류가 날테지만..
- Android SDK, Java JDK
안드로이드 에뮬레이터나 기기에 연결하기 위해선 android sdk와 jdk가 필요합니다. 또한 환경 변수로 설정이 되어 있어야 합니다. 이 과정에 대해선 설명이 잘 되어있는 블로그를 소개해 드립니다!
앱 테스트 자동화 무작정 따라 하기 - 3 - Appium 서버
- UiAutomator2 / XCUITest 설치
appium은 서버를 구동합니다. 따로 포트를 설정하지 않으면 4723 포트로 서버를 구동하고 이 서버는 에뮬레이터나 연결된 모바일 기기로 액션(수행해야 하는 동작)을 전송하는 역할을 하는데, 앱에 있는 화면요소에 대한 접근이나 OS에 접근하기 위해선 드라이버가 필요한데 안드로이드는 UiAutomator2가, IOS는 XCUITest 가 그 역할을 합니다.
appium driver install uiautomator2
- appium-doctor 설치
필요한 준비작업을 다 했는데도 appium 이 실행되지 않는다면 appium-doctor로 검사를 해보는 것이 좋습니다.
npm install appium-doctor -g
appium-doctor --android --ios
appium-doctor를 실행해 보면 위에서 설명한 일련의 과정을 하라고 나올 것인데 만약 위에서 하라는 대로 했는데도 안 됐다고 나온 거면 제대로 진행을 못한 것이니.. 다시 한번 점검하길 바랍니다.
- appium inspector 설치
앱의 요소의 값을 알아야지 접근이 가능합니다. Button의 id 라던지 EditText의 value를 찾아 변경하려면 말입니다. 이때 요소를 확인할 수 있는 툴이 있는데 appium inspector가 그 기능을 제공합니다.
- 테스트 스크립트 작성
appium 이 정상적으로 구동되었으면 이제 스크립트를 작성해야 합니다. JS, python, Java 등 다양한 언어로 스크립트를 작성할 수 있는데 JS로 작성해보려고 합니다. 우선 doc를 참고해 보면
document에 있는 테스트 파일을 작성해 실행해 보면 <설정>을 열어 Battery라는 text를 가진 컴포넌트를 클릭하게 될 것인데 이제 우리 서비스에 맞게 테스트 스크립트를 작성해야 합니다.
우선 여러 테스트 진행을 위해 설정파일과 테스트 파일, 공통 사용 파일로 디렉터리를 나눕니다. (로그인 테스트만 첨부)
├── pages/
│ ├── Login/
│ │ ├── LoginPage.js //로그인 화면 클래스
│ ├── Page.js //페이지 클래스
├── test/
│ ├── login.test.js //로그인 관련 테스트
├── common/
│ ├── setup.js // webdriver 구동 함수
│ ├── selector.js //컴포넌트를 찾는 selector 관련된 클래스
└── config/
├── remote.config.js //appium 서버 연결 설정값
└── wdio.config.js //webdriverIO 설정값
pages : 테스트를 진행해야할 페이지(예를 들어 로그인) 단위로 디렉토리를 구성했다. 모든 하위 페이지들은 Page.js 를 상속받는다.
test : 테스트를 진행할 스크립트를 작성한다. 시나리오 순서대로 진행한다.
common : 공통함수
config : 설정관련 파일
setup
import mlog from "mocha-logger";
import { remote } from "webdriverio";
import { remoteConfig } from "../config/remote.config.js";
export default async function setup() {
mlog.log("starting browser ...");
return await remote(remoteConfig);
}
테스팅 라이브러리에는 mocha와 chai를 사용했습니다. jest와 같은 유명한 라이브러리도 있었는데 한 번도 사용해보지 않은 mocha와 chai 가 더 끌렸습니다 ㅎㅎ
mocha-logger는 일반적인 console.log 보다 더 테스팅 결과에 어울리는 로그를 찍어주기에 사용했습니다.
그리고 webdriver 에는 selenium, webdriverio 등이 있으니 입맛에 사용하면 됩니다.
wdio.config.js
export const wdioConfig = {
platformName: "Android",
"appium:automationName": "UiAutomator2", //사용하려는 드라이버. IOS의 경우 XCUITest
"appium:deviceName": "R3CR70LBV4Z", //command(terminal)에서 adb devices 를 하면 연결된 기기의 uuid가 조회된다. 그 값을 넣으면 된다.
"appium:appWaitForLaunch": true,
"appium:nativeWebScreenshot": true,
"appium:newCommandTimeout": 3600,
"appium:connectHardwareKeyboard": true,
"appium:appPackage": "com._____.___", //앱의 패키지 이름. 안드로이드의 경우 com.___.___의 포맷을 가짐.
"appium:appActivity": ".MainActivity", //앱의 시작점. 원하는 화면의 Activity 명을 입력하면 된다.
"appium:uuid": "R3CR70LBV4Z",
"appium:uiautomator2ServerInstallTimeout": "60000",
"appium:noReset": true,
};
capabilities에 들어갈 설정값입니다. 어떻게 보면 테스팅 스크립트에서 가장 중요한 파일이라고 할 수 있습니다.
위 주석 외의 값들에 대한 설명은 첨부한 링크를 참고 바랍니다.
Original error: Error executing adbExec. error while starting app on phone through appium
처음 실행 시 위와 같은 에러메시지가 괴롭혔는데 capabilities 중에 uiautomator2 ServerInstallTimeout 이 값을 꼭 넣어줘야 합니다. stackoverflow 에는 appWaitForLaunch 값을 false로 하라고 하던데 저의 경우 여전히 에러가 발생했습니다. 그러다 uiautomator2 ServerInstallTimeout 값을 1분 정도로 주니 에러가 수정됐습니다. 설명을 찾아보면 아래와 같이 20초가 default이지만 adb가 구동되는 데까지 20초가 넘게 걸리면 에러가 발생하는 듯싶었습니다.
The maximum number of milliseconds to wait util UiAutomator2 Server is installed on the device. 20000 ms by default
그리고 noReset 값을 false로 주면 테스트를 실행할 때마다 앱을 초기화 상태로 만들어 오랜 시간이 걸리곤 했습니다. 지금은 테스트를 수시로 해봐야 하기 때문에 true로 두어야 합니다.
remote.config.js
import { wdioConfig } from "./wdio.config.js";
export const remoteConfig = {
protocol: "http",
hostname: "127.0.0.1",
port: 4723,
path: "/",
logLevel: "error",
capabilities: wdioConfig,
};
이제 처음 appium을 구동하기 위한 준비작업을 마쳤습니다.
우선 test/login.test.js를 살펴보면 이 파일에는 테스팅 스크립트가 작성되어 있습니다.
한 가지 주의할 점은 거의 모든 함수가 동기처리방식으로 구현되어야 합니다. 모바일 테스트는 사용자가 직접 화면을 터치하고 기다리고 하는 일련의 과정들을 검증하기 때문입니다.
test/login.test.js
import { expect, should } from "chai";
import setup from "../common/setup.js";
import LoginPage from "../Pages/Login/LoginPage.js";
import { Selector, attribute, component } from "../common/selector.js";
import { after, before } from "mocha";
let browser, element;
describe("로그인 프로세스 테스트", function () {
this.timeout(60000); //테스트에 소요되는 시간을 설정한다. 테스트가 60초를 넘어서면 종료된다.
before(async () => {
browser = await setup();
});
it("PIN Login. Check FrameView Loaded", async function () {
this.timeout(30000); //PIN 로그인 테스트에는 30초를 할당했다.
const loginPage = new LoginPage(browser);
await loginPage.doPinLogin("112233");
await loginPage.wait(3000);
element = await loginPage.findElementWithRetry(
new Selector(component.button, attribute.text, "전체메뉴").getSelector()
);
expect(element).to.not.equal(undefined); //로그인에 성공하면 전체메뉴라는 버튼이 있어야 성공임을 알 수 있다.
});
after(async () => {
await browser.deleteSession();
});
});
로그인 방법 중 PIN 로그인을 진행하는 테스트입니다. 우선 LoginPage 클래스에서 doPinLogin 함수를 구현해 놨으며 page.js 에서 findElementWithRetry를 함수를 구현했습니다. 또한 Selector 함수에서 특정 Element를 찾는 selector 쿼리를 반환하는 getSelector 함수를 구현했으니 이어서 소개하겠습니다. 테스트는 상당히 간단한데 비밀번호 "112233"을 입력하고 로그인에 성공하면 화면에 "전체메뉴"라는 버튼이 있을 텐데 해당 Element 가 있으면 테스트 성공입니다!
page/LoginPage/LoginPage.js
import { Selector, attribute, component } from "../../common/selector.js";
import Page from "../Page.js";
export default class LoginPage extends Page {
constructor(browser) {
super(browser);
}
async doPinLogin(pinNumber) {
return new Promise(async (res, rej) => {
for (let i = 0; i < pinNumber.length; i++) {
await this.clickElement(
await this.findElementWithRetry(
new Selector(
component.imageView,
attribute.contentDesc,
pinNumber[i]
).getSelector()
)
);
if (i === pinNumber.length - 1) {
await this.clickElement(
await this.findElementWithRetry(
new Selector(
component.imageView,
attribute.contentDesc,
"입력완료"
).getSelector()
)
);
return res();
}
}
});
}
}
LoginPage는 Page를 상속받고 있습니다. 코드에서 볼 수 있다시피 clickElement와 findElementWithRetry 함수는 page.js 에 구현되어 있습니다. LoginPage.js 는 로그인 화면에 국한되어 있는 메서드를 담는 클래스입니다. PIN 번호는 "112233"으로 주어졌고 화면에서 "1"이라 적힌 버튼을 찾아 두 번 클릭해야 합니다. 이후엔 "2"를 찾아 두 번 클릭해야 합니다. 일련의 과정을 반복문으로 구현했고 blocking-IO(동기 처리 방식. 이전 처리가 끝날 때까지 기다린다.) 방식으로 구현했습니다.
이쯤 되면 page.js 와 Selector 클래스가 매우 궁금해집니다.. page.js 먼저 살펴봅시다. (코드가 긴 관계로 clickElement와 findElementWithRetry 만 봅시다)
import mochaLogger from "mocha-logger";
export default class Page {
constructor(browser) {
this.browser = browser;
}
async findElementWithRetry(selector, isMany = false, maxRetries = 3) {
let element = null;
return new Promise(async (res, rej) => {
for (let i = 0; i < maxRetries; i++) {
element = isMany
? await this.browser.$$(selector)
: await this.browser.$(selector);
if (element && !element.error) {
mochaLogger.success(`FOUND ${selector} Element`);
return res(element);
}
await this.browser.pause(1000);
mochaLogger.log(
`Retrying to find element. Retry ${i + 1}/${maxRetries}`
);
if (i === maxRetries - 1) {
mochaLogger.error(`NOT FOUND ${selector} Element`);
rej();
}
}
});
}
async clickElement(element) {
try {
await element.click();
mochaLogger.success(`Element ${element.selector} clicked`);
} catch (error) {
mochaLogger.error(`Element ${element?.selector} not clickable`);
}
}
}
findElementWithRetry 함수는 특정 Element를 화면 요소 중에서 찾는 역할을 하는데, 최대 3번 1초 단위로 탐색합니다. 모바일의 경우 기종이나 네트워크 환경에 따라 화면이 늦게 로딩되는 경우가 있습니다. 따라서 최대 3번의 탐색 기회를 줍니다. 그리고 여러 요소를 탐색하는 경우 $$를, 한 가지 요소만 탐색하는 경우 $를 붙입니다. $$를 사용하면 Element 가 배열로 들어오기 때문에 혹여나 화면에서 해당 요소가 여러 개라면(예를 들어 "완료"라는 text를 가진 버튼이 여러 개) 몇 번째 요소를 사용할 건지 결정해야 합니다.
이제 마지막으로 Selector 클래스를 봅시다.
common/selector.js
export class Selector {
constructor(comp, attr, text) {
this.comp = comp;
this.attr = attr;
this.text = text;
this.selector = `//android.widget.${comp}[@${attr}="${text}"]`;
if (attr === attribute.resourceId) this.setContains(true);
if (comp === component.view)
this.selector = `//android.view.${comp}[@${attr}="${text}"]`;
}
getSelector() {
return this.selector;
}
setSelector(selector) {
this.selector = selector;
}
setContains(bool) {
if (bool) {
this.selector = `//android.widget.${this.comp}[contains(@${this.attr}, "${this.text}")]`;
}
}
}
export const component = {
button: "Button",
editText: "EditText",
imageView: "ImageView",
view: "View",
};
export const attribute = {
resourceId: "resource-id",
text: "text",
xpath: "xpath",
contentDesc: "content-desc",
};
이 Selector 클래스의 경우 appium-inspector에서 요소를 찾는 쿼리 관련 클래스입니다.
selector를 구성하는 방법은 아래 링크를 참고하길 바랍니다. 저는 XPath 기반으로 selector를 찾았지만 본인의 서비스에 맞게 selector를 구성할 수 있습니다.
new Selector(
component.imageView,
attribute.contentDesc,
"입력완료"
).getSelector();
//=> //android.widget.ImageView[@content-desc="입력완료"]
The End
저 QA Engineer 가 아닙니다. 그런데 왜 테스팅에 이렇게 관심을 가지는가. 서비스를 운영하다 보면 테스트의 중요성을 뼈저리게 느낍니다. 한번만 눌러봤으면 알아챘을 버튼을 눌러보지 않아서 사용자가 발견하게 한다던가 일반적인 케이스만 테스트해 일반적이지 않은 케이스에 대한 대응이 전혀되어있지 않다던가 등의 상황을 마주해보면 개발만큼이나 중요한게 테스트인 것을 알게될 것입니다. 하지만 개발을 하다보면 테스트에 신경쓸 시간이 많지 않습니다..
물론 이렇게 테스트 스크립트를 작성하고 또 수정하고 하다보면 이 또한 하나의 업무가 될 수 있고 개발 업무에 지장을 줄 수 있습니다.
그래도 한 번 해놓을 때 잘 만들어 놓으면 언젠가 꼭 필요하게 될 수도 있고 저에게도 공부가 될 수 있으니 꾸준히 QA 관련 공부를 진행해보려고 합니다 ㅎㅎ 실무에 적용해 보는 것만큼 좋은 공부는 없기 때문에 계속 실무에도 적용해 보기!