왜 만들었나
QA 자동화 테스트 코드를 작성할 때 가장 반복적이고 시간을 잡아먹는 작업이 뭘까? 바로 엘리먼트 셀렉터를 뽑아내는 일이다.
DevTools를 열고, Elements 탭에서 엘리먼트를 찾고, 적절한 셀렉터를 조합하고, 그게 유니크한지 Console 탭에서 document.querySelectorAll()로 확인하고... 한 페이지에 엘리먼트가 20개만 돼도 이 과정이 고통스러워진다.
기존 워크플로우의 문제
DevTools 열기 → Elements 탭 → 엘리먼트 검색 → 속성 확인 → Console에서 유니크 여부 확인 → 셀렉터 복사 → 테스트 코드에 붙여넣기. 엘리먼트 하나에 최소 30초~1분이 소요된다.
이걸 "클릭 한 번"으로 줄일 수 없을까? 라는 생각에서 Selector Extractor 크롬 확장 프로그램을 만들었다. 클릭하면 말풍선에 셀렉터 목록이 뜨고, 유니크한 것이 상단에 정렬되고, Playwright 코드까지 자동으로 생성해준다.
동작 흐름
[1] 확장 프로그램 활성화
↓ 팝업에서 토글 ON
[2] 엘리먼트 클릭
↓ Content Script가 클릭 이벤트 캐치
[3] 셀렉터 추출 + 말풍선 표시
↓ 7종 셀렉터 동시 추출, 우선순위 정렬
[4] 사이드 패널 히스토리 누적
↓ Background Service Worker 중계
검색 / JSON / CSV 내보내기
- 활성화 - 팝업에서 토글 버튼을 클릭하면 Content Script가 활성화된다.
- 하이라이트 - 마우스를 올리면 해당 엘리먼트가 파란 테두리로 하이라이트된다.
- 클릭 → 추출 - 엘리먼트를 클릭하면
SelectorEngine.extract()가 7종류의 셀렉터를 한꺼번에 추출한다. - 말풍선 표시 - 추출된 셀렉터들이 우선순위대로 말풍선에 표시된다. 각 항목 옆에 Copy 버튼이 있다.
- 사이드 패널 히스토리 - Background Service Worker를 통해 추출 결과가 사이드 패널로 전달되어 히스토리에 쌓인다.
핵심 기술 - Manifest V3 아키텍처
Chrome의 Manifest V3 스펙에 맞춰 설계했다. 구성 요소는 크게 4가지다.
manifest.json
{
"manifest_version": 3,
"permissions": ["activeTab", "sidePanel", "storage", "clipboardWrite"],
"content_scripts": [{
"js": ["lib/selector-engine.js", "content.js"],
"css": ["styles/content.css"],
"run_at": "document_idle"
}],
"background": { "service_worker": "background.js" },
"side_panel": { "default_path": "sidepanel.html" }
}
컴포넌트 역할
| 컴포넌트 | 파일 | 역할 |
|---|---|---|
| Content Script | content.js + selector-engine.js | 페이지에 주입, 하이라이트/클릭/말풍선 처리 |
| Background | background.js | 메시지 브릿지, 탭별 상태 관리 |
| Side Panel | sidepanel.js | 히스토리 관리, 검색, 내보내기 |
| Popup | popup.js | ON/OFF 토글 UI |
Manifest V3에서는 Background Page 대신 Service Worker를 사용한다. Service Worker는 필요할 때만 깨어나고 유휴 시 자동 종료되므로, 상태를 메모리에만 들고 있으면 안 된다. 이 프로젝트에서는 히스토리를 chrome.storage.local에 저장하고, 탭 상태만 tabStates 객체로 관리하는 방식을 택했다.
메시지 플로우
Content Script →
chrome.runtime.sendMessage→ Background (브릿지) → Side Panel. Content Script와 Side Panel은 직접 통신할 수 없기 때문에 Background가 중계 역할을 한다.
셀렉터 엔진 - 우선순위 알고리즘
핵심은 lib/selector-engine.js의 SelectorEngine.extract() 함수다. 하나의 엘리먼트에서 7가지 타입의 셀렉터를 동시에 추출한다.
| 우선순위 | 타입 | 예시 | 안정성 |
|---|---|---|---|
| 1 | id | #login-btn | HIGH |
| 2 | data-testid | [data-testid="submit"] | HIGH |
| 3 | class (unique) | .user-profile-card | MEDIUM |
| 4 | css | div.container > button:nth-of-type(2) | MEDIUM |
| 5 | xpath | //div[@id="app"]/form/button[2] | LOW |
| 6 | role | role=button[name="Submit"] | MEDIUM |
| 7 | text | text="로그인" | LOW |
각 추출 함수에는 노이즈를 줄이기 위한 필터링 로직이 있다.
selector-engine.js · getByUniqueClass()
function getByUniqueClass(el) {
const classes = Array.from(el.classList);
for (const cls of classes) {
if (cls.length < 3) continue; // 너무 짧은 클래스 무시
if (/^se-/.test(cls)) continue; // 확장 프로그램 자체 클래스 무시
if (/^[a-z]{1,3}-[a-zA-Z0-9]{5,}$/.test(cls)) continue; // 해시 기반 동적 클래스
if (/^css-/.test(cls)) continue; // CSS-in-JS 클래스
if (/^_/.test(cls)) continue; // 언더스코어 시작 (내부용)
const selector = `.${CSS.escape(cls)}`;
if (isUniqueSelector(selector)) {
return { selector };
}
}
return null;
}
React, Vue, Styled-Components 등 CSS-in-JS 라이브러리가 생성하는 해시 클래스(css-1a2b3c, sc-bZQynM 등)는 빌드마다 바뀌기 때문에 의미 없다. 이런 패턴을 정규식으로 걸러낸다.
유니크 셀렉터 우선 정렬이 중요한 이유
단순히 id → class → xpath 순서로 보여주면 될 것 같지만, 실전에서는 그렇지 않다. id가 있어도 페이지에서 유니크하지 않을 수 있고, class 셀렉터가 오히려 유니크할 수도 있다.
selector-engine.js · 정렬 로직
// 유니크 여부 체크 + 매치 수 측정
results.forEach(r => {
r.unique = checkUnique(r);
r.matchCount = countMatches(r);
});
// 정렬: 유니크 우선 → 매치 수 적은 순 → 기존 priority
return results.sort((a, b) => {
if (a.unique !== b.unique) return a.unique ? -1 : 1;
if (a.matchCount !== b.matchCount) return a.matchCount - b.matchCount;
return a.priority - b.priority;
});
정렬 기준은 3단계다:
- 유니크 여부 - 페이지에서 1개만 매칭되는 셀렉터가 최상위로 올라온다.
- 매치 수 - 유니크하지 않다면 매치 수가 적은 것이 상위.
- 기본 우선순위 - 나머지가 같다면 id > data-testid > class 순서.
실제 효과
말풍선에서 첫 번째 셀렉터를 그냥 복사하면 된다. 그 셀렉터가 해당 페이지에서 가장 안정적이고 유니크한 선택지라는 것을 엔진이 보장한다. 유니크 셀렉터에는 초록색 UNIQUE 뱃지가 표시되고, 유니크하지 않은 경우에는 노란색으로 매치 수가 표시된다.
Playwright 코드 자동 생성
셀렉터를 뽑는 것에서 끝나지 않는다. 가장 좋은 셀렉터를 기반으로 Playwright Python 코드를 즉시 생성해준다.
selector-engine.js · toPlaywright()
function toPlaywright(selectorResult) {
const { type, selector } = selectorResult;
switch (type) {
case 'id':
return `page.locator("${selector}")`;
case 'data-testid':
return `page.get_by_test_id("${...}")`;
case 'role':
return `page.get_by_role("${role}", name="${name}")`;
case 'text':
return `page.get_by_text("${text}")`;
case 'xpath':
return `page.locator("xpath=${selector}")`;
default:
return `page.locator("${selector}")`;
}
}
셀렉터 타입에 따라 Playwright의 시맨틱 API를 적절히 매핑한다. data-testid이면 get_by_test_id(), role이면 get_by_role(), text이면 get_by_text()를 사용한다.
생성되는 코드 예시:
자동 생성된 Playwright 코드
# Locator
page.get_by_test_id("submit-button")
# Click action
await page.get_by_test_id("submit-button").click()
# Fill action
await page.get_by_test_id("submit-button").fill("")
# Assertion
await expect(page.get_by_test_id("submit-button")).to_be_visible()
말풍선과 사이드 패널 양쪽에서 각 액션 코드를 개별 복사할 수 있다. QA 엔지니어가 테스트 코드를 작성할 때 복붙만 하면 되는 수준까지 자동화된다.
JSON / CSV 내보내기
사이드 패널에 쌓인 히스토리를 JSON 또는 CSV 형식으로 내보낼 수 있다. 한 페이지의 셀렉터를 쭉 뽑아놓고 한 번에 내보내면 테스트 스펙 문서로도 활용 가능하다.
sidepanel.js · JSON 내보내기
const exportData = history.map(item => ({
url: item.url,
element: item.elementInfo,
selectors: item.selectors,
playwright: item.playwright,
timestamp: item.timestamp,
}));
downloadFile(
JSON.stringify(exportData, null, 2),
`selectors-${formatDate(new Date())}.json`,
'application/json'
);
CSV 형식은 스프레드시트에서 바로 열 수 있도록 헤더를 포함한다:
selectors-20260402_1230.csv
Timestamp,URL,Tag,Best Selector Type,Best Selector,Playwright Locator
2026-04-02T12:30:00,https://example.com,button,data-testid,"[data-testid=""submit""]","page.get_by_test_id(""submit"")"
2026-04-02T12:30:05,https://example.com,input,id,"#email","page.locator(""#email"")"
히스토리는 chrome.storage.local에 최대 200개까지 저장된다. 검색 기능도 있어서 셀렉터 값, 태그 이름, 텍스트 내용으로 필터링할 수 있다.
개발 과정에서 배운 점
Manifest V3의 메시지 통신
Content Script와 Side Panel은 직접 통신할 수 없다. 반드시 Background Service Worker가 중간에서 메시지를 릴레이해야 한다. 처음에는 이 구조를 몰라서 chrome.runtime.sendMessage로 직접 보냈다가 메시지가 사라지는 현상을 겪었다.
background.js · 메시지 브릿지
// Content Script에서 온 셀렉터 결과를 Side Panel로 전달
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'selectorExtracted') {
chrome.runtime.sendMessage({
type: 'newSelector',
data: msg.data,
}).catch(() => {
// 사이드 패널이 닫혀있으면 무시
});
}
return true; // async sendResponse 유지
});
자기 자신을 선택하는 문제
Content Script가 만든 말풍선 DOM이 사용자의 클릭 이벤트를 가로채는 문제가 있었다. se- 접두사로 확장 프로그램의 모든 클래스를 네이밍하고, isOwnElement() 함수로 자체 UI 요소를 클릭 대상에서 제외시켰다. 또한 셀렉터 추출 시 se-highlight 클래스가 포함되지 않도록 추출 직전에 해당 클래스를 제거하는 순서도 중요했다.
Windows 환경에서의 PIL 아이콘 생성
아이콘을 Pillow(PIL)로 프로그래밍 방식으로 생성했다. 4배 크기 캔버스에 그린 뒤 Image.LANCZOS로 다운스케일하여 안티앨리어싱을 확보하는 방식이다. 크로스헤어 + 그리드 + 초록 포커스 포인트로 "Precision Signal" 디자인 컨셉을 잡았다.
generate_icons.py
def create_icon(size):
scale = 4
s = size * scale + 1 # 홀수로 만들어 정확한 중앙 픽셀 확보
img = Image.new('RGBA', (s, s), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# ... 크로스헤어, 링, 코너 브래킷 등 그리기 ...
# 고품질 안티앨리어싱으로 다운스케일
img = img.resize((size, size), Image.LANCZOS)
return img
동적 클래스 필터링의 중요성
실제 웹 앱에서 테스트해보니 CSS-in-JS 라이브러리가 생성하는 클래스가 셀렉터를 심각하게 오염시켰다. css-1a2b3c, sc-bZQynM, _1f3ab 같은 패턴은 빌드할 때마다 바뀌어서 테스트 셀렉터로 쓸 수 없다. 정규식 패턴 매칭으로 이런 클래스를 걸러내는 것이 예상보다 큰 효과를 가져왔다.
정리
- Manifest V3 - Service Worker 기반의 새로운 확장 프로그램 아키텍처
- 7가지 셀렉터 동시 추출 - id, data-testid, class, css, xpath, role, text
- 유니크 우선 정렬 - 페이지에서 1개만 매칭되는 셀렉터를 최상위로
- 동적 클래스 필터링 - CSS-in-JS 해시 클래스를 정규식으로 제거
- Playwright 자동 생성 - 셀렉터 타입에 따라 시맨틱 API 매핑
- JSON / CSV 내보내기 - 히스토리 200개까지 누적, 테스트 스펙 문서로 활용
결론
QA 자동화에서 셀렉터 추출은 단순 반복 작업이지만, 매일 수십~수백 번 반복되면 상당한 시간 비용이 된다. 도구 하나로 이 과정을 클릭 한 번 + 복사 한 번으로 압축할 수 있다면 그 자체로 가치가 있다. Manifest V3, Content Script 통신 구조, 셀렉터 우선순위 알고리즘 등 크롬 확장 프로그램 개발에 관심이 있다면 직접 만들어보는 것을 추천한다.
'테스트 자동화 > 일반' 카테고리의 다른 글
| 테스트 자동화 구현예제 (0) | 2022.04.27 |
|---|---|
| 테스트 자동화 (0) | 2022.04.04 |




