# 5. [Microsoft / playwright] feat(testType): add support for test.fail.only method #33001

pengooseDev·2024년 10월 17일
0
post-thumbnail

1. Issue

issue #30662

test.fail.only 구현해주세요.

이슈창에서 1차 방향성 확인


2. Context 파악

  1. testType이 추상화 된 파일을 찾아, 디깅.
    fail을 찾아 파일을 따라가보니, TestTypeImpl에 동작 방식이 추상화 되어있음. (아래 코드는 fail.only 추상화가 끝나있는 코드)
export class TestTypeImpl {
  readonly fixtures: FixturesWithLocation[];
  readonly test: TestType<any, any>;

  constructor(fixtures: FixturesWithLocation[]) {
    this.fixtures = fixtures;

    const test: any = wrapFunctionWithLocation(this._createTest.bind(this, 'default'));
    test[testTypeSymbol] = this;
    test.expect = expect;
    test.only = wrapFunctionWithLocation(this._createTest.bind(this, 'only'));
    test.describe = wrapFunctionWithLocation(this._describe.bind(this, 'default'));
    test.describe.only = wrapFunctionWithLocation(this._describe.bind(this, 'only'));
    test.describe.configure = wrapFunctionWithLocation(this._configure.bind(this));
    test.describe.fixme = wrapFunctionWithLocation(this._describe.bind(this, 'fixme'));
    test.describe.parallel = wrapFunctionWithLocation(this._describe.bind(this, 'parallel'));
    test.describe.parallel.only = wrapFunctionWithLocation(this._describe.bind(this, 'parallel.only'));
    test.describe.serial = wrapFunctionWithLocation(this._describe.bind(this, 'serial'));
    test.describe.serial.only = wrapFunctionWithLocation(this._describe.bind(this, 'serial.only'));
    test.describe.skip = wrapFunctionWithLocation(this._describe.bind(this, 'skip'));
    test.beforeEach = wrapFunctionWithLocation(this._hook.bind(this, 'beforeEach'));
    test.afterEach = wrapFunctionWithLocation(this._hook.bind(this, 'afterEach'));
    test.beforeAll = wrapFunctionWithLocation(this._hook.bind(this, 'beforeAll'));
    test.afterAll = wrapFunctionWithLocation(this._hook.bind(this, 'afterAll'));
    test.skip = wrapFunctionWithLocation(this._modifier.bind(this, 'skip'));
    test.fixme = wrapFunctionWithLocation(this._modifier.bind(this, 'fixme'));
    test.fail = wrapFunctionWithLocation(this._modifier.bind(this, 'fail'));
    test.fail.only = wrapFunctionWithLocation(this._createTest.bind(this, 'fail.only'));
    test.slow = wrapFunctionWithLocation(this._modifier.bind(this, 'slow'));
    test.setTimeout = wrapFunctionWithLocation(this._setTimeout.bind(this));
    test.step = this._step.bind(this);
    test.use = wrapFunctionWithLocation(this._use.bind(this));
    test.extend = wrapFunctionWithLocation(this._extend.bind(this));
    test.info = () => {
      const result = currentTestInfo();
      if (!result)
        throw new Error('test.info() can only be called while test is running');
      return result;
    };
    this.test = test;
  }

  private _currentSuite(location: Location, title: string): Suite | undefined {
    const suite = currentlyLoadingFileSuite();
    if (!suite) {
      throw new Error([
        `Playwright Test did not expect ${title} to be called here.`,
        `Most common reasons include:`,
        `- You are calling ${title} in a configuration file.`,
        `- You are calling ${title} in a file that is imported by the configuration file.`,
        `- You have two different versions of @playwright/test. This usually happens`,
        `  when one of the dependencies in your package.json depends on @playwright/test.`,
      ].join('\n'));
    }
    return suite;
  }

  private _createTest(type: 'default' | 'only' | 'skip' | 'fixme' | 'fail' | 'fail.only', location: Location, title: string, fnOrDetails: Function | TestDetails, fn?: Function) {
    throwIfRunningInsideJest();
    const suite = this._currentSuite(location, 'test()');
    if (!suite)
      return;

    let details: TestDetails;
    let body: Function;
    if (typeof fnOrDetails === 'function') {
      body = fnOrDetails;
      details = {};
    } else {
      body = fn!;
      details = fnOrDetails;
    }

    const validatedDetails = validateTestDetails(details);
    const test = new TestCase(title, body, this, location);
    test._requireFile = suite._requireFile;
    test._staticAnnotations.push(...validatedDetails.annotations);
    test._tags.push(...validatedDetails.tags);
    suite._addTest(test);

    if (type === 'only' || type === 'fail.only')
      test._only = true;
    if (type === 'skip' || type === 'fixme' || type === 'fail')
      test._staticAnnotations.push({ type });
    else if (type === 'fail.only')
      test._staticAnnotations.push({ type: 'fail' });
  }

  private _describe(type: 'default' | 'only' | 'serial' | 'serial.only' | 'parallel' | 'parallel.only' | 'skip' | 'fixme', location: Location, titleOrFn: string | Function, fnOrDetails?: TestDetails | Function, fn?: Function) {
    throwIfRunningInsideJest();
    const suite = this._currentSuite(location, 'test.describe()');
    if (!suite)
      return;

    let title: string;
    let body: Function;
    let details: TestDetails;

    if (typeof titleOrFn === 'function') {
      title = '';
      details = {};
      body = titleOrFn;
    } else if (typeof fnOrDetails === 'function') {
      title = titleOrFn;
      details = {};
      body = fnOrDetails;
    } else {
      title = titleOrFn;
      details = fnOrDetails!;
      body = fn!;
    }

    const validatedDetails = validateTestDetails(details);
    const child = new Suite(title, 'describe');
    child._requireFile = suite._requireFile;
    child.location = location;
    child._staticAnnotations.push(...validatedDetails.annotations);
    child._tags.push(...validatedDetails.tags);
    suite._addSuite(child);

    if (type === 'only' || type === 'serial.only' || type === 'parallel.only')
      child._only = true;
    if (type === 'serial' || type === 'serial.only')
      child._parallelMode = 'serial';
    if (type === 'parallel' || type === 'parallel.only')
      child._parallelMode = 'parallel';
    if (type === 'skip' || type === 'fixme')
      child._staticAnnotations.push({ type });

    for (let parent: Suite | undefined = suite; parent; parent = parent.parent) {
      if (parent._parallelMode === 'serial' && child._parallelMode === 'parallel')
        throw new Error('describe.parallel cannot be nested inside describe.serial');
      if (parent._parallelMode === 'default' && child._parallelMode === 'parallel')
        throw new Error('describe.parallel cannot be nested inside describe with default mode');
    }

    setCurrentlyLoadingFileSuite(child);
    body();
    setCurrentlyLoadingFileSuite(suite);
  }

  private _hook(name: 'beforeEach' | 'afterEach' | 'beforeAll' | 'afterAll', location: Location, title: string | Function, fn?: Function) {
    const suite = this._currentSuite(location, `test.${name}()`);
    if (!suite)
      return;
    if (typeof title === 'function') {
      fn = title;
      title = `${name} hook`;
    }

    suite._hooks.push({ type: name, fn: fn!, title, location });
  }

  // ... codes

  private _modifier(type: 'skip' | 'fail' | 'fixme' | 'slow', location: Location, ...modifierArgs: any[]) {
    const suite = currentlyLoadingFileSuite();
    if (suite) {
      if (typeof modifierArgs[0] === 'string' && typeof modifierArgs[1] === 'function' && (type === 'skip' || type === 'fixme' || type === 'fail')) {
        // Support for test.{skip,fixme,fail}(title, body)
        this._createTest(type, location, modifierArgs[0], modifierArgs[1]);
        return;
      }
      if (typeof modifierArgs[0] === 'string' && typeof modifierArgs[1] === 'object' && typeof modifierArgs[2] === 'function' && (type === 'skip' || type === 'fixme' || type === 'fail')) {
        // Support for test.{skip,fixme,fail}(title, details, body)
        this._createTest(type, location, modifierArgs[0], modifierArgs[1], modifierArgs[2]);
        return;
      }

      if (typeof modifierArgs[0] === 'function') {
        suite._modifiers.push({ type, fn: modifierArgs[0], location, description: modifierArgs[1] });
      } else {
        if (modifierArgs.length >= 1 && !modifierArgs[0])
          return;
        const description = modifierArgs[1];
        suite._staticAnnotations.push({ type, description });
      }
      return;
    }

    const testInfo = currentTestInfo();
    if (!testInfo)
      throw new Error(`test.${type}() can only be called inside test, describe block or fixture`);
    if (typeof modifierArgs[0] === 'function')
      throw new Error(`test.${type}() with a function can only be called inside describe block`);
    testInfo[type](...modifierArgs as [any, any]);
  }

  // ...codes
}

위 코드를 읽어보면, 몇 가지 특징을 알 수 있다.

1. Annotation 관리

nested 메서드를 동적으로 파싱해주는 줄 알았는데, 그냥 only는 _only필드로, 나머지 속성들은 _staticAnnotations에서 처리함을 알 수 있었다. (Annotation Type이 리터럴 유니온이 아닌 string이던데, 나중에 이슈 남기고 히스토리가 있는게 아니라면 PR 날릴 예정)

2. nested 메서드 추상화

Test 필드의 메서드의 메서드를 추상화하는 issue였기에 _describe 추상화 방식과, commit history에서 도움을 많이 받았다. 다만, 주의해야 하는게, describe는 test가 아닌 Suite(테스트 뭉치)이다. 즉, createTest와 공통점보다 차이점이 많아 별도의 메서드로 분리하여 사용(this._describe.bind)함을 알 수 있다. 적당히 보고 참고할 부분만 참고하자.

3. Type과 Docs는 빌드 타입에 생성된다

위 코드와는 관련이 없지만, playwright의 type 문서는 빌드 타임에 동적으로 생성된다. 다만, 이에 대한 문서 가이드가 없어서 조금 혼란을 겪었다. 어떻게 관리하는지 타입이 변경되는 커밋 히스토리를 까보면 되겠다싶어서 하나하나 까보다보니, utils/generate_types에서 관리되고 있었다.

이 부분에서 시간을 꽤 많이 허비했는데, 마침 이와 관련된 CONTRIBUTE 업데이트가 추가되었다.

문서를 읽고 간단한 검토를 남기다, type, docs에 대한 명시적 언급을 제안하였다.
문제는...

??? : 아니 이미 추가했잖아.

문서를 자세히 보니 해당 내용이 존재했다. 다만, 영어권이 아닌 사람들이 한 눈에 익히기에는 조금 하이라이팅이 부족하다고 느껴졌다. 뭔가 조금 죄송한 마음도 들어서 조용히 댓글로만 변경사항 제안을 남겨두었다.


3. 기능 구현

잠시 삼천포에 빠졌다. 다시 원래대로 돌아오자.
기능 구현은 너무 간단하다.

위 코드대로 'fail.only'일 때, Annotation과 _only 필드를 적절히 추가해준다. 추상화 끝! 😉


4. 타입 추가

타입을 추가해줄 차례이다. 이 부분에서 조금 고민이 많았다.

  1. only 속성이니, only의 함수 시그니쳐를 따르자. (첫 커밋)
  2. 앗. 근데 보통 우리가 only를 쓸 때 TC를 다 써두고, 특정 테스트만 확인할 때 붙이지 않나? 그럼 fail이 작성된 상태에서 fail.only로 바뀔텐데, fail의 시그니처를 따르는게 UX적으로 더 좋겠네. (두 번째 커밋)

Maintainer : 엥 이전이랑 함수 시그니처 왜 바뀌었나요? only에 없는 fail 시그니처 지우세요.
Pengoose : 네! (내가 모르는 히스토리나 컨벤션이 있구나. 일단 따르자! 혹시 추후 Issue가 나오면, 그때 토의 해보는걸로!)


5. 회귀 테스트 추가

기존 only 테스트에 영향이 없는 테스트에 fail.only 테스트까지 추가해주고,
(예를들어, 여러개의 only. 즉, test.describe.only, test.only에 대한 테스트)

기존 only, describe.only의 컨벤션을 따라 회귀 테스트 작성
나머지는 Reg TC는 너무 기니까 링크로!


6. PR 및 merged

> PR #33001


7. Release

> v1.49.0

0개의 댓글