# 9. [Vitest] fix(expect): Correct generic MatchersObject this type in expect.extend

pengooseDev·2025년 2월 20일
0

PR
Issue

1. Issue

expect.extend의 커스텀 매처에 제네릭 타입을 주입 할 경우, 부모 객체의 커스텀매처 타입이 추론되지 않는 버그.

2. Context 파악

  expect.extend({
    toBeLike<T>(received: unknown, expected: T) {
      const { isNot, utils } = this
      return {
        pass: received == expected,
        message: () => isNot
        // ⛳️ 부모 객체의 utils의 타입이 유실됨.
          ? `Expected ${utils.stringify(received)} to not be like ${utils.stringify(expected)}`
          : `Expected ${utils.stringify(received)} to be like ${utils.stringify(expected)}`,
        actual: received,
        expected
      }
    },
  })

기존 타입은 아래와 같다.

export interface MatcherState {
  customTesters: Array<Tester>
  assertionCalls: number
  currentTestName?: string
  dontThrow?: () => void
  error?: Error
  equals: (
    a: unknown,
    b: unknown,
    customTesters?: Array<Tester>,
    strictCheck?: boolean
  ) => boolean
  expand?: boolean
  expectedAssertionsNumber?: number | null
  expectedAssertionsNumberErrorGen?: (() => Error) | null
  isExpectingAssertions?: boolean
  isExpectingAssertionsError?: Error | null
  isNot: boolean
  // environment: VitestEnvironment
  promise: string
  // snapshotState: SnapshotState
  suppressedErrors: Array<Error>
  testPath?: string
  utils: ReturnType<typeof getMatcherUtils> & {
    diff: typeof diff
    stringify: typeof stringify
    iterableEquality: Tester
    subsetEquality: Tester
  }
  soft?: boolean
  poll?: boolean
}

export interface SyncExpectationResult {
  pass: boolean
  message: () => string
  actual?: any
  expected?: any
}

export type AsyncExpectationResult = Promise<SyncExpectationResult>

export type ExpectationResult = SyncExpectationResult | AsyncExpectationResult

// ⛳️ 3. this에 MatcherState를 주입한다 << 문제점
export interface RawMatcherFn<T extends MatcherState = MatcherState> {
  (this: T, received: any, ...expected: Array<any>): ExpectationResult
}

export type MatchersObject<T extends MatcherState = MatcherState> = Record<
  string,
  RawMatcherFn<T> // ⛳️ 2.RawMatcherFn<T>를 상속받는다.
>

export interface ExpectStatic
  extends Chai.ExpectStatic,
  AsymmetricMatchersContaining {
  <T>(actual: T, message?: string): Assertion<T>
  extend: (expects: MatchersObject) => void // ⛳️ 1. extend의 MatchersObject는
  anything: () => any
  any: (constructor: unknown) => any
  getState: () => MatcherState
  setState: (state: Partial<MatcherState>) => void
  not: AsymmetricMatchersContaining
}

요약: 메서드가 아닌 함수로 인식된다. this에 대한 타입 정보가 명확히 주어지지 않아 제네릭타입을 강제로 주입하더라도, 무시되기(적절한 추론이 이뤄지지 않기) 때문이다.

위 방식에서는 객체 리터럴로 작성된 함수가 단순 함수로 인식된다. 따라서, 문맥적 this 타입 정보가 주입되지 않는다. 함수 시그니처에 선언된 this 타입 정보(<T extends MatcherState = MatcherState>)가 객체 리터럴의 문맥적 타입으로 전달되지 않기 때문이다.

따라서, 실제 this는 말 그대로 MatchersObject로 추론된다. (아마 함수 오버로딩으로 모듈을 많이 만들어본 경험이 있다면, 함수 시그니쳐를 매핑할 때 비슷한 이슈를 많이 겪어보았을 것이다)

즉, typescript가 this를 적절히 추론할 수 있도록 플래그를 추가해주면 된다.

3. 회귀 테스트

기존 테스트코드를 뒤져봤지만, 해당 케이스에 적용하기 적절한 타입 추론 테스트가 없었다. 리뷰 과정에서 아래의 코드로 퉁치는 것으로 마무리했다.

toBeTestedMatcherContext<T>(received: unknown, expected: T) {
  if (typeof this.utils?.stringify !== 'function') {
    throw new TypeError('this.utils.stringify is not available.')
  }
  return {
    pass: received === expected,
    message: () => 'toBeTestedMatcherContext',
  }
},

4. 해결

위에서 언급한대로 this가 적절히 추론되도록 플래그만 추가해주면 된다.
마침, 일전에 상태관리 코어모듈의 this타입(도메인 아톰)에 대한 타입추론 이슈를 겪어본 적이 있었어서 그때 사용했던 ThisType 유틸 함수를 사용해서 쉽게 해결할 수 있었다.

[Typescript Docs] ThisType<T>

export type MatchersObject<T extends MatcherState = MatcherState> = Record<
  string,
  RawMatcherFn<T>
> & ThisType<T> // ⛳️ ThisType 플래그 추가

5. Merged

vitest에 다시 기여를 진행한 건 거의 9개월만이다. PR에 대한 피드백 주기 및 방향성 제안 등에 모종의 불편함을 많이 느꼈었기 때문이다.

playwright 위주로 기여를 진행해왔지만, 이제 playwright 코어모듈의 expect나 test runner의 피쳐 및 버그를 거의 다 해결한 상태이기에 다시 vitest를 기웃거리게 되었다.

9개월 전보다 실력도 많이 올라왔고, vitest의 코드 리뷰 방식도 조금 바뀐 것 같아서 (당시엔 기여자가 많이 적어서 그랬나보다) 다시 vitest에 조금씩 기여를 진행하고자 한다.

0개의 댓글

관련 채용 정보