InfoGrab DocsInfoGrab Docs

프론트엔드 테스트 표준 및 스타일 가이드라인

요약

JavaScript 유닛 및 통합 테스트에는 Jest를 사용하고, e2e(엔드투엔드) 통합 테스트에는 Capybara 기능 테스트를 사용합니다. 모든 새로운 기능에는 유닛 테스트와 기능 테스트가 작성되어야 합니다. 대부분의 경우 기능 테스트에는 RSpec을 사용해야 합니다.


프론트엔드 테스트 표준 및 스타일 가이드라인#

  GitLab에서 프론트엔드 코드를 개발할 때 두 가지 유형의 테스트 스위트를 사용합니다.

JavaScript 유닛 및 통합 테스트에는 Jest를 사용하고, e2e(엔드투엔드) 통합 테스트에는 Capybara 기능 테스트를 사용합니다.

모든 새로운 기능에는 유닛 테스트와 기능 테스트가 작성되어야 합니다.

대부분의 경우 기능 테스트에는 RSpec을 사용해야 합니다. 기능 테스트 시작 방법에 대한 자세한 내용은 기능 테스트 시작하기를 참고하세요.

회귀 테스트는 버그가 향후 재발하지 않도록 버그 수정 시 작성해야 합니다.

GitLab의 일반적인 테스트 관행에 대한 자세한 내용은 테스트 표준 및 스타일 가이드라인 페이지를 참고하세요.

Vue.js 테스트#

Vue 컴포넌트 테스트 가이드를 찾고 있다면 바로 이 섹션으로 이동할 수 있습니다.

Vue 3 테스트에 관한 정보는 이 페이지에 있습니다.

Jest#

프론트엔드 유닛 및 통합 테스트 작성에 Jest를 사용합니다. Jest 테스트는 EE의 /spec/frontend/ee/spec/frontend에서 찾을 수 있습니다.

jsdom#

Jest는 테스트 실행 시 브라우저 대신 jsdom을 사용합니다. 알려진 이슈는 다음과 같습니다:

브라우저에서 Jest 테스트 실행 지원에 대한 이슈도 참고하세요.

Jest 테스트 디버깅#

yarn jest-debug를 실행하면 Jest가 디버그 모드로 실행되어, Jest 문서에 설명된 대로 디버그/검사할 수 있습니다.

타임아웃 오류#

Jest의 기본 타임아웃은 /jest.config.base.js에 설정되어 있습니다.

테스트가 해당 시간을 초과하면 실패합니다.

테스트 성능을 개선할 수 없는 경우, jest.setTimeout을 사용하여 전체 스위트의 타임아웃을 늘릴 수 있습니다.

jest.setTimeout(500);

describe('Component', () => {
  it('does something amazing', () => {
    // ...
  });
});

또는 it의 세 번째 인수를 제공하여 특정 테스트에 대해 설정할 수 있습니다.

describe('Component', () => {
  it('does something amazing', () => {
    // ...
  }, 500)
})

각 테스트의 성능은 환경에 따라 달라진다는 점을 기억하세요.

테스트 전용 스타일시트#

RSpec 통합 테스트를 용이하게 하기 위해 두 가지 테스트 전용 스타일시트가 있습니다. 이를 사용하여 테스트 속도를 개선하기 위해 애니메이션을 비활성화하거나, Capybara 클릭 이벤트의 타깃이 되어야 하는 요소를 표시할 수 있습니다:

  • app/assets/stylesheets/disable_animations.scss

  • app/assets/stylesheets/test_environment.scss

테스트 환경은 가능한 한 프로덕션 환경과 일치해야 하므로, 이를 최소한으로 사용하고 필요한 경우에만 추가하세요.

무엇을, 어떻게 테스트할 것인가#

Jest에서 mock과 spy 같은 세부적인 워크플로를 다루기 전에, Jest로 무엇을 테스트해야 하는지에 대해 간략히 살펴보겠습니다.

라이브러리를 테스트하지 마세요#

라이브러리는 JavaScript 개발자 생활에서 빠질 수 없는 요소입니다. 일반적인 조언은 라이브러리 내부를 테스트하지 말고, 라이브러리가 제 역할을 알고 자체적인 테스트 커버리지를 가지고 있다고 기대하는 것입니다. 일반적인 예시는 다음과 같습니다.

import { convertToFahrenheit } from 'temperatureLibrary'

function getFahrenheit(celsius) {
  return convertToFahrenheit(celsius)
}

getFahrenheit 함수를 테스트하는 것은 의미가 없습니다. 이 함수는 라이브러리 함수를 호출하는 것 이외에 아무것도 하지 않으며, 해당 라이브러리가 의도대로 동작한다고 기대할 수 있기 때문입니다.

Vue에 대해 잠깐 살펴보겠습니다. Vue는 GitLab JavaScript 코드베이스의 핵심 요소입니다. Vue 컴포넌트 spec을 작성할 때 흔히 빠지는 함정은, Vue가 제공하는 기능 자체를 테스트하게 되는 것입니다. 이는 가장 테스트하기 쉬운 것처럼 보이기 때문입니다. 다음은 코드베이스에서 가져온 예시입니다.

// Component script
{
  computed: {
    hasMetricTypes() {
      return this.metricTypes.length;
    },
}
<!-- Component template -->
<template>
  <gl-dropdown v-if="hasMetricTypes">
    <!-- Dropdown content -->
  </gl-dropdown>
</template>

hasMetricTypes computed 속성을 테스트하는 것은 당연해 보일 수 있습니다. 하지만 computed 속성이 metricTypes의 길이를 반환하는지 테스트하는 것은 Vue 라이브러리 자체를 테스트하는 것입니다. 이는 테스트 스위트만 늘릴 뿐 아무런 가치가 없습니다. 사용자가 컴포넌트와 상호작용하는 방식, 즉 렌더링된 템플릿을 확인하는 방식으로 테스트하는 것이 더 좋습니다.

// Bad
describe('computed', () => {
  describe('hasMetricTypes', () => {
    it('returns true if metricTypes exist', () => {
      factory({ metricTypes });
      expect(wrapper.vm.hasMetricTypes).toBe(2);
    });

    it('returns true if no metricTypes exist', () => {
      factory();
      expect(wrapper.vm.hasMetricTypes).toBe(0);
    });
  });
});

// Good
it('displays a dropdown if metricTypes exist', () => {
  factory({ metricTypes });
  expect(wrapper.findComponent(GlDropdown).exists()).toBe(true);
});

it('does not display a dropdown if no metricTypes exist', () => {
  factory();
  expect(wrapper.findComponent(GlDropdown).exists()).toBe(false);
});

이런 종류의 테스트는 로직 업데이트를 불필요하게 취약하고 번거롭게 만드므로 주의해야 합니다. 이는 다른 라이브러리에도 마찬가지로 적용됩니다. 제안 드리자면: wrapper.vm 속성을 확인하고 있다면, 멈추고 렌더링된 템플릿을 확인하도록 테스트를 다시 생각해보세요.

더 많은 예시는 프론트엔드 유닛 테스트 섹션에서 확인할 수 있습니다.

Mock을 테스트하지 마세요#

또 다른 흔한 함정은 spec이 mock이 제대로 동작하는지 검증하게 되는 것입니다. mock을 사용하는 경우, mock은 테스트를 지원하는 역할을 해야지 테스트의 대상이 되어서는 안 됩니다.

const spy = jest.spyOn(idGenerator, 'create')
spy.mockImplementation = () = '1234'

// Bad
expect(idGenerator.create()).toBe('1234')

// Good: actually focusing on the logic of your component and just leverage the controllable mocks output
expect(wrapper.find('div').html()).toBe('<div id="1234">...</div>')

assertion에서 import된 값을 사용하지 마세요#

assertion에서 상수를 import하는 것보다 리터럴 값을 사용하는 것을 권장합니다. 이렇게 하면 테스트를 읽기 쉽고 변경에 유연하게 만들 수 있습니다. 이에 대한 자세한 내용은 국제화 권고사항에서 다룹니다.

// Bad: MY_CONSTANT could accidentally be set to undefined, have a typo etc. and test would still pass
import { MY_CONSTANT } from '../constants';

it('returns the correct value', () => {
  expect(ding()).toBe(MY_CONSTANT);
});

// Good: explicit value is asserted
it('returns the correct value', () => {
  expect(ding()).toBe('expected literal value');
});

사용자를 따르세요#

컴포넌트 중심의 세계에서 유닛 테스트와 통합 테스트 사이의 경계는 꽤 모호할 수 있습니다. 가장 중요한 지침은 다음과 같습니다:

  • 미래에 복잡한 로직이 깨지는 것을 방지하기 위해 격리된 환경에서 테스트하는 것에 실질적인 가치가 있다면 깔끔한 유닛 테스트를 작성하세요.

  • 그렇지 않다면, 사용자의 흐름에 최대한 가깝게 spec을 작성하세요.

예를 들어, 메서드를 수동으로 호출하고 데이터 구조나 계산된 속성을 확인하는 것보다, 생성된 마크업을 사용하여 버튼 클릭을 트리거하고 마크업이 그에 따라 변경되었는지 검증하는 방법이 더 낫습니다. 테스트가 통과되어 거짓된 안정감을 주는 동안 실수로 사용자 플로를 망가뜨릴 가능성은 항상 존재합니다.

공통 관행#

다음은 테스트 스위트의 일부로 포함된 일반적인 공통 관행입니다. 이 가이드를 따르지 않는 코드를 발견하면, 가능하면 즉시 수정하세요. 🎉

DOM 요소 쿼리 방법#

테스트에서 DOM 요소를 쿼리할 때는 요소를 고유하고 의미 있는 방식으로 타깃하는 것이 가장 좋습니다.

가장 권장되는 방법은 DOM Testing Library를 사용하여 사용자가 실제로 보는 것을 기준으로 타깃하는 것입니다. 텍스트로 선택할 때는 접근성 모범 사례를 강화하는 데 도움이 되는 byRole 쿼리를 사용하는 것이 가장 좋습니다. findByRole 및 기타 DOM Testing Library 쿼리shallowMountExtended 또는 mountExtended를 사용할 때 사용할 수 있습니다.

Vue 컴포넌트 단위 테스트를 작성할 때는 자식 컴포넌트의 동작 복잡성을 처리하는 대신 포괄적인 값 커버리지에 집중할 수 있도록 컴포넌트별로 자식을 쿼리하는 것이 현명할 수 있습니다.

위의 방법 중 어느 것도 실행 가능하지 않은 경우가 있습니다. 이런 경우에는 셀렉터를 단순화하기 위해 테스트 속성을 추가하는 것이 최선의 방법일 수 있습니다. 가능한 셀렉터 목록은 다음과 같습니다:

import { shallowMountExtended } from 'helpers/vue_test_utils_helper'

const wrapper = shallowMountExtended(ExampleComponent);

it('exists', () => {
  // Best (especially for integration tests)
  wrapper.findByRole('link', { name: /Click Me/i })
  wrapper.findByRole('link', { name: 'Click Me' })
  wrapper.findByText('Click Me')
  wrapper.findByText(/Click Me/i)

  // Good (especially for unit tests)
  wrapper.findComponent(FooComponent);
  wrapper.find('input[name=foo]');
  wrapper.find('[data-testid="my-foo-id"]');
  wrapper.findByTestId('my-foo-id'); // with shallowMountExtended or mountExtended, check below

  // Bad
  wrapper.find({ ref: 'foo'});
  wrapper.find('.js-foo');
  wrapper.find('.gl-button');
});

data-testid 속성에는 kebab-case를 사용해야 합니다.

테스트 목적만을 위해 .js-* 클래스를 추가하는 것은 권장하지 않습니다. 다른 실행 가능한 옵션이 없는 경우에만 이 방법을 사용하세요. 테스트에서 DOM 요소를 쿼리하기 위해 Vue 템플릿 ref를 사용하는 것은 피하세요. 이는 공개 API가 아닌 컴포넌트의 구현 세부 사항이기 때문입니다.

자식 컴포넌트 쿼리#

@vue/test-utils를 사용하여 Vue 컴포넌트를 테스트할 때 DOM 노드를 쿼리하는 대신 자식 컴포넌트를 쿼리하는 방법도 가능합니다. 이 방법은 테스트 대상 동작의 구현 세부 사항이 해당 컴포넌트의 개별 단위 테스트에서 다루어져야 한다는 가정을 전제로 합니다. 테스트가 테스트 대상 컴포넌트의 예상 동작을 신뢰할 수 있게 커버하는 한, DOM 쿼리와 컴포넌트 쿼리 중 어느 쪽을 선택하든 강한 선호는 없습니다.

예시:

it('exists', () => {
  wrapper.findComponent(FooComponent);
});

단위/컴포넌트 테스트 이름 지정#

단위/컴포넌트 테스트는 ${componentName}_spec.js로 이름을 지정해야 합니다.

테스트 이름이 충분히 구체적이지 않은 경우 컴포넌트 이름 변경을 고려하세요.

예시:

diff_stats_dropdown.vuediff_stats_dropdown_spec.js라는 이름의 단위/컴포넌트 테스트를 가져야 합니다.

Describe 블록 이름 지정#

특정 함수/메서드를 테스트하기 위해 describe 테스트 블록을 작성할 때는 메서드 이름을 describe 블록 이름으로 사용하세요.

잘못된 예시:

describe('#methodName', () => {
  it('passes', () => {
    expect(true).toEqual(true);
  });
});

describe('.methodName', () => {
  it('passes', () => {
    expect(true).toEqual(true);
  });
});

올바른 예시:

describe('methodName', () => {
  it('passes', () => {
    expect(true).toEqual(true);
  });
});

Promise 테스트#

Promise를 테스트할 때는 항상 테스트가 비동기적으로 처리되고 거부(rejection)가 처리되는지 확인해야 합니다. 이제 테스트 스위트에서 async/await 문법을 사용할 수 있습니다:

it('tests a promise', async () => {
  const users = await fetchUsers()
  expect(users.length).toBe(42)
});

it('tests a promise rejection', async () => {
  await expect(user.getUserName(1)).rejects.toThrow('User with 1 not found.');
});

테스트 함수에서 promise를 반환할 수도 있습니다.

promise를 다룰 때 donedone.fail 콜백을 사용하는 것은 권장하지 않습니다. 사용하지 않아야 합니다.

나쁜 예:

// missing return
it('tests a promise', () => {
  promise.then(data => {
    expect(data).toBe(asExpected);
  });
});

// uses done/done.fail
it('tests a promise', done => {
  promise
    .then(data => {
      expect(data).toBe(asExpected);
    })
    .then(done)
    .catch(done.fail);
});

좋은 예:

// verifying a resolved promise
it('tests a promise', () => {
  return promise
    .then(data => {
      expect(data).toBe(asExpected);
    });
});

// verifying a resolved promise using Jest's `resolves` matcher
it('tests a promise', () => {
  return expect(promise).resolves.toBe(asExpected);
});

// verifying a rejected promise using Jest's `rejects` matcher
it('tests a promise rejection', () => {
  return expect(promise).rejects.toThrow(expectedError);
});

시간 조작#

때로는 시간에 민감한 코드를 테스트해야 할 때가 있습니다. 예를 들어, X초마다 실행되는 반복 이벤트 등이 있습니다. 이를 처리하기 위한 몇 가지 전략을 소개합니다:

애플리케이션에서 setTimeout() / setInterval() 사용#

애플리케이션 자체가 일정 시간을 기다리는 경우, 대기를 mock await으로 처리합니다. Jest에서는 이미 기본적으로 처리됩니다 (Jest Timer Mocks도 참조하세요).

const doSomethingLater = () => {
  setTimeout(() => {
    // do something
  }, 4000);
};

Jest에서:

it('does something', () => {
  doSomethingLater();
  jest.runAllTimers();

  expect(something).toBe('done');
});

Jest에서 현재 위치 모킹#

`window.location.href`의 값은 이전 테스트가 이후 테스트에 영향을 미치지 않도록

테스트 실행 전에 초기화됩니다.

테스트에서 window.location.href가 특정 값을 가져야 하는 경우, setWindowLocation 헬퍼를 사용하세요:

import setWindowLocation from 'helpers/set_window_location_helper';

it('passes', () => {
  setWindowLocation('https://gitlab.test/foo?bar=true');

  expect(window.location).toMatchObject({
    hostname: 'gitlab.test',
    pathname: '/foo',
    search: '?bar=true',
  });
});

해시만 수정하려면 setWindowLocation 헬퍼를 사용하거나, 예를 들어 window.location.hash에 직접 값을 할당하면 됩니다:

it('passes', () => {
  window.location.hash = '#foo';

  expect(window.location.href).toBe('http://test.host/#foo');
});

특정 window.location 메서드가 호출되었는지 테스트에서 검증해야 한다면, useMockLocationHelper 헬퍼를 사용하세요:

import { useMockLocationHelper } from 'helpers/mock_window_location_helper';

useMockLocationHelper();

it('passes', () => {
  window.location.reload();

  expect(window.location.reload).toHaveBeenCalled();
});

이벤트 리스너 및 타임아웃 정리 테스트#

컴포넌트에서는 종종 beforeDestroy (Vue 3의 경우 beforeUnmount) 훅에서 이벤트 리스너나 타임아웃을 생성합니다. 컴포넌트 인스턴스가 소멸될 때 리스너와 타임아웃이 모두 정리되는지 테스트하는 것이 중요합니다. 이러한 이벤트를 정리하지 않으면 메모리 누수나 이벤트 리스너의 깨진 참조 같은 문제가 발생할 수 있습니다.

다음 예시를 살펴보세요:

beforeDestroy() {
  removeEventListener('keydown', someListener)
  clearTimeout(timeoutPointer)
}

위 예시에서 컴포넌트는 keydown 이벤트 리스너와 다른 곳에서 생성된 타임아웃을 모두 정리하고 있습니다.

관련 테스트를 살펴보겠습니다.

describe('Cleanup before destroy', () => {
  beforeEach(() => {
    createComponent()

    // Destroy the component immediately to invoke the `beforeDestroy` hook
    wrapper.destroy()
  })

  it('removes the event listener', () => {
    const spy = jest.spyOn(window, 'removeEventListener')
    expect(spy).toHaveBeenCalledTimes(1)
    expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function))
  })

  it('clears the pending timeouts', () => {
    const spy = jest.spyOn(window, 'clearTimeout')
    expect(spy).toHaveBeenCalledTimes(1)
  })
})

위 예시는 keydown 리스너에서 호출되는 함수를 명시적으로 확인하지 않습니다. 이는 보통 구현 세부 사항이기 때문입니다. clearTimeout 호출도 마찬가지로, 매개변수가 컴포넌트 내부에서 생성된 타이머에 대한 포인터이기 때문입니다.

따라서 일반적으로는 스파이가 호출되었는지 확인하는 것으로 충분하며, 호출된 횟수도 함께 확인하는 것을 권장합니다.

테스트에서 대기하기#

때로는 테스트가 계속 진행하기 전에 애플리케이션에서 무언가가 발생하기를 기다려야 할 때가 있습니다.

다음 방법은 피하는 것이 좋습니다:

  • setTimeout: 대기 이유가 불명확해집니다. 또한 테스트에서 가짜(fake)로 처리되기 때문에 사용이 까다롭습니다.

  • setImmediate: Jest 27 이상에서는 더 이상 지원되지 않습니다. 자세한 내용은 이 에픽을 참고하세요.

Promise 및 Ajax 호출#

Promise가 완료될 때까지 기다리려면 핸들러 함수를 등록하세요.

const askTheServer = () => {
  return axios
    .get('/endpoint')
    .then(response => {
      // do something
    })
    .catch(error => {
      // do something else
    });
};

Jest에서:

it('waits for an Ajax call', async () => {
  await askTheServer()
  expect(something).toBe('done');
});

예를 들어 동기 Vue 라이프사이클 훅에서 실행되어 Promise에 핸들러를 등록할 수 없는 경우, waitFor 헬퍼를 참고하거나 다음과 같이 보류 중인 모든 Promise를 플러시하세요:

Jest에서:

it('waits for an Ajax call', async () => {
  synchronousFunction();

  await waitForPromises();

  expect(something).toBe('done');
});

Vue 렌더링#

Vue 컴포넌트가 다시 렌더링될 때까지 기다리려면 nextTick()을 사용하세요.

Jest에서:

import { nextTick } from 'vue';

// ...

it('renders something', async () => {
  wrapper.setProps({ value: 'new value' });

  await nextTick();

  expect(wrapper.text()).toBe('new value');
});

이벤트#

애플리케이션이 테스트에서 기다려야 하는 이벤트를 트리거하는 경우, 어설션을 포함하는 이벤트 핸들러를 등록하세요:

it('waits for an event', () => {
  eventHub.$once('someEvent', eventHandler);

  someFunction();

  return new Promise((resolve) => {
    function expectEventHandler() {
      expect(something).toBe('done');
      resolve();
    }
  });
});

Jest에서는 이를 위해 Promise를 사용할 수도 있습니다:

it('waits for an event', () => {
  const eventTriggered = new Promise(resolve => eventHub.$once('someEvent', resolve));

  someFunction();

  return eventTriggered.then(() => {
    expect(something).toBe('done');
  });
});

gon 객체 조작#

gon(또는 window.gon)은 백엔드에서 데이터를 전달하는 데 사용되는 전역 객체입니다. 테스트가 해당 값에 의존하는 경우 직접 수정할 수 있습니다:

describe('when logged in', () => {
  beforeEach(() => {
    gon.current_user_id = 1;
  });

  it('shows message', () => {
    expect(wrapper.text()).toBe('Logged in!');
  });
})

gon은 테스트 격리를 보장하기 위해 매 테스트마다 초기화됩니다.

테스트 격리 보장#

테스트는 일반적으로 테스트 대상 컴포넌트를 반복적으로 설정해야 하는 패턴으로 구성됩니다. 이는 종종 beforeEach 훅을 사용하여 달성합니다.

예시

  let wrapper;

  beforeEach(() => {
    wrapper = mount(Component);
  });

enableAutoDestroy를 사용하면 더 이상 wrapper.destroy()를 수동으로 호출할 필요가 없습니다. 그러나 일부 목(mock), 스파이(spy), 픽스처(fixture)는 해제가 필요하며, 이를 위해 afterEach 훅을 활용할 수 있습니다.

예시

  let wrapper;

  afterEach(() => {
    fakeApollo = null;
    store = null;
  });

로컬 전용 Apollo 쿼리 및 뮤테이션 테스트#

백엔드에 추가되기 전에 새 쿼리나 뮤테이션을 추가하려면 @client 지시어를 사용할 수 있습니다. 예를 들면:

mutation setActiveBoardItemEE($boardItem: LocalBoardItem, $isIssue: Boolean = true) {
  setActiveBoardItem(boardItem: $boardItem) @client {
    ...Issue @include(if: $isIssue)
    ...EpicDetailed @skip(if: $isIssue)
  }
}

이러한 호출에 대한 테스트 케이스를 작성할 때, 리졸버를 사용하여 올바른 파라미터로 호출되는지 확인할 수 있습니다.

예를 들어, 래퍼를 생성할 때 리졸버가 쿼리 또는 뮤테이션에 매핑되어 있는지 확인해야 합니다. 여기서 모킹하는 뮤테이션은 setActiveBoardItem입니다:

const mockSetActiveBoardItemResolver = jest.fn();
const mockApollo = createMockApollo([], {
    Mutation: {
      setActiveBoardItem: mockSetActiveBoardItemResolver,
    },
});

다음 코드에서는 네 개의 인수를 전달해야 합니다. 두 번째 인수는 모킹된 쿼리 또는 뮤테이션의 입력 변수 집합이어야 합니다. 뮤테이션이 올바른 파라미터로 호출되는지 테스트하려면:

it('calls setActiveBoardItemMutation on close', async () => {
    wrapper.findComponent(GlDrawer).vm.$emit('close');

    await waitForPromises();

    expect(mockSetActiveBoardItemResolver).toHaveBeenCalledWith(
        {},
        {
            boardItem: null,
        },
        expect.anything(),
        expect.anything(),
    );
});

Jest 모범 사례#

기본값 비교 시 toEqual 대신 toBe 사용 권장#

Jest에는 toBetoEqual 매처가 있습니다. toBeObject.is를 사용하여 값을 비교하므로, 기본적으로 toEqual보다 빠릅니다. 후자는 결국 Object.is를 활용하는 방식으로 폴백되지만, 기본값에 대해서는 복잡한 객체 비교가 필요할 때만 사용해야 합니다.

예시:

const foo = 1;

// Bad
expect(foo).toEqual(1);

// Good
expect(foo).toBe(1);

더 적합한 매처 사용 권장#

Jest는 toHaveLengthtoBeUndefined 같은 유용한 매처를 제공하여 테스트를 더 읽기 쉽게 만들고 더 이해하기 쉬운 에러 메시지를 생성합니다. 전체 매처 목록은 공식 문서를 확인하세요.

예시:

const arr = [1, 2];

// prints:
// Expected length: 1
// Received length: 2
expect(arr).toHaveLength(1);

// prints:
// Expected: 1
// Received: 2
expect(arr.length).toBe(1);

// prints:
// expect(received).toBe(expected) // Object.is equality
// Expected: undefined
// Received: "bar"
const foo = 'bar';
expect(foo).toBe(undefined);

// prints:
// expect(received).toBeUndefined()
// Received: "bar"
const foo = 'bar';
expect(foo).toBeUndefined();

toBeTruthy 또는 toBeFalsy 사용 지양#

Jest는 toBeTruthytoBeFalsy 매처도 제공합니다. 이 매처들은 테스트를 약하게 만들고 거짓 양성(false-positive) 결과를 생성하므로 사용하지 않아야 합니다.

예를 들어, expect(someBoolean).toBeFalsy()someBoolean === null일 때도, someBoolean === false일 때도 통과합니다.

까다로운 toBeDefined 매처#

Jest의 toBeDefined 매처는 거짓 양성 테스트를 생성할 수 있는 까다로운 매처입니다. 이 매처는 undefined에 대해서만 주어진 값을 검증하기 때문입니다.

// Bad: if finder returns null, the test will pass
expect(wrapper.find('foo')).toBeDefined();

// Good
expect(wrapper.find('foo').exists()).toBe(true);

setImmediate 사용 피하기#

setImmediate 사용은 가능한 한 피하세요. setImmediate는 I/O가 완료된 후 콜백을 실행하기 위한 임시방편적인 해결책입니다. 또한 Web API의 일부가 아니기 때문에, 유닛 테스트에서 NodeJS 환경을 타깃으로 하게 됩니다.

setImmediate 대신 jest.runAllTimers 또는 jest.runOnlyPendingTimers를 사용해 대기 중인 타이머를 실행하세요. 후자는 코드에 setInterval이 있을 때 유용합니다. 참고: Jest 설정은 가짜 타이머(fake timers)를 사용합니다.

비결정적 스펙 피하기#

비결정성(Non-determinism)은 불안정하고 취약한 스펙의 온상입니다. 이러한 스펙은 CI 파이프라인을 중단시키고, 다른 기여자들의 워크플로를 방해하게 됩니다.

  • 테스트 대상의 협력 객체(예: Axios, Apollo, Lodash 헬퍼)와 테스트 환경(예: Date)이 시스템 전반에 걸쳐 그리고 시간이 지나도 일관되게 동작하는지 확인하세요.

  • 테스트가 집중적이며 "불필요한 작업"(예: 개별 테스트에서 불필요하게 테스트 대상을 두 번 이상 생성하는 것)을 하지 않는지 확인하세요.

결정론을 위한 Date 페이킹#

Date는 Jest 환경에서 기본적으로 페이킹(fake)됩니다. 이는 Date() 또는 Date.now() 호출이 항상 고정된 결정론적 값을 반환한다는 것을 의미합니다.

기본 가짜 날짜를 변경해야 하는 경우, 임의의 describe 블록 내에서 useFakeDate를 호출할 수 있으며, 해당 describe 컨텍스트 내의 스펙에 한해서만 날짜가 교체됩니다:

import { useFakeDate } from 'helpers/fake_date';

describe('cool/component', () => {
  // Default fake `Date`
  const TODAY = new Date();

  // NOTE: `useFakeDate` cannot be called during test execution (that is, inside `it`, `beforeEach`, `beforeAll`, etc.).
  describe("on Ada Lovelace's Birthday", () => {
    useFakeDate(1815, 11, 10)

    it('Date is no longer default', () => {
      expect(new Date()).not.toEqual(TODAY);
    });
  });

  it('Date is still default in this scope', () => {
    expect(new Date()).toEqual(TODAY)
  });
})

마찬가지로, 실제 Date 클래스를 사용해야 하는 경우 임의의 describe 블록 내에서 useRealDate를 import하여 호출할 수 있습니다:

import { useRealDate } from 'helpers/fake_date';

// NOTE: `useRealDate` cannot be called during test execution (that is, inside `it`, `beforeEach`, `beforeAll`, etc.).
describe('with real date', () => {
  useRealDate();
});

결정론을 위한 Math.random 페이킹#

테스트 대상이 Math.random에 의존하는 경우, 가짜(fake)로 교체하는 것을 고려하세요.

beforeEach(() => {
  // https://xkcd.com/221/
  jest.spyOn(Math, 'random').mockReturnValue(0.4);
});

테스트에서의 콘솔 경고 및 오류#

예상치 못한 콘솔 경고와 오류는 프로덕션 코드의 문제를 나타냅니다. 테스트 환경을 엄격하게 유지하고자 하므로, 예상치 못한 console.error 또는 console.warn 호출이 발생하면 테스트가 실패해야 합니다.

watcher에서 콘솔 메시지 무시하기#

제어할 수 없는 코드가 많기 때문에, 기본적으로 무시되어 사용 시 테스트를 실패시키지 않는 일부 콘솔 메시지가 있습니다. 이 무시 목록은 setupConsoleWatcher를 호출하는 곳에서 관리할 수 있습니다. 예시:

setupConsoleWatcher({
  ignores: [
    ...,
    // Any call to `console.error('Foo bar')` or `console.warn('Foo bar')` will be ignored by our console watcher.
    'Foo bar',
    // Use regex to allow for flexible message matching.
    /Lorem ipsum/,
  ]
});

특정 테스트에서 describe 블록의 특정 메시지를 무시해야 하는 경우, describe 상단 근처에서 ignoreConsoleMessages 헬퍼를 사용하세요. 이 헬퍼는 자동으로 beforeAllafterAll을 호출하여 해당 테스트 컨텍스트에 대한 무시 목록을 설정하고 해제합니다.

이 기능은 테스트 유지보수에 절대적으로 필요한 경우에만 드물게 사용하세요. 예시:

import { ignoreConsoleMessages } from 'helpers/console_watcher';

describe('foos/components/foo.vue', () => {
  describe('when blooped', () => {
    // Will not fail a test if `console.warn('Lorem ipsum')` is called
    ignoreConsoleMessages([
      /^Lorem ipsum/
    ]);
  });

  describe('default', () => {
    // Will fail a test if `console.warn('Lorem ipsum')` is called
  });
});

팩토리#

TBU

Jest를 활용한 모킹 전략#

스텁과 모킹#

스텁(stub)과 스파이(spy)는 흔히 같은 의미로 사용됩니다. Jest에서는 .spyOn 메서드 덕분에 이 작업이 매우 쉽습니다. 공식 문서 더 까다로운 부분은 모킹(mock)으로, 함수뿐만 아니라 의존성 전체에도 사용할 수 있습니다.

수동 모듈 모킹#

수동 모킹(manual mock)은 전체 Jest 환경에 걸쳐 모듈을 모킹하는 데 사용됩니다. 이는 테스트 환경에서 쉽게 사용할 수 없는 모듈을 모킹하여 단위 테스트를 단순화하는 데 도움이 되는 매우 강력한 테스트 도구입니다.

모킹이 모든 spec에 일관되게 적용될 필요가 없는 경우(즉, 일부 spec에서만 필요한 경우)에는 수동 모킹을 사용하지 마세요.

대신, 관련 spec 파일에서 jest.mock(..) (또는 유사한 모킹 함수)를 사용하는 것을 고려하세요.

수동 모킹은 어디에 두어야 하나요?#

Jest는 소스 모듈 옆에 있는 __mocks__/ 디렉터리에 모킹을 배치함으로써 수동 모듈 모킹을 지원합니다 (예: app/assets/javascripts/ide/__mocks__). 이렇게 하지 마세요. 테스트 관련 코드는 모두 한 곳(spec/ 폴더)에 유지하는 것이 좋습니다.

node_modules 패키지에 대한 수동 모킹이 필요한 경우 spec/frontend/__mocks__ 폴더를 사용하세요. 다음은 monaco-editor 패키지에 대한 Jest 모킹 예시입니다.

CE 모듈에 대한 수동 모킹이 필요한 경우 구현을 spec/frontend/__helpers__/mocks에 두고, frontend/test_setup (또는 frontend/shared_test_setup)에 다음과 같은 코드를 한 줄 추가하세요:

// "~/lib/utils/axios_utils" is the path to the real module
// "helpers/mocks/axios_utils" is the path to the mocked implementation
jest.mock('~/lib/utils/axios_utils', () => jest.requireActual('helpers/mocks/axios_utils'));

수동 모킹 예시#

  • __helpers__/mocks/axios_utils - 이 모킹은 모킹되지 않은 요청이 어떤 테스트도 통과하지 못하도록 막기 위해 유용합니다. 또한 axios.waitForAll과 같은 테스트 헬퍼를 주입할 수 있습니다.

  • __mocks__/mousetrap/index.js - 이 모킹은 모듈 자체가 webpack은 이해하지만 Jest 환경과는 호환되지 않는 AMD 형식을 사용하기 때문에 유용합니다. 이 모킹은 어떠한 동작도 제거하지 않고, 단지 ES6 호환 래퍼를 제공합니다.

  • __mocks__/monaco-editor/index.js - 이 모킹은 Monaco 패키지가 Jest 환경에서 완전히 호환되지 않기 때문에 유용합니다. 실제로 webpack은 이를 동작시키기 위해 특수 로더가 필요합니다. 이 모킹은 Jest가 이 패키지를 사용할 수 있도록 합니다.

모킹은 간결하게 유지하세요#

전역 모킹은 마법 같은 동작을 유발하고 기술적으로 테스트 커버리지를 줄일 수 있습니다. 모킹이 필요하다고 판단될 때는:

  • 모킹을 짧고 집중적으로 유지하세요.

  • 모킹이 왜 필요한지에 대한 최상위 주석을 남기세요.

추가적인 모킹 기법#

사용 가능한 모킹 기능의 전체 개요는 Jest 공식 문서를 참조하세요.

프론트엔드 테스트 실행하기#

픽스처를 생성하기 전에 GDK 인스턴스가 실행 중인지 확인하세요.

프론트엔드 테스트를 실행하려면 다음 명령어가 필요합니다:

  • rake frontend:fixtures픽스처를 (재)생성합니다. 픽스처가 필요한 테스트를 실행하기 전에 픽스처가 최신 상태인지 확인하세요.

  • yarn jest는 Jest 테스트를 실행합니다.

CE 및 EE 테스트 실행하기#

변경 사항에 EE 기능이 포함되어 있어 CE와 EE 환경 모두에 대한 테스트를 작성할 때마다, 로컬 및 파이프라인에서 두 테스트가 모두 통과할 수 있도록 몇 가지 단계를 거쳐야 합니다.

두 환경 모두를 테스트하는 방법에 대한 자세한 내용은 이 섹션을 참조하세요.

라이브 테스트 및 집중 테스트 – Jest#

테스트 스위트를 작성하는 동안 watch 모드로 spec을 실행하여 저장할 때마다 자동으로 다시 실행되게 할 수 있습니다.

# Watch and rerun all specs matching the name icon
yarn jest --watch icon

# Watch and rerun one specific file
yarn jest --watch path/to/spec/file.spec.js

--watch 플래그 없이 일부 집중 테스트를 실행할 수도 있습니다

# Run specific jest file
yarn jest ./path/to/local_spec.js
# Run specific jest folder
yarn jest ./path/to/folder/
# Run all jest files which path contain term
yarn jest term

프론트엔드 테스트 픽스처#

프론트엔드 픽스처는 백엔드 컨트롤러의 응답을 담은 파일입니다. 이 응답은 HAML 템플릿에서 생성된 HTML이거나 JSON 페이로드일 수 있습니다. 이러한 응답에 의존하는 프론트엔드 테스트는 백엔드 코드와의 올바른 통합을 검증하기 위해 픽스처를 사용하는 경우가 많습니다.

픽스처 사용하기#

JSON 또는 HTML 픽스처를 가져오려면 test_fixtures 별칭을 사용하여 import하세요.

import responseBody from 'test_fixtures/some/fixture.json' // loads tmp/tests/frontend/fixtures-ee/some/fixture.json

it('makes a request', () => {
  axiosMock.onGet(endpoint).reply(200, responseBody);

  myButton.click();

  // ...
});

픽스처 생성#

테스트 픽스처를 생성하는 코드는 다음 위치에서 찾을 수 있습니다:

  • spec/frontend/fixtures/: CE에서 테스트를 실행할 때 사용합니다.

  • ee/spec/frontend/fixtures/: EE에서 테스트를 실행할 때 사용합니다.

다음 명령을 실행하여 픽스처를 생성할 수 있습니다:

  • bin/rake frontend:fixtures: 모든 픽스처를 생성합니다.

  • bin/rspec spec/frontend/fixtures/merge_requests.rb: 특정 픽스처를 생성합니다(이 경우 merge_request.rb에 대한 픽스처).

생성된 픽스처는 tmp/tests/frontend/fixtures-ee에서 찾을 수 있습니다.

_spec.js 파일에 대한 단일 픽스처를 생성하려면 test_fixtures/ 디렉터리에서 import를 확인합니다:

// spec/frontend/authentication/webauthn/authenticate_spec.js

import htmlWebauthnAuthenticate from 'test_fixtures/webauthn/authenticate.html';

해당 픽스처 파일은 spec/frontend/fixtures/webauthn.rb입니다.

커맨드 라인에서 단일 픽스처를 생성하려면 bin/rspec spec/frontend/fixtures/webauthn.rb를 실행합니다.

픽스처 다운로드#

GitLab CI에서 픽스처를 생성하고 패키지 레지스트리에 저장합니다.

scripts/frontend/download_fixtures.sh 스크립트는 로컬에서 사용할 수 있도록 해당 픽스처를 다운로드하고 압축을 해제하기 위한 것입니다:

# Checks if a frontend fixture package exists in the gitlab-org/gitlab
# package registry by looking at the commits on a local branch.
#
# The package is downloaded and extracted if it exists
scripts/frontend/download_fixtures.sh

# Same as above, but only looks at the last 10 commits of the currently checked-out branch
scripts/frontend/download_fixtures.sh --max-commits=10

# Looks at the commits on the local master branch instead of the currently checked-out branch
scripts/frontend/download_fixtures.sh --branch master

새 픽스처 만들기#

각 픽스처에 대해 출력 파일에서 response 변수의 내용을 확인할 수 있습니다. 예를 들어, spec/frontend/fixtures/merge_requests.rb에서 "merge_requests/diff_discussion.json"이라는 이름의 테스트는 출력 파일 tmp/tests/frontend/fixtures-ee/merge_requests/diff_discussion.json을 생성합니다. 테스트가 type: :request 또는 type: :controller로 표시되어 있으면 response 변수가 자동으로 설정됩니다.

새 픽스처를 만들 때는 (ee/)spec/controllers/ 또는 (ee/)spec/requests/에서 해당 엔드포인트에 대한 테스트를 살펴보는 것이 좋습니다.

GraphQL 쿼리 픽스처#

get_graphql_query_as_string 헬퍼 메서드를 사용하여 GraphQL 쿼리 결과를 나타내는 픽스처를 만들 수 있습니다. 예를 들어:

# spec/frontend/fixtures/releases.rb

describe GraphQL::Query, type: :request do
  include GraphqlHelpers

  all_releases_query_path = 'releases/graphql/queries/all_releases.query.graphql'

  it "graphql/#{all_releases_query_path}.json" do
    query = get_graphql_query_as_string(all_releases_query_path)

    post_graphql(query, current_user: admin, variables: { fullPath: project.full_path })

    expect_graphql_errors_to_be_empty
  end
end

이렇게 하면 tmp/tests/frontend/fixtures-ee/graphql/releases/graphql/queries/all_releases.query.graphql.json에 새 픽스처가 생성됩니다.

test_fixtures 별칭을 사용하여 Jest 테스트에서 JSON 픽스처를 import할 수 있습니다. 이전에 설명한 방법을 참고하세요.

데이터 기반 테스트#

RSpec의 매개변수화 테스트와 유사하게, Jest는 다음에 대한 데이터 기반 테스트를 지원합니다:

이는 테스트 내 반복을 줄이는 데 유용합니다. 각 옵션은 데이터 값 배열 또는 태그된 템플릿 리터럴을 받을 수 있습니다.

예를 들어:

// function to test
const icon = status => status ? 'pipeline-passed' : 'pipeline-failed'
const message = status => status ? 'pipeline-passed' : 'pipeline-failed'

// test with array block
it.each([
    [false, 'pipeline-failed'],
    [true, 'pipeline-passed']
])('icon with %s will return %s',
 (status, icon) => {
    expect(renderPipeline(status)).toEqual(icon)
 }
);
스펙 출력에 예쁜 출력이 필요하지 않은 경우에만 템플릿 리터럴 블록을 사용하세요. 예를 들어 빈 문자열, 중첩 객체 등의 경우입니다.

예를 들어 빈 검색 문자열과 비어 있지 않은 검색 문자열의 차이를 테스트할 때는, 예쁜 출력 옵션과 함께 배열 블록 구문을 사용하는 것이 권장됩니다. 그래야 스펙 출력에서 빈 문자열('')과 비어 있지 않은 문자열('search string')의 차이가 명확하게 보입니다. 반면 템플릿 리터럴 블록을 사용하면 빈 문자열이 공백으로 표시되어 개발자가 혼란스러울 수 있습니다.

// bad
it.each`
    searchTerm | expected
    ${''} | ${{ issue: { users: { nodes: [] } } }}
    ${'search term'} | ${{ issue: { other: { nested: [] } } }}
`('when search term is $searchTerm, it returns $expected', ({ searchTerm, expected }) => {
  expect(search(searchTerm)).toEqual(expected)
});

// good
it.each([
    ['', { issue: { users: { nodes: [] } } }],
    ['search term', { issue: { other: { nested: [] } } }],
])('when search term is %p, expect to return %p',
 (searchTerm, expected) => {
    expect(search(searchTerm)).toEqual(expected)
 }
);
// test suite with tagged template literal block
describe.each`
    status   | icon                 | message
    ${false} | ${'pipeline-failed'} | ${'Pipeline failed - boo-urns'}
    ${true}  | ${'pipeline-passed'} | ${'Pipeline succeeded - win!'}
`('pipeline component', ({ status, icon, message }) => {
    it(`returns icon ${icon} with status ${status}`, () => {
        expect(icon(status)).toEqual(message)
    })

    it(`returns message ${message} with status ${status}`, () => {
        expect(message(status)).toEqual(message)
    })
});

주의사항#

JavaScript로 인한 RSpec 오류#

기본적으로 RSpec 단위 테스트는 헤드리스 브라우저에서 JavaScript를 실행하지 않으며, Rails가 생성한 HTML을 검사하는 방식에 의존합니다.

통합 테스트가 올바르게 실행되기 위해 JavaScript가 필요한 경우, 테스트 실행 시 JavaScript를 활성화하도록 스펙을 구성해야 합니다. 이를 설정하지 않으면 스펙 러너가 모호한 오류 메시지를 표시합니다.

RSpec 테스트에서 JavaScript 드라이버를 활성화하려면, 개별 스펙 또는 JavaScript 활성화가 필요한 여러 스펙을 포함하는 컨텍스트 블록에 :js를 추가하세요:

# For one spec
it 'presents information about abuse report', :js do
  # assertions...
end

describe "Admin::AbuseReports", :js do
  it 'presents information about abuse report' do
    # assertions...
  end
  it 'shows buttons for adding to abuse report' do
    # assertions...
  end
end

비동기 임포트로 인한 Jest 테스트 타임아웃#

모듈이 런타임에 다른 모듈을 비동기적으로 임포트하는 경우, 해당 모듈들은 런타임에 Jest 로더에 의해 트랜스파일되어야 합니다. 이로 인해 Jest가 타임아웃될 수 있습니다.

이 문제가 발생하면, 모듈을 즉시(eager) 임포트하는 방식을 고려하여 Jest가 컴파일 시점에 해당 모듈을 컴파일하고 캐시하도록 하면 런타임 타임아웃을 해결할 수 있습니다.

다음 예시를 참고하세요:

// the_subject.js

export default {
  components: {
    // Async import Thing because it is large and isn't always needed.
    Thing: () => import(/* webpackChunkName: 'thing' */ './path/to/thing.vue'),
  }
};

Jest는 thing.vue 모듈을 자동으로 트랜스파일하지 않으며, 파일 크기에 따라 Jest가 타임아웃될 수 있습니다. 다음과 같이 즉시 임포트하여 Jest가 해당 모듈을 트랜스파일하고 캐시하도록 강제할 수 있습니다:

// the_subject_spec.js

import Subject from '~/feature/the_subject.vue';

// Force Jest to transpile and cache
// eslint-disable-next-line no-unused-vars
import _Thing from '~/feature/path/to/thing.vue';
테스트 타임아웃을 무시하지 마세요. 이는 실제 프로덕션 문제가 있다는 신호일 수 있습니다. 이 기회를 활용하여 프로덕션 webpack 번들과 청크를 분석하고, 비동기 임포트와 관련된 프로덕션 문제가 없는지 확인하세요.

프론트엔드 테스트 레벨 개요#

프론트엔드 테스트 레벨에 대한 주요 정보는 테스트 레벨 페이지에서 확인할 수 있습니다.

프론트엔드 개발과 관련된 테스트는 다음 위치에서 찾을 수 있습니다:

  • spec/frontend/ — Jest 단위 테스트, 컴포넌트 테스트, 통합 테스트

  • spec/frontend/msw_integration/ — MSW 통합 테스트

  • spec/features/ — Capybara 기능 테스트

spec/frontend/에는 프론트엔드 단위 테스트, 프론트엔드 컴포넌트 테스트, 프론트엔드 통합 테스트가 포함되어 있습니다. Capybara는 spec/features/에서 프론트엔드 기능 테스트를 실행합니다.

2018년 5월 이전에는 features/에도 Spinach가 실행하는 기능 테스트가 포함되어 있었습니다. 이 테스트들은 2018년 5월에 코드베이스에서 제거되었습니다 (#23036).

Vue 컴포넌트 테스트 관련 참고 사항도 함께 확인하세요.

MSW 통합 테스트#

MSW 통합 테스트는 단위 테스트와 Capybara 기능 테스트 사이의 간극을 메웁니다. jsdom에서 전체 Vue 애플리케이션을 마운트하고(브라우저 불필요), MSW(Mock Service Worker)를 사용하여 API 요청을 가로채고 픽스처 데이터로 응답합니다. 이를 통해 Capybara 대비 훨씬 낮은 비용으로 현실적인 UI 인터랙션 테스트가 가능합니다.

디렉터리 구조#

MSW 통합 테스트는 spec/frontend/msw_integration/에 위치합니다. 구조는 다음과 같습니다:

spec/frontend/msw_integration/
├── constants.js            # Shared constants (for example, base metadata)
├── fixture_utils.js        # Helpers for building dynamic mutation responses
├── handlers.js             # GraphQL router: composes feature handlers
├── handlers/
│   └── work_items.js       # Work item resolver and operation overrides
├── server.js               # MSW server setup (imported by test_setup.js)
├── setup_utils.js          # Router and lifecycle helpers used by test_setup.js
├── test_helpers.js         # Test utilities: assignRouter, fullMount, waitForElement, getText
├── test_setup.js           # Global setup: polyfills, server lifecycle, router reset
├── polyfills.js            # TextEncoder/TextDecoder polyfills for jsdom
└── work_items/
    └── work_item_spec.js   # Integration test file

공유 파일(handlers.js, server.js, test_setup.js, polyfills.js, test_helpers.js)은 jest.config.msw_integration.js를 통해 자동으로 구성됩니다. 테스트 파일은 관련 기능 핸들러 파일 (예: handlers/work_items.js)에서 픽스처 데이터를 직접 임포트합니다.

test_helpers.js에서 내보낸 모든 테스트 헬퍼 유틸리티는 test_setup.js에서 Object.assign(global, testHelpers)를 통해 전역으로 자동 임포트됩니다. 따라서 테스트 파일에서 명시적으로 임포트할 필요가 없습니다. 새 헬퍼를 추가하려면 test_helpers.js에서 내보내면 모든 MSW 통합 테스트에서 전역으로 사용할 수 있습니다.

핸들러 아키텍처#

MSW v1은 적용 가능한 첫 번째 rest.post 핸들러를 매칭하므로, 단일 GraphQL 엔드포인트를 여러 MSW 핸들러로 분리할 수 없습니다. 대신 handlers.js가 얇은 GraphQL 라우터 역할을 합니다. http://test.host/api/graphql에 대해 하나의 rest.post 핸들러를 등록하고, 기능별 리졸버 함수에 순서대로 위임합니다:

import { rest } from 'msw';
import { handleWorkItemOperation } from './handlers/work_items';

// Thin router: Import feature handlers here
const graphqlFeatureHandlers = [handleWorkItemOperation];

// Collect all REST endpoints from feature handlers
const restEndpoints = [...workItemRestEndpoints];

const restEndpointsHandlers = restEndpoints.map((endpoint) =>
  rest[endpoint.method](endpoint.path, (req, res, ctx) => {
    return res(ctx.json(endpoint.response));
  }),
);

export const handlers = [
  // Single GraphQL endpoint that routes to feature handlers
  rest.post('http://test.host/api/graphql', (req, res, ctx) => {
    const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
    const { operationName, variables } = body;

    // Try each feature handler until one returns a result
    for (const handler of graphqlFeatureHandlers) {
      const result = handler({ operationName, variables, res, ctx });
      if (result) return result;
    }

    console.log(`No handler for operationName: ${operationName}`);
    return res(ctx.status(400));
  }),
  ...restEndpointsHandlers,
];

각 리졸버는 { operationName, variables, res, ctx }를 받아 해당 작업을 처리하면 MSW 응답을 반환하고, 처리하지 않으면 null을 반환하여 다음 리졸버로 넘깁니다. 처리되지 않은 작업은 400 상태를 반환하는 캐치올로 넘어갑니다. 이러한 의도적인 실패는 누락된 핸들러를 조기에 발견할 수 있게 합니다. 테스트 중 실행되는 모든 GraphQL 작업에는 대응하는 핸들러가 있어야 합니다. ServerParseError: Unexpected end of JSON input으로 테스트가 실패하면 해당 작업을 관련 기능 핸들러 파일에 추가하세요.

새 기능 도메인 추가#

새 기능 영역(예: 머지 리퀘스트)에 MSW 핸들러를 추가하려면:

loadFixturesMap을 사용하여 픽스처를 자동으로 로드하고 핸들러를 빌드하는 리졸버 파일을 handlers/ 디렉터리에 생성합니다. 자동 로드 및 핸들러 빌드에 대한 자세한 내용은 기능 핸들러 작성을 참조하세요.

handlers.js에 리졸버를 등록합니다:

import { handleMergeRequestOperation } from './handlers/merge_requests';

// Thin router: Import feature handlers here
const graphqlFeatureHandlers = [
  handleWorkItemOperation,
  handleMergeRequestOperation,
];

ee/spec/frontend/fixtures/에 RSpec spec을 추가하고 실행하여 픽스처를 생성합니다. 자세한 내용은 픽스처 생성을 참조하세요.

Generate fixtures#

픽스처는 테스트 데이터베이스에 대해 실행된 실제 GraphQL 쿼리로부터 생성됩니다. 각 픽스처 생성기는 ee/spec/frontend/fixtures/에 있는 RSpec spec입니다. 예를 들어:

bundle exec rspec ee/spec/frontend/fixtures/work_items_integration.rb

이 명령은 JSON 파일을 tmp/tests/frontend/fixtures-ee/graphql/에 씁니다. 이 픽스처는 기능 핸들러 파일의 loadFixturesMap에 의해 자동으로 로드되어 MSW를 통해 제공됩니다.

새 픽스처를 추가하려면 픽스처 생성기 spec에 새 it 블록을 추가합니다. 테스트 이름에 따라 출력 파일 경로가 결정됩니다:

it "graphql/work_items/integration/my_query.query.graphql.json" do
  query = get_graphql_query_as_string('work_items/graphql/my_query.query.graphql')
  post_graphql(query, current_user: user, variables: { fullPath: project.full_path })
  expect_graphql_errors_to_be_empty
end

Fixture naming convention for auto-loading#

픽스처 파일명이 GraphQL 오퍼레이션 이름에 올바르게 매핑되려면 loadFixturesMap으로 픽스처 자동 로드에 설명된 명명 규칙을 따르세요.

Write feature handlers#

handlers/의 각 기능 핸들러 파일은 해당 기능 영역의 오퍼레이션-픽스처 매핑과 뮤테이션 로직을 담당합니다.

Auto-load fixtures with loadFixturesMap#

fixture_utils.jsloadFixturesMap을 사용하면 디렉터리의 모든 JSON 픽스처를 자동으로 로드하고 오퍼레이션 이름에 매핑할 수 있습니다. 이 함수는 지정된 경로의 모든 .json 파일을 읽고, .query.graphql.json 또는 .mutation.graphql.json 접미사를 제거한 뒤, 나머지 파일명을 camelCase로 변환하여 오퍼레이션 이름 키로 사용합니다.

예를 들어 get_work_items_full.query.graphql.json이라는 파일은 키 getWorkItemsFull에 매핑됩니다.

픽스처 파일명은 이 camelCase 변환 후 GraphQL 오퍼레이션 이름과 일치해야 합니다. 예를 들어 GraphQL 오퍼레이션 이름이 getWorkItemStateCounts인 경우, 픽스처 파일 이름을 get_work_item_state_counts.query.graphql.json으로 지정합니다. 로더는 이를 getWorkItemStateCounts로 변환하며, 이 이름은 Apollo 클라이언트가 전송하는 오퍼레이션 이름과 일치합니다.

오퍼레이션 이름이 파생된 파일명과 일치하지 않는 경우(예: getWorkItemsFullEE와 같이 EE 접미사가 붙은 오퍼레이션), 핸들러 파일의 OPERATION_NAME_OVERRIDES에 항목을 추가합니다:

const OPERATION_NAME_OVERRIDES = {
  getWorkItemsFullEE: fixtures.getWorkItemsFull,
};
import { join } from 'node:path';
import { loadFixturesMap } from '../fixture_utils';

const FIXTURES_PATH = join('tmp/tests/frontend/fixtures-ee/graphql/my_feature/integration/');
const fixtures = loadFixturesMap(FIXTURES_PATH);

이 방식을 사용하면 각 픽스처 파일을 수동으로 import할 필요가 없습니다. fixtures 객체에는 파생된 camelCase 오퍼레이션 이름을 키로 하여 모든 픽스처가 포함됩니다.

Build the handler from the fixtures map#

자동 로드된 fixturesFIXTURE_RESPONSES에 스프레드하고, 이름이 맞지 않는 경우를 위한 OPERATION_NAME_OVERRIDES도 함께 포함합니다 (위 내용 참조):

const FIXTURE_RESPONSES = {
  ...fixtures,
  ...OPERATION_NAME_OVERRIDES,
};

정적 오퍼레이션(쿼리)은 자동으로 핸들러로 변환됩니다.

입력 변수를 기반으로 동적 응답이 필요한 뮤테이션의 경우, MUTATION_OPERATION_HANDLERS에 항목을 추가합니다:

const MUTATION_OPERATION_HANDLERS = {
  myMutation: ({ variables }) => buildMyResponse(variables),
};

두 가지를 단일 OPERATION_HANDLERS 맵으로 합치고, 리졸버에서 해당 작업을 조회합니다:

const STATIC_OPERATION_HANDLERS = Object.fromEntries(
  Object.entries(FIXTURE_RESPONSES).map(([op, fixture]) => [
    op,
    () => ({ data: fixture.data }),
  ]),
);

const OPERATION_HANDLERS = {
  ...STATIC_OPERATION_HANDLERS,
  ...MUTATION_OPERATION_HANDLERS,
};

export function handleMyFeatureOperation({ operationName, variables, res, ctx }) {
  const handler = OPERATION_HANDLERS[operationName];
  if (!handler) return null;
  return res(ctx.json(handler({ operationName, variables })));
}

테스트 파일 작성#

테스트 파일은 기능 영역을 반영하는 하위 디렉터리 구조로 spec/frontend/msw_integration/ 아래에 위치합니다. 각 파일은 다음 규칙을 따라야 합니다:

  • 라우터 팩토리를 직접 호출하는 대신 test_helpers.jsassignRouter를 사용하여 라우터를 생성합니다. 이렇게 하면 라우터가 전역으로 등록되어 test_setup.js가 테스트 간 라우터를 초기화할 수 있습니다.

  • 루트 컴포넌트를 test_helpers.jsfullMount와 실제 apolloProvider로 마운트합니다. fullMount@vue/test-utilsmount를 래핑하며 자동으로 document.body에 연결합니다.

  • API 호출을 트리거하는 액션 이후에는 @testing-library/domwaitFor를 사용합니다.

  • 네이티브 DOM API(.click(), .dispatchEvent(), .querySelector())를 통해 UI와 상호작용합니다.

  • Vue 컴포넌트 상태가 아닌 DOM을 대상으로 단언합니다.

Vue Test Utils 래퍼보다 DOM 단언을 우선 사용#

컴포넌트를 생성할 때만 test_helpers.jsfullMount를 사용합니다. 마운트 이후에는 DOM과 직접 상호작용하고 단언을 수행합니다. 이렇게 하면 테스트가 Vue 버전에 독립적으로 유지되고 향후 Vue 3 마이그레이션이 간단해집니다.

아래와 같은 Vue 종속 패턴은 피합니다:

  • wrapper.find(), wrapper.findComponent(), wrapper.trigger(), wrapper.text(), wrapper.attributes(), wrapper.exists().

  • vm.$emit(), vm.$data, 또는 컴포넌트 인스턴스 속성에 직접 접근하는 것.

  • DOM 엘리먼트에서 VTU 래퍼를 얻기 위해 el.__vue__ 또는 createWrapper()를 사용하는 것.

대신 아래와 같은 네이티브 DOM 동등 표현을 사용합니다:

Vue Test Utils DOM equivalent
.find(selector) .querySelector(selector)
.trigger('click') .click()
.trigger('submit') .dispatchEvent(new Event('submit', { bubbles: true }))
.text() getText(el) from test_helpers.js
.attributes('name') .getAttribute('name') or .dataset
.exists() !== null
.setValue(val) el.value = val; el.dispatchEvent(new Event('input', { bubbles: true }))

다음은 최소한의 예시입니다:

import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { waitFor } from '@testing-library/dom';
import { apolloProvider } from '~/graphql_shared/issuable_client';
import { createRouter } from '~/my_feature/router';
import MyApp from '~/my_feature/components/app.vue';
import { assignRouter, fullMount, waitForElement, getText } from '../test_helpers';

Vue.use(VueApollo);

describe('My feature test', () => {
  const router = assignRouter(createRouter, {
    fullPath: 'gitlab-org/gitlab',
    routerPath: 'my_feature',
  });

  const findResult = () =>
    document.querySelector('[data-testid="result"]');

  const createComponent = () => {
    fullMount(MyApp, {
      router,
      apolloProvider,
      provide: {
        fullPath: 'gitlab-org/gitlab',
      },
    });
  };

  beforeEach(async () => {
    await apolloProvider.defaultClient.cache.reset();
  });

  it('renders the page and responds to user interaction', async () => {
    createComponent();

    const el = await waitForElement(
      () => document.querySelector('[data-testid="my-element"]'),
    );
    expect(el).not.toBe(null);

    document.querySelector('[data-testid="my-button"]').click();

    await waitFor(() => {
      expect(getText(findResult())).toContain('Updated');
    });
  });
});

단위 테스트와의 주요 차이점:

  • createMockApollo 대신 실제 apolloProvider를 사용합니다. MSW가 실제 네트워크 요청을 가로챕니다.

  • shallowMountExtendedmountExtended 대신 test_helpers.jsfullMount를 사용합니다. 실제적인 상호작용 테스트를 위해 전체 컴포넌트 트리가 렌더링되어야 합니다. fullMountmount를 래핑하며 자동으로 document.body에 연결합니다.

  • 라우터 생성에는 test_helpers.jsassignRouter를 사용합니다. 이렇게 하면 라우터가 전역적으로 등록되어 test_setup.js가 테스트 간 라우터를 재설정할 수 있습니다. 라우터 팩토리 함수를 직접 호출하거나 라우트를 수동으로 푸시하지 마세요.

  • 마운트 후에는 모든 상호작용과 어설션에 네이티브 DOM API를 사용합니다. 이렇게 하면 Vue 내부에 대한 결합을 방지하고 Vue 3 호환성을 보장합니다.

  • 자식 컴포넌트를 모킹하지 마세요. 목표는 컴포넌트들이 함께 어떻게 동작하는지 테스트하는 것입니다.

  • 테스트 간 상태가 누출되지 않도록 beforeEach에서 Apollo 캐시를 재설정합니다.

  • wrapper 소멸이나 Apollo 클라이언트 종료를 위한 afterEach 정리를 추가하지 마세요. 전역 test_setup.js가 라우터 재설정, wrapper 소멸, 메타데이터 정리를 처리합니다.

  • 서버 라이프사이클(server.listen, server.resetHandlers, server.close)은 test_setup.js가 전역으로 처리합니다. 개별 테스트 파일에 이러한 호출을 추가하지 마세요.

MSW 통합 테스트 실행#

모든 MSW 통합 테스트 실행:

yarn jest:msw-integration

단일 파일 실행:

yarn jest:msw-integration spec/frontend/msw_integration/work_items/work_item_spec.js

CI에서 이 테스트는 jest-msw-integration job(tier-2+ 파이프라인)에서 실행됩니다.

테스트 헬퍼#

테스트 헬퍼는 spec/frontend/__helpers__에서 찾을 수 있습니다. 새 헬퍼를 추가할 경우 해당 디렉터리에 배치하세요.

Vuex 헬퍼: testAction#

공식 문서에 따라 액션 테스트를 더 쉽게 만들 수 있는 헬퍼가 있습니다:

// 이 방식을 권장합니다. 단일 객체 인자를 사용하면 테스트를 읽을 때 매개변수가 명확합니다
await testAction({
  action: actions.actionName,
  payload: { deleteListId: 1 },
  state: { lists: [1, 2, 3] },
  expectedMutations: [ { type: types.MUTATION} ],
  expectedActions: [],
});

// 이전 방식이므로 새 테스트에서는 사용하지 마세요
testAction(
  actions.actionName, // action
  { }, // params to be passed to action
  state, // state
  [
    { type: types.MUTATION},
    { type: types.MUTATION_1, payload: {}},
  ], // mutations committed
  [
    { type: 'actionName', payload: {}},
    { type: 'actionName1', payload: {}},
  ] // actions dispatched
  done,
);

Axios 요청 완료 대기#

spec/frontend/__helpers__/mocks/axios_utils.js에 위치한 Axios Utils 모킹 모듈에는 HTTP 요청을 생성하는 Jest 테스트를 위한 두 가지 헬퍼 메서드가 포함되어 있습니다. 예를 들어 Vue 컴포넌트가 라이프사이클의 일부로 요청을 수행할 때처럼 요청의 Promise에 대한 핸들을 갖고 있지 않은 경우에 매우 유용합니다.

  • waitFor(url, callback): url에 대한 요청이 완료되면(성공 또는 실패 여부와 관계없이) callback을 실행합니다.

  • waitForAll(callback): 보류 중인 모든 요청이 완료되면 callback을 실행합니다. 보류 중인 요청이 없으면 다음 틱에서 callback을 실행합니다.

두 함수 모두 요청이 완료된 후 다음 틱에서 callback을 실행합니다(setImmediate() 사용). 이를 통해 .then() 또는 .catch() 핸들러가 실행될 수 있습니다.

shallowMountExtended와 mountExtended#

shallowMountExtendedmountExtended 유틸리티는 find 또는 findAll 접두사를 붙여 사용 가능한 DOM Testing Library 쿼리를 수행하는 기능을 제공합니다.

import { shallowMountExtended } from 'helpers/vue_test_utils_helper';

describe('FooComponent', () => {
  const wrapper = shallowMountExtended({
    template: `
      <div data-testid="gitlab-frontend-stack">
        <p>GitLab frontend stack</p>
        <div role="tablist">
          <button role="tab" aria-selected="true">Vue.js</button>
          <button role="tab" aria-selected="false">GraphQL</button>
          <button role="tab" aria-selected="false">SCSS</button>
        </div>

      </div>

    `,
  });

  it('finds elements with `findByTestId`', () => {
    expect(wrapper.findByTestId('gitlab-frontend-stack').exists()).toBe(true);
  });

  it('finds elements with `findByText`', () => {
    expect(wrapper.findByText('GitLab frontend stack').exists()).toBe(true);
    expect(wrapper.findByText('TypeScript').exists()).toBe(false);
  });

  it('finds elements with `findAllByRole`', () => {
    expect(wrapper.findAllByRole('tab').length).toBe(3);
  });
});

spec/frontend/alert_management/components/alert_details_spec.js에서 예제를 확인할 수 있습니다.

구형 브라우저 테스트#

일부 회귀 문제는 특정 브라우저 버전에서만 발생합니다. 다음 단계에 따라 Firefox 또는 BrowserStack을 사용하여 특정 브라우저를 설치하고 테스트할 수 있습니다:

BrowserStack#

BrowserStack을 사용하면 1200개 이상의 모바일 디바이스 및 브라우저를 테스트할 수 있습니다. 라이브 앱을 통해 직접 사용하거나, 편리한 접근을 위해 크롬 확장 프로그램을 설치할 수 있습니다. GitLab 공유 1Password 계정Engineering 볼트에 저장된 자격 증명으로 BrowserStack에 로그인하세요.

Firefox#

macOS#

릴리즈 FTP 서버 https://ftp.mozilla.org/pub/firefox/releases/에서 이전 버전의 Firefox를 다운로드할 수 있습니다:

  • 웹사이트에서 버전을 선택합니다. 여기서는 50.0.1을 예로 들겠습니다.

  • mac 폴더로 이동합니다.

  • 원하는 언어를 선택합니다. DMG 패키지가 그 안에 있습니다. 다운로드하세요.

  • 애플리케이션을 Applications 폴더가 아닌 다른 폴더로 드래그 앤 드롭합니다.

  • 애플리케이션 이름을 Firefox_Old와 같이 변경합니다.

  • 애플리케이션을 Applications 폴더로 이동합니다.

  • 터미널을 열고 /Applications/Firefox_Old.app/Contents/MacOS/firefox-bin -profilemanager를 실행하여 해당 Firefox 버전 전용의 새 프로필을 생성합니다.

  • 프로필 생성이 완료되면 앱을 종료하고 평소처럼 다시 실행합니다. 이제 구형 Firefox 버전이 정상적으로 동작합니다.

스냅샷#

Jest 스냅샷 테스트는 특정 컴포넌트의 HTML 출력에 예기치 않은 변경이 발생하는 것을 방지하는 데 유용한 방법입니다. 다른 테스트 방법(예: vue-tests-utils로 요소를 검증하는 방법)으로 필요한 사용 사례를 다루기 어려운 경우에 한해서만 사용해야 합니다. GitLab 내에서 스냅샷 테스트를 사용할 때는 다음과 같은 몇 가지 지침을 숙지해야 합니다:

  • 스냅샷을 코드처럼 다루세요

  • 스냅샷 파일을 블랙박스처럼 취급하지 마세요

  • 스냅샷의 출력 결과에 주의를 기울이세요. 그렇지 않으면 실질적인 가치를 제공하지 못합니다. 이를 위해 생성된 스냅샷 파일을 다른 코드를 읽듯이 직접 확인해야 합니다

스냅샷 테스트는 테스트 대상 항목에 입력한 내용의 원시 String 표현을 저장하는 간단한 방법으로 생각하면 됩니다. 이를 통해 컴포넌트, 스토어, 복잡한 생성 결과물 등의 변경 사항을 평가할 수 있습니다. 아래 목록에서 권장하는 Do's and Don'ts를 확인할 수 있습니다. 스냅샷 테스트는 매우 강력한 도구가 될 수 있지만, 단위 테스트를 대체하는 것이 아니라 보완하는 수단으로 사용해야 합니다.

Jest는 스냅샷을 생성할 때 염두에 두어야 할 모범 사례에 대한 훌륭한 문서를 제공합니다.

스냅샷은 어떻게 동작하나요?#

스냅샷은 함수 호출의 좌변에서 테스트하도록 지정한 내용을 문자열로 변환한 것입니다. 즉, 문자열 서식을 변경하면 결과에 영향을 미칩니다. 이 과정은 직렬화(serializer)를 활용한 자동 변환 단계를 통해 이루어집니다. Vue의 경우 적절한 직렬화 도구를 제공하는 vue-jest 패키지를 활용하여 이미 처리되어 있습니다.

스펙의 결과가 생성된 스냅샷 파일의 내용과 다를 경우, 테스트 스위트에서 실패한 테스트로 알림을 받게 됩니다.

자세한 내용은 Jest 공식 문서 https://jestjs.io/docs/snapshot-testing에서 확인하세요.

장단점#

장점

  • 중요한 HTML 구조의 의도치 않은 변경에 대한 경고 역할

  • 설정 용이성

단점

  • 요소를 찾고 존재 여부를 직접 검증하는 vue-tests-utils가 제공하는 명확성과 가이드라인이 부족함

  • 의도적으로 컴포넌트를 업데이트할 때 불필요한 노이즈 발생

  • 버그의 스냅샷을 찍을 위험이 높으며, 이 경우 해당 문제를 수정할 때 테스트가 실패하게 됨

  • 스냅샷 내에 의미 있는 검증이나 기대값이 없어 이해하거나 교체하기 어려움

  • GitLab UI와 같은 의존성과 함께 사용할 경우, 기반 라이브러리가 테스트 중인 컴포넌트의 HTML을 변경할 때 테스트의 취약성이 증가함

사용 시기#

스냅샷을 사용해야 하는 경우

  • 중요한 HTML 구조가 실수로 변경되지 않도록 보호할 때

  • 복잡한 유틸리티 함수의 JS 객체 또는 JSON 출력을 검증할 때

사용하지 말아야 할 시기#

스냅샷을 사용하지 말아야 하는 경우

  • vue-tests-utils를 사용하여 테스트를 작성할 수 있는 경우

  • 컴포넌트의 로직을 검증할 때

  • 데이터 구조의 출력을 예측할 때

  • 리포지터리 외부에 UI 요소가 있는 경우 (GitLab UI 버전 업데이트를 생각해 보세요)

예제#

보시다시피, 스냅샷 테스트의 단점이 장점보다 일반적으로 훨씬 많습니다. 이를 더 잘 이해하기 위해, 이 섹션에서는 스냅샷 테스트를 사용하고 싶어질 수 있는 몇 가지 예제와 왜 그것이 좋지 않은 패턴인지 설명합니다.

예제 #1 - 요소 가시성#

요소 가시성을 테스트할 때는 vue-tests-utils (VTU)를 사용하여 특정 컴포넌트를 찾은 다음 VTU 래퍼에서 기본적인 .exists() 메서드를 호출하는 방법을 선호하세요. 이 방법이 더 나은 가독성과 더 견고한 테스트를 제공합니다. 아래 예제를 살펴보면, 스냅샷에 대한 검증이 무엇을 기대하는지를 알려주지 않는다는 것을 알 수 있습니다. 우리는 컨텍스트를 파악하기 위해 전적으로 it 설명에 의존하고 있으며, 스냅샷이 원하는 동작을 캡처했다는 가정에 의존합니다.

<template>
  <my-component v-if="isVisible" />
</template>

Bad:

it('hides the component', () => {
  createComponent({ props: { isVisible: false }})

  expect(wrapper.element).toMatchSnapshot()
})

it('shows the component', () => {
  createComponent({ props: { isVisible: true }})

  expect(wrapper.element).toMatchSnapshot()
})

좋은 예:

it('hides the component', () => {
  createComponent({ props: { isVisible: false }})

  expect(findMyComponent().exists()).toBe(false)
})

it('shows the component', () => {
  createComponent({ props: { isVisible: true }})

  expect(findMyComponent().exists()).toBe(true)
})

뿐만 아니라, 컴포넌트에 잘못된 prop을 전달하여 잘못된 가시성이 적용된 경우를 상상해 보세요. 스냅샷 테스트는 문제가 있는 상태의 HTML을 캡처했기 때문에 여전히 통과됩니다. 따라서 스냅샷 출력을 직접 확인하지 않는 한, 테스트가 잘못되었다는 사실을 절대 알 수 없습니다.

예시 #2 - 텍스트 존재 여부#

컴포넌트 내에서 텍스트를 찾는 것은 vue-test-utilswrapper.text() 메서드를 사용하면 매우 간단합니다. 하지만 포맷팅이나 HTML 중첩으로 인해 반환 값에 일관성 없는 공백이 많이 포함될 경우, 스냅샷을 사용하고 싶은 유혹이 생길 수 있습니다.

이러한 경우에는 스냅샷으로 공백을 무시하는 것보다, 각 문자열을 개별적으로 단언하여 여러 assertion을 작성하는 것이 더 좋습니다. DOM 레이아웃이 변경되면 텍스트가 완벽하게 포맷팅되어 있더라도 스냅샷 테스트가 실패하기 때문입니다.

<template>
  <gl-sprintf :message="my-message">
    <template #code="{ content }">
      <code>{{ content }}</code>
    </template>
  </gl-sprintf>
  <p> My second message </p>
</template>

나쁜 예:

it('renders the text as I expect', () => {
  expect(wrapper.text()).toMatchSnapshot()
})

좋은 예:

it('renders the code snippet', () => {
  expect(findCodeTag().text()).toContain("myFunction()")
})

it('renders the paragraph text', () => {
  expect(findOtherText().text()).toBe("My second message")
})

예시 #3 - 복잡한 HTML#

매우 복잡한 HTML이 있을 때는 전체를 캡처하는 것보다 중요하고 민감한 특정 지점을 구체적으로 단언하는 데 집중해야 합니다. 스냅샷 테스트의 가치는 개발자에게 경고를 주는 것, 즉 의도하지 않게 HTML 구조를 변경했을 수 있음을 알리는 데 있습니다. 복잡한 HTML 출력처럼 변경 결과를 읽기 어려운 경우, 무언가가 변경되었다는 신호 자체만으로 충분할까요? 그리고 그것이 충분하다면, 스냅샷 없이도 달성할 수 있을까요?

복잡한 HTML 출력의 좋은 예로 GlTable이 있습니다. 행과 열 구조를 캡처할 수 있어 스냅샷 테스트가 좋은 선택처럼 느껴질 수 있지만, 대신 예상되는 텍스트를 단언하거나 행과 열의 수를 직접 세는 방법을 사용해야 합니다.

<template>
  <gl-table ...all-them-props />
</template>

나쁜 예:

it('renders GlTable as I expect', () => {
  expect(findGlTable().element).toMatchSnapshot()
})

좋은 예:

it('renders the right number of rows', () => {
  expect(findGlTable().findAllRows()).toHaveLength(expectedLength)
})

it('renders the special icon that only appears on a full moon', () => {
  expect(findGlTable().findMoonIcon().exists()).toBe(true)
})

it('renders the correct email format', () => {
  expect(findGlTable().text()).toContain('my_strange_email@shaddyprovide.com')
})

다소 장황하더라도, 이렇게 하면 GlTable이 내부 구현을 변경하더라도 테스트가 깨지지 않으며, 다른 개발자(또는 6개월 후의 자신)에게 테이블을 리팩토링하거나 기능을 추가할 때 무엇을 보존해야 하는지 명확하게 전달할 수 있습니다.

스냅샷 찍는 방법#

it('makes the name look pretty', () => {
  expect(prettifyName('Homer Simpson')).toMatchSnapshot()
})

이 테스트가 처음 실행되면 새로운 .snap 파일이 생성됩니다. 파일의 내용은 다음과 같습니다:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`makes the name look pretty`] = `
Sir Homer Simpson the Third
`

이제 이 테스트를 호출할 때마다 새 스냅샷이 이전에 생성된 버전과 비교하여 평가됩니다. 스냅샷 파일의 내용을 이해하고 신중하게 다루는 것이 중요하다는 점을 이를 통해 알 수 있습니다. 스냅샷 출력이 너무 크거나 복잡하여 읽기 어렵다면 스냅샷의 가치를 잃게 됩니다. 따라서 스냅샷은 머지 리퀘스트 리뷰에서 평가할 수 있거나 절대 변경되지 않을 것이 보장되는 사람이 읽을 수 있는 항목으로 한정해야 합니다. wrappers 또는 elements에 대해서도 동일하게 적용할 수 있습니다.

it('renders the component correctly', () => {
  expect(wrapper).toMatchSnapshot()
  expect(wrapper.element).toMatchSnapshot();
})

위 테스트는 두 개의 스냅샷을 생성합니다. 어떤 스냅샷이 코드베이스 안전성에 더 많은 가치를 제공하는지 결정하는 것이 중요합니다. 즉, 이 스냅샷 중 하나가 변경된다면 코드베이스에 가능한 손상이 발생했음을 나타내는지 생각해야 합니다. 이는 기반 의존성 중 일부가 우리도 모르게 변경되었을 때 예상치 못한 변경을 감지하는 데 도움이 됩니다.

기능 테스트 시작하기#

기능 테스트(feature test)는 실제 UI에서 의미 있는 동작을 수행하여 기능의 전체 흐름을 검증합니다.

기능 테스트를 사용할 때#

다음과 같은 경우에 기능 테스트를 사용하세요:

  • 페이지에서 여러 컴포넌트가 서로 상호작용하는 경우.

  • 사용자가 여러 페이지를 탐색해야 하는 경우.

  • 폼을 제출하고 다른 곳에서 결과를 관찰하는 경우.

  • 단위 테스트로 수행하면 과도한 모킹(mocking)과 스터빙(stubbing)이 필요한 경우.

기능 테스트는 다음을 테스트하려 할 때 특히 유용합니다:

  • 여러 컴포넌트가 함께 성공적으로 동작하는지 여부.

  • 단위 테스트 모킹으로 재현하기 어려운 복잡한 API 상호작용. 기능 테스트는 더 느리지만 어떤 수준의 모킹도 필요하지 않습니다.

기능 테스트를 사용하지 말아야 할 때#

동일한 테스트 결과를 얻을 수 있다면 기능 테스트 대신 jestvue-test-utils 단위 테스트를 사용하세요. 기능 테스트는 단위 테스트보다 실행 비용이 더 많이 듭니다.

다음과 같은 경우에는 단위 테스트를 사용하세요:

  • 동작이 하나의 컴포넌트 안에서 모두 이루어지는 경우.

  • 다른 컴포넌트의 동작을 시뮬레이션하여 원하는 효과를 트리거할 수 있는 경우.

  • 가상 DOM에서 UI 요소를 선택하여 원하는 효과를 트리거할 수 있는 경우.

적합한 기능 테스트 유형 선택#

기능 테스트가 적합하다고 판단했다면 GitLab에는 두 가지 유형이 있습니다. MSW 통합 테스트가 훨씬 더 빠르므로 기본적으로 MSW 통합 테스트를 사용하세요.

다음과 같은 경우에 MSW 통합 테스트(spec/frontend/msw_integration/)를 사용하세요:

  • 단일 페이지에서 다중 컴포넌트 상호작용을 다루는 경우 (예: 목록 + 드로어).

  • 백엔드 응답을 자동 생성된 픽스처(fixture)로 표현할 수 있는 경우.

  • 데이터베이스 상태, 인가, 서버 측 유효성 검사 또는 실시간 업데이트를 검증할 필요가 없는 경우.

다음과 같은 경우에 Capybara 기능 테스트(spec/features/)를 사용하세요:

  • 실제 백엔드(데이터베이스 쓰기, 인가 확인, 서버 측 유효성 검사)가 필요한 경우.

  • 여러 서버 렌더링 페이지 간의 탐색이 필요한 경우.

  • 픽스처로 표현할 수 없는 백엔드 상태에 의존하는 동작을 검증해야 하는 경우.

  • 같은 페이지의 여러 Vue 애플리케이션에 의존하는 동작을 테스트해야 하는 경우.

Capybara 기능 테스트#

Capybara 기능 테스트화이트박스 테스팅(white-box testing)이라고도 하며, 브라우저를 생성하고 Capybara 헬퍼를 사용하는 테스트입니다. 즉, 이 테스트는 다음을 수행할 수 있습니다:

  • 브라우저에서 요소를 찾습니다.

  • 해당 요소를 클릭합니다.

  • API를 호출합니다.

Capybara 기능 테스트는 실행 비용이 높습니다. 작성하기 전에 MSW 통합 테스트로 동일한 커버리지를 달성할 수 없는지 반드시 확인하세요.

모든 Capybara 기능 테스트는 Ruby로 작성되지만, 사용자 대면 기능을 구현하는 JavaScript 엔지니어가 작성하는 경우도 많습니다. 다음 섹션에서는 RubyCapybara에 대한 사전 지식 없이도 이러한 테스트를 언제, 어떻게 사용해야 하는지에 대한 명확한 지침을 제공합니다.

또한 새 코드의 동작에 여러 컴포넌트가 함께 동작해야 한다면, 컴포넌트 트리의 더 높은 곳에서 동작을 테스트하는 것을 고려해야 합니다. 예를 들어, 다음 코드를 가진 ParentComponent라는 컴포넌트를 생각해보세요:

  <script>
  export default{
    name: ParentComponent,
    data(){
      return {
        internalData: 'oldValue'
      }
    },
     methods:{
      changeSomeInternalData(newVal){
        this.internalData = newVal
      }
     }
  }
  </script>
  <template>
   <div>
    <child-component-1 @child-event="changeSomeInternalData" />
    <child-component-2 :parent-data="internalData" />
   </div>

  </template>

이 예시에서:

  • ChildComponent1이 이벤트를 emit합니다.

  • ParentComponentinternalData 값을 변경합니다.

  • ParentComponentChildComponent2에 props를 전달합니다.

대신 유닛 테스트를 사용하면 다음과 같이 할 수 있습니다:

  • ParentComponent 유닛 테스트 파일 내부에서 childComponent1의 예상 이벤트를 emit

  • prop이 childComponent2로 전달되는지 확인합니다.

그런 다음 각 자식 컴포넌트의 유닛 테스트에서 이벤트가 emit될 때와 prop이 변경될 때 어떤 일이 일어나는지 테스트합니다.

이 예시는 더 큰 규모와 더 깊은 컴포넌트 트리에도 동일하게 적용됩니다. 다음과 같은 경우에는 유닛 테스트를 사용하고 기능 테스트의 추가 비용을 피하는 것이 분명히 가치 있습니다:

  • 자식 컴포넌트를 자신 있게 마운트할 수 있는 경우.

  • 가상 DOM에서 이벤트를 emit하거나 요소를 선택할 수 있는 경우.

  • 원하는 테스트 동작을 얻을 수 있는 경우.

테스트를 만들 위치#

기능 테스트는 spec/features 폴더에 위치합니다. 기능을 추가하려는 페이지를 테스트할 수 있는 기존 파일을 찾아보세요. 해당 폴더 내에서 섹션을 찾을 수 있습니다. 예를 들어, 파이프라인 페이지에 새로운 기능 테스트를 추가하려면 spec/features/projects/pipelines에서 작성하려는 테스트가 이미 존재하는지 확인합니다.

기능 테스트 실행 방법#

작동 중인 GDK 환경이 있는지 확인합니다.

gdk start 명령으로 gdk 환경을 시작합니다.

터미널에서 다음을 실행합니다:

 bundle exec rspec path/to/file:line_of_my_test

이 명령어 앞에 WEBDRIVER_HEADLESS=0을 붙이면 컴퓨터에서 실제 브라우저를 열어 테스트를 실행하므로 디버깅에 매우 유용합니다.

Chrome 대신 Firefox를 사용하려면 명령어 앞에 WEBDRIVER=firefox를 붙입니다.

테스트 작성 방법#

기본 파일 구조#

모든 문자열 리터럴을 변경 불가능하게 만들기

모든 기능 테스트에서 첫 번째 줄은 다음이어야 합니다:

# frozen_string_literal: true

이는 모든 Ruby 파일에 포함되며, 모든 문자열 리터럴을 변경 불가능하게 만듭니다. 성능상의 이점도 있지만 이 섹션의 범위를 벗어납니다.

의존성 가져오기.

필요한 모듈을 가져와야 합니다. 대부분의 경우 항상 spec_helper를 require해야 합니다:

require 'spec_helper'

다른 관련 모듈도 가져옵니다.

RSpec이 테스트를 정의할 전역 스코프를 만들기. jest에서 초기 describe 블록으로 하는 것과 동일합니다.

그런 다음 첫 번째 RSpec 스코프를 만들어야 합니다.

RSpec.describe 'Pipeline', :js do
  ...
end

Ruby의 모든 것과 마찬가지로, 이것은 실제로 class입니다. 즉, 맨 위에서 테스트에 필요한 모듈을 include할 수 있습니다. 예를 들어, 더 쉽게 이동하기 위해 RoutesHelpers를 include할 수 있습니다.

RSpec.describe 'Pipeline', :js do
  include RoutesHelpers
  ...
end

이 모든 구현 후에는 다음과 같은 파일이 완성됩니다:

# frozen_string_literal: true

require 'spec_helper'

RSpec.describe 'Pipeline', :js do
  include RoutesHelpers
end

데이터 시딩#

각 테스트는 자체 환경에서 실행되므로 필요한 데이터를 시딩하려면 팩토리를 사용해야 합니다. 예를 들어, /namespace/project/-/pipelines/:id/ 경로의 메인 파이프라인 페이지로 이동하는 테스트를 만들려면 다음과 같이 합니다.

대부분의 기능 테스트는 적어도 사용자를 생성해야 합니다. 로그인 상태를 원하기 때문입니다. 로그인하지 않아도 되는 경우 이 단계를 건너뛸 수 있지만, 일반적인 규칙으로 익명 사용자가 보는 기능을 구체적으로 테스트하는 경우가 아니라면 항상 사용자를 생성해야 합니다. 이렇게 하면 섹션이 변경될 때 테스트에서 필요에 따라 편집하거나 새로운 권한 수준을 테스트할 수 있는 권한 수준을 명시적으로 설정할 수 있습니다. 사용자를 생성하려면:

  let(:user) { create(:user) }

이렇게 하면 새로 생성된 사용자를 담는 변수가 만들어지며, spec_helper를 임포트했기 때문에 create를 사용할 수 있습니다.

그러나 이 사용자는 아직 변수일 뿐이므로 아무 작업도 하지 않은 상태입니다. 따라서 spec의 before do 블록에서 해당 사용자로 로그인하면, 모든 spec이 인증된 사용자로 시작됩니다.

  let(:user) { create(:user) }

  before do
    sign_in(user)
  end

이제 사용자가 생겼으므로, 파이프라인 페이지에서 무언가를 검증하기 전에 무엇이 더 필요한지 살펴봐야 합니다. 라우트 /namespace/project/-/pipelines/:id/를 보면 프로젝트와 파이프라인이 필요하다는 것을 알 수 있습니다.

따라서 프로젝트와 파이프라인을 생성하고 서로 연결해야 합니다. 일반적으로 팩토리에서는 자식 요소가 부모를 인수로 필요로 합니다. 이 경우 파이프라인은 프로젝트의 자식입니다. 그래서 먼저 프로젝트를 생성하고, 파이프라인을 생성할 때 프로젝트를 인수로 전달하면 파이프라인이 해당 프로젝트에 "바인딩"됩니다. 파이프라인은 사용자에 의해 소유되기도 하므로 사용자도 필요합니다. 예를 들어 다음 코드는 프로젝트와 파이프라인을 생성합니다:

  let(:user) { create(:user) }
  let(:project) { create(:project, :repository) }
  let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id, user: user) }

같은 방식으로, build 팩토리를 사용하고 부모 파이프라인을 전달하여 job(빌드)을 생성할 수 있습니다:

  create(:ci_build, pipeline: pipeline, stage_idx: 10, stage: 'publish', name: 'CentOS')

이미 존재하는 팩토리가 많으므로, 필요한 것이 이미 있는지 확인하기 위해 기존 파일들을 살펴보세요.

네비게이션#

visit 메서드에 경로를 인수로 전달하면 해당 페이지로 이동할 수 있습니다. Rails는 헬퍼 경로를 자동으로 생성하므로, 하드코딩된 문자열 대신 이를 사용하세요. 라우트 모델을 사용하여 생성되므로, 파이프라인으로 이동하려면 다음과 같이 사용합니다:

  visit project_pipeline_path(project, pipeline)

요소 상호작용#

요소를 찾고 상호작용하는 방법은 다양합니다. 모범 사례는 UI 테스트 섹션을 참조하세요.

버튼을 클릭하려면 버튼 안의 텍스트 문자열과 함께 click_button을 사용하세요:

  click_button 'Text inside the button element'

링크를 따라가려면 click_link를 사용하세요:

  click_link 'Text inside the link tag'

입력/폼 요소를 채우려면 fill_in을 사용할 수 있습니다. 첫 번째 인수는 선택자이고, 두 번째는 전달할 값인 with:입니다.

  fill_in 'current_password', with: '123devops'

또는 find 선택자와 send_keys를 함께 사용하여 이전 텍스트를 지우지 않고 필드에 키를 추가하거나, 입력 요소의 값을 완전히 대체하는 set을 사용할 수 있습니다.

더 포괄적인 액션 목록은 기능 테스트 액션 문서에서 확인할 수 있습니다.

검증(Assertions)#

페이지에서 무언가를 검증하려면 언제든지 page 변수에 접근할 수 있습니다. 이 변수는 자동으로 정의되며 실제로는 페이지 문서를 의미합니다. 즉, page가 선택자나 콘텐츠 같은 특정 구성 요소를 가지고 있는지 검증할 수 있습니다. 다음은 몇 가지 예시입니다:

  # Finding a button
  expect(page).to have_button('Submit review')
  # Finding by text
  expect(page).to have_text('build')
  # Finding by `href` value
  expect(page).to have_link(pipeline.ref)
  # Find by data-testid
  # Like CSS selector, this is acceptable when there isn't a specific matcher available.
  expect(page).to have_css('[data-testid="pipeline-multi-actions-dropdown"]')
  # Finding by CSS selector. This is a last resort.
  # For example, when you cannot add attributes on the desired element.
  expect(page).to have_css('.js-icon-retry')
  # When a test case has back to back expectations,
  # it is recommended to group them using `:aggregate_failures`
  it 'shows the issue description and design references', :aggregate_failures do
    expect(page).to have_text('The designs I mentioned')
    expect(page).to have_link(design_tab_ref)
    expect(page).to have_link(design_ref_a)
    expect(page).to have_link(design_ref_b)
  end

하위 블록을 생성하여 다음을 수행할 수도 있습니다:

  • 어서션을 수행하는 범위를 좁혀 의도하지 않은 다른 요소를 찾을 위험을 줄입니다.

  • 요소가 올바른 경계 내에서 발견되는지 확인합니다.

  page.within('[data-testid="pipeline-multi-actions-dropdown"]') do
    ...
  end

더 포괄적인 매처 목록은 피처 테스트 매처 문서에서 확인할 수 있습니다.

백엔드 속성에 대한 어서션을 수행하기 전에, 먼저 가시적인 요소에 대해 어서션을 수행하여 작업이 완료되었는지 확인하세요. wait_for_requests는 요청이 발생하기 전에 대기가 호출될 경우 경쟁 조건이 발생할 수 있으므로 사용을 피하세요.

  click_button 'Leave project'

  # This ensures that the request to leave the project has completed
  expect(page).to have_text 'You left the project.'

  expect(project.reload.users.exists?(user.id)).to be(false)

Feature flags#

기본적으로 모든 피처 플래그는 YAML 정의나 GDK에서 수동으로 설정한 플래그에 관계없이 활성화됩니다. 피처 플래그가 비활성화되었을 때 테스트하려면, 이상적으로는 before do 블록에서 플래그를 수동으로 스텁 처리해야 합니다.

  stub_feature_flags(my_feature_flag: false)

ee 피처 플래그를 스텁 처리하는 경우 다음을 사용하세요:

  stub_licensed_features(my_feature_flag: false)

브라우저 콘솔 오류 어서션#

기본적으로 피처 스펙은 브라우저 콘솔 오류가 발견되더라도 실패하지 않습니다. 통합 문제를 나타낼 수 있는 예기치 않은 콘솔 오류가 없는지 확인하고 싶을 때가 있습니다.

브라우저 콘솔 오류가 발생하면 피처 스펙이 실패하도록 설정하려면, BrowserConsoleHelpers 지원 모듈의 expect_page_to_have_no_console_errors를 사용하세요:

RSpec.describe 'Pipeline', :js do
  after do
    expect_page_to_have_no_console_errors
  end

  # ...
end
`expect_page_to_have_no_console_errors`는 `WEBDRIVER=firefox`에서는 동작하지 않습니다. 로그는 Chrome 드라이버를 사용할 때만 캡처됩니다.

알려진 콘솔 오류 중 무시하고 싶은 것이 있을 수 있습니다. 특정 메시지 집합을 무시하여 해당 메시지가 관찰되더라도 테스트가 실패하지 않도록 하려면, expect_page_to_have_no_console_errorsallow: 파라미터를 전달하세요:

RSpec.describe 'Pipeline', :js do
  after do
    expect_page_to_have_no_console_errors(allow: [
      "Blow up!",
      /Foo.*happens/
    ])
  end

  # ...
end

spec/support/helpers/browser_console_helpers.rbBROWSER_CONSOLE_ERROR_FILTER 상수를 업데이트하여 전역적으로 무시해야 할 콘솔 오류 목록을 변경할 수 있습니다.

디버깅#

WEBDRIVER_HEADLESS=0 접두사를 사용하여 스펙을 실행하면 실제 브라우저를 열 수 있습니다. 그러나 스펙이 명령을 빠르게 처리하기 때문에 둘러볼 시간이 없습니다.

이 문제를 해결하려면 Capybara가 실행을 멈추길 원하는 줄에 binding.pry를 작성하면 됩니다. 그러면 표준 사용법으로 브라우저 내부에 진입하게 됩니다. 특정 요소를 찾을 수 없는 이유를 파악하려면:

  • 요소를 선택합니다.

  • 콘솔 및 네트워크 탭을 사용합니다.

  • 브라우저 콘솔 내에서 셀렉터를 실행합니다.

Capybara가 실행 중인 터미널에서 next를 실행하면 테스트를 한 줄씩 진행할 수 있습니다. 이 방법으로 모든 단일 상호작용을 하나씩 확인하여 문제의 원인을 파악할 수 있습니다.

GDK에서 실행 시간 개선#

Jest 테스트 스위트를 실행할 때 워커 수는 머신의 사용 가능한 코어의 60%를 사용하도록 설정됩니다. 이는 실행 시간을 단축시키지만 메모리 소비는 증가합니다. 이에 대한 더 많은 벤치마크는 이슈 456885를 참조하세요.

ChromeDriver 업데이트#

Selenium 4.6부터 ChromeDriver는 selenium-webdriver gem에 포함된 Selenium Manager에 의해 자동으로 관리될 수 있습니다. 더 이상 chromedriver를 수동으로 동기화할 필요가 없습니다.


테스트 문서로 돌아가기

프론트엔드 테스트 표준 및 스타일 가이드라인

GitLab v19.1
원문 보기
요약

JavaScript 유닛 및 통합 테스트에는 Jest를 사용하고, e2e(엔드투엔드) 통합 테스트에는 Capybara 기능 테스트를 사용합니다. 모든 새로운 기능에는 유닛 테스트와 기능 테스트가 작성되어야 합니다. 대부분의 경우 기능 테스트에는 RSpec을 사용해야 합니다.


프론트엔드 테스트 표준 및 스타일 가이드라인#

  GitLab에서 프론트엔드 코드를 개발할 때 두 가지 유형의 테스트 스위트를 사용합니다.

JavaScript 유닛 및 통합 테스트에는 Jest를 사용하고, e2e(엔드투엔드) 통합 테스트에는 Capybara 기능 테스트를 사용합니다.

모든 새로운 기능에는 유닛 테스트와 기능 테스트가 작성되어야 합니다.

대부분의 경우 기능 테스트에는 RSpec을 사용해야 합니다. 기능 테스트 시작 방법에 대한 자세한 내용은 기능 테스트 시작하기를 참고하세요.

회귀 테스트는 버그가 향후 재발하지 않도록 버그 수정 시 작성해야 합니다.

GitLab의 일반적인 테스트 관행에 대한 자세한 내용은 테스트 표준 및 스타일 가이드라인 페이지를 참고하세요.

Vue.js 테스트#

Vue 컴포넌트 테스트 가이드를 찾고 있다면 바로 이 섹션으로 이동할 수 있습니다.

Vue 3 테스트에 관한 정보는 이 페이지에 있습니다.

Jest#

프론트엔드 유닛 및 통합 테스트 작성에 Jest를 사용합니다. Jest 테스트는 EE의 /spec/frontend/ee/spec/frontend에서 찾을 수 있습니다.

jsdom#

Jest는 테스트 실행 시 브라우저 대신 jsdom을 사용합니다. 알려진 이슈는 다음과 같습니다:

브라우저에서 Jest 테스트 실행 지원에 대한 이슈도 참고하세요.

Jest 테스트 디버깅#

yarn jest-debug를 실행하면 Jest가 디버그 모드로 실행되어, Jest 문서에 설명된 대로 디버그/검사할 수 있습니다.

타임아웃 오류#

Jest의 기본 타임아웃은 /jest.config.base.js에 설정되어 있습니다.

테스트가 해당 시간을 초과하면 실패합니다.

테스트 성능을 개선할 수 없는 경우, jest.setTimeout을 사용하여 전체 스위트의 타임아웃을 늘릴 수 있습니다.

jest.setTimeout(500);

describe('Component', () => {
  it('does something amazing', () => {
    // ...
  });
});

또는 it의 세 번째 인수를 제공하여 특정 테스트에 대해 설정할 수 있습니다.

describe('Component', () => {
  it('does something amazing', () => {
    // ...
  }, 500)
})

각 테스트의 성능은 환경에 따라 달라진다는 점을 기억하세요.

테스트 전용 스타일시트#

RSpec 통합 테스트를 용이하게 하기 위해 두 가지 테스트 전용 스타일시트가 있습니다. 이를 사용하여 테스트 속도를 개선하기 위해 애니메이션을 비활성화하거나, Capybara 클릭 이벤트의 타깃이 되어야 하는 요소를 표시할 수 있습니다:

  • app/assets/stylesheets/disable_animations.scss

  • app/assets/stylesheets/test_environment.scss

테스트 환경은 가능한 한 프로덕션 환경과 일치해야 하므로, 이를 최소한으로 사용하고 필요한 경우에만 추가하세요.

무엇을, 어떻게 테스트할 것인가#

Jest에서 mock과 spy 같은 세부적인 워크플로를 다루기 전에, Jest로 무엇을 테스트해야 하는지에 대해 간략히 살펴보겠습니다.

라이브러리를 테스트하지 마세요#

라이브러리는 JavaScript 개발자 생활에서 빠질 수 없는 요소입니다. 일반적인 조언은 라이브러리 내부를 테스트하지 말고, 라이브러리가 제 역할을 알고 자체적인 테스트 커버리지를 가지고 있다고 기대하는 것입니다. 일반적인 예시는 다음과 같습니다.

import { convertToFahrenheit } from 'temperatureLibrary'

function getFahrenheit(celsius) {
  return convertToFahrenheit(celsius)
}

getFahrenheit 함수를 테스트하는 것은 의미가 없습니다. 이 함수는 라이브러리 함수를 호출하는 것 이외에 아무것도 하지 않으며, 해당 라이브러리가 의도대로 동작한다고 기대할 수 있기 때문입니다.

Vue에 대해 잠깐 살펴보겠습니다. Vue는 GitLab JavaScript 코드베이스의 핵심 요소입니다. Vue 컴포넌트 spec을 작성할 때 흔히 빠지는 함정은, Vue가 제공하는 기능 자체를 테스트하게 되는 것입니다. 이는 가장 테스트하기 쉬운 것처럼 보이기 때문입니다. 다음은 코드베이스에서 가져온 예시입니다.

// Component script
{
  computed: {
    hasMetricTypes() {
      return this.metricTypes.length;
    },
}
<!-- Component template -->
<template>
  <gl-dropdown v-if="hasMetricTypes">
    <!-- Dropdown content -->
  </gl-dropdown>
</template>

hasMetricTypes computed 속성을 테스트하는 것은 당연해 보일 수 있습니다. 하지만 computed 속성이 metricTypes의 길이를 반환하는지 테스트하는 것은 Vue 라이브러리 자체를 테스트하는 것입니다. 이는 테스트 스위트만 늘릴 뿐 아무런 가치가 없습니다. 사용자가 컴포넌트와 상호작용하는 방식, 즉 렌더링된 템플릿을 확인하는 방식으로 테스트하는 것이 더 좋습니다.

// Bad
describe('computed', () => {
  describe('hasMetricTypes', () => {
    it('returns true if metricTypes exist', () => {
      factory({ metricTypes });
      expect(wrapper.vm.hasMetricTypes).toBe(2);
    });

    it('returns true if no metricTypes exist', () => {
      factory();
      expect(wrapper.vm.hasMetricTypes).toBe(0);
    });
  });
});

// Good
it('displays a dropdown if metricTypes exist', () => {
  factory({ metricTypes });
  expect(wrapper.findComponent(GlDropdown).exists()).toBe(true);
});

it('does not display a dropdown if no metricTypes exist', () => {
  factory();
  expect(wrapper.findComponent(GlDropdown).exists()).toBe(false);
});

이런 종류의 테스트는 로직 업데이트를 불필요하게 취약하고 번거롭게 만드므로 주의해야 합니다. 이는 다른 라이브러리에도 마찬가지로 적용됩니다. 제안 드리자면: wrapper.vm 속성을 확인하고 있다면, 멈추고 렌더링된 템플릿을 확인하도록 테스트를 다시 생각해보세요.

더 많은 예시는 프론트엔드 유닛 테스트 섹션에서 확인할 수 있습니다.

Mock을 테스트하지 마세요#

또 다른 흔한 함정은 spec이 mock이 제대로 동작하는지 검증하게 되는 것입니다. mock을 사용하는 경우, mock은 테스트를 지원하는 역할을 해야지 테스트의 대상이 되어서는 안 됩니다.

const spy = jest.spyOn(idGenerator, 'create')
spy.mockImplementation = () = '1234'

// Bad
expect(idGenerator.create()).toBe('1234')

// Good: actually focusing on the logic of your component and just leverage the controllable mocks output
expect(wrapper.find('div').html()).toBe('<div id="1234">...</div>')

assertion에서 import된 값을 사용하지 마세요#

assertion에서 상수를 import하는 것보다 리터럴 값을 사용하는 것을 권장합니다. 이렇게 하면 테스트를 읽기 쉽고 변경에 유연하게 만들 수 있습니다. 이에 대한 자세한 내용은 국제화 권고사항에서 다룹니다.

// Bad: MY_CONSTANT could accidentally be set to undefined, have a typo etc. and test would still pass
import { MY_CONSTANT } from '../constants';

it('returns the correct value', () => {
  expect(ding()).toBe(MY_CONSTANT);
});

// Good: explicit value is asserted
it('returns the correct value', () => {
  expect(ding()).toBe('expected literal value');
});

사용자를 따르세요#

컴포넌트 중심의 세계에서 유닛 테스트와 통합 테스트 사이의 경계는 꽤 모호할 수 있습니다. 가장 중요한 지침은 다음과 같습니다:

  • 미래에 복잡한 로직이 깨지는 것을 방지하기 위해 격리된 환경에서 테스트하는 것에 실질적인 가치가 있다면 깔끔한 유닛 테스트를 작성하세요.

  • 그렇지 않다면, 사용자의 흐름에 최대한 가깝게 spec을 작성하세요.

예를 들어, 메서드를 수동으로 호출하고 데이터 구조나 계산된 속성을 확인하는 것보다, 생성된 마크업을 사용하여 버튼 클릭을 트리거하고 마크업이 그에 따라 변경되었는지 검증하는 방법이 더 낫습니다. 테스트가 통과되어 거짓된 안정감을 주는 동안 실수로 사용자 플로를 망가뜨릴 가능성은 항상 존재합니다.

공통 관행#

다음은 테스트 스위트의 일부로 포함된 일반적인 공통 관행입니다. 이 가이드를 따르지 않는 코드를 발견하면, 가능하면 즉시 수정하세요. 🎉

DOM 요소 쿼리 방법#

테스트에서 DOM 요소를 쿼리할 때는 요소를 고유하고 의미 있는 방식으로 타깃하는 것이 가장 좋습니다.

가장 권장되는 방법은 DOM Testing Library를 사용하여 사용자가 실제로 보는 것을 기준으로 타깃하는 것입니다. 텍스트로 선택할 때는 접근성 모범 사례를 강화하는 데 도움이 되는 byRole 쿼리를 사용하는 것이 가장 좋습니다. findByRole 및 기타 DOM Testing Library 쿼리shallowMountExtended 또는 mountExtended를 사용할 때 사용할 수 있습니다.

Vue 컴포넌트 단위 테스트를 작성할 때는 자식 컴포넌트의 동작 복잡성을 처리하는 대신 포괄적인 값 커버리지에 집중할 수 있도록 컴포넌트별로 자식을 쿼리하는 것이 현명할 수 있습니다.

위의 방법 중 어느 것도 실행 가능하지 않은 경우가 있습니다. 이런 경우에는 셀렉터를 단순화하기 위해 테스트 속성을 추가하는 것이 최선의 방법일 수 있습니다. 가능한 셀렉터 목록은 다음과 같습니다:

import { shallowMountExtended } from 'helpers/vue_test_utils_helper'

const wrapper = shallowMountExtended(ExampleComponent);

it('exists', () => {
  // Best (especially for integration tests)
  wrapper.findByRole('link', { name: /Click Me/i })
  wrapper.findByRole('link', { name: 'Click Me' })
  wrapper.findByText('Click Me')
  wrapper.findByText(/Click Me/i)

  // Good (especially for unit tests)
  wrapper.findComponent(FooComponent);
  wrapper.find('input[name=foo]');
  wrapper.find('[data-testid="my-foo-id"]');
  wrapper.findByTestId('my-foo-id'); // with shallowMountExtended or mountExtended, check below

  // Bad
  wrapper.find({ ref: 'foo'});
  wrapper.find('.js-foo');
  wrapper.find('.gl-button');
});

data-testid 속성에는 kebab-case를 사용해야 합니다.

테스트 목적만을 위해 .js-* 클래스를 추가하는 것은 권장하지 않습니다. 다른 실행 가능한 옵션이 없는 경우에만 이 방법을 사용하세요. 테스트에서 DOM 요소를 쿼리하기 위해 Vue 템플릿 ref를 사용하는 것은 피하세요. 이는 공개 API가 아닌 컴포넌트의 구현 세부 사항이기 때문입니다.

자식 컴포넌트 쿼리#

@vue/test-utils를 사용하여 Vue 컴포넌트를 테스트할 때 DOM 노드를 쿼리하는 대신 자식 컴포넌트를 쿼리하는 방법도 가능합니다. 이 방법은 테스트 대상 동작의 구현 세부 사항이 해당 컴포넌트의 개별 단위 테스트에서 다루어져야 한다는 가정을 전제로 합니다. 테스트가 테스트 대상 컴포넌트의 예상 동작을 신뢰할 수 있게 커버하는 한, DOM 쿼리와 컴포넌트 쿼리 중 어느 쪽을 선택하든 강한 선호는 없습니다.

예시:

it('exists', () => {
  wrapper.findComponent(FooComponent);
});

단위/컴포넌트 테스트 이름 지정#

단위/컴포넌트 테스트는 ${componentName}_spec.js로 이름을 지정해야 합니다.

테스트 이름이 충분히 구체적이지 않은 경우 컴포넌트 이름 변경을 고려하세요.

예시:

diff_stats_dropdown.vuediff_stats_dropdown_spec.js라는 이름의 단위/컴포넌트 테스트를 가져야 합니다.

Describe 블록 이름 지정#

특정 함수/메서드를 테스트하기 위해 describe 테스트 블록을 작성할 때는 메서드 이름을 describe 블록 이름으로 사용하세요.

잘못된 예시:

describe('#methodName', () => {
  it('passes', () => {
    expect(true).toEqual(true);
  });
});

describe('.methodName', () => {
  it('passes', () => {
    expect(true).toEqual(true);
  });
});

올바른 예시:

describe('methodName', () => {
  it('passes', () => {
    expect(true).toEqual(true);
  });
});

Promise 테스트#

Promise를 테스트할 때는 항상 테스트가 비동기적으로 처리되고 거부(rejection)가 처리되는지 확인해야 합니다. 이제 테스트 스위트에서 async/await 문법을 사용할 수 있습니다:

it('tests a promise', async () => {
  const users = await fetchUsers()
  expect(users.length).toBe(42)
});

it('tests a promise rejection', async () => {
  await expect(user.getUserName(1)).rejects.toThrow('User with 1 not found.');
});

테스트 함수에서 promise를 반환할 수도 있습니다.

promise를 다룰 때 donedone.fail 콜백을 사용하는 것은 권장하지 않습니다. 사용하지 않아야 합니다.

나쁜 예:

// missing return
it('tests a promise', () => {
  promise.then(data => {
    expect(data).toBe(asExpected);
  });
});

// uses done/done.fail
it('tests a promise', done => {
  promise
    .then(data => {
      expect(data).toBe(asExpected);
    })
    .then(done)
    .catch(done.fail);
});

좋은 예:

// verifying a resolved promise
it('tests a promise', () => {
  return promise
    .then(data => {
      expect(data).toBe(asExpected);
    });
});

// verifying a resolved promise using Jest's `resolves` matcher
it('tests a promise', () => {
  return expect(promise).resolves.toBe(asExpected);
});

// verifying a rejected promise using Jest's `rejects` matcher
it('tests a promise rejection', () => {
  return expect(promise).rejects.toThrow(expectedError);
});

시간 조작#

때로는 시간에 민감한 코드를 테스트해야 할 때가 있습니다. 예를 들어, X초마다 실행되는 반복 이벤트 등이 있습니다. 이를 처리하기 위한 몇 가지 전략을 소개합니다:

애플리케이션에서 setTimeout() / setInterval() 사용#

애플리케이션 자체가 일정 시간을 기다리는 경우, 대기를 mock await으로 처리합니다. Jest에서는 이미 기본적으로 처리됩니다 (Jest Timer Mocks도 참조하세요).

const doSomethingLater = () => {
  setTimeout(() => {
    // do something
  }, 4000);
};

Jest에서:

it('does something', () => {
  doSomethingLater();
  jest.runAllTimers();

  expect(something).toBe('done');
});

Jest에서 현재 위치 모킹#

`window.location.href`의 값은 이전 테스트가 이후 테스트에 영향을 미치지 않도록

테스트 실행 전에 초기화됩니다.

테스트에서 window.location.href가 특정 값을 가져야 하는 경우, setWindowLocation 헬퍼를 사용하세요:

import setWindowLocation from 'helpers/set_window_location_helper';

it('passes', () => {
  setWindowLocation('https://gitlab.test/foo?bar=true');

  expect(window.location).toMatchObject({
    hostname: 'gitlab.test',
    pathname: '/foo',
    search: '?bar=true',
  });
});

해시만 수정하려면 setWindowLocation 헬퍼를 사용하거나, 예를 들어 window.location.hash에 직접 값을 할당하면 됩니다:

it('passes', () => {
  window.location.hash = '#foo';

  expect(window.location.href).toBe('http://test.host/#foo');
});

특정 window.location 메서드가 호출되었는지 테스트에서 검증해야 한다면, useMockLocationHelper 헬퍼를 사용하세요:

import { useMockLocationHelper } from 'helpers/mock_window_location_helper';

useMockLocationHelper();

it('passes', () => {
  window.location.reload();

  expect(window.location.reload).toHaveBeenCalled();
});

이벤트 리스너 및 타임아웃 정리 테스트#

컴포넌트에서는 종종 beforeDestroy (Vue 3의 경우 beforeUnmount) 훅에서 이벤트 리스너나 타임아웃을 생성합니다. 컴포넌트 인스턴스가 소멸될 때 리스너와 타임아웃이 모두 정리되는지 테스트하는 것이 중요합니다. 이러한 이벤트를 정리하지 않으면 메모리 누수나 이벤트 리스너의 깨진 참조 같은 문제가 발생할 수 있습니다.

다음 예시를 살펴보세요:

beforeDestroy() {
  removeEventListener('keydown', someListener)
  clearTimeout(timeoutPointer)
}

위 예시에서 컴포넌트는 keydown 이벤트 리스너와 다른 곳에서 생성된 타임아웃을 모두 정리하고 있습니다.

관련 테스트를 살펴보겠습니다.

describe('Cleanup before destroy', () => {
  beforeEach(() => {
    createComponent()

    // Destroy the component immediately to invoke the `beforeDestroy` hook
    wrapper.destroy()
  })

  it('removes the event listener', () => {
    const spy = jest.spyOn(window, 'removeEventListener')
    expect(spy).toHaveBeenCalledTimes(1)
    expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function))
  })

  it('clears the pending timeouts', () => {
    const spy = jest.spyOn(window, 'clearTimeout')
    expect(spy).toHaveBeenCalledTimes(1)
  })
})

위 예시는 keydown 리스너에서 호출되는 함수를 명시적으로 확인하지 않습니다. 이는 보통 구현 세부 사항이기 때문입니다. clearTimeout 호출도 마찬가지로, 매개변수가 컴포넌트 내부에서 생성된 타이머에 대한 포인터이기 때문입니다.

따라서 일반적으로는 스파이가 호출되었는지 확인하는 것으로 충분하며, 호출된 횟수도 함께 확인하는 것을 권장합니다.

테스트에서 대기하기#

때로는 테스트가 계속 진행하기 전에 애플리케이션에서 무언가가 발생하기를 기다려야 할 때가 있습니다.

다음 방법은 피하는 것이 좋습니다:

  • setTimeout: 대기 이유가 불명확해집니다. 또한 테스트에서 가짜(fake)로 처리되기 때문에 사용이 까다롭습니다.

  • setImmediate: Jest 27 이상에서는 더 이상 지원되지 않습니다. 자세한 내용은 이 에픽을 참고하세요.

Promise 및 Ajax 호출#

Promise가 완료될 때까지 기다리려면 핸들러 함수를 등록하세요.

const askTheServer = () => {
  return axios
    .get('/endpoint')
    .then(response => {
      // do something
    })
    .catch(error => {
      // do something else
    });
};

Jest에서:

it('waits for an Ajax call', async () => {
  await askTheServer()
  expect(something).toBe('done');
});

예를 들어 동기 Vue 라이프사이클 훅에서 실행되어 Promise에 핸들러를 등록할 수 없는 경우, waitFor 헬퍼를 참고하거나 다음과 같이 보류 중인 모든 Promise를 플러시하세요:

Jest에서:

it('waits for an Ajax call', async () => {
  synchronousFunction();

  await waitForPromises();

  expect(something).toBe('done');
});

Vue 렌더링#

Vue 컴포넌트가 다시 렌더링될 때까지 기다리려면 nextTick()을 사용하세요.

Jest에서:

import { nextTick } from 'vue';

// ...

it('renders something', async () => {
  wrapper.setProps({ value: 'new value' });

  await nextTick();

  expect(wrapper.text()).toBe('new value');
});

이벤트#

애플리케이션이 테스트에서 기다려야 하는 이벤트를 트리거하는 경우, 어설션을 포함하는 이벤트 핸들러를 등록하세요:

it('waits for an event', () => {
  eventHub.$once('someEvent', eventHandler);

  someFunction();

  return new Promise((resolve) => {
    function expectEventHandler() {
      expect(something).toBe('done');
      resolve();
    }
  });
});

Jest에서는 이를 위해 Promise를 사용할 수도 있습니다:

it('waits for an event', () => {
  const eventTriggered = new Promise(resolve => eventHub.$once('someEvent', resolve));

  someFunction();

  return eventTriggered.then(() => {
    expect(something).toBe('done');
  });
});

gon 객체 조작#

gon(또는 window.gon)은 백엔드에서 데이터를 전달하는 데 사용되는 전역 객체입니다. 테스트가 해당 값에 의존하는 경우 직접 수정할 수 있습니다:

describe('when logged in', () => {
  beforeEach(() => {
    gon.current_user_id = 1;
  });

  it('shows message', () => {
    expect(wrapper.text()).toBe('Logged in!');
  });
})

gon은 테스트 격리를 보장하기 위해 매 테스트마다 초기화됩니다.

테스트 격리 보장#

테스트는 일반적으로 테스트 대상 컴포넌트를 반복적으로 설정해야 하는 패턴으로 구성됩니다. 이는 종종 beforeEach 훅을 사용하여 달성합니다.

예시

  let wrapper;

  beforeEach(() => {
    wrapper = mount(Component);
  });

enableAutoDestroy를 사용하면 더 이상 wrapper.destroy()를 수동으로 호출할 필요가 없습니다. 그러나 일부 목(mock), 스파이(spy), 픽스처(fixture)는 해제가 필요하며, 이를 위해 afterEach 훅을 활용할 수 있습니다.

예시

  let wrapper;

  afterEach(() => {
    fakeApollo = null;
    store = null;
  });

로컬 전용 Apollo 쿼리 및 뮤테이션 테스트#

백엔드에 추가되기 전에 새 쿼리나 뮤테이션을 추가하려면 @client 지시어를 사용할 수 있습니다. 예를 들면:

mutation setActiveBoardItemEE($boardItem: LocalBoardItem, $isIssue: Boolean = true) {
  setActiveBoardItem(boardItem: $boardItem) @client {
    ...Issue @include(if: $isIssue)
    ...EpicDetailed @skip(if: $isIssue)
  }
}

이러한 호출에 대한 테스트 케이스를 작성할 때, 리졸버를 사용하여 올바른 파라미터로 호출되는지 확인할 수 있습니다.

예를 들어, 래퍼를 생성할 때 리졸버가 쿼리 또는 뮤테이션에 매핑되어 있는지 확인해야 합니다. 여기서 모킹하는 뮤테이션은 setActiveBoardItem입니다:

const mockSetActiveBoardItemResolver = jest.fn();
const mockApollo = createMockApollo([], {
    Mutation: {
      setActiveBoardItem: mockSetActiveBoardItemResolver,
    },
});

다음 코드에서는 네 개의 인수를 전달해야 합니다. 두 번째 인수는 모킹된 쿼리 또는 뮤테이션의 입력 변수 집합이어야 합니다. 뮤테이션이 올바른 파라미터로 호출되는지 테스트하려면:

it('calls setActiveBoardItemMutation on close', async () => {
    wrapper.findComponent(GlDrawer).vm.$emit('close');

    await waitForPromises();

    expect(mockSetActiveBoardItemResolver).toHaveBeenCalledWith(
        {},
        {
            boardItem: null,
        },
        expect.anything(),
        expect.anything(),
    );
});

Jest 모범 사례#

기본값 비교 시 toEqual 대신 toBe 사용 권장#

Jest에는 toBetoEqual 매처가 있습니다. toBeObject.is를 사용하여 값을 비교하므로, 기본적으로 toEqual보다 빠릅니다. 후자는 결국 Object.is를 활용하는 방식으로 폴백되지만, 기본값에 대해서는 복잡한 객체 비교가 필요할 때만 사용해야 합니다.

예시:

const foo = 1;

// Bad
expect(foo).toEqual(1);

// Good
expect(foo).toBe(1);

더 적합한 매처 사용 권장#

Jest는 toHaveLengthtoBeUndefined 같은 유용한 매처를 제공하여 테스트를 더 읽기 쉽게 만들고 더 이해하기 쉬운 에러 메시지를 생성합니다. 전체 매처 목록은 공식 문서를 확인하세요.

예시:

const arr = [1, 2];

// prints:
// Expected length: 1
// Received length: 2
expect(arr).toHaveLength(1);

// prints:
// Expected: 1
// Received: 2
expect(arr.length).toBe(1);

// prints:
// expect(received).toBe(expected) // Object.is equality
// Expected: undefined
// Received: "bar"
const foo = 'bar';
expect(foo).toBe(undefined);

// prints:
// expect(received).toBeUndefined()
// Received: "bar"
const foo = 'bar';
expect(foo).toBeUndefined();

toBeTruthy 또는 toBeFalsy 사용 지양#

Jest는 toBeTruthytoBeFalsy 매처도 제공합니다. 이 매처들은 테스트를 약하게 만들고 거짓 양성(false-positive) 결과를 생성하므로 사용하지 않아야 합니다.

예를 들어, expect(someBoolean).toBeFalsy()someBoolean === null일 때도, someBoolean === false일 때도 통과합니다.

까다로운 toBeDefined 매처#

Jest의 toBeDefined 매처는 거짓 양성 테스트를 생성할 수 있는 까다로운 매처입니다. 이 매처는 undefined에 대해서만 주어진 값을 검증하기 때문입니다.

// Bad: if finder returns null, the test will pass
expect(wrapper.find('foo')).toBeDefined();

// Good
expect(wrapper.find('foo').exists()).toBe(true);

setImmediate 사용 피하기#

setImmediate 사용은 가능한 한 피하세요. setImmediate는 I/O가 완료된 후 콜백을 실행하기 위한 임시방편적인 해결책입니다. 또한 Web API의 일부가 아니기 때문에, 유닛 테스트에서 NodeJS 환경을 타깃으로 하게 됩니다.

setImmediate 대신 jest.runAllTimers 또는 jest.runOnlyPendingTimers를 사용해 대기 중인 타이머를 실행하세요. 후자는 코드에 setInterval이 있을 때 유용합니다. 참고: Jest 설정은 가짜 타이머(fake timers)를 사용합니다.

비결정적 스펙 피하기#

비결정성(Non-determinism)은 불안정하고 취약한 스펙의 온상입니다. 이러한 스펙은 CI 파이프라인을 중단시키고, 다른 기여자들의 워크플로를 방해하게 됩니다.

  • 테스트 대상의 협력 객체(예: Axios, Apollo, Lodash 헬퍼)와 테스트 환경(예: Date)이 시스템 전반에 걸쳐 그리고 시간이 지나도 일관되게 동작하는지 확인하세요.

  • 테스트가 집중적이며 "불필요한 작업"(예: 개별 테스트에서 불필요하게 테스트 대상을 두 번 이상 생성하는 것)을 하지 않는지 확인하세요.

결정론을 위한 Date 페이킹#

Date는 Jest 환경에서 기본적으로 페이킹(fake)됩니다. 이는 Date() 또는 Date.now() 호출이 항상 고정된 결정론적 값을 반환한다는 것을 의미합니다.

기본 가짜 날짜를 변경해야 하는 경우, 임의의 describe 블록 내에서 useFakeDate를 호출할 수 있으며, 해당 describe 컨텍스트 내의 스펙에 한해서만 날짜가 교체됩니다:

import { useFakeDate } from 'helpers/fake_date';

describe('cool/component', () => {
  // Default fake `Date`
  const TODAY = new Date();

  // NOTE: `useFakeDate` cannot be called during test execution (that is, inside `it`, `beforeEach`, `beforeAll`, etc.).
  describe("on Ada Lovelace's Birthday", () => {
    useFakeDate(1815, 11, 10)

    it('Date is no longer default', () => {
      expect(new Date()).not.toEqual(TODAY);
    });
  });

  it('Date is still default in this scope', () => {
    expect(new Date()).toEqual(TODAY)
  });
})

마찬가지로, 실제 Date 클래스를 사용해야 하는 경우 임의의 describe 블록 내에서 useRealDate를 import하여 호출할 수 있습니다:

import { useRealDate } from 'helpers/fake_date';

// NOTE: `useRealDate` cannot be called during test execution (that is, inside `it`, `beforeEach`, `beforeAll`, etc.).
describe('with real date', () => {
  useRealDate();
});

결정론을 위한 Math.random 페이킹#

테스트 대상이 Math.random에 의존하는 경우, 가짜(fake)로 교체하는 것을 고려하세요.

beforeEach(() => {
  // https://xkcd.com/221/
  jest.spyOn(Math, 'random').mockReturnValue(0.4);
});

테스트에서의 콘솔 경고 및 오류#

예상치 못한 콘솔 경고와 오류는 프로덕션 코드의 문제를 나타냅니다. 테스트 환경을 엄격하게 유지하고자 하므로, 예상치 못한 console.error 또는 console.warn 호출이 발생하면 테스트가 실패해야 합니다.

watcher에서 콘솔 메시지 무시하기#

제어할 수 없는 코드가 많기 때문에, 기본적으로 무시되어 사용 시 테스트를 실패시키지 않는 일부 콘솔 메시지가 있습니다. 이 무시 목록은 setupConsoleWatcher를 호출하는 곳에서 관리할 수 있습니다. 예시:

setupConsoleWatcher({
  ignores: [
    ...,
    // Any call to `console.error('Foo bar')` or `console.warn('Foo bar')` will be ignored by our console watcher.
    'Foo bar',
    // Use regex to allow for flexible message matching.
    /Lorem ipsum/,
  ]
});

특정 테스트에서 describe 블록의 특정 메시지를 무시해야 하는 경우, describe 상단 근처에서 ignoreConsoleMessages 헬퍼를 사용하세요. 이 헬퍼는 자동으로 beforeAllafterAll을 호출하여 해당 테스트 컨텍스트에 대한 무시 목록을 설정하고 해제합니다.

이 기능은 테스트 유지보수에 절대적으로 필요한 경우에만 드물게 사용하세요. 예시:

import { ignoreConsoleMessages } from 'helpers/console_watcher';

describe('foos/components/foo.vue', () => {
  describe('when blooped', () => {
    // Will not fail a test if `console.warn('Lorem ipsum')` is called
    ignoreConsoleMessages([
      /^Lorem ipsum/
    ]);
  });

  describe('default', () => {
    // Will fail a test if `console.warn('Lorem ipsum')` is called
  });
});

팩토리#

TBU

Jest를 활용한 모킹 전략#

스텁과 모킹#

스텁(stub)과 스파이(spy)는 흔히 같은 의미로 사용됩니다. Jest에서는 .spyOn 메서드 덕분에 이 작업이 매우 쉽습니다. 공식 문서 더 까다로운 부분은 모킹(mock)으로, 함수뿐만 아니라 의존성 전체에도 사용할 수 있습니다.

수동 모듈 모킹#

수동 모킹(manual mock)은 전체 Jest 환경에 걸쳐 모듈을 모킹하는 데 사용됩니다. 이는 테스트 환경에서 쉽게 사용할 수 없는 모듈을 모킹하여 단위 테스트를 단순화하는 데 도움이 되는 매우 강력한 테스트 도구입니다.

모킹이 모든 spec에 일관되게 적용될 필요가 없는 경우(즉, 일부 spec에서만 필요한 경우)에는 수동 모킹을 사용하지 마세요.

대신, 관련 spec 파일에서 jest.mock(..) (또는 유사한 모킹 함수)를 사용하는 것을 고려하세요.

수동 모킹은 어디에 두어야 하나요?#

Jest는 소스 모듈 옆에 있는 __mocks__/ 디렉터리에 모킹을 배치함으로써 수동 모듈 모킹을 지원합니다 (예: app/assets/javascripts/ide/__mocks__). 이렇게 하지 마세요. 테스트 관련 코드는 모두 한 곳(spec/ 폴더)에 유지하는 것이 좋습니다.

node_modules 패키지에 대한 수동 모킹이 필요한 경우 spec/frontend/__mocks__ 폴더를 사용하세요. 다음은 monaco-editor 패키지에 대한 Jest 모킹 예시입니다.

CE 모듈에 대한 수동 모킹이 필요한 경우 구현을 spec/frontend/__helpers__/mocks에 두고, frontend/test_setup (또는 frontend/shared_test_setup)에 다음과 같은 코드를 한 줄 추가하세요:

// "~/lib/utils/axios_utils" is the path to the real module
// "helpers/mocks/axios_utils" is the path to the mocked implementation
jest.mock('~/lib/utils/axios_utils', () => jest.requireActual('helpers/mocks/axios_utils'));

수동 모킹 예시#

  • __helpers__/mocks/axios_utils - 이 모킹은 모킹되지 않은 요청이 어떤 테스트도 통과하지 못하도록 막기 위해 유용합니다. 또한 axios.waitForAll과 같은 테스트 헬퍼를 주입할 수 있습니다.

  • __mocks__/mousetrap/index.js - 이 모킹은 모듈 자체가 webpack은 이해하지만 Jest 환경과는 호환되지 않는 AMD 형식을 사용하기 때문에 유용합니다. 이 모킹은 어떠한 동작도 제거하지 않고, 단지 ES6 호환 래퍼를 제공합니다.

  • __mocks__/monaco-editor/index.js - 이 모킹은 Monaco 패키지가 Jest 환경에서 완전히 호환되지 않기 때문에 유용합니다. 실제로 webpack은 이를 동작시키기 위해 특수 로더가 필요합니다. 이 모킹은 Jest가 이 패키지를 사용할 수 있도록 합니다.

모킹은 간결하게 유지하세요#

전역 모킹은 마법 같은 동작을 유발하고 기술적으로 테스트 커버리지를 줄일 수 있습니다. 모킹이 필요하다고 판단될 때는:

  • 모킹을 짧고 집중적으로 유지하세요.

  • 모킹이 왜 필요한지에 대한 최상위 주석을 남기세요.

추가적인 모킹 기법#

사용 가능한 모킹 기능의 전체 개요는 Jest 공식 문서를 참조하세요.

프론트엔드 테스트 실행하기#

픽스처를 생성하기 전에 GDK 인스턴스가 실행 중인지 확인하세요.

프론트엔드 테스트를 실행하려면 다음 명령어가 필요합니다:

  • rake frontend:fixtures픽스처를 (재)생성합니다. 픽스처가 필요한 테스트를 실행하기 전에 픽스처가 최신 상태인지 확인하세요.

  • yarn jest는 Jest 테스트를 실행합니다.

CE 및 EE 테스트 실행하기#

변경 사항에 EE 기능이 포함되어 있어 CE와 EE 환경 모두에 대한 테스트를 작성할 때마다, 로컬 및 파이프라인에서 두 테스트가 모두 통과할 수 있도록 몇 가지 단계를 거쳐야 합니다.

두 환경 모두를 테스트하는 방법에 대한 자세한 내용은 이 섹션을 참조하세요.

라이브 테스트 및 집중 테스트 – Jest#

테스트 스위트를 작성하는 동안 watch 모드로 spec을 실행하여 저장할 때마다 자동으로 다시 실행되게 할 수 있습니다.

# Watch and rerun all specs matching the name icon
yarn jest --watch icon

# Watch and rerun one specific file
yarn jest --watch path/to/spec/file.spec.js

--watch 플래그 없이 일부 집중 테스트를 실행할 수도 있습니다

# Run specific jest file
yarn jest ./path/to/local_spec.js
# Run specific jest folder
yarn jest ./path/to/folder/
# Run all jest files which path contain term
yarn jest term

프론트엔드 테스트 픽스처#

프론트엔드 픽스처는 백엔드 컨트롤러의 응답을 담은 파일입니다. 이 응답은 HAML 템플릿에서 생성된 HTML이거나 JSON 페이로드일 수 있습니다. 이러한 응답에 의존하는 프론트엔드 테스트는 백엔드 코드와의 올바른 통합을 검증하기 위해 픽스처를 사용하는 경우가 많습니다.

픽스처 사용하기#

JSON 또는 HTML 픽스처를 가져오려면 test_fixtures 별칭을 사용하여 import하세요.

import responseBody from 'test_fixtures/some/fixture.json' // loads tmp/tests/frontend/fixtures-ee/some/fixture.json

it('makes a request', () => {
  axiosMock.onGet(endpoint).reply(200, responseBody);

  myButton.click();

  // ...
});

픽스처 생성#

테스트 픽스처를 생성하는 코드는 다음 위치에서 찾을 수 있습니다:

  • spec/frontend/fixtures/: CE에서 테스트를 실행할 때 사용합니다.

  • ee/spec/frontend/fixtures/: EE에서 테스트를 실행할 때 사용합니다.

다음 명령을 실행하여 픽스처를 생성할 수 있습니다:

  • bin/rake frontend:fixtures: 모든 픽스처를 생성합니다.

  • bin/rspec spec/frontend/fixtures/merge_requests.rb: 특정 픽스처를 생성합니다(이 경우 merge_request.rb에 대한 픽스처).

생성된 픽스처는 tmp/tests/frontend/fixtures-ee에서 찾을 수 있습니다.

_spec.js 파일에 대한 단일 픽스처를 생성하려면 test_fixtures/ 디렉터리에서 import를 확인합니다:

// spec/frontend/authentication/webauthn/authenticate_spec.js

import htmlWebauthnAuthenticate from 'test_fixtures/webauthn/authenticate.html';

해당 픽스처 파일은 spec/frontend/fixtures/webauthn.rb입니다.

커맨드 라인에서 단일 픽스처를 생성하려면 bin/rspec spec/frontend/fixtures/webauthn.rb를 실행합니다.

픽스처 다운로드#

GitLab CI에서 픽스처를 생성하고 패키지 레지스트리에 저장합니다.

scripts/frontend/download_fixtures.sh 스크립트는 로컬에서 사용할 수 있도록 해당 픽스처를 다운로드하고 압축을 해제하기 위한 것입니다:

# Checks if a frontend fixture package exists in the gitlab-org/gitlab
# package registry by looking at the commits on a local branch.
#
# The package is downloaded and extracted if it exists
scripts/frontend/download_fixtures.sh

# Same as above, but only looks at the last 10 commits of the currently checked-out branch
scripts/frontend/download_fixtures.sh --max-commits=10

# Looks at the commits on the local master branch instead of the currently checked-out branch
scripts/frontend/download_fixtures.sh --branch master

새 픽스처 만들기#

각 픽스처에 대해 출력 파일에서 response 변수의 내용을 확인할 수 있습니다. 예를 들어, spec/frontend/fixtures/merge_requests.rb에서 "merge_requests/diff_discussion.json"이라는 이름의 테스트는 출력 파일 tmp/tests/frontend/fixtures-ee/merge_requests/diff_discussion.json을 생성합니다. 테스트가 type: :request 또는 type: :controller로 표시되어 있으면 response 변수가 자동으로 설정됩니다.

새 픽스처를 만들 때는 (ee/)spec/controllers/ 또는 (ee/)spec/requests/에서 해당 엔드포인트에 대한 테스트를 살펴보는 것이 좋습니다.

GraphQL 쿼리 픽스처#

get_graphql_query_as_string 헬퍼 메서드를 사용하여 GraphQL 쿼리 결과를 나타내는 픽스처를 만들 수 있습니다. 예를 들어:

# spec/frontend/fixtures/releases.rb

describe GraphQL::Query, type: :request do
  include GraphqlHelpers

  all_releases_query_path = 'releases/graphql/queries/all_releases.query.graphql'

  it "graphql/#{all_releases_query_path}.json" do
    query = get_graphql_query_as_string(all_releases_query_path)

    post_graphql(query, current_user: admin, variables: { fullPath: project.full_path })

    expect_graphql_errors_to_be_empty
  end
end

이렇게 하면 tmp/tests/frontend/fixtures-ee/graphql/releases/graphql/queries/all_releases.query.graphql.json에 새 픽스처가 생성됩니다.

test_fixtures 별칭을 사용하여 Jest 테스트에서 JSON 픽스처를 import할 수 있습니다. 이전에 설명한 방법을 참고하세요.

데이터 기반 테스트#

RSpec의 매개변수화 테스트와 유사하게, Jest는 다음에 대한 데이터 기반 테스트를 지원합니다:

이는 테스트 내 반복을 줄이는 데 유용합니다. 각 옵션은 데이터 값 배열 또는 태그된 템플릿 리터럴을 받을 수 있습니다.

예를 들어:

// function to test
const icon = status => status ? 'pipeline-passed' : 'pipeline-failed'
const message = status => status ? 'pipeline-passed' : 'pipeline-failed'

// test with array block
it.each([
    [false, 'pipeline-failed'],
    [true, 'pipeline-passed']
])('icon with %s will return %s',
 (status, icon) => {
    expect(renderPipeline(status)).toEqual(icon)
 }
);
스펙 출력에 예쁜 출력이 필요하지 않은 경우에만 템플릿 리터럴 블록을 사용하세요. 예를 들어 빈 문자열, 중첩 객체 등의 경우입니다.

예를 들어 빈 검색 문자열과 비어 있지 않은 검색 문자열의 차이를 테스트할 때는, 예쁜 출력 옵션과 함께 배열 블록 구문을 사용하는 것이 권장됩니다. 그래야 스펙 출력에서 빈 문자열('')과 비어 있지 않은 문자열('search string')의 차이가 명확하게 보입니다. 반면 템플릿 리터럴 블록을 사용하면 빈 문자열이 공백으로 표시되어 개발자가 혼란스러울 수 있습니다.

// bad
it.each`
    searchTerm | expected
    ${''} | ${{ issue: { users: { nodes: [] } } }}
    ${'search term'} | ${{ issue: { other: { nested: [] } } }}
`('when search term is $searchTerm, it returns $expected', ({ searchTerm, expected }) => {
  expect(search(searchTerm)).toEqual(expected)
});

// good
it.each([
    ['', { issue: { users: { nodes: [] } } }],
    ['search term', { issue: { other: { nested: [] } } }],
])('when search term is %p, expect to return %p',
 (searchTerm, expected) => {
    expect(search(searchTerm)).toEqual(expected)
 }
);
// test suite with tagged template literal block
describe.each`
    status   | icon                 | message
    ${false} | ${'pipeline-failed'} | ${'Pipeline failed - boo-urns'}
    ${true}  | ${'pipeline-passed'} | ${'Pipeline succeeded - win!'}
`('pipeline component', ({ status, icon, message }) => {
    it(`returns icon ${icon} with status ${status}`, () => {
        expect(icon(status)).toEqual(message)
    })

    it(`returns message ${message} with status ${status}`, () => {
        expect(message(status)).toEqual(message)
    })
});

주의사항#

JavaScript로 인한 RSpec 오류#

기본적으로 RSpec 단위 테스트는 헤드리스 브라우저에서 JavaScript를 실행하지 않으며, Rails가 생성한 HTML을 검사하는 방식에 의존합니다.

통합 테스트가 올바르게 실행되기 위해 JavaScript가 필요한 경우, 테스트 실행 시 JavaScript를 활성화하도록 스펙을 구성해야 합니다. 이를 설정하지 않으면 스펙 러너가 모호한 오류 메시지를 표시합니다.

RSpec 테스트에서 JavaScript 드라이버를 활성화하려면, 개별 스펙 또는 JavaScript 활성화가 필요한 여러 스펙을 포함하는 컨텍스트 블록에 :js를 추가하세요:

# For one spec
it 'presents information about abuse report', :js do
  # assertions...
end

describe "Admin::AbuseReports", :js do
  it 'presents information about abuse report' do
    # assertions...
  end
  it 'shows buttons for adding to abuse report' do
    # assertions...
  end
end

비동기 임포트로 인한 Jest 테스트 타임아웃#

모듈이 런타임에 다른 모듈을 비동기적으로 임포트하는 경우, 해당 모듈들은 런타임에 Jest 로더에 의해 트랜스파일되어야 합니다. 이로 인해 Jest가 타임아웃될 수 있습니다.

이 문제가 발생하면, 모듈을 즉시(eager) 임포트하는 방식을 고려하여 Jest가 컴파일 시점에 해당 모듈을 컴파일하고 캐시하도록 하면 런타임 타임아웃을 해결할 수 있습니다.

다음 예시를 참고하세요:

// the_subject.js

export default {
  components: {
    // Async import Thing because it is large and isn't always needed.
    Thing: () => import(/* webpackChunkName: 'thing' */ './path/to/thing.vue'),
  }
};

Jest는 thing.vue 모듈을 자동으로 트랜스파일하지 않으며, 파일 크기에 따라 Jest가 타임아웃될 수 있습니다. 다음과 같이 즉시 임포트하여 Jest가 해당 모듈을 트랜스파일하고 캐시하도록 강제할 수 있습니다:

// the_subject_spec.js

import Subject from '~/feature/the_subject.vue';

// Force Jest to transpile and cache
// eslint-disable-next-line no-unused-vars
import _Thing from '~/feature/path/to/thing.vue';
테스트 타임아웃을 무시하지 마세요. 이는 실제 프로덕션 문제가 있다는 신호일 수 있습니다. 이 기회를 활용하여 프로덕션 webpack 번들과 청크를 분석하고, 비동기 임포트와 관련된 프로덕션 문제가 없는지 확인하세요.

프론트엔드 테스트 레벨 개요#

프론트엔드 테스트 레벨에 대한 주요 정보는 테스트 레벨 페이지에서 확인할 수 있습니다.

프론트엔드 개발과 관련된 테스트는 다음 위치에서 찾을 수 있습니다:

  • spec/frontend/ — Jest 단위 테스트, 컴포넌트 테스트, 통합 테스트

  • spec/frontend/msw_integration/ — MSW 통합 테스트

  • spec/features/ — Capybara 기능 테스트

spec/frontend/에는 프론트엔드 단위 테스트, 프론트엔드 컴포넌트 테스트, 프론트엔드 통합 테스트가 포함되어 있습니다. Capybara는 spec/features/에서 프론트엔드 기능 테스트를 실행합니다.

2018년 5월 이전에는 features/에도 Spinach가 실행하는 기능 테스트가 포함되어 있었습니다. 이 테스트들은 2018년 5월에 코드베이스에서 제거되었습니다 (#23036).

Vue 컴포넌트 테스트 관련 참고 사항도 함께 확인하세요.

MSW 통합 테스트#

MSW 통합 테스트는 단위 테스트와 Capybara 기능 테스트 사이의 간극을 메웁니다. jsdom에서 전체 Vue 애플리케이션을 마운트하고(브라우저 불필요), MSW(Mock Service Worker)를 사용하여 API 요청을 가로채고 픽스처 데이터로 응답합니다. 이를 통해 Capybara 대비 훨씬 낮은 비용으로 현실적인 UI 인터랙션 테스트가 가능합니다.

디렉터리 구조#

MSW 통합 테스트는 spec/frontend/msw_integration/에 위치합니다. 구조는 다음과 같습니다:

spec/frontend/msw_integration/
├── constants.js            # Shared constants (for example, base metadata)
├── fixture_utils.js        # Helpers for building dynamic mutation responses
├── handlers.js             # GraphQL router: composes feature handlers
├── handlers/
│   └── work_items.js       # Work item resolver and operation overrides
├── server.js               # MSW server setup (imported by test_setup.js)
├── setup_utils.js          # Router and lifecycle helpers used by test_setup.js
├── test_helpers.js         # Test utilities: assignRouter, fullMount, waitForElement, getText
├── test_setup.js           # Global setup: polyfills, server lifecycle, router reset
├── polyfills.js            # TextEncoder/TextDecoder polyfills for jsdom
└── work_items/
    └── work_item_spec.js   # Integration test file

공유 파일(handlers.js, server.js, test_setup.js, polyfills.js, test_helpers.js)은 jest.config.msw_integration.js를 통해 자동으로 구성됩니다. 테스트 파일은 관련 기능 핸들러 파일 (예: handlers/work_items.js)에서 픽스처 데이터를 직접 임포트합니다.

test_helpers.js에서 내보낸 모든 테스트 헬퍼 유틸리티는 test_setup.js에서 Object.assign(global, testHelpers)를 통해 전역으로 자동 임포트됩니다. 따라서 테스트 파일에서 명시적으로 임포트할 필요가 없습니다. 새 헬퍼를 추가하려면 test_helpers.js에서 내보내면 모든 MSW 통합 테스트에서 전역으로 사용할 수 있습니다.

핸들러 아키텍처#

MSW v1은 적용 가능한 첫 번째 rest.post 핸들러를 매칭하므로, 단일 GraphQL 엔드포인트를 여러 MSW 핸들러로 분리할 수 없습니다. 대신 handlers.js가 얇은 GraphQL 라우터 역할을 합니다. http://test.host/api/graphql에 대해 하나의 rest.post 핸들러를 등록하고, 기능별 리졸버 함수에 순서대로 위임합니다:

import { rest } from 'msw';
import { handleWorkItemOperation } from './handlers/work_items';

// Thin router: Import feature handlers here
const graphqlFeatureHandlers = [handleWorkItemOperation];

// Collect all REST endpoints from feature handlers
const restEndpoints = [...workItemRestEndpoints];

const restEndpointsHandlers = restEndpoints.map((endpoint) =>
  rest[endpoint.method](endpoint.path, (req, res, ctx) => {
    return res(ctx.json(endpoint.response));
  }),
);

export const handlers = [
  // Single GraphQL endpoint that routes to feature handlers
  rest.post('http://test.host/api/graphql', (req, res, ctx) => {
    const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
    const { operationName, variables } = body;

    // Try each feature handler until one returns a result
    for (const handler of graphqlFeatureHandlers) {
      const result = handler({ operationName, variables, res, ctx });
      if (result) return result;
    }

    console.log(`No handler for operationName: ${operationName}`);
    return res(ctx.status(400));
  }),
  ...restEndpointsHandlers,
];

각 리졸버는 { operationName, variables, res, ctx }를 받아 해당 작업을 처리하면 MSW 응답을 반환하고, 처리하지 않으면 null을 반환하여 다음 리졸버로 넘깁니다. 처리되지 않은 작업은 400 상태를 반환하는 캐치올로 넘어갑니다. 이러한 의도적인 실패는 누락된 핸들러를 조기에 발견할 수 있게 합니다. 테스트 중 실행되는 모든 GraphQL 작업에는 대응하는 핸들러가 있어야 합니다. ServerParseError: Unexpected end of JSON input으로 테스트가 실패하면 해당 작업을 관련 기능 핸들러 파일에 추가하세요.

새 기능 도메인 추가#

새 기능 영역(예: 머지 리퀘스트)에 MSW 핸들러를 추가하려면:

loadFixturesMap을 사용하여 픽스처를 자동으로 로드하고 핸들러를 빌드하는 리졸버 파일을 handlers/ 디렉터리에 생성합니다. 자동 로드 및 핸들러 빌드에 대한 자세한 내용은 기능 핸들러 작성을 참조하세요.

handlers.js에 리졸버를 등록합니다:

import { handleMergeRequestOperation } from './handlers/merge_requests';

// Thin router: Import feature handlers here
const graphqlFeatureHandlers = [
  handleWorkItemOperation,
  handleMergeRequestOperation,
];

ee/spec/frontend/fixtures/에 RSpec spec을 추가하고 실행하여 픽스처를 생성합니다. 자세한 내용은 픽스처 생성을 참조하세요.

Generate fixtures#

픽스처는 테스트 데이터베이스에 대해 실행된 실제 GraphQL 쿼리로부터 생성됩니다. 각 픽스처 생성기는 ee/spec/frontend/fixtures/에 있는 RSpec spec입니다. 예를 들어:

bundle exec rspec ee/spec/frontend/fixtures/work_items_integration.rb

이 명령은 JSON 파일을 tmp/tests/frontend/fixtures-ee/graphql/에 씁니다. 이 픽스처는 기능 핸들러 파일의 loadFixturesMap에 의해 자동으로 로드되어 MSW를 통해 제공됩니다.

새 픽스처를 추가하려면 픽스처 생성기 spec에 새 it 블록을 추가합니다. 테스트 이름에 따라 출력 파일 경로가 결정됩니다:

it "graphql/work_items/integration/my_query.query.graphql.json" do
  query = get_graphql_query_as_string('work_items/graphql/my_query.query.graphql')
  post_graphql(query, current_user: user, variables: { fullPath: project.full_path })
  expect_graphql_errors_to_be_empty
end

Fixture naming convention for auto-loading#

픽스처 파일명이 GraphQL 오퍼레이션 이름에 올바르게 매핑되려면 loadFixturesMap으로 픽스처 자동 로드에 설명된 명명 규칙을 따르세요.

Write feature handlers#

handlers/의 각 기능 핸들러 파일은 해당 기능 영역의 오퍼레이션-픽스처 매핑과 뮤테이션 로직을 담당합니다.

Auto-load fixtures with loadFixturesMap#

fixture_utils.jsloadFixturesMap을 사용하면 디렉터리의 모든 JSON 픽스처를 자동으로 로드하고 오퍼레이션 이름에 매핑할 수 있습니다. 이 함수는 지정된 경로의 모든 .json 파일을 읽고, .query.graphql.json 또는 .mutation.graphql.json 접미사를 제거한 뒤, 나머지 파일명을 camelCase로 변환하여 오퍼레이션 이름 키로 사용합니다.

예를 들어 get_work_items_full.query.graphql.json이라는 파일은 키 getWorkItemsFull에 매핑됩니다.

픽스처 파일명은 이 camelCase 변환 후 GraphQL 오퍼레이션 이름과 일치해야 합니다. 예를 들어 GraphQL 오퍼레이션 이름이 getWorkItemStateCounts인 경우, 픽스처 파일 이름을 get_work_item_state_counts.query.graphql.json으로 지정합니다. 로더는 이를 getWorkItemStateCounts로 변환하며, 이 이름은 Apollo 클라이언트가 전송하는 오퍼레이션 이름과 일치합니다.

오퍼레이션 이름이 파생된 파일명과 일치하지 않는 경우(예: getWorkItemsFullEE와 같이 EE 접미사가 붙은 오퍼레이션), 핸들러 파일의 OPERATION_NAME_OVERRIDES에 항목을 추가합니다:

const OPERATION_NAME_OVERRIDES = {
  getWorkItemsFullEE: fixtures.getWorkItemsFull,
};
import { join } from 'node:path';
import { loadFixturesMap } from '../fixture_utils';

const FIXTURES_PATH = join('tmp/tests/frontend/fixtures-ee/graphql/my_feature/integration/');
const fixtures = loadFixturesMap(FIXTURES_PATH);

이 방식을 사용하면 각 픽스처 파일을 수동으로 import할 필요가 없습니다. fixtures 객체에는 파생된 camelCase 오퍼레이션 이름을 키로 하여 모든 픽스처가 포함됩니다.

Build the handler from the fixtures map#

자동 로드된 fixturesFIXTURE_RESPONSES에 스프레드하고, 이름이 맞지 않는 경우를 위한 OPERATION_NAME_OVERRIDES도 함께 포함합니다 (위 내용 참조):

const FIXTURE_RESPONSES = {
  ...fixtures,
  ...OPERATION_NAME_OVERRIDES,
};

정적 오퍼레이션(쿼리)은 자동으로 핸들러로 변환됩니다.

입력 변수를 기반으로 동적 응답이 필요한 뮤테이션의 경우, MUTATION_OPERATION_HANDLERS에 항목을 추가합니다:

const MUTATION_OPERATION_HANDLERS = {
  myMutation: ({ variables }) => buildMyResponse(variables),
};

두 가지를 단일 OPERATION_HANDLERS 맵으로 합치고, 리졸버에서 해당 작업을 조회합니다:

const STATIC_OPERATION_HANDLERS = Object.fromEntries(
  Object.entries(FIXTURE_RESPONSES).map(([op, fixture]) => [
    op,
    () => ({ data: fixture.data }),
  ]),
);

const OPERATION_HANDLERS = {
  ...STATIC_OPERATION_HANDLERS,
  ...MUTATION_OPERATION_HANDLERS,
};

export function handleMyFeatureOperation({ operationName, variables, res, ctx }) {
  const handler = OPERATION_HANDLERS[operationName];
  if (!handler) return null;
  return res(ctx.json(handler({ operationName, variables })));
}

테스트 파일 작성#

테스트 파일은 기능 영역을 반영하는 하위 디렉터리 구조로 spec/frontend/msw_integration/ 아래에 위치합니다. 각 파일은 다음 규칙을 따라야 합니다:

  • 라우터 팩토리를 직접 호출하는 대신 test_helpers.jsassignRouter를 사용하여 라우터를 생성합니다. 이렇게 하면 라우터가 전역으로 등록되어 test_setup.js가 테스트 간 라우터를 초기화할 수 있습니다.

  • 루트 컴포넌트를 test_helpers.jsfullMount와 실제 apolloProvider로 마운트합니다. fullMount@vue/test-utilsmount를 래핑하며 자동으로 document.body에 연결합니다.

  • API 호출을 트리거하는 액션 이후에는 @testing-library/domwaitFor를 사용합니다.

  • 네이티브 DOM API(.click(), .dispatchEvent(), .querySelector())를 통해 UI와 상호작용합니다.

  • Vue 컴포넌트 상태가 아닌 DOM을 대상으로 단언합니다.

Vue Test Utils 래퍼보다 DOM 단언을 우선 사용#

컴포넌트를 생성할 때만 test_helpers.jsfullMount를 사용합니다. 마운트 이후에는 DOM과 직접 상호작용하고 단언을 수행합니다. 이렇게 하면 테스트가 Vue 버전에 독립적으로 유지되고 향후 Vue 3 마이그레이션이 간단해집니다.

아래와 같은 Vue 종속 패턴은 피합니다:

  • wrapper.find(), wrapper.findComponent(), wrapper.trigger(), wrapper.text(), wrapper.attributes(), wrapper.exists().

  • vm.$emit(), vm.$data, 또는 컴포넌트 인스턴스 속성에 직접 접근하는 것.

  • DOM 엘리먼트에서 VTU 래퍼를 얻기 위해 el.__vue__ 또는 createWrapper()를 사용하는 것.

대신 아래와 같은 네이티브 DOM 동등 표현을 사용합니다:

Vue Test Utils DOM equivalent
.find(selector) .querySelector(selector)
.trigger('click') .click()
.trigger('submit') .dispatchEvent(new Event('submit', { bubbles: true }))
.text() getText(el) from test_helpers.js
.attributes('name') .getAttribute('name') or .dataset
.exists() !== null
.setValue(val) el.value = val; el.dispatchEvent(new Event('input', { bubbles: true }))

다음은 최소한의 예시입니다:

import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { waitFor } from '@testing-library/dom';
import { apolloProvider } from '~/graphql_shared/issuable_client';
import { createRouter } from '~/my_feature/router';
import MyApp from '~/my_feature/components/app.vue';
import { assignRouter, fullMount, waitForElement, getText } from '../test_helpers';

Vue.use(VueApollo);

describe('My feature test', () => {
  const router = assignRouter(createRouter, {
    fullPath: 'gitlab-org/gitlab',
    routerPath: 'my_feature',
  });

  const findResult = () =>
    document.querySelector('[data-testid="result"]');

  const createComponent = () => {
    fullMount(MyApp, {
      router,
      apolloProvider,
      provide: {
        fullPath: 'gitlab-org/gitlab',
      },
    });
  };

  beforeEach(async () => {
    await apolloProvider.defaultClient.cache.reset();
  });

  it('renders the page and responds to user interaction', async () => {
    createComponent();

    const el = await waitForElement(
      () => document.querySelector('[data-testid="my-element"]'),
    );
    expect(el).not.toBe(null);

    document.querySelector('[data-testid="my-button"]').click();

    await waitFor(() => {
      expect(getText(findResult())).toContain('Updated');
    });
  });
});

단위 테스트와의 주요 차이점:

  • createMockApollo 대신 실제 apolloProvider를 사용합니다. MSW가 실제 네트워크 요청을 가로챕니다.

  • shallowMountExtendedmountExtended 대신 test_helpers.jsfullMount를 사용합니다. 실제적인 상호작용 테스트를 위해 전체 컴포넌트 트리가 렌더링되어야 합니다. fullMountmount를 래핑하며 자동으로 document.body에 연결합니다.

  • 라우터 생성에는 test_helpers.jsassignRouter를 사용합니다. 이렇게 하면 라우터가 전역적으로 등록되어 test_setup.js가 테스트 간 라우터를 재설정할 수 있습니다. 라우터 팩토리 함수를 직접 호출하거나 라우트를 수동으로 푸시하지 마세요.

  • 마운트 후에는 모든 상호작용과 어설션에 네이티브 DOM API를 사용합니다. 이렇게 하면 Vue 내부에 대한 결합을 방지하고 Vue 3 호환성을 보장합니다.

  • 자식 컴포넌트를 모킹하지 마세요. 목표는 컴포넌트들이 함께 어떻게 동작하는지 테스트하는 것입니다.

  • 테스트 간 상태가 누출되지 않도록 beforeEach에서 Apollo 캐시를 재설정합니다.

  • wrapper 소멸이나 Apollo 클라이언트 종료를 위한 afterEach 정리를 추가하지 마세요. 전역 test_setup.js가 라우터 재설정, wrapper 소멸, 메타데이터 정리를 처리합니다.

  • 서버 라이프사이클(server.listen, server.resetHandlers, server.close)은 test_setup.js가 전역으로 처리합니다. 개별 테스트 파일에 이러한 호출을 추가하지 마세요.

MSW 통합 테스트 실행#

모든 MSW 통합 테스트 실행:

yarn jest:msw-integration

단일 파일 실행:

yarn jest:msw-integration spec/frontend/msw_integration/work_items/work_item_spec.js

CI에서 이 테스트는 jest-msw-integration job(tier-2+ 파이프라인)에서 실행됩니다.

테스트 헬퍼#

테스트 헬퍼는 spec/frontend/__helpers__에서 찾을 수 있습니다. 새 헬퍼를 추가할 경우 해당 디렉터리에 배치하세요.

Vuex 헬퍼: testAction#

공식 문서에 따라 액션 테스트를 더 쉽게 만들 수 있는 헬퍼가 있습니다:

// 이 방식을 권장합니다. 단일 객체 인자를 사용하면 테스트를 읽을 때 매개변수가 명확합니다
await testAction({
  action: actions.actionName,
  payload: { deleteListId: 1 },
  state: { lists: [1, 2, 3] },
  expectedMutations: [ { type: types.MUTATION} ],
  expectedActions: [],
});

// 이전 방식이므로 새 테스트에서는 사용하지 마세요
testAction(
  actions.actionName, // action
  { }, // params to be passed to action
  state, // state
  [
    { type: types.MUTATION},
    { type: types.MUTATION_1, payload: {}},
  ], // mutations committed
  [
    { type: 'actionName', payload: {}},
    { type: 'actionName1', payload: {}},
  ] // actions dispatched
  done,
);

Axios 요청 완료 대기#

spec/frontend/__helpers__/mocks/axios_utils.js에 위치한 Axios Utils 모킹 모듈에는 HTTP 요청을 생성하는 Jest 테스트를 위한 두 가지 헬퍼 메서드가 포함되어 있습니다. 예를 들어 Vue 컴포넌트가 라이프사이클의 일부로 요청을 수행할 때처럼 요청의 Promise에 대한 핸들을 갖고 있지 않은 경우에 매우 유용합니다.

  • waitFor(url, callback): url에 대한 요청이 완료되면(성공 또는 실패 여부와 관계없이) callback을 실행합니다.

  • waitForAll(callback): 보류 중인 모든 요청이 완료되면 callback을 실행합니다. 보류 중인 요청이 없으면 다음 틱에서 callback을 실행합니다.

두 함수 모두 요청이 완료된 후 다음 틱에서 callback을 실행합니다(setImmediate() 사용). 이를 통해 .then() 또는 .catch() 핸들러가 실행될 수 있습니다.

shallowMountExtended와 mountExtended#

shallowMountExtendedmountExtended 유틸리티는 find 또는 findAll 접두사를 붙여 사용 가능한 DOM Testing Library 쿼리를 수행하는 기능을 제공합니다.

import { shallowMountExtended } from 'helpers/vue_test_utils_helper';

describe('FooComponent', () => {
  const wrapper = shallowMountExtended({
    template: `
      <div data-testid="gitlab-frontend-stack">
        <p>GitLab frontend stack</p>
        <div role="tablist">
          <button role="tab" aria-selected="true">Vue.js</button>
          <button role="tab" aria-selected="false">GraphQL</button>
          <button role="tab" aria-selected="false">SCSS</button>
        </div>

      </div>

    `,
  });

  it('finds elements with `findByTestId`', () => {
    expect(wrapper.findByTestId('gitlab-frontend-stack').exists()).toBe(true);
  });

  it('finds elements with `findByText`', () => {
    expect(wrapper.findByText('GitLab frontend stack').exists()).toBe(true);
    expect(wrapper.findByText('TypeScript').exists()).toBe(false);
  });

  it('finds elements with `findAllByRole`', () => {
    expect(wrapper.findAllByRole('tab').length).toBe(3);
  });
});

spec/frontend/alert_management/components/alert_details_spec.js에서 예제를 확인할 수 있습니다.

구형 브라우저 테스트#

일부 회귀 문제는 특정 브라우저 버전에서만 발생합니다. 다음 단계에 따라 Firefox 또는 BrowserStack을 사용하여 특정 브라우저를 설치하고 테스트할 수 있습니다:

BrowserStack#

BrowserStack을 사용하면 1200개 이상의 모바일 디바이스 및 브라우저를 테스트할 수 있습니다. 라이브 앱을 통해 직접 사용하거나, 편리한 접근을 위해 크롬 확장 프로그램을 설치할 수 있습니다. GitLab 공유 1Password 계정Engineering 볼트에 저장된 자격 증명으로 BrowserStack에 로그인하세요.

Firefox#

macOS#

릴리즈 FTP 서버 https://ftp.mozilla.org/pub/firefox/releases/에서 이전 버전의 Firefox를 다운로드할 수 있습니다:

  • 웹사이트에서 버전을 선택합니다. 여기서는 50.0.1을 예로 들겠습니다.

  • mac 폴더로 이동합니다.

  • 원하는 언어를 선택합니다. DMG 패키지가 그 안에 있습니다. 다운로드하세요.

  • 애플리케이션을 Applications 폴더가 아닌 다른 폴더로 드래그 앤 드롭합니다.

  • 애플리케이션 이름을 Firefox_Old와 같이 변경합니다.

  • 애플리케이션을 Applications 폴더로 이동합니다.

  • 터미널을 열고 /Applications/Firefox_Old.app/Contents/MacOS/firefox-bin -profilemanager를 실행하여 해당 Firefox 버전 전용의 새 프로필을 생성합니다.

  • 프로필 생성이 완료되면 앱을 종료하고 평소처럼 다시 실행합니다. 이제 구형 Firefox 버전이 정상적으로 동작합니다.

스냅샷#

Jest 스냅샷 테스트는 특정 컴포넌트의 HTML 출력에 예기치 않은 변경이 발생하는 것을 방지하는 데 유용한 방법입니다. 다른 테스트 방법(예: vue-tests-utils로 요소를 검증하는 방법)으로 필요한 사용 사례를 다루기 어려운 경우에 한해서만 사용해야 합니다. GitLab 내에서 스냅샷 테스트를 사용할 때는 다음과 같은 몇 가지 지침을 숙지해야 합니다:

  • 스냅샷을 코드처럼 다루세요

  • 스냅샷 파일을 블랙박스처럼 취급하지 마세요

  • 스냅샷의 출력 결과에 주의를 기울이세요. 그렇지 않으면 실질적인 가치를 제공하지 못합니다. 이를 위해 생성된 스냅샷 파일을 다른 코드를 읽듯이 직접 확인해야 합니다

스냅샷 테스트는 테스트 대상 항목에 입력한 내용의 원시 String 표현을 저장하는 간단한 방법으로 생각하면 됩니다. 이를 통해 컴포넌트, 스토어, 복잡한 생성 결과물 등의 변경 사항을 평가할 수 있습니다. 아래 목록에서 권장하는 Do's and Don'ts를 확인할 수 있습니다. 스냅샷 테스트는 매우 강력한 도구가 될 수 있지만, 단위 테스트를 대체하는 것이 아니라 보완하는 수단으로 사용해야 합니다.

Jest는 스냅샷을 생성할 때 염두에 두어야 할 모범 사례에 대한 훌륭한 문서를 제공합니다.

스냅샷은 어떻게 동작하나요?#

스냅샷은 함수 호출의 좌변에서 테스트하도록 지정한 내용을 문자열로 변환한 것입니다. 즉, 문자열 서식을 변경하면 결과에 영향을 미칩니다. 이 과정은 직렬화(serializer)를 활용한 자동 변환 단계를 통해 이루어집니다. Vue의 경우 적절한 직렬화 도구를 제공하는 vue-jest 패키지를 활용하여 이미 처리되어 있습니다.

스펙의 결과가 생성된 스냅샷 파일의 내용과 다를 경우, 테스트 스위트에서 실패한 테스트로 알림을 받게 됩니다.

자세한 내용은 Jest 공식 문서 https://jestjs.io/docs/snapshot-testing에서 확인하세요.

장단점#

장점

  • 중요한 HTML 구조의 의도치 않은 변경에 대한 경고 역할

  • 설정 용이성

단점

  • 요소를 찾고 존재 여부를 직접 검증하는 vue-tests-utils가 제공하는 명확성과 가이드라인이 부족함

  • 의도적으로 컴포넌트를 업데이트할 때 불필요한 노이즈 발생

  • 버그의 스냅샷을 찍을 위험이 높으며, 이 경우 해당 문제를 수정할 때 테스트가 실패하게 됨

  • 스냅샷 내에 의미 있는 검증이나 기대값이 없어 이해하거나 교체하기 어려움

  • GitLab UI와 같은 의존성과 함께 사용할 경우, 기반 라이브러리가 테스트 중인 컴포넌트의 HTML을 변경할 때 테스트의 취약성이 증가함

사용 시기#

스냅샷을 사용해야 하는 경우

  • 중요한 HTML 구조가 실수로 변경되지 않도록 보호할 때

  • 복잡한 유틸리티 함수의 JS 객체 또는 JSON 출력을 검증할 때

사용하지 말아야 할 시기#

스냅샷을 사용하지 말아야 하는 경우

  • vue-tests-utils를 사용하여 테스트를 작성할 수 있는 경우

  • 컴포넌트의 로직을 검증할 때

  • 데이터 구조의 출력을 예측할 때

  • 리포지터리 외부에 UI 요소가 있는 경우 (GitLab UI 버전 업데이트를 생각해 보세요)

예제#

보시다시피, 스냅샷 테스트의 단점이 장점보다 일반적으로 훨씬 많습니다. 이를 더 잘 이해하기 위해, 이 섹션에서는 스냅샷 테스트를 사용하고 싶어질 수 있는 몇 가지 예제와 왜 그것이 좋지 않은 패턴인지 설명합니다.

예제 #1 - 요소 가시성#

요소 가시성을 테스트할 때는 vue-tests-utils (VTU)를 사용하여 특정 컴포넌트를 찾은 다음 VTU 래퍼에서 기본적인 .exists() 메서드를 호출하는 방법을 선호하세요. 이 방법이 더 나은 가독성과 더 견고한 테스트를 제공합니다. 아래 예제를 살펴보면, 스냅샷에 대한 검증이 무엇을 기대하는지를 알려주지 않는다는 것을 알 수 있습니다. 우리는 컨텍스트를 파악하기 위해 전적으로 it 설명에 의존하고 있으며, 스냅샷이 원하는 동작을 캡처했다는 가정에 의존합니다.

<template>
  <my-component v-if="isVisible" />
</template>

Bad:

it('hides the component', () => {
  createComponent({ props: { isVisible: false }})

  expect(wrapper.element).toMatchSnapshot()
})

it('shows the component', () => {
  createComponent({ props: { isVisible: true }})

  expect(wrapper.element).toMatchSnapshot()
})

좋은 예:

it('hides the component', () => {
  createComponent({ props: { isVisible: false }})

  expect(findMyComponent().exists()).toBe(false)
})

it('shows the component', () => {
  createComponent({ props: { isVisible: true }})

  expect(findMyComponent().exists()).toBe(true)
})

뿐만 아니라, 컴포넌트에 잘못된 prop을 전달하여 잘못된 가시성이 적용된 경우를 상상해 보세요. 스냅샷 테스트는 문제가 있는 상태의 HTML을 캡처했기 때문에 여전히 통과됩니다. 따라서 스냅샷 출력을 직접 확인하지 않는 한, 테스트가 잘못되었다는 사실을 절대 알 수 없습니다.

예시 #2 - 텍스트 존재 여부#

컴포넌트 내에서 텍스트를 찾는 것은 vue-test-utilswrapper.text() 메서드를 사용하면 매우 간단합니다. 하지만 포맷팅이나 HTML 중첩으로 인해 반환 값에 일관성 없는 공백이 많이 포함될 경우, 스냅샷을 사용하고 싶은 유혹이 생길 수 있습니다.

이러한 경우에는 스냅샷으로 공백을 무시하는 것보다, 각 문자열을 개별적으로 단언하여 여러 assertion을 작성하는 것이 더 좋습니다. DOM 레이아웃이 변경되면 텍스트가 완벽하게 포맷팅되어 있더라도 스냅샷 테스트가 실패하기 때문입니다.

<template>
  <gl-sprintf :message="my-message">
    <template #code="{ content }">
      <code>{{ content }}</code>
    </template>
  </gl-sprintf>
  <p> My second message </p>
</template>

나쁜 예:

it('renders the text as I expect', () => {
  expect(wrapper.text()).toMatchSnapshot()
})

좋은 예:

it('renders the code snippet', () => {
  expect(findCodeTag().text()).toContain("myFunction()")
})

it('renders the paragraph text', () => {
  expect(findOtherText().text()).toBe("My second message")
})

예시 #3 - 복잡한 HTML#

매우 복잡한 HTML이 있을 때는 전체를 캡처하는 것보다 중요하고 민감한 특정 지점을 구체적으로 단언하는 데 집중해야 합니다. 스냅샷 테스트의 가치는 개발자에게 경고를 주는 것, 즉 의도하지 않게 HTML 구조를 변경했을 수 있음을 알리는 데 있습니다. 복잡한 HTML 출력처럼 변경 결과를 읽기 어려운 경우, 무언가가 변경되었다는 신호 자체만으로 충분할까요? 그리고 그것이 충분하다면, 스냅샷 없이도 달성할 수 있을까요?

복잡한 HTML 출력의 좋은 예로 GlTable이 있습니다. 행과 열 구조를 캡처할 수 있어 스냅샷 테스트가 좋은 선택처럼 느껴질 수 있지만, 대신 예상되는 텍스트를 단언하거나 행과 열의 수를 직접 세는 방법을 사용해야 합니다.

<template>
  <gl-table ...all-them-props />
</template>

나쁜 예:

it('renders GlTable as I expect', () => {
  expect(findGlTable().element).toMatchSnapshot()
})

좋은 예:

it('renders the right number of rows', () => {
  expect(findGlTable().findAllRows()).toHaveLength(expectedLength)
})

it('renders the special icon that only appears on a full moon', () => {
  expect(findGlTable().findMoonIcon().exists()).toBe(true)
})

it('renders the correct email format', () => {
  expect(findGlTable().text()).toContain('my_strange_email@shaddyprovide.com')
})

다소 장황하더라도, 이렇게 하면 GlTable이 내부 구현을 변경하더라도 테스트가 깨지지 않으며, 다른 개발자(또는 6개월 후의 자신)에게 테이블을 리팩토링하거나 기능을 추가할 때 무엇을 보존해야 하는지 명확하게 전달할 수 있습니다.

스냅샷 찍는 방법#

it('makes the name look pretty', () => {
  expect(prettifyName('Homer Simpson')).toMatchSnapshot()
})

이 테스트가 처음 실행되면 새로운 .snap 파일이 생성됩니다. 파일의 내용은 다음과 같습니다:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`makes the name look pretty`] = `
Sir Homer Simpson the Third
`

이제 이 테스트를 호출할 때마다 새 스냅샷이 이전에 생성된 버전과 비교하여 평가됩니다. 스냅샷 파일의 내용을 이해하고 신중하게 다루는 것이 중요하다는 점을 이를 통해 알 수 있습니다. 스냅샷 출력이 너무 크거나 복잡하여 읽기 어렵다면 스냅샷의 가치를 잃게 됩니다. 따라서 스냅샷은 머지 리퀘스트 리뷰에서 평가할 수 있거나 절대 변경되지 않을 것이 보장되는 사람이 읽을 수 있는 항목으로 한정해야 합니다. wrappers 또는 elements에 대해서도 동일하게 적용할 수 있습니다.

it('renders the component correctly', () => {
  expect(wrapper).toMatchSnapshot()
  expect(wrapper.element).toMatchSnapshot();
})

위 테스트는 두 개의 스냅샷을 생성합니다. 어떤 스냅샷이 코드베이스 안전성에 더 많은 가치를 제공하는지 결정하는 것이 중요합니다. 즉, 이 스냅샷 중 하나가 변경된다면 코드베이스에 가능한 손상이 발생했음을 나타내는지 생각해야 합니다. 이는 기반 의존성 중 일부가 우리도 모르게 변경되었을 때 예상치 못한 변경을 감지하는 데 도움이 됩니다.

기능 테스트 시작하기#

기능 테스트(feature test)는 실제 UI에서 의미 있는 동작을 수행하여 기능의 전체 흐름을 검증합니다.

기능 테스트를 사용할 때#

다음과 같은 경우에 기능 테스트를 사용하세요:

  • 페이지에서 여러 컴포넌트가 서로 상호작용하는 경우.

  • 사용자가 여러 페이지를 탐색해야 하는 경우.

  • 폼을 제출하고 다른 곳에서 결과를 관찰하는 경우.

  • 단위 테스트로 수행하면 과도한 모킹(mocking)과 스터빙(stubbing)이 필요한 경우.

기능 테스트는 다음을 테스트하려 할 때 특히 유용합니다:

  • 여러 컴포넌트가 함께 성공적으로 동작하는지 여부.

  • 단위 테스트 모킹으로 재현하기 어려운 복잡한 API 상호작용. 기능 테스트는 더 느리지만 어떤 수준의 모킹도 필요하지 않습니다.

기능 테스트를 사용하지 말아야 할 때#

동일한 테스트 결과를 얻을 수 있다면 기능 테스트 대신 jestvue-test-utils 단위 테스트를 사용하세요. 기능 테스트는 단위 테스트보다 실행 비용이 더 많이 듭니다.

다음과 같은 경우에는 단위 테스트를 사용하세요:

  • 동작이 하나의 컴포넌트 안에서 모두 이루어지는 경우.

  • 다른 컴포넌트의 동작을 시뮬레이션하여 원하는 효과를 트리거할 수 있는 경우.

  • 가상 DOM에서 UI 요소를 선택하여 원하는 효과를 트리거할 수 있는 경우.

적합한 기능 테스트 유형 선택#

기능 테스트가 적합하다고 판단했다면 GitLab에는 두 가지 유형이 있습니다. MSW 통합 테스트가 훨씬 더 빠르므로 기본적으로 MSW 통합 테스트를 사용하세요.

다음과 같은 경우에 MSW 통합 테스트(spec/frontend/msw_integration/)를 사용하세요:

  • 단일 페이지에서 다중 컴포넌트 상호작용을 다루는 경우 (예: 목록 + 드로어).

  • 백엔드 응답을 자동 생성된 픽스처(fixture)로 표현할 수 있는 경우.

  • 데이터베이스 상태, 인가, 서버 측 유효성 검사 또는 실시간 업데이트를 검증할 필요가 없는 경우.

다음과 같은 경우에 Capybara 기능 테스트(spec/features/)를 사용하세요:

  • 실제 백엔드(데이터베이스 쓰기, 인가 확인, 서버 측 유효성 검사)가 필요한 경우.

  • 여러 서버 렌더링 페이지 간의 탐색이 필요한 경우.

  • 픽스처로 표현할 수 없는 백엔드 상태에 의존하는 동작을 검증해야 하는 경우.

  • 같은 페이지의 여러 Vue 애플리케이션에 의존하는 동작을 테스트해야 하는 경우.

Capybara 기능 테스트#

Capybara 기능 테스트화이트박스 테스팅(white-box testing)이라고도 하며, 브라우저를 생성하고 Capybara 헬퍼를 사용하는 테스트입니다. 즉, 이 테스트는 다음을 수행할 수 있습니다:

  • 브라우저에서 요소를 찾습니다.

  • 해당 요소를 클릭합니다.

  • API를 호출합니다.

Capybara 기능 테스트는 실행 비용이 높습니다. 작성하기 전에 MSW 통합 테스트로 동일한 커버리지를 달성할 수 없는지 반드시 확인하세요.

모든 Capybara 기능 테스트는 Ruby로 작성되지만, 사용자 대면 기능을 구현하는 JavaScript 엔지니어가 작성하는 경우도 많습니다. 다음 섹션에서는 RubyCapybara에 대한 사전 지식 없이도 이러한 테스트를 언제, 어떻게 사용해야 하는지에 대한 명확한 지침을 제공합니다.

또한 새 코드의 동작에 여러 컴포넌트가 함께 동작해야 한다면, 컴포넌트 트리의 더 높은 곳에서 동작을 테스트하는 것을 고려해야 합니다. 예를 들어, 다음 코드를 가진 ParentComponent라는 컴포넌트를 생각해보세요:

  <script>
  export default{
    name: ParentComponent,
    data(){
      return {
        internalData: 'oldValue'
      }
    },
     methods:{
      changeSomeInternalData(newVal){
        this.internalData = newVal
      }
     }
  }
  </script>
  <template>
   <div>
    <child-component-1 @child-event="changeSomeInternalData" />
    <child-component-2 :parent-data="internalData" />
   </div>

  </template>

이 예시에서:

  • ChildComponent1이 이벤트를 emit합니다.

  • ParentComponentinternalData 값을 변경합니다.

  • ParentComponentChildComponent2에 props를 전달합니다.

대신 유닛 테스트를 사용하면 다음과 같이 할 수 있습니다:

  • ParentComponent 유닛 테스트 파일 내부에서 childComponent1의 예상 이벤트를 emit

  • prop이 childComponent2로 전달되는지 확인합니다.

그런 다음 각 자식 컴포넌트의 유닛 테스트에서 이벤트가 emit될 때와 prop이 변경될 때 어떤 일이 일어나는지 테스트합니다.

이 예시는 더 큰 규모와 더 깊은 컴포넌트 트리에도 동일하게 적용됩니다. 다음과 같은 경우에는 유닛 테스트를 사용하고 기능 테스트의 추가 비용을 피하는 것이 분명히 가치 있습니다:

  • 자식 컴포넌트를 자신 있게 마운트할 수 있는 경우.

  • 가상 DOM에서 이벤트를 emit하거나 요소를 선택할 수 있는 경우.

  • 원하는 테스트 동작을 얻을 수 있는 경우.

테스트를 만들 위치#

기능 테스트는 spec/features 폴더에 위치합니다. 기능을 추가하려는 페이지를 테스트할 수 있는 기존 파일을 찾아보세요. 해당 폴더 내에서 섹션을 찾을 수 있습니다. 예를 들어, 파이프라인 페이지에 새로운 기능 테스트를 추가하려면 spec/features/projects/pipelines에서 작성하려는 테스트가 이미 존재하는지 확인합니다.

기능 테스트 실행 방법#

작동 중인 GDK 환경이 있는지 확인합니다.

gdk start 명령으로 gdk 환경을 시작합니다.

터미널에서 다음을 실행합니다:

 bundle exec rspec path/to/file:line_of_my_test

이 명령어 앞에 WEBDRIVER_HEADLESS=0을 붙이면 컴퓨터에서 실제 브라우저를 열어 테스트를 실행하므로 디버깅에 매우 유용합니다.

Chrome 대신 Firefox를 사용하려면 명령어 앞에 WEBDRIVER=firefox를 붙입니다.

테스트 작성 방법#

기본 파일 구조#

모든 문자열 리터럴을 변경 불가능하게 만들기

모든 기능 테스트에서 첫 번째 줄은 다음이어야 합니다:

# frozen_string_literal: true

이는 모든 Ruby 파일에 포함되며, 모든 문자열 리터럴을 변경 불가능하게 만듭니다. 성능상의 이점도 있지만 이 섹션의 범위를 벗어납니다.

의존성 가져오기.

필요한 모듈을 가져와야 합니다. 대부분의 경우 항상 spec_helper를 require해야 합니다:

require 'spec_helper'

다른 관련 모듈도 가져옵니다.

RSpec이 테스트를 정의할 전역 스코프를 만들기. jest에서 초기 describe 블록으로 하는 것과 동일합니다.

그런 다음 첫 번째 RSpec 스코프를 만들어야 합니다.

RSpec.describe 'Pipeline', :js do
  ...
end

Ruby의 모든 것과 마찬가지로, 이것은 실제로 class입니다. 즉, 맨 위에서 테스트에 필요한 모듈을 include할 수 있습니다. 예를 들어, 더 쉽게 이동하기 위해 RoutesHelpers를 include할 수 있습니다.

RSpec.describe 'Pipeline', :js do
  include RoutesHelpers
  ...
end

이 모든 구현 후에는 다음과 같은 파일이 완성됩니다:

# frozen_string_literal: true

require 'spec_helper'

RSpec.describe 'Pipeline', :js do
  include RoutesHelpers
end

데이터 시딩#

각 테스트는 자체 환경에서 실행되므로 필요한 데이터를 시딩하려면 팩토리를 사용해야 합니다. 예를 들어, /namespace/project/-/pipelines/:id/ 경로의 메인 파이프라인 페이지로 이동하는 테스트를 만들려면 다음과 같이 합니다.

대부분의 기능 테스트는 적어도 사용자를 생성해야 합니다. 로그인 상태를 원하기 때문입니다. 로그인하지 않아도 되는 경우 이 단계를 건너뛸 수 있지만, 일반적인 규칙으로 익명 사용자가 보는 기능을 구체적으로 테스트하는 경우가 아니라면 항상 사용자를 생성해야 합니다. 이렇게 하면 섹션이 변경될 때 테스트에서 필요에 따라 편집하거나 새로운 권한 수준을 테스트할 수 있는 권한 수준을 명시적으로 설정할 수 있습니다. 사용자를 생성하려면:

  let(:user) { create(:user) }

이렇게 하면 새로 생성된 사용자를 담는 변수가 만들어지며, spec_helper를 임포트했기 때문에 create를 사용할 수 있습니다.

그러나 이 사용자는 아직 변수일 뿐이므로 아무 작업도 하지 않은 상태입니다. 따라서 spec의 before do 블록에서 해당 사용자로 로그인하면, 모든 spec이 인증된 사용자로 시작됩니다.

  let(:user) { create(:user) }

  before do
    sign_in(user)
  end

이제 사용자가 생겼으므로, 파이프라인 페이지에서 무언가를 검증하기 전에 무엇이 더 필요한지 살펴봐야 합니다. 라우트 /namespace/project/-/pipelines/:id/를 보면 프로젝트와 파이프라인이 필요하다는 것을 알 수 있습니다.

따라서 프로젝트와 파이프라인을 생성하고 서로 연결해야 합니다. 일반적으로 팩토리에서는 자식 요소가 부모를 인수로 필요로 합니다. 이 경우 파이프라인은 프로젝트의 자식입니다. 그래서 먼저 프로젝트를 생성하고, 파이프라인을 생성할 때 프로젝트를 인수로 전달하면 파이프라인이 해당 프로젝트에 "바인딩"됩니다. 파이프라인은 사용자에 의해 소유되기도 하므로 사용자도 필요합니다. 예를 들어 다음 코드는 프로젝트와 파이프라인을 생성합니다:

  let(:user) { create(:user) }
  let(:project) { create(:project, :repository) }
  let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id, user: user) }

같은 방식으로, build 팩토리를 사용하고 부모 파이프라인을 전달하여 job(빌드)을 생성할 수 있습니다:

  create(:ci_build, pipeline: pipeline, stage_idx: 10, stage: 'publish', name: 'CentOS')

이미 존재하는 팩토리가 많으므로, 필요한 것이 이미 있는지 확인하기 위해 기존 파일들을 살펴보세요.

네비게이션#

visit 메서드에 경로를 인수로 전달하면 해당 페이지로 이동할 수 있습니다. Rails는 헬퍼 경로를 자동으로 생성하므로, 하드코딩된 문자열 대신 이를 사용하세요. 라우트 모델을 사용하여 생성되므로, 파이프라인으로 이동하려면 다음과 같이 사용합니다:

  visit project_pipeline_path(project, pipeline)

요소 상호작용#

요소를 찾고 상호작용하는 방법은 다양합니다. 모범 사례는 UI 테스트 섹션을 참조하세요.

버튼을 클릭하려면 버튼 안의 텍스트 문자열과 함께 click_button을 사용하세요:

  click_button 'Text inside the button element'

링크를 따라가려면 click_link를 사용하세요:

  click_link 'Text inside the link tag'

입력/폼 요소를 채우려면 fill_in을 사용할 수 있습니다. 첫 번째 인수는 선택자이고, 두 번째는 전달할 값인 with:입니다.

  fill_in 'current_password', with: '123devops'

또는 find 선택자와 send_keys를 함께 사용하여 이전 텍스트를 지우지 않고 필드에 키를 추가하거나, 입력 요소의 값을 완전히 대체하는 set을 사용할 수 있습니다.

더 포괄적인 액션 목록은 기능 테스트 액션 문서에서 확인할 수 있습니다.

검증(Assertions)#

페이지에서 무언가를 검증하려면 언제든지 page 변수에 접근할 수 있습니다. 이 변수는 자동으로 정의되며 실제로는 페이지 문서를 의미합니다. 즉, page가 선택자나 콘텐츠 같은 특정 구성 요소를 가지고 있는지 검증할 수 있습니다. 다음은 몇 가지 예시입니다:

  # Finding a button
  expect(page).to have_button('Submit review')
  # Finding by text
  expect(page).to have_text('build')
  # Finding by `href` value
  expect(page).to have_link(pipeline.ref)
  # Find by data-testid
  # Like CSS selector, this is acceptable when there isn't a specific matcher available.
  expect(page).to have_css('[data-testid="pipeline-multi-actions-dropdown"]')
  # Finding by CSS selector. This is a last resort.
  # For example, when you cannot add attributes on the desired element.
  expect(page).to have_css('.js-icon-retry')
  # When a test case has back to back expectations,
  # it is recommended to group them using `:aggregate_failures`
  it 'shows the issue description and design references', :aggregate_failures do
    expect(page).to have_text('The designs I mentioned')
    expect(page).to have_link(design_tab_ref)
    expect(page).to have_link(design_ref_a)
    expect(page).to have_link(design_ref_b)
  end

하위 블록을 생성하여 다음을 수행할 수도 있습니다:

  • 어서션을 수행하는 범위를 좁혀 의도하지 않은 다른 요소를 찾을 위험을 줄입니다.

  • 요소가 올바른 경계 내에서 발견되는지 확인합니다.

  page.within('[data-testid="pipeline-multi-actions-dropdown"]') do
    ...
  end

더 포괄적인 매처 목록은 피처 테스트 매처 문서에서 확인할 수 있습니다.

백엔드 속성에 대한 어서션을 수행하기 전에, 먼저 가시적인 요소에 대해 어서션을 수행하여 작업이 완료되었는지 확인하세요. wait_for_requests는 요청이 발생하기 전에 대기가 호출될 경우 경쟁 조건이 발생할 수 있으므로 사용을 피하세요.

  click_button 'Leave project'

  # This ensures that the request to leave the project has completed
  expect(page).to have_text 'You left the project.'

  expect(project.reload.users.exists?(user.id)).to be(false)

Feature flags#

기본적으로 모든 피처 플래그는 YAML 정의나 GDK에서 수동으로 설정한 플래그에 관계없이 활성화됩니다. 피처 플래그가 비활성화되었을 때 테스트하려면, 이상적으로는 before do 블록에서 플래그를 수동으로 스텁 처리해야 합니다.

  stub_feature_flags(my_feature_flag: false)

ee 피처 플래그를 스텁 처리하는 경우 다음을 사용하세요:

  stub_licensed_features(my_feature_flag: false)

브라우저 콘솔 오류 어서션#

기본적으로 피처 스펙은 브라우저 콘솔 오류가 발견되더라도 실패하지 않습니다. 통합 문제를 나타낼 수 있는 예기치 않은 콘솔 오류가 없는지 확인하고 싶을 때가 있습니다.

브라우저 콘솔 오류가 발생하면 피처 스펙이 실패하도록 설정하려면, BrowserConsoleHelpers 지원 모듈의 expect_page_to_have_no_console_errors를 사용하세요:

RSpec.describe 'Pipeline', :js do
  after do
    expect_page_to_have_no_console_errors
  end

  # ...
end
`expect_page_to_have_no_console_errors`는 `WEBDRIVER=firefox`에서는 동작하지 않습니다. 로그는 Chrome 드라이버를 사용할 때만 캡처됩니다.

알려진 콘솔 오류 중 무시하고 싶은 것이 있을 수 있습니다. 특정 메시지 집합을 무시하여 해당 메시지가 관찰되더라도 테스트가 실패하지 않도록 하려면, expect_page_to_have_no_console_errorsallow: 파라미터를 전달하세요:

RSpec.describe 'Pipeline', :js do
  after do
    expect_page_to_have_no_console_errors(allow: [
      "Blow up!",
      /Foo.*happens/
    ])
  end

  # ...
end

spec/support/helpers/browser_console_helpers.rbBROWSER_CONSOLE_ERROR_FILTER 상수를 업데이트하여 전역적으로 무시해야 할 콘솔 오류 목록을 변경할 수 있습니다.

디버깅#

WEBDRIVER_HEADLESS=0 접두사를 사용하여 스펙을 실행하면 실제 브라우저를 열 수 있습니다. 그러나 스펙이 명령을 빠르게 처리하기 때문에 둘러볼 시간이 없습니다.

이 문제를 해결하려면 Capybara가 실행을 멈추길 원하는 줄에 binding.pry를 작성하면 됩니다. 그러면 표준 사용법으로 브라우저 내부에 진입하게 됩니다. 특정 요소를 찾을 수 없는 이유를 파악하려면:

  • 요소를 선택합니다.

  • 콘솔 및 네트워크 탭을 사용합니다.

  • 브라우저 콘솔 내에서 셀렉터를 실행합니다.

Capybara가 실행 중인 터미널에서 next를 실행하면 테스트를 한 줄씩 진행할 수 있습니다. 이 방법으로 모든 단일 상호작용을 하나씩 확인하여 문제의 원인을 파악할 수 있습니다.

GDK에서 실행 시간 개선#

Jest 테스트 스위트를 실행할 때 워커 수는 머신의 사용 가능한 코어의 60%를 사용하도록 설정됩니다. 이는 실행 시간을 단축시키지만 메모리 소비는 증가합니다. 이에 대한 더 많은 벤치마크는 이슈 456885를 참조하세요.

ChromeDriver 업데이트#

Selenium 4.6부터 ChromeDriver는 selenium-webdriver gem에 포함된 Selenium Manager에 의해 자동으로 관리될 수 있습니다. 더 이상 chromedriver를 수동으로 동기화할 필요가 없습니다.


테스트 문서로 돌아가기