테스트 자동화

6. Appium과 Cucumber를 이용해 UI Automation Testing

cwchoiit 2024. 4. 17. 17:00
728x90
반응형
SMALL

가장 먼저 작업할 내용은 AndroidDriver를 초기화해야 한다.

AndroidDriver는 어떤 역할을 하냐면, 연결된 Real Device를 PC에서 컨트롤할 수 있게 해주는 말 그대로 드라이버이다.

 

그리고 이 Driver는 모든 테스트를 돌리면서 딱 한개만 있으면 된다. 그래서, 싱글톤 패턴으로 드라이버를 초기화하는 클래스가 필요하다.

프로젝트 구조

└── src
    ├── main
    │   ├── java
    │   │   └── kro
    │   │       └── kr
    │   │           └── tbell
    │   └── resources
    └── test
        ├── java
        │   ├── AppiumSampleTest.java
        │   └── kro
        │       └── kr
        │           └── tbell
        │               ├── AppiumConnector.java
        │               ├── Constants.java
        │               ├── cucumber
        │               │   └── CucumberRunner.java
        │               └── stepdefinitions
        │                   └── PocFeatureStepDefs.java
        └── resources
            ├── env.yaml
            └── features
                └── poc.feature

 

자바 프로젝트를 만들면, src폴더 내부에 main, test 두 개의 폴더가 기본으로 생성된다.

여기서 test 폴더에서 우리가 원하는 구조를 만들어 나갈 것.

AppiumConnector 클래스

AppiumConnector

package kro.kr.tbell;

import io.appium.java_client.AppiumBy;
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.android.options.UiAutomator2Options;
import lombok.extern.slf4j.Slf4j;
import org.openqa.selenium.WebElement;
import org.yaml.snakeyaml.Yaml;

import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.util.Map;

@Slf4j
public class AppiumConnector {
    private static AndroidDriver androidDriver;

    private static final Yaml yaml = new Yaml();

    private AppiumConnector() {}

    private static AndroidDriver getAndroidDriver() {
        if (androidDriver == null) {
            Map<String, Object> env = initializeYaml();

            String udid = (String) env.get("udid");
            String url = (String) env.get("url");

            try {
                URL appiumServer = URI.create(url).toURL();
                UiAutomator2Options options = new UiAutomator2Options().setUdid(udid);

                androidDriver = new AndroidDriver(appiumServer, options);
            } catch (MalformedURLException e) {
                log.error("[getAndroidDriver]: MalformedURLException", e);
                throw new RuntimeException(e);
            }
        }
        return androidDriver;
    }

    private static Map<String, Object> initializeYaml() {
        InputStream inputStream = AppiumConnector.class
                .getClassLoader()
                .getResourceAsStream("env.yaml");

        return yaml.load(inputStream);
    }

    public static WebElement getElementById(String id) {
        return getAndroidDriver().findElement(AppiumBy.id(id));
    }
}

 

이 클래스에서 핵심은 AndroidDriver 타입의 드라이버를 한번만 초기화하고, 해당 드라이버를 통해 Real Device에 접근하는 것이다.

외부에서 직접 접근하지 못하도록 private으로 선언한 두 개의 필드.

private static AndroidDriver androidDriver;
private static final Yaml yaml = new Yaml();

 

AndroidDriver 타입의 androidDriver 필드는 Real Device와 통신하기 위한 드라이버이다.

Yaml 타입의 yaml 필드는 resources 폴더 내 .yaml 파일에서 설정한 변수들을 가져오기 위해 필요한 필드이다. 

 

그래서 드라이버를 초기화하거나, 이전에 초기화했다면 기존 드라이버를 반환하는 메서드 getAndroidDriver().

private static AndroidDriver getAndroidDriver() {
    if (androidDriver == null) {
        Map<String, Object> env = initializeYaml();

        String udid = (String) env.get("udid");
        String url = (String) env.get("url");

        try {
            URL appiumServer = URI.create(url).toURL();
            UiAutomator2Options options = new UiAutomator2Options().setUdid(udid);

            androidDriver = new AndroidDriver(appiumServer, options);
        } catch (MalformedURLException e) {
            log.error("[getAndroidDriver]: MalformedURLException", e);
            throw new RuntimeException(e);
        }
    }
    return androidDriver;
}

일단은 이 메서드는 private으로 만들었다. 외부에서 사용하지 않아도 될 것 같아서.

외부에서 사용안해도 되는 이유는 이후에 차차 만들면서 이해할 수 있다.

 

가장 첫번째는, 이 클래스의 클래스 변수인 androidDriver가 초기화 되지 않았는지를 판단한다. 초기화 되지 않았다면 초기화해야 한다.

initializeYaml() 메서드는 .yaml 파일을 읽어오기 위해 필요한 작업을 하는 메서드이다.

private static Map<String, Object> initializeYaml() {
    InputStream inputStream = AppiumConnector.class
            .getClassLoader()
            .getResourceAsStream("env.yaml");

    return yaml.load(inputStream);
}

 

내용은 간단하다. env.yaml 파일을 읽어서 InputStream으로 넣고 yaml이 해당 스트림을 읽으면 끝.

# env.yaml
url: http://0.0.0.0:4723
udid: HVA1FG23

 

yaml 변수에 저장된 값 중 udid, url 값을 읽어온다. 읽어오면 두가지 작업을 한다.

1. URL 타입으로 변환

2. UiAutomator2Options로 Capabilities를 설정할 수 있는데 여기서는 딱 하나 Udid만 설정했다.

 

AndroidDriver 인스턴스를 만들어낸 후 반환한다. 

 

이제 다음 메서드를 보자.

public static WebElement getElementById(String id) {
    return getAndroidDriver().findElement(AppiumBy.id(id));
}

이 메서드는 드라이버를 통해 Real Device와 연결해서 특정 Resource ID를 통해 UI 요소를 가져오는 메서드이다.

앞으로 이 메서드처럼 WebElement 자체를 반환하는 메서드만 public으로 만들어서 외부에서 사용하면 될 것 같아 드라이버를 받는 getAndroidDriver() 메서드는 private으로 선언했다.

 

코드 리팩토링

지금 상태에서 코드의 개선이 많이 필요해 보인다. 코드를 개선해보자.

 

첫번째, getAndroidDriver() 메서드는 멀티쓰레드 환경에서 안전하지 않다.

동시에 여러 쓰레드가 접근할 때 인스턴스가 여러번 생성될 수 있다. 이 부분을 해결해보자.

 

◾️ synchronized 키워드와 Double-Check Locking.

private static AndroidDriver getAndroidDriver() {
    if (androidDriver == null) {
        synchronized (AppiumConnector.class) {
            if (androidDriver == null) {
               // androidDriver 인스턴스 초기화 
            }
        }
    }
}

synchronized 키워드는 메서드나 코드 블럭에 대한 동시 접근을 제한해서 한 시점에 하나의 스레드만이 그 영역을 실행할 수 있게 하는 역할을 한다. 이를 사용해서 여러 스레드가 공유하는 데이터의 동시성 문제를 해결할 수 있다.

 

근데 If (androidDriver == null) 두번 체크한다.

1. 성능 최적화: 첫번째 if로 체크해서 null이 아니라면 synchronized 블록을 비롯한 추가적인 처리 없이 바로 반환된다. 이는 대부분의 호출에서 락을 획득하는 비용을 줄일 수 있다.

2. 스레드 안정성 보장: 만약, 첫번째 검사에서 null임이 판명되면, synchronized 블록으로 진입한다. 이 블록 내에서 한번 더 검사하는 이유는 두번째 스레드 이상이 동시에 블록 안으로 들어와 대기하고 있는 경우, 첫번째 스레드가 이미 객체를 생성했을 가능성을 다시 한번 검사하기 위함이다. 즉, 이중 검사를 통해 객체가 중복 생성되는 것을 방지한다.

 

◾️ try-with-resourceInputStream 자원 해제 및 에러 처리

private static Map<String, Object> initializeYaml() {
    try (InputStream inputStream = AppiumConnector.class
            .getClassLoader()
            .getResourceAsStream("env.yaml")) {

        if (inputStream == null) {
            throw new IllegalStateException("env.yaml not found.");
        }

        return yaml.load(inputStream);
    } catch (IOException e) {
        log.error("[initializeYaml]: Error occurred when loading yaml file ", e);
        throw new RuntimeException(e);
    }
}

 

1. InputStream은 사용 후 반드시 닫아야 한다. 닫는 코드를 작성하거나 아예 try-with-resource 구문으로 사용후 끝나면 자동으로 닫아주는 방식을 택해 자원 해제를 해준다.

2. 찾고자 하는 yaml 파일이 없는 경우를 대비해 if (inputStream == null) 체크를 한다.

3. yaml 파일을 읽어들이는 중 발생하는 에러를 catch 구문으로 처리한다.

 

 

◾️공개 메서드인 경우 명확한 문서화

/**
 * 주어진 ID를 가진 웹 요소를 찾아 반환합니다.
 * 이 메서드는 안드로이드 드라이버를 사용하여 애플리케이션에서 해당 ID를 가진 요소를 검색합니다.
 *
 * @param id 찾고자 하는 웹 요소의 resource id 입니다.
 * @return WebElement 객체를 반환합니다. ID에 해당하는 요소가 없는 경우 {@code null}을 반환할 수 있습니다.
 * @throws org.openqa.selenium.NoSuchElementException 요소를 찾을 수 없는 경우 발생합니다.
 * @throws IllegalStateException AndroidDriver가 초기화되지 않았거나 접근할 수 없는 경우 발생합니다.
 * @throws org.openqa.selenium.WebDriverException 드라이버와의 통신 중 문제가 발생한 경우 발생합니다.
 * */
public static WebElement getElementById(String id) {
    return getAndroidDriver().findElement(AppiumBy.id(id));
}

외부에서 이 메서드를 가져다가 사용하는 클라이언트 코드 쪽(여러 서비스)에서 어떤 메서드인지 명확히 이해할 수 있게 JavaDoc을 활용하자. 어떤 파라미터를 줘야 하는지, null을 반환할수 있는지 없는지, 어떤 에러를 던질 수 있는지 등을 말이다.

 

리팩토링 후 최종 코드

AppiumConnector

package kro.kr.tbell;

import io.appium.java_client.AppiumBy;
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.android.options.UiAutomator2Options;
import lombok.extern.slf4j.Slf4j;
import org.openqa.selenium.WebElement;
import org.yaml.snakeyaml.Yaml;

import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.util.Map;

@Slf4j
public class AppiumConnector {
    private static AndroidDriver androidDriver;

    private static final Yaml yaml = new Yaml();

    private AppiumConnector() {}

    private static AndroidDriver getAndroidDriver() {
        if (androidDriver == null) {
            synchronized (AppiumConnector.class) {
                if (androidDriver == null) {
                    Map<String, Object> env = initializeYaml();

                    String udid = (String) env.get("udid");
                    String url = (String) env.get("url");

                    try {
                        URL appiumServer = URI.create(url).toURL();
                        UiAutomator2Options options = new UiAutomator2Options().setUdid(udid);

                        androidDriver = new AndroidDriver(appiumServer, options);
                    } catch (MalformedURLException e) {
                        log.error("[getAndroidDriver]: MalformedURLException", e);
                        throw new RuntimeException(e);
                    }
                }
            }
        }
        return androidDriver;
    }

    private static Map<String, Object> initializeYaml() {
        try (InputStream inputStream = AppiumConnector.class
                .getClassLoader()
                .getResourceAsStream("env.yaml")) {

            if (inputStream == null) {
                throw new IllegalStateException("env.yaml not found.");
            }

            return yaml.load(inputStream);
        } catch (IOException e) {
            log.error("[initializeYaml]: Error occurred when loading yaml file ", e);
            throw new RuntimeException(e);
        }
    }

    /**
     * 주어진 ID를 가진 웹 요소를 찾아 반환합니다.
     * 이 메서드는 안드로이드 드라이버를 사용하여 애플리케이션에서 해당 ID를 가진 요소를 검색합니다.
     *
     * @param id 찾고자 하는 웹 요소의 resource id 입니다.
     * @return WebElement 객체를 반환합니다. ID에 해당하는 요소가 없는 경우 {@code null}을 반환할 수 있습니다.
     * @throws org.openqa.selenium.NoSuchElementException 요소를 찾을 수 없는 경우 발생합니다.
     * @throws IllegalStateException AndroidDriver가 초기화되지 않았거나 접근할 수 없는 경우 발생합니다.
     * @throws org.openqa.selenium.WebDriverException 드라이버와의 통신 중 문제가 발생한 경우 발생합니다.
     * */
    public static WebElement getElementById(String id) {
        return getAndroidDriver().findElement(AppiumBy.id(id));
    }
}

 

Runner 클래스 만들기

BDD 방법론에 맞게 Gherkin 문법으로 테스트 시나리오를 작성을 하고, 그 시나리오를 수행하려면 Cucumber의 도움을 받아야 한다.

Cucumber가 Gherkin 테스트 시나리오를 수행할 수 있게 해주는 도구이다.

 

CucumberRunner

package kro.kr.tbell.cucumber;

import io.cucumber.junit.Cucumber;
import io.cucumber.junit.CucumberOptions;
import org.junit.runner.RunWith;

@RunWith(Cucumber.class)
@CucumberOptions(
        features = {"src/test/kro/kr/tbell/features"},
        glue = "stepDefs"
)
public class CucumberRunner {

}

 

@RunWith(Cucumber.class) 애노테이션은 JUnit 프레임워크에서 테스트를 실행할 때 사용되는 실행자(runner)를 지정한다.

여기서 Cucumber.class를 사용함으로써, JUnit은 표준 테스트 실행자 대신 Cucumber 테스트 실행자를 사용하게 됩니다.

 

이렇게 하면 다음과 같은 작업을 한다.

  • Feature 파일 파싱: features 옵션에서 지정된 경로 내의 .feature 파일들을 찾아서 파싱한다. 이 파일들은 Gherkin 언어로 작성된 사용자 스토리나 비즈니스 요구사항을 담은 테스트 시나리오들이라고 보면 된다.
  • 스텝 정의와 연결: 실행자는 각 스텝(Given, When, Then)이 구현된 자바 메서드와 .feature 파일 내의 스텝을 연결한다. 이 연결은 glue 옵션에서 지정된 패키지 내에서 이루어진다.
  • 테스트 실행: 연결된 스텝 정의를 사용하여 실제 테스트를 실행하고 결과를 보고한다.

결과적으로 @RunWtih(Cucumber.class)는 Cucumber를 사용하여 BDD 접근 방식으로 정의된 테스트를 JUnit 환경에서 실행할 수 있도록 설정한다. 


Feature 파일 만들기

poc.feature

Feature: POC Feature

  Scenario: 테스트 케이스 1번
    When 개발 HTTPS 서버 버튼 클릭
    Then 간편 비밀번호 입력 문구가 노출된다

 

BDD 접근 방식으로 정의된 Gherkin 문법으로 만들어진 테스트 시나리오를 가진 poc.feature 파일이다.

.feature 파일은 대분류 Feature가 있고 소분류 Scenario가 있다.

 

Feature는 이 .feature 파일이 어떤 부분을 커버하는지를 어떤 화면도 좋다. 어떤 파트도 좋다. 큰 분류를 담당한다.

Scenario는 그 파트에서 커버되어야 할 시나리오들을 쭉 작성하는데 하나하나의 시나리오를 말한다.

 

그래서 Given - When - Then 키워드로 시나리오를 작성한다.

StepDefinition 파일 만들기

저 Feature 파일에 정의한 각각의 스텝들(Given, When, Then)에 대한 코드를 작성하는 부분이다.

PocFeatureStepDefs

package kro.kr.tbell.stepDefs;

import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;
import kro.kr.tbell.AppiumConnector;
import kro.kr.tbell.Constants;
import lombok.extern.slf4j.Slf4j;
import org.openqa.selenium.WebElement;

@Slf4j
public class PocFeatureStepDefs {

    @When("개발 HTTPS 서버 버튼 클릭")
    public void clickDevHttpsServerBtn() {
        WebElement devHttpsServerBtn =
                AppiumConnector.getElementById(Constants.DEV_HTTPS_SERVER_BUTTON_ID);

        devHttpsServerBtn.click();
    }


    @Then("간편 비밀번호 입력 문구가 노출된다")
    public void assertSimplePasswordText() {
        AppiumConnector
                .getElementById(Constants.SIMPLE_PASSWORD_TEXT_ID)
                .isDisplayed();
    }
}

 

이렇게 @When, @Then 애노테이션으로 어떤 스텝인지 명확하게 알 수 있어 가시성이 뛰어나다.

그리고 그 안에서 위에서 만든 싱글톤 패턴의 AppiumConnector 클래스의 클래스 변수 AndroidDriver를 가져와서 원하는 요소를 찾아내고 요소에 대해 어떤 행위를 한다. @Then에서 isDisplayed()는 보이지 않으면 그 자체로 에러를 반환하기 때문에 따로 Assertion이 필요가 없다. 

 

파라미터로 넘겨주는 값은 상수값으로 따로 정의를 했다.

Constants

package kro.kr.tbell;

public interface Constants {
    String DEV_HTTPS_SERVER_BUTTON_ID = "id-button-1"; //개발 HTTPS 서버 버튼 Resource ID
    String SIMPLE_PASSWORD_TEXT_ID = "id-title-1"; // 간편 비밀번호를 입력해주세요 문구 Resource ID
}

저 Resource ID는 Appium Inspector를 통해 알 수 있다. [아래 사진 참고]

테스트 실행

이제 제일 중요한 테스트를 직접 실행해보는 시간이다. 테스트 실행은 매우 간단하게 IDE에서 할 수 있다.

.feature 파일을 보면 좌측에 테스트 실행 버튼이 있고 Scenario 옆에 있는 버튼은 해당 시나리오만, Feature 옆에 있는 버튼은 해당 .feature 파일의 모든 시나리오를 수행한다.

 

실행해보면 JUnit으로 테스트하듯 똑같이 테스트가 진행된다. 

 

결과

 

테스트 실행을 IDE로 해봤지만 Jekins Pipeline을 사용하려면 IDE로 실행하는 법만 알아선 안된다.

 

Gradle + Cucumber 테스트 실행 (CLI)

우선, 커맨드라인으로 테스트를 실행하기 앞서, Gradle을 사용할땐 자주 사용되는 실행명령어가 있으면 그것을 task로 만들어 낼 수가 있다. 그래서 이 cucumber 실행 테스트를 task로 만들어보자.

 

build.gradle

configurations {
    cucumberRuntime {
        extendsFrom testImplementation
    }
}

tasks.register('cucumberRun') {
    dependsOn assemble, testClasses
    doLast {
        javaexec {

            main = 'io.cucumber.core.cli.Main'
            classpath = configurations.cucumberRuntime + sourceSets.main.output + sourceSets.test.output
            args = ['--glue', 'kro.kr.tbell.stepdefinitions',
                    'classpath:features',
                    '--plugin', 'pretty',
                    '--plugin', 'html:build/cucumber-report.html'
            ]
        }
    }
}

이 코드는 cucumber test를 위해 build.gradle에 추가적으로 설정해야 하는 코드이다.

 

configurations {
    cucumberRuntime {
        extendsFrom testImplementation
    }
}

Gradle Configuration은 빌드 과정에서 사용되는 종속성들을 관리하는 일종의 컨테이너 역할을 한다.

 

cucumberRuntime 이라는 configuration을 만들고, extendsFrom testImplementationcucumberRuntimetestImplementation으로 선언된 모든 종속성을 상속받는다는 의미이다. 즉,  testImplementation으로 선언된 모든 라이브러리 및 파일들이 cucumberRuntime에도 속하게 된다.

 

이렇게 설정하면, Cucumber와 관련된 테스트 실행 시 필요한 모든 종속성을 cucumberRuntime에 포함시킬 수 있으며, 추가적인 종속성을 cucumberRuntime에만 지정하여 관리할 수도 있다.

 

tasks.register('cucumberRun') {
    dependsOn assemble, testClasses
    doLast {
        javaexec {

            main = 'io.cucumber.core.cli.Main'
            classpath = configurations.cucumberRuntime + sourceSets.main.output + sourceSets.test.output
            args = ['--glue', 'kro.kr.tbell.stepdefinitions',
                    'classpath:features',
                    '--plugin', 'pretty',
                    '--plugin', 'html:build/cucumber-report.html'
            ]
        }
    }
}

cucumberRun 이라는 이름의 task를 만든다.

dependsOnGradle 빌드 스크립트에서 사용되는 키워드로, 하나의 Task가 다른 하나 또는 여러 Task의 실행 결과에 의존한다는 의미이다. 즉, 지정된 Task가 실행되기 전에 의존하는 Task들이 먼저 완료되어야 한다는 것을 의미한다.

 

그럼 저기서는 assemble, testClasses 두 개의 Task들이 먼저 완료가 되어야 한다는 것을 말한다.

  • assemble: 이 Task는 일반적으로 프로젝트의 모든 메인 소스 세트를 컴파일하고, 필요한 모든 리소스를 처리하며, 실행 가능한 Artifact(예: JAR파일)를 빌드하는 데 사용된다. assemble task는 메인 소스 코드가 변경되었을 때 변경사항을 반영하여 새로운 아티팩트를 만들어낸다.
  • testClasses: 이 Task는 프로젝트의 테스트 소스 코드를 컴파일한다. 테스트를 실행하기 전에 테스트 소스 코드가 최신 상태인지 확인하고 필요한 경우 컴파일을 수행하여 테스트 실행 준비를 마친다. testClasses Task는 테스트 코드에 대한 변경사항이 있을 때마다 테스트를 다시 컴파일하여 최신 상태로 유지한다.

 

그 다음 doLast는 Gradle 태스크의 생명주기 중 '실행'단계가 끝난 후 실행할 작업을 추가하는 메서드다. 태스크가 주 작업을 완료한 후 실행되어야 하는 추가적인 작업들을 정의할 때 사용된다. 그래서 do'Last'이다. 보통은 그래서 정리 작업, 로깅, 조건부 추가 작업등을 위해 사용된다. 

 

그 안에서 javaexec가 있는데 이건 Gradle에서 Java 프로그램을 실행하기 위한 built-in 함수다. 이 메서드를 통해 Java 애플리케이션을 실행하거나, Java 기반의 커맨드라인 도구를 호출할 수 있다. javaexec 블록 내에서는 실행할 Java 클래스, 클래스패스, 프로그램 인자 등을 설정할 수 있다.

 

  • main: javaexec에서 실행할 메인 클래스를 지정한다. 여기서는 Cucumber의 커맨드라인 인터페이스인 'io.cucumber.core.cli.Main'이 지정됐다. 이 클래스는 Cucumber 테스트를 실행하는 엔트리 포인트이다.
  • classpath: 실행 시 클래스패스를 지정한다. configuration.cucumberRuntime + sourceSets.main.output + sourceSets.test.output을 통해 Cucumber 실행에 필요한 모든 종속성과 컴파일된 클래스 파일들이 포함된 클래스패스를 구성했다. 'sourceSets.main.output' 이 녀석은 프로젝트의 메인 소스 셋 (src/main/java및 src/main/resources에 위치한 소스들)이 컴파일된 후 생성된 모든 클래스 및 리소스 파일들의 출력 위치를 가리킨다. 'sourceSets.test.output' 이 녀석은 테스트 소스 셋(src/test/javasrc/test/resources)이 컴파일 된 후 생성된 모든 클래스 및 리소스 파일들의 출력 위치를 나타낸다.
  • args: 프로그램 실행 시 건네 줄 인자를 작성한다.
    • --glue: Cucumber가 스텝 정의를 찾을 수 있는 패키지 경로
    • classpath:features: Cucumber가 feature 파일들을 찾을 경로 classpath에 src/test/resources가 정의되어 있으니 그 안에 features 폴더에서 .feature 파일을 찾겠다는 의미가 된다.
    • --plugin pretty: Cucumber 실행 결과를 사람이 읽기 좋게 출력하는 플러그인을 활성화
    • --plugin html:build/cucumber-report.html: 테스트 결과를 HTML 형식으로 build/cucumber-report.html에 저장

 

이렇게 설정한 Task를 커맨드라인을 통해 실행만 하면 된다.

./gradlew cucumberRun // gradlew 또는 gradle로 실행하면 된다.

728x90
반응형
LIST

'테스트 자동화' 카테고리의 다른 글

Appium, OpenCV를 활용한 Visual Testing  (0) 2024.04.25
5. 프로젝트 환경 설정  (2) 2024.04.17
4. Appium Inspector 연결  (0) 2024.04.17
3. APK 설치  (0) 2024.04.17
2. Appium  (0) 2024.04.17