| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
- 포그라운드
- xpath
- Appium Inspector
- push notification
- XCUIElementStaticText
- IOS
- XCUITest
- Web Driver Agent
- foreground
- WDA
- iOS Class Chain
- appium
- XCode Console
- 푸시알림
- 자동화 테스트
- java
- Today
- Total
성장기의 히동
[Appium, iOS] TypePickerWheel을 조작할 때 발생한 에러 직면기 및 Locator 리팩토링 본문
🌼
이번 글에는 TypePickerWheel을 이용해야 하는 자동화 테스트 코드를 작성하면서 마주한 에러의 해결과정을 담았다.
글이 길어 가독성이 떨어질 수 있을 것 같아, 사전에 순서를 정리하겠다.
[ 이벤트 테스트 ]
1. 이벤트 생성 테스트 중, TypePickerWheel을 이용해 종료 시간을 한 시간 늦추는 함수 작성
2. 비슷한 상황에서 해당 메서드를 활용하려고 하니 에러 발생
3. 로케이터가 계층 변화에 취약한 구조로 이뤄져 있었기 때문에 요소를 찾지 못한 것으로 원인 파악
4. TypePickerWheel 로케이터 리팩토링
[ 루틴 테스트 ]
5. 루틴 테스트 진행 중 한 시간 늦추는 것을 넘어 원하는 시간까지 스와이프 해야 하는 메서드가 필요해짐
6. Appium에서 제공하는 selectPickerWheelValue 활용 -> 실패
7. 원하는 값에 도달할 때까지 스와이프 반복 -> 매우 느림
8. sendKey() 메서드 활용 -> 매우 빠르고 정확함 (실제 해결책)
‼️ 잘 동작했던 TypePickerWheel 조작 메서드에서 NoSuchElement Exception 에러 발생
TypePickerWheel은 아래 사진 처럼 생긴 사용자가 스와이프를 통해 시간 값을 변경해서 입력할 수 있는 UI 요소이다.
- 이벤트 생성 할 때 TypePickerWheel로 시작, 종료 시간을 지정할 수 있다.
- 루틴을 생성하고, 내부 액션을 생성할 때 TypePickerWheel로 시작, 종료 시간을 지정할 수 있다.

TypePickerWheel을 조정해가면서 테스트 해야 할 TC가 있어서, 자동화를 위해 로케이터를 찾았었다.
각각의 Wheel에 고유 accessibility id가 부여돼있다면 정말 너무 행복했겠지만, 현실은 녹록치 않았다.
사실 이전에 시간을 한 시간 뒤로 조정하는 코드를 작성해뒀었다.
안정적인 로직은 아니었지만, 그 때는 그래도 성공했다는 생각에 내버려뒀던 나의 무지막지한 코드는 UI 계층 변화에 굉장히 취약하고 굉장히 지저분했다.
아래는 시작 시간과 종료 시간이 같을 경우, 종료 시간을 한 시간 뒤로 조정하는 코드이다.
(테스트 중, 앱을 실행한 뒤 "첫 터치"로 이벤트를 생성할 때, 시작 시간과 종료 시간이 같게 표시되는 에러를 발견했다.
이후 이벤트 생성이 정상적으로 이뤄지는 지에 대한 테스트만 진행하기 위해 임시로 작성해두었다.)
if (startTime.equals(endTime)) {
endTimePicker.click();
String classChain = "**/XCUIElementTypeWindow/XCUIElementTypeOther[4]/XCUIElementTypeOther/XCUIElementTypeOther/XCUIElementTypeOther/XCUIElementTypeOther/XCUIElementTypeOther[2]/XCUIElementTypeDatePicker/XCUIElementTypePicker/XCUIElementTypePickerWheel[2]";
WebElement endTimeHourWheel = driver.findElement(iOSClassChain(classChain));
Map<String, Object> params = new HashMap<>();
params.put("order", "next");
params.put("offset", 0.15);
params.put("element", ((RemoteWebElement) endTimeHourWheel).getId());
driver.executeScript("mobile: selectPickerWheelValue", params);
}
저 길고 긴 classchain은 다시는 보기 싫은 정말 더러운 코드여서, 개선해보려고 건드렸다.
왜냐면 저 코드는 누가 봐도 UI 계층 변화에 취약하고, 실제로 오동작한 경우도 있어서 어떤 상황에서도 안정적인 코드로 만들고 싶었기 때문이다.
➡️ 같은 이벤트 도메인 내에서도, 어떤 작업을 먼저 수행하느냐에 따라 UI 계층이 달라져서 위 코드로는 절대 동작이 불가능했고, 실제로 NoSuchElement 에러가 발생했다.
그리고 루틴 도메인에서도 절대 못 사용했을 것이다.
테스트 코드는 어떤 상황에서도 견고하게 동작해야 한다고 생각하기 때문에, 가장 먼저 코드 리팩토링을 진행했다.
☁️ TypePickerWheel Locator 리팩토링

위 사진에서 중요한 정보는 총 세 가지다.
1. accessibility id가 없어서, UI 계층 변화에 취약한 class chain이나 xpath를 사용해야 한다는 것.
2. 시간을 표현하는 wheel은 "시"로 끝난다는 것. (오전/오후는 각각 value가 정말 오전/오후고, 분을 표현하는 wheel은 "분"으로 끝난다.)
3. 그래도 다행인건, predicate string을 통해 "시"로 끝나는 wheel을 구별해낼 수 있다는 것과 xpath로 "시"를 포함한 요소를 찾아낼 수 있다는 것.
리팩토링은 두 가지 방법으로 시도했다.
1. xpath 활용
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement endTimeHourWheel = wait.until(ExpectedConditions.presenceOfElementLocated(xpath("//XCUIElementTypePickerWheel[contains(@value, \"시\")]")));
2. ios predicate string 활용
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement endTimeHourWheel = wait.until(ExpectedConditions.presenceOfElementLocated(iOSNsPredicateString("type == 'XCUIElementTypePickerWheel' AND name ENDSWITH '시'")));
이 중, Android와 iOS 두 개의 환경에서 동작할 수 있는 코드를 작성하기 위해 Xpath로 리팩토링했다.
이렇게 해결됐으면, 현실이 녹록치 않다고 생각하지도 않았다.
‼️ 직면한 진짜 문제, TypePickerWheel 조작이 원하는대로 이뤄지지 않았다. 에러도 뜨지 않고.
앞서 작성한 코드는 사실 종료 시간의 TypePickerWheel에서 "시간"을 표시하는 요소를 찾아 한 시간 뒤로 조정하면 되는 쉬운 길이었다.
하지만, 루틴 테스트를 진행하면서 TypePickerWheel에서 "원하는 값"에 도달할 수 있도록 코드를 작성해야 했다.
원하는 값까지 도달하게 하는 방법은 두 가지를 고안했다.
1. Appium에서 제공하는 Execute Method 중 "mobile: selectPickerWheelValue" 활용
1-1. selectPickerWheelValue에서 value값을 제외해 원하는 값에 도달할 때까지 반복하기
2. SendKeys() 메서드 이용
🛠️ 해결 과정
1. Appium에서 제공하는 Execute Method 중 "mobile: selectPickerWheelValue" 활용
Performs selection of the next or previous picker wheel value. This might be useful if these values are populated dynamically, so you don't know which one to select or value selection suing sendKeys API does not work because of an XCTest bug. The method throws an exception if it fails to change the current picker value.
다음이나 이전의 picker wheel 값을 선택한다는 뜻이다.
sendKeys가 먹히지 않거나, 동적인 값을 받아 움직여야 할 때 유용하게 쓰일 수 있다고 한다. 만약 현재 Picker의 값을 변경하지 못했을 때 에러를 던지는 메서드라고 한다^.^..

사진을 보면 selectPickerWheelValue 메서드를 사용하는데 필요한 인자값이 나와있다.
* elementId 필수
: 조작할 PickerWheel의 내부 id
* order 필수
: 이동할 방향
즉, 값 변경을 원하는 TypePickerWheel의 elementId와 PickerWheel의 조작 방향이 필수라는 것!
* offset
: PickerWheel의 조작 정도? 라고 보면 될 것 같다. 너무 크게 잡으면 한 번에 두 칸이 이동할 수도 있고, 너무 작게 잡으면 아예 움직이지 않을 수 있다고 한다. 기본 값은 0.2다.
* value
: value값은 필수는 아니지만, 만약에 넘겨주면 WDA가 해당 값에 도달할 때까지 지정된 방향으로 자동으로 스크롤 한다고 한다.
이 때 지정된 방향은 앞서 설명한 order이다.
* maxAttempts
: 최대 스크롤 횟수를 정해줄 수 있다. 이 값을 넘기면 에러가 발생한다. 만약 10번 안에 도달해야 하는 값이라면, 최대 스크롤 횟수는 10번으로 정하는 식이다. 기본 값은 25다.
➡️ 그래서 value 값을 넘겨서 WDA가 해당 값에 자동으로 도달하게 만들고자 했다.
아래는 해당 과정을 시도한 메서드이다..
private void regulateToDoHourPickerWheel(int targetHour) {
if (targetHour < 0 || targetHour > 23) {
System.out.println("시간은 음수 혹은 23시 초과로 설정할 수 없습니다.");
} else {
WebElement routineHourPickerWheel = driver.findElement(iOSClassChain(
"**/XCUIElementTypePicker[`name == \"시간\"`]/XCUIElementTypePickerWheel"));
int currentHour = Integer.parseInt(routineHourPickerWheel.getAttribute("value"));
String direction = (currentHour > targetHour) ? "previous" : "next";
Map<String, Object> params = new HashMap<>();
params.put("element", ((RemoteWebElement) routineHourPickerWheel).getId());
params.put("order", direction);
params.put("value", targetHour);
params.put("offset", 0.15);
driver.executeScript("mobile: selectPickerWheelValue", params);
}
}
에러조차 뜨지 않고 한 번만 움직인 뒤 동작하지 않았다.
그래서 동작하지 않은 원인을 분석해봤다.
a. 요소 id가 잘못됐을 경우
> 그렇다면 TypePickerWheel이 한 번이라도 동작하지 않았어야 했다.
> 그리고 아래 사진에서도, elementId를 아래와 같이 가져오는 걸 확인할 수 있었다. (여러 글을 확인해보니 저 사람 진짜 잘 하는 것 같다)

b. value값은 String인데, targetHour가 int기 때문
> targetHour를 String으로도 바꿔봤다. 안 된다.
> 그렇다면 5시로 표현되니까 "시"를 붙이면 되지 않을까? targetHour + "시" 안 된다.
원인을 파악할 수 없었다. 동작하지 않았다.
아무런 에러 메시지도 뜨지 않은 채, 정확하게 동작하지 않았기 때문에 너무 답답한 상황이었지만 정말 원인을 알 수 없었다.
누군가 제발 알려줬으면 좋겠다. 왜 저 코드가 동작하지 않았는지요..
1-1. selectPickerWheelValue에서 value값을 제외해 원하는 값에 도달할 때까지 반복하기
else {
while (true) {
WebElement routineHourPickerWheel = driver.findElement(iOSClassChain(
"**/XCUIElementTypePicker[`name == \"분\"`]/XCUIElementTypePickerWheel"));
int currentHour = Integer.parseInt(routineHourPickerWheel.getAttribute("value"));
// 목표 값에 도달했는지 확인
if (currentHour == targetMinute) {
System.out.println("분 설정 완료: " + currentHour);
break;
}
String direction = (currentHour > targetMinute) ? "previous" : "next";
Map<String, Object> params = new HashMap<>();
params.put("element", ((RemoteWebElement) routineHourPickerWheel).getId());
params.put("order", direction);
params.put("offset", 0.15);
driver.executeScript("mobile: selectPickerWheelValue", params);
}
}
이 코드는 정상적으로 동작했다. 느리지만, 천천히 원하는 값에 도달할 수 있게 동작했다.
하지만 치명적인 단점이 있었다. 진짜 매우 굉장히 "느리다"
2. SendKeys() 메서드 이용 (실제 해결책, 최고👍🏻)
앞선 방식을 이용했던게, sendKey() 는 textField에만 사용할 수 있다고 오판했기 때문이다.
저 긴 코드가 얼마나 간단하게 바뀌는지 확인해보시라.
private void regulateToDoHourPickerWheel(int targetHour) {
if (targetHour < 0 || targetHour > 23) {
System.out.println("시간은 음수 혹은 23시 초과로 설정할 수 없습니다.");
} else {
WebElement routineHourPickerWheel = driver.findElement(iOSClassChain(
"**/XCUIElementTypePicker[`name == \"시간\"`]/XCUIElementTypePickerWheel"));
routineHourPickerWheel.sendKeys(Integer.toString(targetHour));
}
}
체감이 안 될 것 같아서 영상을 가져왔다. 진짜 한 번 꼭 봤으면 좋겠다. 이렇게 차이가 납니다..
왼쪽의 시간 조절이 sendKey() 메서드로 한 것이고, 오른쪽 분 조절이 selectPickerWheelValue를 활용해 원하는 값에 도달할 때까지 스와이프 한 것이다.
⭐️ 후기
Appium에서 제공하는 메서드라 뭐가 있을 줄 알았는데, 너무 느렸다.
그리고 에러조차 뜨지 않아서 무엇이 문제인지 파악하지 못해 너무 아쉽고 답답하다.
글에는 작성하지 못했지만, 더 많은 상황과 더 많은 에러를 직면했었는데 그 때마다 캡쳐해두지 않았던게 굉장히 아쉽다.
하지만 글을 작성하고 보니, 그 중간에 겪었던 에러들은 크게 중요하지 않았던 것 같다.
그래도 남겨두고 싶으니까 하나는 작성해보겠다.
- null point exception : picker wheel을 한 번 찾고, 여러 번 반복해서 요소의 value 값을 가져오는 코드가 있었다. (원하는 시간 값에 도달했는지 보려고)
요소가 stale 한 상태가 돼서 그런 것으로 추측하지만, 차라리 요소에 접근할 수 없어서 못 가져왔다고 해주지 그냥 null 띄우는 건 너무 불친절한 에러처럼 느껴졌다.
그래서 글의 마지막 즈음 작성된 원하는 값에 도달할 때까지 반복하는 코드를 보면, while문 안에서 picker wheel 요소를 반복해서 찾아주는 걸 볼 수 있다..!
sendKeys()가 동작한다고 알려준 건.. 아까 그 분이다

만세 만세 만만세.. 앞으로도 레퍼런스를 더 다양하게 찾아보는 연습을 해야할 것 같다.
그리고 이렇게 찾아보니까 영어.. 좀 늘었을지도