home

웹 E2E 테스트 자동화 구현

안녕하세요. 아이들나라 QA팀 배종원입니다.
아이들나라 QA팀에서 구현한 웹 UI 테스트 자동화에 대해 소개하려고 합니다.
테스트 자동화는 QA 엔지니어들의 오랜 숙원이자, 숙제이지 않을까 생각되는데요. 하지만 환경 구성이 어렵고, 테스팅 툴에 따라 프로그래밍 언어를 배워야 하는 러닝 커브가 필요합니다.
단순히 스크립트 개발로 끝이 아닌 각 서비스마다 필요한 테스트 영역이 상이하기 때문에, 적절한 커스터마이징과 무엇보다 끊임없이 요구되는 유지 보수가 QA 엔지니어의 발목을 잡습니다. 따라서 테스트 자동화는 막연히 ‘매뉴얼 테스트를 자동화한다.’ 의 개념으로 접근하면 위험하다고 생각합니다.
자동화 테스트는 QA 엔지니어가 품질 보증을 달성하기 위한 수단 중 하나일 뿐, 자동화 테스트 자체가 Goal이 되어서는 안 됩니다. 자동화 테스트의 구현에만 의미를 두면, 위에 언급한 끊임없이 요구되는 유지보수와 최악의 경우 기술적 욕심에 매몰되어 정작 QA 엔지니어의 근간인 품질 보증 업무와는 점점 거리가 멀어지게 됩니다.
아앗….
따라서 구현 전 명확한 명분 또는 조건, 그리고 구현 후 Output에 대한 확신이 있을 때 도입해야 한다고 생각합니다.
그럼, 이번 글에서는 아이들나라 QA 팀에서 왜 테스트 자동화를 도입하게 되었고, 어떻게 구현했는지, 어떤 이점이 있었는지 다뤄보고자 합니다.

테스트 자동화 구현 배경

아이들나라는 최근 새로운 서비스들과 콘텐츠들을 출시하며 새 단장이 되었습니다. 이에 따라 아이들나라 App 내 수많은 기능이 변경되고, 새로 만들어졌는데요. 이런 상황 속에서 QA 팀의 우선 과제는 3주 단위 스프린트 위에서 사용자가 접하는 App의 UI가 문제 없이 잘 동작 하는지 검증하는 것이었습니다.
상황이 이렇다 보니, 자연스레 백오피스에 대한 테스트는 QA 팀 내 리소스 및 사용자에게 미치는 영향도 등을 고려했을 때 저희 팀 테스트 범위 밖에 놓이고 말았습니다.
QA의 Test 없이 상시 배포되고 있던 상황
한정된 리소스 속에서 테스트를 할 수 있는 방안이 없을까? 를 고민하다, 백오피스의 배포가 어떻게 되고 있는지 시간을 가지고 파악해 본 결과는 다음과 같습니다.
서버/클라이언트 모두 일정 주기로 배포가 되는 것이 아닌 상시 배포
클라이언트 배포 빈도가 높지 않았고 배포 시 UI 변경 점이 많지 않음
그렇다면 해당 테스트를 매뉴얼로 진행한다면?
해당 테스트에 대한 Scheduling 불가
회귀 테스트를 위해 수많은 기존 UI에 대한 동일한 테스트가 반복됨
자동화로 구현한다면?
테스트 진행이 어려웠던 사이드 영역의 테스트를 빠르게 진행 가능
SRE팀의 도움을 받아 CI 파이프라인 구축 가능
자동화로 테스트를 진행할 시 주는 첫 번째 Output이 저에게는 너무 큰 이점으로 다가왔습니다.
또한 아이들나라는 DevOps의 지원이 가능하여 자동화 테스트를 구현하기 아주 좋은 환경에 있어, 해볼법한 시도라고 생각했습니다. 따라서 자동화 테스트를 구현하기로 하고 도구를 검토해 보았습니다.

도구 검토

테스트 자동화 도구는 아래의 기준에 부합하는 도구를 선정했습니다.
1.
도구를 신뢰할 수 있어야 함 (품질을 확보하는 도구의 품질이 나쁘면..?)
2.
사용 간 습득할 수 있는 정보(풀)의 양
3.
다양한 언어를 통해 커스터마이징이 용이하여 다른 도구와 Intergration에 제약이 없어야 함
4.
오픈소스여야 함
우선 범용성이 가장 높은 E2E Testing 도구들을 서칭하여 살펴보았습니다.
1.
Selenium
a.
출시된 지 20년(!)이 넘었으며 꾸준한 업데이트를 통한 신뢰성 확보
b.
모든 테스트 자동화 도구 중 가장 큰 커뮤니티 풀 보유
c.
메이저 언어 대부분 지원
d.
오픈소스
2.
Cypress
a.
역시 꾸준한 업데이트를 통해 신뢰성을 확보
b.
적절한 풀 보유
c.
JS만 사용 가능
d.
유료 패키지도 있으나, 대부분 무료로 사용 가능
3.
Playwright
a.
MS에서 만든 도구로 정리 가능
후보를 선정한 후, 각 도구의 장/단점을 살펴보았습니다.

Selenium

장점
대부분의 언어를 지원하며 병렬 테스트 및 여러 브라우저 검증 가능
3rd party 라이브러리를 사용하여 스크린샷 및 화면 녹화가 가능
단점
자체 리포트 기능 없음. 직접 만들어야 합니다..
환경 구성이 복잡하고 어려움 (path 설정 등..)

Cypress

장점
자체 인스펙터 내장
자체 러너/리포트 내장
툴 내에서 스크린샷 및 화면 녹화를 지원
단점
자바스크립트만 지원
병렬 테스트를 하려면 돈을 내야합니다..
좋아지고 있지만 아직도 미비한 크로스 브라우징 지원

Playwright

장점
MS! MicroSoft! 마이크로소프트! 만세!
코드 두줄이면 완료되는 어마어마하게 간단한 환경 구성
도구 자체는 프레임워크 보다는 API를 제공하는 프로그램이며, 가볍고 빠른 속도를 보여줌
자체 러너/리포트 지원
유일한단점
아직 정식 출시된 지 얼마 되지 않아 상대적으로 적은 정보의 풀

도구 선정

도구 선정은 아래 두 가지 요인을 고려하며 진행하였습니다.
1.
언어는 생산성을 위해 파이썬 사용
2.
테스트 프레임워크가 있는 Git 레포지토리를 Docker 컨테이너화된 app으로 빌드하여 리눅스 서버에 올린다는 가정
결론적으로, 저희는 셀레니움을 선택하게 되었습니다. 그 이유는 Cypress의 경우, JS만 사용 가능하다는 부분 탓에 탈락 되었고, Playwright는 정말 모든 것을 갖췄으나 유일한 단점이 유지 보수에 중대한 영향을 끼칠 것으로 예상되어 탈락하였습니다.
그래서 최종적으로 셀레니움을 선택하게 되었습니다. 셀레니움 웹 드라이버가 어떻게 브라우저를 컨트롤 하는지에 대해서는 아래 공식 문서 링크로 대체합니다.

구현

프레임워크는 아래와 같이 구성하였습니다.
.(Repo) ├── Dockerfile # 도커 이미지 빌드 ├── config.yaml # 공통으로 사용할 변수 관리 ├── library_list # 자주 사용하는 라이브러리 디렉토리 ├── qa_slack_bot # 슬랙 리포트를 위한 봇 소스 디렉토리 ├── utility # 편의를 위해 직접 만든 라이브러리 디렉토리 └── web_automation # TC 및 러너가 있는 디렉토리 └── Env_Beta ├── test_cases │   ├── livelib_admin_contents_category.py │   ├── livelib_admin_contents_contents.py │   ├── livelib_admin_login.py │   ├── livelib_admin_papaer_category.py │   └── livelib_admin_paper_contents.py └── test_run ├── Base.py # 테스트 러너 └── report
Plain Text
복사
테스트 실행의 뇌 역할을 하는 Base 파일의 기본 환경구성 부분을 살펴보겠습니다. (기본적인 환경 구성은 개발하는 QA 엔지니어의 성향 및 검증 진행하는 서비스에 따라 다를 수 있습니다.)
우선, 해당 파일은 Docker에서 빌드되기 때문에 파일을 가져오는 path는 상대 경로로 지정해 주었습니다. 또한 리눅스 서버에 띄워진 헤드리스 브라우저에서 원활한 테스트를 위해 --no-sandbox, --disable-dev-shm-usage 옵션을 추가하였습니다.
추가로 지정된 url을 띄우지 못할 경우를 대비해 Exception 컨트롤을 넣어주었습니다. 마지막으로 저는 암묵적 대기를 선호하지 않는데, 경험 상 동적 페이지에서 요소를 찾을 수 없는 경우가 상당히 많았으므로 cls.wait = WebDriverWait(cls.driver, 15)를 통해 명시적 대기를 걸어두었습니다.
def setUpClass(cls): # TestCase 변수 가져오기 with open('../../../info.yaml') as f: cls.pconf = yaml.load(f, Loader=yaml.FullLoader) # 봇 변수 가져오기 path = "../../../qa_slack_bot/config_bot.json" with open(path, 'r') as json_file: cls.conf = json.load(json_file) # 테스트 info. options = webdriver.ChromeOptions() cls.users = 'QA ' cls.platform = "Web" # Server(linux) print('building session...') options.add_argument('--no-sandbox') options.add_argument("--disable-dev-shm-usage") options.add_argument('--headless') options.add_argument("lang=ko_KR") options.add_argument("--window-size=1600,1080") cls.driver = webdriver.Chrome(options=options) run_env = 'Server' print('Env. = Server') cls.wait = WebDriverWait(cls.driver, 15) cls.url = cls.pconf['url'] # 최초 도메인 연결 실패 시 Exception cls.driver.set_page_load_timeout(10) try: cls.driver.get(cls.pconf['url']) except TimeoutException as e: print(e, '도메인 연결 실패') cls.driver.quit()
Python
복사
TC 파일 중 로그인 부분을 살펴보겠습니다.
모든 상호작용 코드는 wait.until(EC.visibility_of_element_located) 문법이 적용되어 있어 Base 파일에서 적용한 명시적 대기가 동작합니다.
추가로 셀레니움 공식 코멘트에 따라 모두가 지양해야 하는 XPath 사용(느리고, 요소가 빈번히 변경되기 때문) 빈도가 꽤 높은데, Css selector와 실제 구동 속도 차이가 없는 수준이었으며 text를 지정하거나 변하지 않는 쿼리를 지정해 주기 때문에 정확성에 문제가 없다고 판단하여 사용하였습니다.
# ID 입력 wait.until(EC.visibility_of_element_located((By.ID, ":r0:"))).send_keys(pconf['main_id']) # PW 입력 wait.until(EC.visibility_of_element_located((By.ID, ":r1:"))).send_keys(pconf['main_pw']) # 인증 방법 선택 (select-react로 구현되어 있는듯.. Select 메서드 활용 불가) auth = wait.until(EC.visibility_of_element_located((By.ID, ":r2:"))) if auth.get_attribute("innerText") == "카카오톡": pass else: auth.click() wait.until(EC.visibility_of_element_located((By.XPATH, "//li[@data-value='카카오톡']"))).click() # 로그인 wait.until(EC.visibility_of_element_located((By.XPATH, "//button[text()='로그인']"))).click() # 인증번호 입력 wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, f"div>input[placeholder='{pconf['login_auth_word']}']"))).send_keys(pconf['login_auth']) # 로그인 wait.until(EC.visibility_of_element_located((By.XPATH, "//button[text()='확인']"))).click() sleep(4) # 로그인 계정 이름 확인하고 마무리 elements = wait.until(EC.visibility_of_all_elements_located((By.TAG_NAME, "p"))) for i in elements: account_name = i.get_attribute("innerText") if account_name == pconf['name']: print('로그인 테스트 성공') break else: msg = '로그인 테스트 실패' raise Exception
Python
복사
위 스크립트는 아래와 같이 동작합니다.
다음은 개인적으로 구현이 까다로웠던 부분입니다.
아래 페이지 구조를 보면, 페이지 안에 2개의 동적 스크롤 영역이 존재합니다.
좌측 영역에 대한 테스트를 하고자 합니다.
테스트 시나리오는 다음과 같습니다.
1좌측 영역의 콘텐츠 전체 선택 체크박스 클릭 2> 전체 선택 체크박스 한번 더 클릭하여 전체 선택 해제 3> 최하단 스크롤 4> 최하단 콘텐츠의 체크박스 선택
Plain Text
복사
아래와 같이 노드 구조를 살펴 보니, 화면 범위를 벗어나는 Element(이하 요소)가 존재합니다.
이를 타겟하기 위해서는 스크롤 영역을 직접 스크롤 하여 브라우저 상에 요소가 나타나야 합니다.
스크롤 영역의 최상위 부모 요소를 찾기 위해 좌측 영역을 담당하는 노드를 바인딩 해야 합니다.
찾아보니 id값으로 board-wrapper값을 가진 요소를 찾았습니다. 이 요소가 좌측 영역을 담당하는 부모 요소네요! 이제 해당 부모 노드부터, 스크롤 영역을 담당하는 자식(하위)노드를 탐색하여 원하는 요소를 바인딩 해보겠습니다.
총 3번의 자식 노드를 내려간 끝에 class값으로 MuiDataGrid-virtualScroller를 가진 요소를 찾았습니다.
이제 이 친구를 컨트롤하는 스크립트를 작성할 수 있게 되었습니다.
해당 요소를 변수로 할당하고, 스크롤하여 보이지 않던 요소를 탐색하는 스크립트를 작성해 보겠습니다.
스크립트를 간단하게 설명드리겠습니다.
첫번째로 좌측 부모 요소를 left_list 변수로 할당하였습니다. 우측 영역에 똑같은 요소가 존재하기에, index값으로 [0]을 주었습니다.
이후 위에서 바인딩했던 left_list 부모 요소 하위에 존재하는 스크롤 영역 요소를 left_scroll_area 변수로 할당하였습니다.
# 좌측 콘텐츠 영역 변수 할당 left_list = wait.until(EC.visibility_of_all_elements_located((By.ID, "board-wrapper")))[0] # 전체 체크박스 선택 all_checkbox = wait.until(EC.visibility_of_element_located((By.CLASS_NAME, "MuiDataGrid-columnHeaderDraggableContainer"))) all_checkbox.click() sleep(1) # 전체 체크박스 선택 해제 all_checkbox.click() # 좌측 콘텐츠 영역 스크롤러 잡기 left_scroll_area = wait.until(lambda d: left_list.find_element(By.CLASS_NAME, "MuiDataGrid-virtualScroller")) # 최하단 스크롤 wd.execute_script("arguments[0].scrollBy(0, 1000);", left_scroll_area) # 최하단 콘텐츠 선택 target_contents = wait.until(EC.visibility_of_element_located((By.XPATH, "//div[@aria-rowindex='21']"))) # 체크박스 선택여부 탐지 checkbox = target_contents.find_element(By.CLASS_NAME, "PrivateSwitchBase-input") checkbox.click() if checkbox.is_selected() is True: pass else: raise Exception
Python
복사
특이점은 11번 라인의 left_scroll_area변수를 할당하는 과정에서 람다식이 사용되었는데요.
부모 요소로 자식 요소를 locating 하는 과정에서 어떤 변수가 일어날지 모르기에 명시적 대기를 유지하고 싶었습니다.
부모 요소로 자식 요소를 찾는 과정에서 명시적 대기가 필요할 때 제가 사용한 방법을 소개하겠습니다.
스크립트 내에서 until() 메소드의 인자로 많이 사용 된 EC 모듈의 visibility_of_element_located 메소드는 이런 상황에서 사용할 수 없습니다(EC 모듈 공식 스펙 문서 참고).
이 때 until() 메소드의 인자로 lambda d: left_list.find_element(By.CLASS_NAME, "MuiDataGrid-virtualScroller")를 넘겨 주면 lambda 함수의 매개변수 d가 until() 메소드에게 값을 넘겨주게 됩니다. 해당 원리는 셀레니움 공식 소스 코드의 until 함수에서 확인할 수 있습니다. def until(self, method, message: str = ""
여기서 method 매개변수에 lambda 함수의 표현식 결과가 전달되어 명시적 대기가 동작하게 됩니다.
다음은 구현한 스크립트를 서버에서 실행 되게끔 컨테이너화 하는 작업입니다.
테스트를 로컬에서 수동으로 돌려야 한다면 자동화 테스트라 할 수 없겠죠? 그럼, 실제 서버에 얹을 컨테이너를 생성할 Docker 파일을 살펴보겠습니다.
먼저, 이미지의 용량 관리를 위해 slim-buster 파이썬 바이너리를 선정했습니다. 또한 리눅스 서버에서 빌드될 예정이기에 linux/amd64 옵션을 주었습니다.
더하여, 크롬/크롬드라이버를 항상 최신화해 주기 위해 컨테이너 내부에 최신 크롬 및 크롬 드라이버를 설치하였고 엔트리 포인트는 러너를 바라보도록 하였습니다. 이후 k8s에서 테스트가 구동될 수 있도록 아이들나라 SRE팀에서 도와주셨습니다. (이 자리를 빌려 다시 한번 감사드립니다)
# 용량 관리를 위한 slim-buster base(Debian) FROM --platform=linux/amd64 python:3.10.8-slim-buster as base ## start builder stage # 첫번째 빌드 스테이지 # 모든 디펜던시 내려 받음 FROM base as builder # 컨테이너 내부에 크롬/크롬드라이버 설치: https://gist.github.com/varyonic/dea40abcf3dd891d204ef235c6e8dd79 RUN apt-get update && \ apt-get install -y xvfb gnupg wget curl unzip --no-install-recommends && \ wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && \ echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list && \ apt-get update -y && \ apt-get install fonts-unfonts-core && \ apt-get install -y google-chrome-stable && \ CHROMEVER=$(google-chrome --product-version | grep -o "[^\.]*\.[^\.]*\.[^\.]*") && \ DRIVERVER=$(curl -s "https://chromedriver.storage.googleapis.com/LATEST_RELEASE_$CHROMEVER") && \ wget -q --continue -P /chromedriver "http://chromedriver.storage.googleapis.com/$DRIVERVER/chromedriver_linux64.zip" && \ unzip /chromedriver/chromedriver* -d /chromedriver # requirements 설치 COPY requirements.txt /requirements.txt RUN pip install --upgrade --no-cache-dir -r /requirements.txt RUN rm /requirements.txt # remove requirements file from container. # 소스 복사 COPY . /app ## end builder stage ## start base stage # 이미지 생성 FROM builder # Python path 지정 ENV PYTHONPATH "${PYTHONPATH}:/app" # 기본 경로 및 ENTRYPOINT 지정 WORKDIR /app/web_automation/Env_Beta/test_run ENTRYPOINT ["python", "Base.py"] ## end base stage.
Go
복사
이렇게 테스트 러너가 실행되고, 테스트가 완료되면 아래와 같이 슬랙 알림이 발송됩니다.
Pass일 경우
Fail일 경우
Exception 및 스크린샷 출력
종합해보면, 현재는 아래와 같은 단순한 테스트 인프라가 구성되었습니다.

마치며

지금까지 아이들나라 QA 팀에서 왜 테스트 자동화를 도입하게 되었고, 어떻게 구현했는지 간략하게 다루어 보았습니다.
현재는 구현 초기 단계이기 때문에 많은 개선점이 필요합니다. (Flacky code, 무거운 도커 이미지 등)
하지만 팀 차원에서 그동안 하지 못했던 테스트를 진행할 수 있게 된 점, 개인적 차원에서는 사용해보지 않았던 셀레니움에 대한 이해도가 올라간 점, 두 이점을 생각해 보면 유의미한 시간이었다고 생각됩니다.
도입부에서 언급했던 “무엇보다 끊임없이 요구되는 유지 보수가 QA 엔지니어의 발목을 잡습니다” 라는 허들로 인해 많은 QA 엔지니어가 자동화 테스트를 포기하곤 합니다.
그러나 유지 보수가 없는 프로그램은 존재하지 않습니다. 구현해야 할 목적 및 구현 시 결과가 주는 이점이 명확하다면 도입하시는 것을 적극 추천합니다.
해당 글이 E2E 테스트 자동화 도입을 고려 중이신 분들께 도움이 되었으면 합니다. 감사합니다.
++ 다음 블로깅은 Mobile 박왕근님을 추천합니다!