expect.extend의 커스텀 매처에 제네릭 타입을 주입 할 경우, 부모 객체의 커스텀매처 타입이 추론되지 않는 버그.
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를 적절히 추론할 수 있도록 플래그를 추가해주면 된다.
기존 테스트코드를 뒤져봤지만, 해당 케이스에 적용하기 적절한 타입 추론 테스트가 없었다. 리뷰 과정에서 아래의 코드로 퉁치는 것으로 마무리했다.
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',
}
},
위에서 언급한대로 this가 적절히 추론되도록 플래그만 추가해주면 된다.
마침, 일전에 상태관리 코어모듈의 this타입(도메인 아톰)에 대한 타입추론 이슈를 겪어본 적이 있었어서 그때 사용했던 ThisType 유틸 함수를 사용해서 쉽게 해결할 수 있었다.
export type MatchersObject<T extends MatcherState = MatcherState> = Record<
string,
RawMatcherFn<T>
> & ThisType<T> // ⛳️ ThisType 플래그 추가
vitest에 다시 기여를 진행한 건 거의 9개월만이다. PR에 대한 피드백 주기 및 방향성 제안 등에 모종의 불편함을 많이 느꼈었기 때문이다.
playwright 위주로 기여를 진행해왔지만, 이제 playwright 코어모듈의 expect나 test runner의 피쳐 및 버그를 거의 다 해결한 상태이기에 다시 vitest를 기웃거리게 되었다.
9개월 전보다 실력도 많이 올라왔고, vitest의 코드 리뷰 방식도 조금 바뀐 것 같아서 (당시엔 기여자가 많이 적어서 그랬나보다) 다시 vitest에 조금씩 기여를 진행하고자 한다.