본문으로 건너뛰기
wooncloud

메서드에 기능을 얹은 JS Proxy가 vi.spyOn을 삼킨다

query 호출에 SQL 태그를 붙이는 Proxy 래퍼를 만들었더니 vi.spyOn 이 전혀 먹지 않았습니다. get 트랩이 spy 를 가리는 이유와 원본 identity 비교로 푸는 방법을 정리했습니다.

·6 min read·— views··

DB 클라이언트의 query() 호출마다 SQL 주석 태그(/* service */ ...)를 자동으로 붙이고 싶었습니다. 그래서 클라이언트를 Proxy 로 감싸 query 만 가로채는 래퍼를 만들었습니다.

function wrapClient(client) {
  const real = client.query.bind(client);
  const taggingQuery = (sql, params) => real(tag(sql), params);
  return new Proxy(client, {
    get(target, prop) {
      if (prop === "query") return taggingQuery; // 항상 래퍼 반환
      return Reflect.get(target, prop, target);
    },
  });
}

운영에서는 잘 돌았습니다. 그런데 테스트에서 DB 실패를 흉내 내려고 이렇게 썼더니 mock 이 전혀 먹지 않았습니다.

vi.spyOn(wrapped, "query").mockRejectedValue(new Error("boom"));
// wrapped.query(...) 는 여전히 진짜 쿼리를 실행한다 (spy 미적용)

에러도 없이 그냥 진짜 코드가 도니, 원인을 찾기가 꽤 까다로웠습니다.

spy 는 어디로 갔을까

vi.spyOn(obj, "method") 는 내부적으로 obj.method 를 읽어 원본을 저장한 뒤 obj.method = spy 로 교체합니다. Proxy 에는 set 트랩이 없으니 이 대입은 타깃(원본 객체)에 own 프로퍼티 query 를 만듭니다.

그런데 get 트랩은 prop === "query" 이면 무조건 taggingQuery 클로저를 반환합니다. 심어 둔 spy 는 읽힐 기회가 없죠. 프록시가 spy 를 삼킨 겁니다.

그럼 "own query 가 있으면 그걸 돌려주자"(hasOwnProperty)로 고치면 될까요? 이러면 새 함정에 빠집니다. pg 같은 클라이언트의 query 는 프로토타입 메서드라 인스턴스에 own query 가 없어서 운영은 괜찮습니다. 하지만 테스트 stub 처럼 처음부터 own query 를 가진 객체를 감싸면 그것마저 spy 로 오인해 태깅을 건너뜁니다. 어떤 클라이언트든 query 를 태깅한다는 래퍼의 본래 계약이 깨지는 거죠.

존재 여부가 아니라 원본과의 identity 비교

wrap 시점의 원본 query 를 기억해 두고, 읽는 시점의 query 가 그 원본과 같으면 래퍼를, 달라졌으면 그대로 노출합니다. 달라졌다는 건 wrap 이후에 누군가(spyOn)가 교체했다는 뜻입니다.

래퍼는 항상 wrap 시점의 원본(real)만 호출합니다. 그래서 spy 가 원본으로 폴백해도 재귀가 생기지 않습니다.

function wrapClient(client) {
  const originalQuery = client.query;      // wrap 시점 원본 (프로토타입 메서드 또는 stub own)
  const real = originalQuery.bind(client);
  const taggingQuery = (sql, params) => real(tag(sql), params); // 고정 원본만 호출
 
  return new Proxy(client, {
    get(target, prop) {
      if (prop === "query") {
        const cur = Reflect.get(target, "query", target);
        // 원본 그대로면 태깅, 교체됐으면(=spy) 노출
        return cur === originalQuery ? taggingQuery : cur;
      }
      return Reflect.get(target, prop, target);
    },
  });
}

운영과 stub 은 query 교체가 없으니 항상 taggingQuery 로 가서 태깅이 유지됩니다. vi.spyOn 은 own query 가 원본과 다르니 spy 가 그대로 노출되고, mockRejectedValue 도 제대로 먹습니다.

하나 더, 복원은 꼭 걸어 두기

여러 테스트 파일이 한 프로세스에서 상태를 공유하는 설정(예: Vitest isolate: false)에서는 주의가 필요합니다. spy 를 심고 vi.restoreAllMocks() 를 빠뜨리면 공유 클라이언트가 오염되어 뒤 파일이 연쇄로 깨집니다. spy 를 쓰는 테스트는 afterEach 에서 복원을 꼭 걸어 둡니다.

메서드에 부가 동작을 얹는 Proxy 는 "방금 쓴 값을 읽으면 그 값이 나온다"는 기본 불변식을 깨서 spyOn 류 mock 을 조용히 무력화합니다. override 를 주입하는 경로(테스트 spy)를 어떻게 존중할지는 처음 설계할 때 같이 정해 두는 게 좋겠습니다.

참고