자동화 테스트는 개발 프로세스의 효율성과 신뢰성을 높이는 데 있어 필수적인 요소가 되었다. 그러나 웹 페이지에 접속했을 때 나타나는 "n일간 보지 않기" 옵션이 포함된 팝업은 자동화 테스트의 진행에 걸림돌이 되곤 한다. 이 팝업은 사용자 경험을 개선하기 위해 주로 사용되지만, 자동화 테스트를 수행할 때에는 예상치 못한 실패를 초래할 수 있기 때문이다. 이 경우 CDP를 활용하여 UI 자동화 테스트의 성공률을 높이고, 커버리지를 높일 수 있다.
일반적으로 "n일간 보지 않기" 옵션은 서버에 저장하기보다 브라우저의 Local Storage에 저장한다. 왜냐하면 비로그인 상태에서 해당 팝업이 출력될 수 있기 때문에 사용자를 특정하여 이 사용자가 팝업을 봤는지 여부를 판단하기 어렵기 때문이다. 따라서 사용자가 "n일간 보지 않기" 버튼을 눌렀는지 여부와 누른 시점 등을 Local Storage에 저장하게 된다.
CDP를 활용하면 브라우저의 Local Storage 목록을 가져올 수 있다. 따라서 "n일간 보지 않기" 버튼을 누른 후 키와 값이 로컬 스토리지에 저장되었는지 확인하여 테스트 커버리지를 높일 수 있고, 반대로 로컬 스토리지에 저장된 키와 값을 가져와 해당 팝업을 제어하는 코드를 건너뛸 수도 있다.
다음은 코드를 통해 Local Storage의 값을 얻어오는 방법을 간단하게 구현한 예시이다. 아래 코드를 통해 Local Storage의 값을 가져와 테스트 케이스에 반영할 수 있다.
드라이버 초기화 코드는 제외하였다.
def get_localstorage_via_cdp(self, key: str) -> str or None:
"""
CDP를 통해 로컬 스토리지 특정 키로 밸류 가져오기.
:param key: 로컬 스토리지에서 가져오고자 하는 키 값
:return: 로컬 스토리지 밸류. 없으면 None
"""
return self.driver.execute_script(f"return localStorage.getItem(arguments[0]);", key)
이전 게시글에서 간략하게 설명한 바 있지만, 몇 가지 중요한 포인트들에 대한 부연 설명이 누락되어 있어 추가로 서술하고자 한다.
네트워크 요청과 응답을 가져올 때 주의해야 할 사항들이 있다. CDP로 네트워크 요청과 응답을 모니터 할 때, 불필요한 OPTIONS METHODE나 PREFLIGHT REQUEST까지 가져오게 되어 가져 온 데이터를 가공할 때 에러를 발생시키는 경우가 있다. 따라서, 이를 예외 처리에 적절히 반영하여야 한다.
또한 네트워크 요청과 응답이 많아질수록 성능 관점에서도 고민이 필요한데, 순회하는 동안 전체 컬렉션에 메모리를 로드하지 않는 이터레이터를 활용하여 현재 처리 중인 요소만 메모리에 유지할 수 있도록 처리해주는 것이 좋다.
단, 모든 상황에서 이터레이터가 더 나은 성능을 보장하는 것은 아니니 데이터 크기와 사용 사례에 따라 적절하게 선택하는 것을 권장한다.
(작은 데이터 세트의 경우, 이터레이터의 오버헤드가 전체 컬렉션을 메모리에 로드하는 것보다 더 비효율적일 수 있으니….)
다음과 같은 순서로 네트워크 요청, 응답 데이터를 가공하고, 예외 처리 추가하면 된다.
드라이버 초기화 코드와 실제 실행하는 코드는 제외하였다.
CDP로 네트워크 요청, 응답 정보가 포함된 performance 정보를 가져오고, 가져온 데이터를 JSON 형태로 파싱한다.
def _parse_network_logs(self) -> list[dict]:
"""
성능 로그를 원시 형태로 가져와 JSON 형태로 파싱하기.
:return: JSON 형태로 파싱된 요청, 응답 정보
"""
raw_network_logs = self.driver.get_log("performance")
return [json.loads(raw_log["message"])["message"] for raw_log in raw_network_logs]
파싱된 데이터를 request_id와 request_method로 매핑한다.
def _is_request(self, network_log: dict) -> bool:
"""
주어진 네트워크 로그가 네트워크 요청인지 판단하기.
:param network_log: 분석할 네트워크 로그 항목.
:return: 인자로 넘어온 네트워크 로그가 요청이면 True, 그렇지 않으면 False.
"""
return network_log["method"] == "Network.requestWillBeSent"
def _map_request_methods(self, network_logs: list[dict]) -> dict:
"""
요청 ID와 메서드 유형을 매핑하기.
:param network_logs: JSON 형태로 파싱된 네트워크 정보.
:return: request_id와 매핑된 request_method {'8429AEF5AED57FB8BE9FFDFCAF07642C': 'GET', '79278.123646': 'POST'}
"""
request_method_map = {}
for log in filter(self._is_request, network_logs):
request_id = log["params"]["requestId"]
request_method = log["params"]["request"]["method"]
request_method_map[request_id] = request_method
return request_method_map
매핑된 request_id와 request_method로 response_body를 가져오는데, Preflight 요청이거나 devtools 요청이면 스킵한다.
def _is_response(self, network_log: dict) -> bool:
"""
주어진 네트워크 로그가 네트워크 응답인지 판단하기.
:param network_log: 분석할 네트워크 로그 항목.
:return: 인자로 넘어온 네트워크 로그가 응답이면 True, 그렇지 않으면 False.
"""
return network_log["method"] == "Network.responseReceived"
def _extract_log_info(self, log: dict) -> tuple:
"""
네트워크 로그 항목으로부터 주요 정보(요청 ID, 응답 URL, 상태 코드)를 추출하기.
:param log: 분석할 네트워크 로그 항목.
:return: 요청 ID, 응답 URL, 상태 코드를 포함하는 튜플.
"""
request_id = log["params"]["requestId"]
response_url = log["params"]["response"]["url"]
status_code = log["params"]["response"]["status"]
return request_id, response_url, status_code
def _get_response_body(self, request_id: str) -> dict or None:
"""
주어진 요청 ID에 대한 응답 본문을 가져오기.
응답 본문의 추출 시도 중 발생하는 모든 예외를 처리하고 로깅.
:param request_id: 응답 본문을 조회할 네트워크 요청의 ID.
:return: 응답 본문을 포함하는 딕셔너리. 요청 처리에 실패한 경우 None 반환.
"""
try:
return self.driver.execute_cdp_cmd("Network.getResponseBody", {"requestId": request_id})
except Exception as e:
log.logger.error(f"response_body를 얻어오는데 실패하였습니다. request_id: {request_id}, \nException Message: {e}")
return None
def _create_network_data(self, response_url: str, status_code: int, response_body: dict) -> dict:
"""
네트워크 응답 데이터를 기반으로 딕셔너리를 생성하기.
:param response_url: 응답의 URL.
:param status_code: 응답의 HTTP 상태 코드.
:param response_body: 응답 본문 및 기타 정보를 담은 딕셔너리.
:return: 네트워크 응답 정보를 담은 딕셔너리.
"""
return {
"response_url": response_url,
"status_code": status_code,
"response_body": response_body["body"],
"base64_encoded": response_body.get("base64Encoded", False)
}
def _should_skip_log(self, request_id: str, response_url: str, request_method_map: dict) -> bool:
"""
특정 조건에 따라 네트워크 로그를 스킵할지 여부를 결정하기.
OPTIONS 메서드를 사용하는 요청 또는 devtools://로 시작하는 URL을 가진 요청은 스킵한다.
:param request_id: 분석할 네트워크 로그의 요청 ID.
:param response_url: 분석할 네트워크 로그의 응답 URL.
:param request_method_map: 요청 ID와 해당 HTTP 메서드를 매핑한 딕셔너리.
:return: 로그를 스킵해야 하는 경우 True, 그렇지 않으면 False.
"""
return request_method_map.get(request_id) == "OPTIONS" or response_url.startswith("devtools://")
def _collect_network_data(self, network_logs: list[dict], request_method_map: dict) -> list[dict]:
"""
네트워크 데이터를 수집하기.
:param network_logs: JSON 형태로 파싱된 네트워크 정보.
:param request_method_map: request_id와 매핑된 request_method
:return: 네트워크 응답이 담긴 배열
"""
network_data_list = []
for log in filter(self._is_response, network_logs):
request_id, response_url, status_code = self._extract_log_info(log)
if self._should_skip_log(request_id, response_url, request_method_map):
continue
response_body = self._get_response_body(request_id)
if response_body is None:
continue # 에러가 발생한 경우, 이 요청을 건너뛰고 다음 요청으로 이동
network_data_list.append(self._create_network_data(response_url, status_code, response_body))
return network_data_list
이를 통해 확인할 수 있는 것은 브라우저가 WebDriver에 의해 제어되는 동안 Local Storage의 데이터를 읽는 것뿐만 아니라, 네트워크 요청과 응답 정보도 추출할 수 있다는 점이다.
이러한 기능은 단순히 자동화 테스트로 UI만 검증하는 것이 아니라 Local Storage의 데이터를 직접 접근하여 확인함으로써 애플리케이션이 사용자의 데이터를 올바르게 저장하고 관리하는지 검증할 수 있으며,
네트워크 요청과 응답을 추출함으로써 애플리케이션이 서버와 효율적으로 통신하고 있는지, 예상치 못한 네트워크 오류를 적절히 처리하는지 등을 확인할 수도 있다.