프론트엔드 테스트 표준 및 스타일 가이드라인
GitLab v19.4요약
GitLab에서 프론트엔드 코드를 개발할 때 만나는 테스트 스위트는 두 종류입니다. 모든 신규 기능에는 단위 테스트와 기능 테스트를 작성해야 합니다. 대부분의 경우 기능 테스트에는 RSpec을 사용해야 합니다. 버그 수정에는 같은 버그가 다시 발생하지 않도록 회귀 테스트를 작성해야 합니다.
GitLab에서 프론트엔드 코드를 개발할 때 만나는 테스트 스위트는 두 종류입니다. JavaScript 단위 테스트와 통합 테스트에는 Jest를 사용하고, e2e(end-to-end) 통합 테스트에는 Capybara 기능 테스트를 사용합니다.
모든 신규 기능에는 단위 테스트와 기능 테스트를 작성해야 합니다.
대부분의 경우 기능 테스트에는 RSpec을 사용해야 합니다. 기능 테스트를 시작하는 방법은 기능 테스트 시작하기를 참고합니다.
버그 수정에는 같은 버그가 다시 발생하지 않도록 회귀 테스트를 작성해야 합니다.
GitLab의 일반적인 테스트 관행에 대한 자세한 내용은 테스트 표준 및 스타일 가이드라인 페이지를 참고합니다.
Vue.js 테스트#
Vue 컴포넌트 테스트 가이드를 찾고 있다면 이 섹션으로 바로 이동할 수 있습니다.
Vue 3 테스트에 대한 정보는 이 페이지에 있습니다.
Jest#
프론트엔드 단위 테스트와 통합 테스트를 작성할 때 Jest를 사용합니다.
Jest 테스트는 /spec/frontend에 있으며, EE에서는 /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.scssapp/assets/stylesheets/test_environment.scss
테스트 환경은 프로덕션 환경과 최대한 일치해야 하므로, 이 스타일시트는 최소한으로 사용하고 꼭 필요할 때만 내용을 추가합니다.
테스트 대상과 방법#
목과 스파이 같은 Jest 고유의 워크플로를 자세히 다루기 전에, 먼저 Jest로 무엇을 테스트해야 하는지 간단히 살펴보겠습니다.
라이브러리를 테스트하지 않기#
라이브러리는 모든 JavaScript 개발자의 삶에서 빼놓을 수 없는 부분입니다. 일반적인 조언은 라이브러리 내부를 테스트하지 말고, 라이브러리가 해야 할 일을 알고 있으며 자체 테스트 커버리지가 있다고 기대하라는 것입니다. 일반적인 예는 다음과 같습니다.
import { convertToFahrenheit } from 'temperatureLibrary'
function getFahrenheit(celsius) {
return convertToFahrenheit(celsius)
}
getFahrenheit 함수는 내부적으로 라이브러리 함수를 호출하는 것 외에는 아무 일도 하지 않고, 그 라이브러리 함수는 의도대로 동작한다고 기대할 수 있으므로 이 함수를 테스트하는 것은 의미가 없습니다.
Vue의 세계를 짧게 살펴보겠습니다. Vue는 GitLab JavaScript 코드베이스의 핵심 부분입니다. Vue 컴포넌트의 스펙을 작성할 때 흔히 빠지는 함정은, 가장 테스트하기 쉬워 보이는 Vue 제공 기능을 실제로 테스트하게 되는 것입니다. 다음은 코드베이스에서 가져온 예시입니다.
// Component script
{
computed: {
hasMetricTypes() {
return this.metricTypes.length;
},
}
<!-- Component template -->
<template>
<gl-dropdown v-if="hasMetricTypes">
<!-- Dropdown content -->
</gl-dropdown>
</template>
hasMetricTypes 계산된 속성을 테스트하는 것이 당연해 보입니다. 그러나 이 계산된 속성이 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 속성을 확인하고 있다면 멈추고, 대신 렌더링된 템플릿을 확인하도록 테스트를 다시 생각해 보는 것이 좋습니다.
더 많은 예시는 프론트엔드 단위 테스트 섹션에서 확인할 수 있습니다.
목을 테스트하지 않기#
또 다른 흔한 함정은 스펙이 결국 목이 동작하는지를 검증하게 되는 것입니다. 목을 사용한다면 목은 테스트를 지원해야 하며, 테스트의 대상이 되어서는 안 됩니다.
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>')
어설션에서 가져온 값을 사용하지 않기#
어설션에는 상수를 가져와 쓰기보다 리터럴 값을 사용하는 편이 좋습니다. 그러면 테스트를 읽기 쉽고 변경에 강해집니다. 이에 대해서는 국제화 권장 사항에서 더 자세히 다룹니다.
// 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');
});
사용자 흐름 따르기#
컴포넌트 중심의 세계에서는 단위 테스트와 통합 테스트의 경계가 꽤 모호할 수 있습니다. 가장 중요한 지침은 다음과 같습니다.
- 복잡한 로직을 격리해서 테스트하는 데 실질적인 가치가 있다면, 나중에 깨지지 않도록 깔끔한 단위 테스트를 작성합니다
- 그렇지 않다면 가능한 한 사용자 흐름에 가깝게 스펙을 작성합니다
예를 들어 메서드를 직접 호출해 데이터 구조나 계산된 속성을 검증하기보다, 생성된 마크업으로 버튼 클릭을 발생시키고 마크업이 그에 맞게 바뀌었는지 검증하는 편이 좋습니다. 테스트는 통과하면서 사용자 흐름은 실수로 깨질 가능성이 항상 있으며, 이는 잘못된 안도감을 줍니다.
일반적인 관행#
다음은 테스트 스위트에 포함된 몇 가지 일반적인 관행입니다. 이 가이드를 따르지 않는 부분을 발견하면 가능한 한 바로 수정하는 것이 좋습니다.
DOM 요소를 조회하는 방법#
테스트에서 DOM 요소를 조회할 때는 요소를 고유하고 의미론적으로 지정하는 것이 가장 좋습니다.
가능하면 DOM Testing Library를 사용해 사용자가 실제로 보는 것을 대상으로 지정합니다.
텍스트로 선택할 때는 접근성 모범 사례를 지키는 데 도움이 되므로 byRole 쿼리를 사용하는 것이 가장 좋습니다.
findByRole과 그 밖의 DOM Testing Library 쿼리는 shallowMountExtended 또는 mountExtended를 사용할 때 이용할 수 있습니다.
Vue 컴포넌트 단위 테스트를 작성할 때는 자식 컴포넌트의 동작이 가진 복잡성을 다루기보다 값을 포괄적으로 검증하는 데 집중할 수 있도록, 자식을 컴포넌트로 조회하는 것이 현명할 수 있습니다.
때로는 위 두 방법 모두 적용하기 어렵습니다. 이런 경우에는 셀렉터를 단순하게 만드는 테스트용 속성을 추가하는 것이 최선일 수 있습니다. 사용할 수 있는 셀렉터는 다음과 같습니다.
name같은 시맨틱 속성 (name이 제대로 설정되었는지도 함께 검증됩니다)data-testid속성 (@vue/test-utils관리자가 권장하는 방식) 필요에 따라shallowMountExtended또는mountExtended와 함께 사용
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-* 클래스를 추가하는 것은 권장하지 않습니다. 다른 방법이 전혀 없을 때만 그렇게 합니다.
Vue 템플릿 ref는 컴포넌트의 공개 API가 아니라 구현 세부 사항이므로, 테스트에서 DOM 요소를 조회하는 데 사용하지 않아야 합니다.
자식 컴포넌트 조회#
@vue/test-utils로 Vue 컴포넌트를 테스트할 때는 DOM 노드를 조회하는 대신 자식
컴포넌트를 조회하는 방법도 있습니다. 이는 테스트 대상 동작의 구현 세부 사항을
해당 컴포넌트의 개별 단위 테스트가 다룬다는 전제를 둡니다. 테스트가 대상 컴포넌트의 예상 동작을
안정적으로 다루는 한, DOM 쿼리와 컴포넌트 쿼리 중 어느 쪽을 쓸지에 대한
강한 선호는 없습니다.
예시:
it('exists', () => {
wrapper.findComponent(FooComponent);
});
단위/컴포넌트 테스트 이름 짓기#
단위/컴포넌트 테스트의 이름은 ${componentName}_spec.js로 지어야 합니다.
테스트 이름이 충분히 구체적이지 않다면 컴포넌트 이름을 바꾸는 것을 고려합니다.
예시:
diff_stats_dropdown.vue의 단위/컴포넌트 테스트 이름은 diff_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);
});
});
조건을 describe 블록으로 추출#
it 블록 설명에 "when X"가 나타나면 조건을 자체 describe 블록으로 추출하고,
설정은 beforeEach에서 수행합니다.
컨텍스트 중심 스펙은 관련 테스트를 함께 묶어, 사람과 AI 에이전트 모두 어떤 시나리오가 이미 다뤄졌는지 파악하고
올바른 위치에서 스펙을 업데이트할 수 있게 합니다.
나쁜 예:
it('displays an alert when the request fails', () => {
createComponent({ props: { hasError: true } });
expect(findAlert().exists()).toBe(true);
});
좋은 예:
describe('when the request fails', () => {
beforeEach(() => {
createComponent({ props: { hasError: true } });
});
it('displays an alert', () => {
expect(findAlert().exists()).toBe(true);
});
});
같은 이유로, 바깥쪽 beforeEach가 이미 컴포넌트를 생성했다면 it 블록에서 컴포넌트를 다시 생성하는 것은
피합니다.
대신 전용 설정이 있는 별도의 describe 블록으로 옮깁니다.
프라미스 테스트#
프라미스를 테스트할 때는 항상 테스트가 비동기이고 거부(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.');
});
테스트 함수에서 프라미스를 반환할 수도 있습니다.
프라미스를 다룰 때 done과 done.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()#
애플리케이션 자체가 일정 시간을 기다린다면 그 대기를 목으로 대체합니다. 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: 대기하는 이유가 불분명해지기 때문입니다. 또한 테스트에서는 가짜로 대체되어 있어 사용하기 까다롭습니다.setImmediate: Jest 27 이후 버전에서는 더 이상 지원되지 않기 때문입니다. 자세한 내용은 이 에픽을 참고합니다.
프라미스와 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()를 수동으로 호출할 필요가 없습니다.
다만 일부 목, 스파이, 픽스처는 여전히 정리해야 하며, 이때 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에는 toBe와
toEqual 매처가 있습니다.
toBe는
Object.is를 사용해
값을 비교하므로 (기본적으로) toEqual을 사용하는 것보다 빠릅니다.
toEqual도 결국에는 Object.is를 활용하는 방식으로 되돌아가지만,
원시 값에 대해서는 복잡한 객체를 비교해야 할 때만 toEqual을 사용해야 합니다.
예시:
const foo = 1;
// Bad
expect(foo).toEqual(1);
// Good
expect(foo).toBe(1);
더 적합한 매처를 우선 사용#
Jest는 toHaveLength나 toBeUndefined 같은 유용한 매처를 제공하여 테스트를 더 읽기 쉽게 하고 더 이해하기 쉬운 오류 메시지를
생성합니다. 자세한 내용은 문서의
매처 전체 목록을 확인합니다.
예시:
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는 toBeTruthy와 toBeFalsy 매처도 제공합니다. 이 매처는 테스트를 약하게 만들고 거짓 양성 결과를 내므로
사용하지 않아야 합니다.
예를 들어 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 설정은 가짜 타이머를 사용합니다.
비결정적 스펙 피하기#
비결정성은 불안정하고 깨지기 쉬운 스펙이 생기는 온상입니다. 이런 스펙은 결국 CI 파이프라인을 깨뜨려 다른 기여자의 작업 흐름을 방해합니다.
- 테스트 대상의 협력 객체(예: Axios, Apollo, Lodash 헬퍼)와 테스트 환경(예: Date)이 시스템과 시간에 관계없이 일관되게 동작하도록 합니다.
- 테스트가 집중되어 있고 "불필요한 작업"(예: 개별 테스트 안에서 테스트 대상을 불필요하게 두 번 이상 생성)을 하지 않도록 합니다.
결정성을 위한 Date 페이크 처리#
Jest 환경에서는 Date가 기본적으로 가짜로 대체됩니다. 즉 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 { 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에 의존한다면 이를 가짜로 대체하는 것을 고려합니다.
beforeEach(() => {
// https://xkcd.com/221/
jest.spyOn(Math, 'random').mockReturnValue(0.4);
});
테스트의 콘솔 경고와 오류#
예상하지 못한 콘솔 경고와 오류는 프로덕션 코드에 문제가 있다는 신호입니다.
테스트 환경은 엄격해야 하므로, 예상하지 못한 console.error나 console.warn 호출이
발생하면 테스트가 실패해야 합니다.
워처의 콘솔 메시지 무시#
우리가 제어할 수 없는 코드가 많기 때문에 기본적으로 무시되는 콘솔 메시지가 있으며,
이런 메시지가 사용되어도 테스트는 실패하지 않습니다. 무시되는 메시지 목록은
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 헬퍼를 사용합니다. 이 헬퍼는 자동으로
beforeAll과 afterAll을 호출해 테스트 컨텍스트에 대해 이 무시 목록을
설정하고 해제합니다.
테스트 유지 관리에 꼭 필요한 경우에만 아껴서 사용합니다. 예를 들면 다음과 같습니다.
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 목 처리 전략#
스터빙과 목 처리#
스텁과 스파이는 흔히 같은 의미로 쓰입니다. Jest에서는 .spyOn 메서드 덕분에 꽤 쉽습니다.
공식 문서
더 어려운 부분은 함수나 의존성에도 사용할 수 있는 목입니다.
수동 모듈 목#
수동 목은 Jest 환경 전체에서 모듈을 목으로 대체하는 데 사용합니다. 이는 테스트 환경에서 쉽게 사용할 수 없는 모듈을 목으로 대체해 단위 테스트를 단순화하는 매우 강력한 테스트 도구입니다.
목을 모든 스펙에 일관되게 적용하지 않아야 한다면(즉 일부 스펙에서만 필요하다면) 수동 목을 사용하지 않습니다.
대신 관련 스펙 파일에서 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이 이해하는 AMD 형식을 사용하지만 jest 환경과는 호환되지 않기 때문에 유용합니다. 이 목은 어떤 동작도 제거하지 않고 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) 모드로 스펙을 실행할 수 있습니다.
# 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
병합되지 않은 브랜치가 게시한 픽스처를 다운로드하려면 먼저 해당 브랜치의 파이프라인에서 수동
upload-frontend-fixtures-on-demand job을 실행합니다. 그런 다음 브랜치를 가져와
--branch에 전달하거나, 파이프라인 ID로 GitLab API를 통해 픽스처를 확인할 수 있습니다.
# Fetch the branch, then walk its commits
git fetch origin my-feature-branch
scripts/frontend/download_fixtures.sh --branch origin/my-feature-branch
# Or download fixtures published by a specific pipeline ID
scripts/frontend/download_fixtures.sh --pipeline 123456789
새 픽스처 생성#
각 픽스처의 response 변수 내용은 출력 파일에서 확인할 수 있습니다.
예를 들어 spec/frontend/fixtures/merge_requests.rb의 "merge_requests/diff_discussion.json"이라는 테스트는
tmp/tests/frontend/fixtures-ee/merge_requests/diff_discussion.json 출력 파일을 생성합니다.
response 변수는 테스트가 type: :request 또는 type: :controller로 표시되어 있으면 자동으로 설정됩니다.
새 픽스처를 만들 때는 (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.
이 JSON 픽스처는 앞에서 설명한 대로 test_fixtures 별칭을 사용해
Jest 테스트에서 가져올 수 있습니다.
데이터 기반 테스트#
RSpec의 매개변수화된 테스트와 비슷하게, Jest는 다음에 대한 데이터 기반 테스트를 지원합니다.
test.each를 사용하는 개별 테스트 (it.each로도 사용할 수 있음).describe.each를 사용하는 테스트 그룹.
이는 테스트 내 반복을 줄이는 데 유용합니다. 각 옵션은 데이터 값의 배열이나 태그된 템플릿 리터럴을 받을 수 있습니다.
예를 들면 다음과 같습니다.
// 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)
}
);
스펙 출력에서 보기 좋게 출력(pretty print)할 필요가 없는 경우에만 템플릿 리터럴 블록을 사용합니다. 예를 들어 빈 문자열, 중첩 객체 등이 해당합니다.
예를 들어 빈 검색 문자열과 비어 있지 않은 검색 문자열의 차이를 테스트할 때는 보기 좋게 출력 옵션이 있는 배열 블록 구문을 사용하는 것이 좋습니다. 그러면 빈 문자열('')과 비어 있지 않은 문자열('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
비동기 import로 인한 Jest 테스트 타임아웃#
모듈이 런타임에 다른 모듈을 비동기적으로 가져오면, 이 모듈들은 런타임에 Jest 로더가 트랜스파일해야 합니다. 이로 인해 Jest가 타임아웃될 수 있습니다.
이 문제가 발생하면 Jest가 컴파일 시점에 컴파일하고 캐시하도록 모듈을 즉시(eager) 가져오는 것을 고려합니다. 그러면 런타임 타임아웃이 해결됩니다.
다음 예시를 살펴보겠습니다.
// 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 번들과 청크를 분석하여 비동기 import에 프로덕션 문제가 없는지 확인합니다.
프론트엔드 테스트 수준 개요#
프론트엔드 테스트 수준에 대한 주요 정보는 테스트 수준 페이지에서 확인할 수 있습니다.
프론트엔드 개발과 관련된 테스트는 다음 위치에서 찾을 수 있습니다.
spec/frontend/: Jest 단위, 컴포넌트, 통합 테스트ee/spec/frontend/msw_integration/: MSW 통합 테스트 (EE 전용)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 통합 테스트는 EE 전용입니다. 모든 스펙과 공유 하네스는
ee/spec/frontend/msw_integration/ 아래에 있습니다.
ee/spec/frontend/msw_integration/
├── handlers.js # Aggregates the per-feature GraphQL handlers
├── polyfills.js # Environment polyfills (loaded by jest.config.msw_integration.js)
├── server.js # MSW server setup
├── test_setup.js # Global Jest setup and teardown; wires helpers into scope
├── core/ # Shared harness: fixture loading, variants, request assertions
│ ├── constants.js
│ ├── fixture_utils.js
│ ├── fixture_variant_schema.js
│ └── operation_helpers.js
├── helpers/ # Test lifecycle and mount helpers
│ ├── setup_utils.js
│ └── test_helpers.js
└── <feature>/ # One directory per feature area
├── handlers.js # Per-feature GraphQL handler
├── test_setup.js # Feature-specific mount helpers and provide config
├── fixture_variants/ # Named fixture variants for the feature
└── *_spec.js # Per-feature integration specs
공유 파일은 jest.config.msw_integration.js를 통해
자동으로 구성됩니다.
helpers/test_helpers.js에서 내보내는 모든 헬퍼 유틸리티는 test_setup.js의 Object.assign(global, testHelpers)를 통해
전역으로 자동 import됩니다. 새 헬퍼를 추가하려면
helpers/test_helpers.js에서 내보내면 되고, 그러면 모든 MSW 통합 테스트에서
전역으로 사용할 수 있습니다.
MSW 통합 테스트가 EE 전용인 이유#
MSW는 인증 헤더와 세션 상태를 포함한 네트워크 계층을 목으로 대체하므로, 라이선스도 암묵적으로 목으로 대체합니다. 그 결과 이 테스트로는 CE(FOSS)와 EE 동작의 차이를 단언할 수 없습니다. FOSS와 라이선스가 적용된 동작을 검증해야 한다면 대신 Capybara 기능 스펙을 사용합니다.
CE 경로 린트 가드#
spec/frontend/msw_integration/ 아래에 파일을 추가하면 다음 메시지와 함께 ESLint가
실패합니다. "MSW integration tests are EE-only; use Capybara for FOSS/licensed
behavior." 이는 의도된 동작입니다. 대신 ee/spec/frontend/msw_integration/ 아래에
파일을 배치합니다.
커뮤니티 기여자#
MSW 통합 스펙에는 EE 픽스처 생성 인프라가 필요합니다. 로컬에서 EE를 실행할 수 없다면 이슈를 열거나 GitLab 팀원에게 스펙 추가를 요청합니다.
핸들러 아키텍처#
MSW v1은 적용되는 첫 번째 rest.post 핸들러와 일치시키므로,
단일 GraphQL 엔드포인트를 여러 MSW 핸들러로 나눌 수 없습니다.
대신 handlers.js가 얇은 GraphQL 라우터 역할을 합니다. http://test.host/api/graphql에 대해
rest.post 핸들러를 하나 등록하고 기능별 리졸버 함수에 순서대로
위임합니다.
import { rest } from 'msw';
import { handleWorkItemOperation } from './work_items/handlers';
// 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을 사용해 픽스처를 자동으로 로드하고 핸들러를 빌드하는<feature>/handlers.js파일을 만듭니다. 자동 로드와 핸들러 빌드에 대한 자세한 내용은 기능 핸들러 작성을 참고합니다. -
handlers.js에 리졸버를 등록합니다.import { handleMergeRequestOperation, mergeRequestRestEndpoints } from './merge_requests/handlers'; export const featureHandlers = [ handleWorkItemOperation, handleMergeRequestOperation, ]; export const restEndpoints = [ ...workItemRestEndpoints, ...mergeRequestRestEndpoints, ]; -
ee/spec/frontend/fixtures/에 RSpec 스펙을 추가하고 실행하여 픽스처를 생성합니다. 자세한 내용은 픽스처 생성을 참고합니다.
픽스처 생성#
픽스처는 테스트 데이터베이스에 대해 실행한 실제 GraphQL 쿼리로 생성합니다.
각 픽스처 생성기는 ee/spec/frontend/fixtures/에 있는 RSpec 스펙입니다.
예를 들면 다음과 같습니다.
bundle exec rspec ee/spec/frontend/fixtures/work_items_integration.rb
이 명령은 tmp/tests/frontend/fixtures-ee/graphql/에 JSON 파일을 씁니다.
이 픽스처는 기능 핸들러 파일의 loadFixturesMap이 자동으로 로드하고
MSW가 제공합니다.
새 픽스처를 추가하려면 픽스처 생성기 스펙에 새 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
자동 로드를 위한 픽스처 이름 규칙#
픽스처 파일 이름이 GraphQL 작업 이름에 올바르게 대응하려면
loadFixturesMap으로 픽스처 자동 로드에
설명된 이름 규칙을 따릅니다.
기능 핸들러 작성#
각 <feature>/handlers.js 파일은 해당 기능 영역의 작업-픽스처
맵과 뮤테이션 로직을 담당합니다.
loadFixturesMap으로 픽스처 자동 로드#
fixture_utils.js의 loadFixturesMap을 사용하면 디렉터리에 있는
모든 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 'ee_jest/msw_integration/core/fixture_utils';
const FIXTURES_PATH = join('tmp/tests/frontend/fixtures-ee/graphql/my_feature/integration/');
const fixtures = loadFixturesMap(FIXTURES_PATH);
이 방식을 사용하면 각 픽스처 파일을 수동으로 import할 필요가 없습니다.
fixtures 객체에는 파생된 camelCase 작업 이름을 키로 하는 모든 픽스처가
들어 있습니다.
픽스처 맵으로 핸들러 빌드#
자동 로드된 fixtures를 FIXTURE_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 })));
}
픽스처 변형#
녹화된 픽스처는 GraphQL 쿼리의 기본 응답을 제공합니다. 오류, 빈 목록, 뒤집힌 플래그처럼 다른 형태의 응답을 테스트하려면 핸들러를 수정하는 대신 이름이 있는 변형을 선언합니다.
ee/spec/frontend/msw_integration/<feature>/fixture_variants/<query>.js 위치에 변형 파일을 둡니다.
이 파일은 기본 내보내기로 defineFixtureVariants({ query, variants })를 호출합니다.
query는 camelCase GraphQL 작업 이름입니다.
variants는 UPPER_SNAKE_CASE 키를 픽스처에 매핑합니다.
BASE는 필수이며 기본으로 제공됩니다.
fixture_utils.js의 변환 헬퍼 세 가지로 변형을 만듭니다.
각 헬퍼는 입력을 깊은 복제하여 새 픽스처를 반환하므로, import한 값을 직접 복제하거나 변경할 필요가 없습니다.
setFixtureData(fixture, lookupKey, value)는 data를 깊이 우선으로 탐색하여 처음 일치하는 키를 설정합니다.
setFixtureErrors(fixture, ['message'])는 errors를 설정하고 data를 비웁니다.
setFixtureItemsCount({ fixture, lookupKey, itemCount })는 lookupKey 아래에 있는 연결의 nodes 배열 크기를 조정합니다. 여기서 lookupKey는 nodes가 아니라 연결 키입니다.
import base from 'test_fixtures/graphql/work_items/integration/get_work_items_full.query.graphql.json';
import { defineFixtureVariants } from 'ee_jest/msw_integration/core/fixture_variant_schema';
import { setFixtureItemsCount } from 'ee_jest/msw_integration/core/fixture_utils';
export default defineFixtureVariants({
query: 'getWorkItemsFullEE',
variants: {
BASE: base,
EMPTY: setFixtureItemsCount({ fixture: base, lookupKey: 'workItems', itemCount: 0 }),
},
});
테스트에서는 ee_jest/msw_integration/helpers/setup_utils에서 가져온 setQueryVariant로 변형을 활성화합니다.
변형 파일의 기본 내보내기인 쿼리 상수를 전달하고, 변형 키 이름을 딴 메서드를 호출합니다.
EMPTY는 empty()가 되고 WITH_ERROR는 withError()가 되므로, 알 수 없는 키는 철자를 입력할 수 없고 에디터 자동 완성은 해당 쿼리의 변형을 나열합니다.
활성 변형은 afterEach에서 자동으로 BASE로 초기화됩니다.
import { setQueryVariant } from 'ee_jest/msw_integration/helpers/setup_utils';
import getWorkItemsFull from 'ee_jest/msw_integration/work_items/fixture_variants/get_work_items_full';
it('renders the empty state', async () => {
setQueryVariant(getWorkItemsFull).empty();
// mount and assert
});
변형 키가 런타임 값인 경우(예: 키를 받는 공유 헬퍼) 대신 저수준 activateVariant('operationName', variantKey)를 사용합니다.
기능 핸들러는 getActiveVariant('operationName')을 호출해 활성 변형을 제공하고, 활성 변형이 없으면 기본 픽스처로 되돌아갑니다.
변형 레지스트리 동작 방식#
defineFixtureVariants는 fixture_variant_schema.js 안의 모듈 수준 레지스트리에 쿼리를 추가하여 모듈 로드 시점에 스스로 등록합니다. 이를 사용하기 전에 이해해야 할 결과가 세 가지 있습니다.
변형 파일은 기능 핸들러에서 import해야 합니다.
변형 파일의 기본 내보내기가 아닌 것을 setQueryVariant에 전달하면 "expected a query constant" 오류가 발생합니다.
저수준 activateVariant(query, key)는 전달된 쿼리 이름이 등록된 적이 없으면 "no variants registered for query" 오류가 발생합니다.
어느 경우든 변형 파일을 import하지 않은 것입니다. 스펙이 아니라 기능 핸들러에서 (부수 효과 import로) import합니다.
// <feature>/handlers.js
import './fixture_variants/my_query'; // registers myQuery on load
getActiveVariant는 BASE 픽스처가 아니라 BASE에 대해 null을 반환합니다.
활성 변형이 없을 때(또는 활성 변형이 BASE일 때) 핸들러 자체의 기본 픽스처가 제공되도록, 핸들러는 널 병합 대체 패턴을 사용해야 합니다.
myQuery: () => getActiveVariant('myQuery') ?? myQueryFixture,
여기서는 ||보다 ??를 사용합니다. getActiveVariant는 검증된 픽스처 객체 또는 null만 반환하므로 실제로는 두 연산자가 똑같이 동작하지만, ??는 활성 변형이 없을 때만 대체한다는 의도를 나타내고 모든 핸들러의 일관성을 유지합니다.
각 쿼리는 한 번만 등록할 수 있습니다.
같은 쿼리 이름에 대해 defineFixtureVariants가 두 번 실행되면 "variants for query X are already registered" 오류가 발생합니다. 변형 파일 하나를 여러 곳에서 import해도 안전합니다. 모듈 본문은 한 번만 실행되므로 등록도 한 번만 실행됩니다. 이 오류는 서로 다른 변형 파일 두 개가 같은 query 이름을 선언했다는 뜻이며, 대개 복사하여 붙여넣기 때문에 생깁니다. 쿼리마다 변형 파일을 하나만 두고 기능 핸들러에서 import합니다.
yarn msw:variants로 등록된 모든 쿼리와 해당 변형 키의 매니페스트를 생성합니다.
키만 담은 JSON 파일이 tmp/tests/frontend/msw_variants.manifest.json에 작성됩니다.
매니페스트는 커밋하지 않습니다.
필요할 때 다시 생성하므로 유지 관리 비용이 없습니다.
어떤 쿼리에 어떤 변형이 있는지 확인하려면 이 파일을 읽습니다.
Apollo 캐시 무결성 검증#
operation_helpers.js의 요청 추적 유틸리티를 사용하여
뮤테이션이 Apollo 캐시를 업데이트하며, 캐시 불일치로 인해 불필요한 네트워크 호출이
발생하지 않는지 검증합니다.
가로챈 모든 GraphQL 작업과 REST 엔드포인트는
capturedRequests에 기록됩니다. 전역 test_setup.js가 afterEach에서 이 기록을 초기화하므로
각 테스트는 깨끗한 횟수로 시작합니다. 최초 초기화 이후에 예기치 않은 작업이 발생한다면
자체 테스트 스위트에서 수동으로 호출해야 할 수 있습니다.
| 함수 | 설명 |
|---|---|
snapshotRequests() |
현재 시점의 operationName -> count 맵을 반환합니다. |
getSnapshotRequestsDiff(baseline, current) |
두 스냅샷 사이에 발생한 작업을 반환합니다. |
expectGraphQLCalls(baseline, { expect, forbid }) |
expect 작업은 발생했고 forbid 작업은 발생하지 않았음을 단언합니다. |
lastRequestVariables(operationName) |
operationName에 대해 마지막으로 캡처된 호출의 변수를 반환합니다. 작업이 한 번도 호출되지 않았다면 예외를 발생시킵니다. 올바른 필터 변수가 전송되었는지 단언할 때 유용합니다. |
작업 전에 스냅샷을 찍고, 작업을 수행한 다음 waitFor 안에서
단언합니다. forbid의 항목은 문자열이거나 작업 계열과 일치하는
정규 표현식일 수 있습니다.
import { snapshotRequests, expectGraphQLCalls } from 'ee_jest/msw_integration/core/operation_helpers';
it('updates the comment count without refetching the list', async () => {
const baseline = snapshotRequests();
await waitAndSetValue(findTextarea, 'Test comment from drawer');
await waitAndClick(findConfirmButton);
await waitFor(() => {
expect(getText(findIssuableComments())).toContain('1');
expectGraphQLCalls(baseline, {
expect: ['createWorkItemNote'],
forbid: ['getWorkItemsFullEE', 'getWorkItemsFull'],
});
});
});
expectGraphQLCalls 없이 snapshotRequests 두 개를 일치시키는 방식은 피합니다. 금지된 작업이 발생하면 expectGraphQLCalls가 예기치 않은 호출을 강조하는 Jest diff와 함께 예외를 발생시켜 디버깅이 쉬워집니다. 예시는
댓글을 만들 때 불필요한 목록 다시 가져오기가 발생했던 캐시 무결성 회귀를
머지 리퀘스트 236298에서
확인할 수 있습니다.
테스트 파일 작성#
테스트 파일은 ee/spec/frontend/msw_integration/ 아래에서 기능 영역을
그대로 반영한 하위 디렉터리에 둡니다. 각 파일은 다음을 따라야 합니다.
- 라우터 팩토리를 직접 호출하지 않고
test_helpers.js의assignRouter로 라우터를 만듭니다. 이렇게 하면 라우터가 전역으로 등록되어test_setup.js가 테스트 사이에 초기화할 수 있습니다. test_helpers.js의fullMount와 실제apolloProvider로 루트 컴포넌트를 마운트합니다.fullMount는@vue/test-utils의mount를 감싸며 자동으로document.body에 연결합니다.- API 호출을 발생시키는 동작 후에는
@testing-library/dom의waitFor를 사용합니다. - 네이티브 DOM API(
.click(),.dispatchEvent(),.querySelector())로 UI와 상호작용합니다. - Vue 컴포넌트 상태가 아니라 DOM을 단언합니다.
Vue Test Utils 래퍼보다 DOM 단언 우선#
test_helpers.js의 fullMount는 컴포넌트를 만들 때만 사용합니다. 마운트한 후에는
DOM과 직접 상호작용하고 DOM을 단언합니다. 그러면 테스트가
Vue 버전에 종속되지 않으며 향후 Vue 3 마이그레이션이 수월해집니다.
다음과 같이 Vue에 결합된 패턴은 피합니다.
wrapper.find(),wrapper.findComponent(),wrapper.trigger(),wrapper.text(),wrapper.attributes(),wrapper.exists().vm.$emit(),vm.$data또는 컴포넌트 인스턴스 속성에 접근하는 것.el.__vue__또는createWrapper()를 사용해 DOM 요소에서 VTU 래퍼를 얻는 것.
대신 네이티브 DOM 대응 항목을 사용합니다.
| Vue Test Utils | DOM 대응 항목 |
|---|---|
.find(selector) |
.querySelector(selector) |
.trigger('click') |
.click() |
.trigger('submit') |
.dispatchEvent(new Event('submit', { bubbles: true })) |
.text() |
test_helpers.js의 getText(el) |
.attributes('name') |
.getAttribute('name') 또는 .dataset |
.exists() |
!== null |
.setValue(val) |
el.value = val; el.dispatchEvent(new Event('input', { bubbles: true })) |
ee_jest/msw_integration/helpers/test_helpers의 다음 헬퍼는 일반적인 비동기 상호작용 패턴을 다룹니다.
| 헬퍼 | 설명 |
|---|---|
waitForElement(finder) |
finder()가 null이 아닌 요소를 반환할 때까지 폴링한 다음 그 요소를 반환합니다. |
waitForElementToBeNull(finder) |
finder()가 null을 반환할 때까지 폴링합니다. 요소가 사라지는지 단언할 때 사용합니다. |
waitAndClick(finder) |
요소가 나타나기를 기다린 다음 클릭합니다. |
waitAndSetValue(finder, value[, eventType]) |
입력 요소가 나타나기를 기다린 다음 값을 설정하고 이벤트를 디스패치합니다. |
findButtonByText(text[, container]) |
보이는 레이블이나 aria-label이 text와 일치하는 첫 번째 버튼을 반환합니다. |
setInputValue(input, value[, eventType]) |
입력 요소에 값을 설정하고 이벤트를 동기적으로 디스패치합니다. |
waitForAssertion(fn) |
일반 단언 함수를 waitFor로 감쌉니다. 요소를 찾을 필요 없이 기다리기만 하면 될 때 사용합니다. |
getText(el) |
공백이 정규화된 el의 텍스트 콘텐츠를 반환합니다. VTU .text()와 같습니다. |
findByGraphQLId(graphqlId, getIdFromGraphQLId[, prefix]) |
ID가 GraphQL ID에서 파생된 DOM 요소를 찾습니다. |
다음은 최소한의 예시입니다.
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가 실제 네트워크 요청을 가로챕니다.test_helpers.js의fullMount를 사용합니다(shallowMountExtended나mountExtended는 사용하지 않습니다). 현실적인 상호작용 테스트를 위해 전체 컴포넌트 트리가 렌더링되어야 합니다.fullMount는mount를 감싸며 자동으로document.body에 연결합니다.- 라우터를 만들 때는
test_helpers.js의assignRouter를 사용합니다. 이렇게 하면 라우터가 전역으로 등록되어test_setup.js가 테스트 사이에 초기화할 수 있습니다. 라우터 팩토리 함수를 직접 호출하거나 경로를 수동으로 push하지 않습니다. - 마운트한 후에는 모든 상호작용과 단언에 네이티브 DOM API를 사용합니다. 그러면 Vue 내부 구현에 결합되는 것을 피하고 Vue 3 호환성을 보장할 수 있습니다.
- 자식 컴포넌트를 목으로 대체하지 않습니다. 목적은 자식 컴포넌트들이 함께 동작하는 방식을 테스트하는 것입니다.
- 테스트 간 상태 누수를 방지하기 위해
beforeEach에서 Apollo 캐시를 초기화합니다. MSW 2는 이벤트 루프 틱에 걸쳐 응답 본문을 스트리밍하여 테스트가 읽는 도중에 끝날 수 있으므로, 테스트 하네스는 진행 중인 Apollo 작업도 취소합니다. - 래퍼 소멸이나 Apollo 클라이언트 해제를 위한
afterEach정리를 추가하지 않습니다. 전역test_setup.js가 라우터 초기화, 래퍼 소멸, 메타데이터 정리를 처리합니다. - 서버 라이프사이클(
server.listen,server.resetHandlers,server.close)은test_setup.js가 전역으로 처리합니다. 개별 테스트 파일에 이 호출을 추가하지 않습니다.
Apollo 요청의 테스트 격리#
MSW 2는 여러 이벤트 루프 틱에 걸쳐 응답 본문을 스트리밍합니다. Apollo가 응답을 읽는 도중에 테스트가
끝나면, cache.reset()이 진행 중인 요청을 취소하지 않으므로 그 읽기가 다음 테스트가 이미 시작된 뒤에
완료될 수 있습니다. 그러면 Apollo의 쿼리 중복 제거가 다음 테스트의 동일한 쿼리에
그 오래된 응답을 재사용합니다. 이를 방지하기 위해
test_setup.js는 beforeEach에서 clearMountedApolloStores()를 호출하며,
이 함수는 각 테스트가 실행되기 전에 진행 중인 가져오기를 취소합니다.
스펙 파일은 평소의 cache.reset() 외에
추가로 할 일이 없습니다.
MSW 통합 테스트 실행#
모든 MSW 통합 테스트를 실행합니다.
yarn jest:msw-integration
단일 파일을 실행합니다.
yarn jest:msw-integration ee/spec/frontend/msw_integration/work_items/agent_plan_spec.js
CI에서 이 테스트는 jest-msw-integration job(tier-2 이상 파이프라인)에서 실행됩니다.
테스트 헬퍼#
테스트 헬퍼는 spec/frontend/__helpers__에서 찾을 수 있습니다.
새 헬퍼를 추가한다면 해당 디렉터리에 둡니다.
Vuex 헬퍼: testAction#
공식 문서에 따라 액션 테스트를 쉽게 해주는 헬퍼가 있습니다.
// prefer using like this, a single object argument so parameters are obvious from reading the test
await testAction({
action: actions.actionName,
payload: { deleteListId: 1 },
state: { lists: [1, 2, 3] },
expectedMutations: [ { type: types.MUTATION} ],
expectedActions: [],
});
// old way, don't do this for new tests
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 테스트용 헬퍼 메서드 두 개가 있습니다.
요청의 Promise에 대한 핸들이 없을 때, 예를 들어 Vue 컴포넌트가 라이프사이클의 일부로 요청을 보낼 때 매우 유용합니다.
waitFor(url, callback):url에 대한 요청이 (성공하든 실패하든) 끝난 후callback을 실행합니다.waitForAll(callback): 대기 중인 모든 요청이 끝나면callback을 실행합니다. 대기 중인 요청이 없으면 다음 틱에callback을 실행합니다.
두 함수 모두 .then() 또는 .catch() 핸들러가 실행될 수 있도록 요청이 끝난 다음 틱에 (setImmediate()를 사용해) callback을 실행합니다.
shallowMountExtended 및 mountExtended#
shallowMountExtended와 mountExtended 유틸리티를 사용하면 사용 가능한
DOM Testing Library 쿼리를
find 또는 findAll을 앞에 붙여 수행할 수 있습니다.
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개 이상의 모바일 기기와 브라우저를 테스트할 수 있습니다. 라이브 앱을 통해 직접 사용하거나 Chrome 확장 프로그램을 설치해 쉽게 접근할 수 있습니다. 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는 모범 사례에 대한 훌륭한 문서를 제공하며, 스냅샷을 만들 때 이를 염두에 두어야 합니다.
스냅샷의 동작 방식#
스냅샷은 함수 호출의 왼쪽에서 테스트하도록 요청한 대상을 문자열로 변환한 것일 뿐입니다. 따라서 문자열 형식에 어떤 변경을 하든 결과에 영향을 줍니다. 이 과정은 직렬 변환기를 활용한 자동 변환 단계로 수행됩니다. 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>
나쁜 예:
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-utils 메서드 wrapper.text()로 매우 쉽게 찾을 수 있습니다. 다만 서식이나 HTML 중첩 때문에 반환된 값에 일관되지 않은 공백이 많을 때는 스냅샷을 사용하고 싶어질 수 있습니다.
이런 경우에는 스냅샷으로 공백을 무시하기보다 각 문자열을 개별적으로 단언하여 여러 단언을 작성하는 편이 좋습니다. 텍스트 형식이 여전히 완벽하더라도 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();
})
위 테스트는 스냅샷을 두 개 생성합니다. 어느 스냅샷이 코드베이스 안전성에 더 큰 가치를 제공하는지 결정하는 것이 중요합니다. 즉 이 스냅샷 중 하나가 변경되면 코드베이스에서 발생할 수 있는 문제를 알려 주는지 판단해야 합니다. 이렇게 하면 우리가 모르는 사이에 기반 의존성의 무언가가 바뀌었을 때 예기치 않은 변경을 잡아내는 데 도움이 됩니다.
기능 테스트 시작하기#
기능 테스트는 실제 UI에서 의미 있는 동작을 수행하여 기능의 전체 흐름을 검증합니다.
기능 테스트를 사용하는 경우#
다음과 같은 테스트에는 기능 테스트를 사용합니다.
- 페이지에서 서로 상호작용하는 여러 컴포넌트에 걸쳐 있는 경우.
- 사용자가 여러 페이지를 이동해야 하는 경우.
- 폼을 제출하고 다른 곳에서 결과를 확인하는 경우.
- 단위 테스트로 작성하면 과도한 목과 스텁이 필요한 경우.
기능 테스트는 다음을 테스트하려는 경우에 특히 유용합니다.
- 여러 컴포넌트가 함께 성공적으로 동작하는지.
- 단위 테스트 목으로 재현하기 어려운 복잡한 API 상호작용. 속도는 느리지만 어떤 수준의 목 처리도 필요하지 않습니다.
기능 테스트를 사용하지 않는 경우#
이러한 방법으로 같은 테스트 결과를 얻을 수 있다면 기능 테스트 대신 jest와 vue-test-utils 단위 테스트를 사용합니다.
기능 테스트는 단위 테스트보다
실행 비용이 큽니다.
다음과 같은 경우에는 단위 테스트를 사용합니다.
- 동작이 모두 하나의 컴포넌트 안에 있는 경우.
- 다른 컴포넌트의 동작을 시뮬레이션하여 원하는 효과를 발생시킬 수 있는 경우.
- 가상 DOM에서 UI 요소를 선택하여 원하는 효과를 발생시킬 수 있는 경우.
적합한 기능 테스트 유형 선택#
기능 테스트가 적절하다고 판단했다면 GitLab에는 두 가지 유형이 있습니다. MSW 통합 테스트가 속도 면에서 훨씬 빠르므로 기본적으로 MSW 통합 테스트를 사용합니다.
다음과 같은 경우 MSW 통합 테스트(ee/spec/frontend/msw_integration/, EE 전용)를 사용합니다.
- 테스트가 단일 페이지에서 여러 컴포넌트의 상호작용(예: 목록 + 드로어)을 다루는 경우.
- 백엔드 응답을 자동 생성된 픽스처로 표현할 수 있는 경우.
- 데이터베이스 상태, 인가, 서버 측 유효성 검사 또는 실시간 업데이트를 검증할 필요가 없는 경우.
- FOSS와 EE 사이에 동작 차이가 없는 경우. MSW는 라이선스를 목으로 대체하므로 FOSS와 라이선스가 적용된 동작의 차이를 단언할 수 없습니다. 이런 경우에는 Capybara를 사용합니다.
다음과 같은 경우 Capybara 기능 테스트(spec/features/)를 사용합니다.
- 테스트에 실제 백엔드가 필요한 경우(데이터베이스 쓰기, 인가 확인, 서버 측 유효성 검사).
- 테스트에 서버에서 렌더링되는 여러 페이지 간 이동이 필요한 경우.
- 픽스처로 표현할 수 없는 백엔드 상태에 의존하는 동작을 검증해야 하는 경우.
- 같은 페이지의 여러 Vue 애플리케이션에 의존하는 동작을 테스트해야 하는 경우
Capybara 기능 테스트#
Capybara 기능 테스트는 화이트 박스 테스트라고도 하며, 브라우저를 실행하고 Capybara 헬퍼를 사용하는 테스트입니다. 즉 이 테스트는 다음을 할 수 있습니다.
- 브라우저에서 요소를 찾습니다.
- 해당 요소를 클릭합니다.
- API를 호출합니다.
Capybara 기능 테스트는 실행 비용이 큽니다. 작성하기 전에 MSW 통합 테스트로 같은 커버리지를 달성할 수 없는지 확인합니다.
모든 Capybara 기능 테스트는 Ruby로 작성되지만, 사용자에게 보이는 기능을 구현하는 JavaScript 엔지니어가 작성하게 되는 경우가 많습니다. 다음 섹션은 Ruby나 Capybara에 대한 사전 지식이 없다고 가정하며, 이러한 테스트를 언제 어떻게 사용해야 하는지에 대한 명확한 지침을 제공합니다.
또한 새 코드의 동작에 여러 컴포넌트가 함께 동작해야 한다면 컴포넌트 트리의 더 높은 위치에서 동작을 테스트하는 것을 고려해야 합니다. 예를 들어 다음 코드를 가진 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이 이벤트를 내보냅니다.ParentComponent가internalData값을 변경합니다.ParentComponent가 props를ChildComponent2로 전달합니다.
대신 다음과 같이 단위 테스트를 사용할 수 있습니다.
ParentComponent단위 테스트 파일 안에서childComponent1이 내보내는 것으로 기대하는 이벤트를 발생시킵니다- prop이
childComponent2로 전달되는지 확인합니다.
그런 다음 각 자식 컴포넌트의 단위 테스트에서 이벤트가 발생했을 때와 prop이 변경되었을 때 어떤 일이 일어나는지 테스트합니다.
이 예시는 더 큰 규모와 더 깊은 컴포넌트 트리에도 적용됩니다. 다음과 같은 경우 단위 테스트를 사용하여 기능 테스트의 추가 비용을 피하는 것이 확실히 가치가 있습니다.
- 자식 컴포넌트를 안정적으로 마운트할 수 있는 경우.
- 이벤트를 발생시키거나 가상 DOM에서 요소를 선택할 수 있는 경우.
- 원하는 테스트 동작을 얻을 수 있는 경우.
테스트를 만들 위치#
기능 테스트는 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'그 밖에 관련된 모듈도 가져옵니다.
-
jest에서 처음 describe 블록을 만드는 것처럼, RSpec이 테스트를 정의할 전역 스코프를 만듭니다.
그런 다음 가장 처음의 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를 사용할 수 있습니다.
그러나 이 사용자는 변수일 뿐이므로 아직 아무것도 하지 않았습니다. 그래서 스펙의 before do 블록에서 사용자로 로그인하면 모든 스펙이 인증된 사용자로 시작하게 할 수 있습니다.
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(build)을 만들 수 있습니다.
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을 사용하면 입력 요소의 값을 완전히 바꿉니다.
더 포괄적인 동작 목록은 기능 테스트 동작 문서에서 확인할 수 있습니다.
단언#
페이지에서 무언가를 단언하려면 자동으로 정의되어 있으며 실제로 페이지 문서를 의미하는 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나
wait_for_all_requests를 사용하지 않습니다. 이유와 대안은 wait_for_requests 또는 wait_for_all_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)
기능 플래그#
기본적으로 모든 기능 플래그는 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_errors에
allow: 매개변수를 전달할 수 있습니다.
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.rb의 BROWSER_CONSOLE_ERROR_FILTER 상수를
업데이트합니다. 이 필터는 실패한 :js 예시 뒤에 실행되는 자동 검사와 공유되므로, 이를 업데이트하면 expect_page_to_have_no_console_errors가
허용하는 항목과, 관련 없는 실패와 함께 BrowserConsoleError를 발생시킬 수 있는 항목이 모두
바뀝니다.
디버깅#
WEBDRIVER_HEADLESS=0 접두사를 붙여 스펙을 실행하면 실제 브라우저가 열립니다. 그러나 스펙이 명령을 빠르게 지나가 살펴볼 시간이 없습니다.
이 문제를 피하려면 Capybara가 실행을 멈추기를 원하는 줄에 binding.pry를 작성합니다. 그러면 표준 사용 방식으로 브라우저 안에 들어가게 됩니다. 특정 요소를 찾을 수 없는 이유를 파악하려면 다음을 할 수 있습니다.
- 요소를 선택합니다.
- 콘솔과 네트워크 탭을 사용합니다.
- 브라우저 콘솔에서 셀렉터를 실행합니다.
Capybara가 실행 중인 터미널에서 next를 실행할 수도 있으며, 테스트를 한 줄씩 진행합니다. 이렇게 하면 모든 상호작용을 하나씩 확인하여 무엇이 문제를 일으키는지 알아볼 수 있습니다.
GDK에서 실행 시간 개선#
Jest 테스트 스위트를 실행할 때 워커 수는 사용 가능한 머신 코어의 60%를 사용하도록 설정됩니다. 이렇게 하면 실행 시간은 빨라지지만 메모리 소비는 늘어납니다. 이 방식의 벤치마크에 대한 자세한 내용은 이슈 456885를 참고합니다.
ChromeDriver 업데이트#
Selenium 4.6부터는 selenium-webdriver gem에 포함된 Selenium Manager가 ChromeDriver를 자동으로 관리할 수 있습니다.
더 이상 chromedriver를 수동으로 동기화할 필요가 없습니다.