Playwright E2E 자동화 - 2: 테스트는 다 통과하는데 검증이 누락된다.

1편에서 “주기적으로 E2E를 돌리고 실패하면 알림” 까지 만들었다. 실제로 운영하니 다음 문제가 나왔다 — 코드가 바뀌면 테스트도 직접 고쳐야 한다. 테스트 갱신이 누락되면 테스트는 계속 통과하는데 새 기능은 아무도 검증하지 않는 상태가 된다. 이 글은 그걸 자동화하면서 겪은 내용을 기록한다.

배경

  • 대상: 등록 화면. spec 파일 7개가 각각 등록부터 외부 채널 전송까지 40여 단계를 돈다.
  • 1편에서 정한 구조 그대로다 — mode: 'serial' + 공유 page, CI 에서는 workers: 1.
  • 목표: 등록 화면 코드가 배포되면, 실패한 테스트를 고치고 검증이 누락된 새 기능은 테스트를 만들어 PR 까지 올린다.

무엇을 잡아야 하나

처음엔 “실패한 테스트를 고친다” 하나로 생각했다. 그런데 실제로 잡아야 할 건 두 가지다.

  회귀 검증 누락
증상 기존 테스트가 실패한다 테스트가 전부 통과한다
실행이 알려주나 알려준다 알려주지 않는다
예 기본값이 'A' → 'B' 로 바뀌어 toHaveValue('A') 실패 새 입력 항목이 추가됐는데 아무 spec 도 안 건드림

실제 사례가 하나 있었다. 등록 폼에 권리관계 항목이 하나 신설됐는데, 기존 테스트 12개가 전부 통과했다. 새 필드가 추가됐을 뿐 기존 동작을 바꾸지 않았기 때문이다. 실행 결과는 “이상 없음” 이었고, 그 항목을 확인하는 테스트는 없었다.

결론 ①: 회귀는 실행하면 실패로 나타난다. 검증 누락은 실행 결과가 통과이므로 실행만으로는 판별되지 않는다.

작업은 어떻게 진행했나

1단계: 소스와 테스트 코드를 문자열로 맞춰보려다 버린 것

검증 누락을 찾으려면 “소스에 새로 생긴 요소 중 어떤 spec 도 사용하지 않는 것” 을 알아내야 한다.

처음 떠올린 건 셀렉터 맵이었다. spec 이 쓰는 문자열(#bcategory, '확인' 같은 것)을 전부 모아두고, 소스에서 뽑은 문자열과 이름으로 맞춰본다. 양쪽에 다 있으면 “이미 테스트됨”, 소스에만 있으면 “검증 누락” 으로 판정하는 방식이다.

시제작해서 돌렸더니 한쪽을 고칠 때마다 다른 쪽이 틀렸다.

  1. 템플릿 리터럴의 ${...} 가 셀렉터로 추출됐다. 실제 셀렉터가 아닌데 “spec 이 쓰는 것” 으로 집계된다.
  2. 그걸 걸러냈더니 이번엔 확인·선택 같은 두 글자가 소스 96줄에 매칭됐다. 아무 ‘확인’ 버튼이나 “이미 테스트됨” 으로 판정된다.
  3. 너무 흔한 단어를 목록에서 빼자, 이번엔 상수 파일 변경이 “영향 없음” 으로 분류됐다. 실제로는 spec 의 기대값과 직결되는 값인데 놓친 것이다.
  4. 상수를 다시 넣었더니 빌라 가 빌라/주택 의 부분 문자열로 15곳에 걸렸다.

느슨하게 잡으면 없는 관계를 만들어내고(1·2·4), 빡빡하게 잡으면 있는 관계를 놓쳤다(3). 규칙을 어느 쪽으로 옮겨도 반대쪽 오류가 늘었다.

결론 ②: 소스의 id="x" 와 spec 의 #x 가 같은 것을 가리킨다는 보장은 어디에도 선언돼 있지 않기때문에 선언되지 않은 관계를 문자열 유사도로 추측하면 오탐과 누락은 번갈아 나온다.

2단계: 추측을 빼고 두 가지 사실에만 기댄다

1단계가 실패한 이유는 “이 문자열과 저 문자열이 같은 걸 가리킬 것이다” 라는 추측이 들어갔기 때문이다. 그래서 추측이 개입하지 않는 것만 근거로 삼기로 했다.

① 회귀는 스위트를 돌려서 나온 실패 목록으로 잡는다. 실제로 실패한 것이라 판단이 들어갈 자리가 없다. diff 를 보고 “이건 실패하겠다” 로 예측하면 1단계의 오탐이 그대로 돌아온다.

② 검증 누락은 diff 의 + 라인에 적혀 있는 것만 뽑는다 — id=, for=, <option value=>, <li> 텍스트, 상수 신규 값. 사람이 소스에 직접 쓴 값이라 유추가 아니다. 1단계가 “비슷해 보이는 문자열” 을 맞춰봤다면, 여기서는 양쪽에서 선언된 이름끼리만 맞춰본다.

여기에 하나가 더 필요했다. 테스트가 실패했다고 원인이 항상 코드 변경인 건 아니다. 서비스 쪽 진짜 버그일 수도 있고, dev 환경이나 외부 서비스 문제일 수도 있다.

결론 ③: 실패를 고치기 전에 코드 변경 탓인지 · 서비스 버그인지 · 환경 문제인지 먼저 가른다. 안 가르고 고치면 기대값을 실제값에 맞춰 버그를 덮게 된다.

3단계: 찾는 일까지 LLM 에 맡기면 안 되는 이유

여기까지 만들고 보니, “diff 에서 신규 요소를 뽑는” 일을 LLM 이 하고 있었다. 문맥 판단이 필요해 보였기 때문이다 — <li id="x"> 는 커스텀 드롭다운의 옵션이라 테스트 대상이지만 <th id="y"> 는 표시용이라 아니다. 이건 정규식으로 안 갈린다.

문제는 LLM 이 두 가지 일을 동시에 하고 있었다는 점이다.

LLM:  diff 200줄을 읽는다
       → ① 새로 생긴 id·값을 빠짐없이 훑는다   (찾기)
       → ② 그중 뭐가 테스트 대상인지 고른다     (분류)

②는 LLM 이 해야 하는 일이 맞다. 문제는 ①이었다.

  • 놓쳐도 알 수 없다. 200줄에서 id 하나를 못 보고 지나가도 어떤 부분이 누락되었는 지 알 수 없다.
  • 다 찾았는지 확인할 방법이 없다. 결과만 보고는 빠진 게 있는지 알 수 없다.
  • 같은 입력에 같은 결과가 나온다는 보장이 없다.

그래서 ①을 스크립트로 내렸다. 정규식으로 뽑으면 놓치는 패턴이 있어도 어느 패턴이 없어서 놓쳤는지 알 수 있다.

spec 에서 뽑는 규칙과 소스에서 뽑는 규칙을 정했다.

  뽑는 곳 무엇을
spec 규칙 테스트 코드 #id · label[for] · getByText 문구 · toHaveValue 값
diff 규칙 소스 변경분 id= · for= · <option value=> · <li> 텍스트 · 상수 신규 값

두 규칙이 같은 이름을 뽑아야 대조가 성립한다. spec 쪽이 #x 를 뽑고 소스 쪽이 id="x" 를 뽑으면, 문법은 달라도 결과가 둘 다 x 라서 맞춰볼 수 있다.

문제는 규칙을 늘릴 때 생긴다. spec 쪽에만 새 패턴을 추가하면 조회할 준비는 됐는데 후보가 안 나온다. 찾을 대상이 없으니 “누락 0건” 으로 끝나고, 이건 에러가 아니라 정상 종료다.

그래서 두 규칙을 한 파일에 나란히 두고, 위에 “양쪽은 같은 이름을 뽑아야 한다” 를 주석으로 남겼다.

이렇게 바꾸니 결과를 비교해서 맞춰볼 수 있게 됐다.

스크립트가 뽑은 후보   8건
LLM 이 판정한 항목     8건   →  일치

스크립트가 뽑은 목록이 곧 판정해야 할 항목의 전부다. 판정 수가 적으면 뭔가를 건너뛴 것이고, 그 차이가 로그에 남는다.

결론 ④: 누락의 성격이 바뀐다. 전에는 “왜 놓쳤는지 모르는 누락” 이었지만, 이제는 “이 패턴을 안 넣어서 놓친 누락” 이다. 후자는 고칠 수 있다.

못 잡는 것은 매번 출력한다

정규식으로 안 되는 게 있다. — 여러 줄에 걸친 요소, 런타임에 주입되는 id, 클래스, 패턴 밖의 상수. 이걸 스크립트가 실행할 때마다 출력하게 했다.

실제로 효과가 있었다. LLM 이 그 목록을 읽고 클래스 4개를 추가로 조회했다. 안내 문구 하나(* 등기부등본의… 같은 표시용 <li>)는 후보로 잡혔지만 “테스트 대상 아님” 으로 걸러냈다.

넓게 뽑고 LLM 이 거른다. 반대 방향(좁게 뽑고 LLM 이 채운다)은 누락이 드러나지 않아 위험하다.

증상만으로는 원인을 알 수 없던 것들

이번 작업에서 시간을 가장 많이 쓴 건 전부 아래와 같은 문제들이었다. 에러가 아예 안 나거나, 나더라도 엉뚱한 곳을 가리킨다.

1. zsh 는 따옴표 없는 변수를 단어 분할하지 않는다

W='path/a path/b'
git diff "$RANGE" -- $W     # ← 공백 포함 단일 pathspec 이 된다

bash 는 단어로 쪼개지만 zsh 는 안 쪼갠다. 결과는 에러가 아니라 “변경 없음” 이다. 파이프라인이 아무 일도 안 하고 정상 종료한다. 이 글 쓰는 동안에도 같은 실수를 두 번 더 했다. 경로는 리터럴로 나열한다.

2. isVisible() 은 자동 대기하지 않는다

if (await notFound.isVisible({ timeout: 3000 })) { /* 폴백 */ }

timeout 옵션이 있어서 기다릴 것 같지만, 호출 시점의 상태를 그대로 돌려준다. 서버 조회 결과가 오기 전에 false 가 나온다. 이것 때문에 주소 확인 실패 시 대체값으로 넘어가는 폴백이 한 번도 동작하지 않았다.

조건 분기가 필요하면 waitFor 나 Promise.race 를 쓴다.

const outcome = await Promise.race([
  label.waitFor({ state: 'visible', timeout: 15000 }).then(() => 'ok').catch(() => null),
  alert.waitFor({ state: 'visible', timeout: 15000 }).then(() => 'alert').catch(() => null),
]);

3. actionTimeout 기본값이 0(무제한)이다

요소가 클릭 가능 상태가 안 되면 테스트 timeout 을 통째로 소진한다. 모달 클릭 하나가 10분을 소모하고 있었다. config 에서 고정해두는 편이 낫다.

use: { actionTimeout: 15000 }   // 개별 액션에 더 짧은 값을 주면 그쪽이 우선

4. 같은 앱에 모달이 세 종류였다

오래된 화면이라 레이어마다 다른 걸 쓰고 있었다 — 신규 프론트 화면은 role="dialog", 일부 확인창은 SweetAlert, 폼 검증 안내는 Bootstrap 모달이다. 셋의 DOM 구조가 전부 달라서 셀렉터를 공유할 수 없다.

폴백 셀렉터가 엉뚱한 종류를 가리키고 있었는데, 위의 isVisible 문제 때문에 “안내가 안 떴다” 로만 보였다. 두 버그가 겹쳐 있어 한쪽만 수정해도 테스트는 계속 실패했다.

모달을 다룰 때는 어느 종류인지부터 확인한다. 실패 시점의 DOM 스냅샷을 보면 바로 갈린다.

값을 재는 건 시작 전에

개편을 앞두고 있어서 기준선을 떠봤다. 그러다 알게 된 건, 코드에서 세는 것과 실행해야 아는 것이 다르다는 점이다.

항목 나중에 구할 수 있나
셀렉터 수 · 단계 수 가능 — git 히스토리에 옛 코드가 남는다
실행 시간 불가능 — 그 시점에만 잴 수 있다

기준선을 뜨다가 전 spec 이 같은 단계에서 멈추는 것도 발견했다. 7개가 전부 같은 지점이면 개별 spec 문제가 아니라 공통 원인(환경·외부 서비스·공유 화면 변경)이다. 실제로 그 화면에 약관 동의 팝업이 새로 추가돼 있었다.

결론 ⑤: 여러 spec 이 같은 단계명에서 실패하면 spec 을 고치기 전에 공통 원인부터 의심한다.

한 줄 요약

테스트가 실패하는 건 실행하면 드러나지만, 새 기능이 검증되지 않는 건 실행 결과에 나타나지 않는다. 둘을 각각 실행 실패와 diff 에 적힌 것으로 잡고, 고친 뒤 다시 돌려 통과한 것만 반영한다. 그리고 찾기는 스크립트, 분류는 LLM — 놓쳐도 알 수 없는 일은 기계에 맡겨야 세어서 맞춰볼 수 있다.