Vue 3 테스팅
GitLab v19.1Vue 3으로 전환하는 과정에서 테스트가 Vue 3 모드에서 통과되는 것이 중요합니다. 현재 다음 경우에 파이프라인이 실패합니다: Vue 3 모드에서 실패하는 새 테스트 파일이 추가된 경우. 이전에는 통과하던 기존 테스트 파일이 Vue 3에서 실패하는 경우.
Vue 3으로 전환하는 과정에서 테스트가 Vue 3 모드에서 통과되는 것이 중요합니다. 올바른 Vue 3 테스팅을 강제하기 위해 파이프라인에 점진적으로 더 엄격한 검사를 추가하고 있습니다.
현재 다음 경우에 파이프라인이 실패합니다:
-
Vue 3 모드에서 실패하는 새 테스트 파일이 추가된 경우.
-
이전에는 통과하던 기존 테스트 파일이 Vue 3에서 실패하는 경우.
-
격리 목록에 있는 알려진 실패 테스트 중 하나가 이제 통과하는데 격리 목록에서 제거되지 않은 경우.
Vue 3로 로컬에서 단위 테스트 실행#
Vue 3을 사용하여 단위 테스트를 실행하려면, jest를 실행할 때 VUE_VERSION 환경 변수를 3으로 설정하세요.
VUE_VERSION=3 yarn jest #[file-path]
파이프라인도 단위 테스트 스위트를 실행합니다.
Vue 3로 로컬에서 시스템 테스트(spec/features) 실행#
Vue 3을 사용하여 시스템 테스트를 실행하려면 GDK가 Vue 3가 활성화된 상태로 실행 중이어야 합니다. 다음 명령어로 활성화할 수 있습니다:
gdk config set vite.vue_version 3 # 또는 vue 2로 돌아가려면 2
gdk reconfigure
gdk restart
평소처럼 rspec으로 테스트를 실행하세요.
bundle exec rspec spec/features/abuse_report_spec.rb
브라우저에서 로컬 인스턴스를 방문하면 Vue 3(@vue/compat)로도 실행됩니다.
이를 통해 Vue 3에서 기능을 수동으로 테스트할 수 있습니다.
머지 리퀘스트에서 Vue 3로 시스템 테스트 스위트 실행#
머지 리퀘스트 변경 사항이 Vue 3와 호환되는지 확인하려면, ~pipeline:run-rspec-vue3 라벨을 추가하여 파이프라인에서 시스템 테스트 스위트를 실행하세요.
rspec system pg17 vue3 및 rspec-ee system pg17 vue3라는 이름의 RSpec job이 추가되어 일반적인 rspec system job과 병렬로 실행되지만, Vue 3 빌드를 사용합니다!
단위 테스팅 주의사항#
컴포저블 모킹 시 Ref 관리#
Vue 3 컴포저블을 테스트할 때 흔한 패턴은 해당 파일이 반환하는 ref 또는 computed 값을 모킹하는 것입니다.
다음 데모 컴포저블을 살펴보세요:
export const useCounter = () => {
const counter = ref(1)
const increase = () => { counter.value += 1 }
return { counter, increase }
}
이 컴포저블을 현재 사용하고 카운터를 노출하는 컴포넌트가 있다면, 기능을 커버하는 테스트를 작성하고 싶을 것입니다. 이 간단한 예시처럼 일부 경우에는 컴포저블을 전혀 모킹하지 않아도 되지만, Tanstack Query 래퍼나 Apollo 래퍼와 같이 더 복잡한 기능의 경우에는 jest.mock을 활용하는 것이 필요할 수 있습니다.
이런 경우 테스트 파일은 컴포저블 모킹이 필요합니다:
<script setup>
const { counter, increase } = useCounter()
</script>
<template>
<p>Super useful counter: {{ counter }}</p>
<button @click="increase">+</button>
</template>
import { ref } from 'vue'
import { useCounter } from '~/composables/useCounter'
jest.mock('~/composables/useCounter')
describe('MyComponent', () => {
const increaseMock = jest.fn()
const counter = ref(1)
beforeEach(() => {
useCounter.mockReturnValue({
increase: increaseMock,
counter
})
})
describe('When the counter is 2', () => {
beforeEach(() => {
counter.value = 2
createComponent()
})
it('...', () => {})
})
it('should default to 1', () => {
createComponent()
expect(findSuperUsefulCounter().text()).toBe(1)
// failure
})
})
위 예시에서 컴포저블이 반환하는 함수의 목과 counter ref 모두 생성하고 있지만, 예시에서 매우 중요한 단계가 빠져 있습니다.
counter 상수는 ref이므로, 매 테스트마다 수정하면 할당한 값이 유지됩니다. 예시에서 두 번째 it 블록은 counter가 이전 테스트에서 할당된 값을 유지하기 때문에 실패할 것입니다.
해결책이자 모범 사례는 최상위 beforeEach 블록에서 항상 ref를 초기화하는 것입니다.
import { ref } from 'vue'
import { useCounter } from '~/composables/useCounter'
jest.mock('~/composables/useCounter')
describe('MyComponent', () => {
const increaseMock = jest.fn()
// 더 안전하게 하려면 `undefined`로 초기화할 수 있습니다
const counter = ref(undefined)
beforeEach(() => {
counter.value = 1
useCounter.mockReturnValue({
increase: increaseMock,
counter
})
})
describe('When the counter is 2', () => {
beforeEach(() => {
counter.value = 2
createComponent()
})
it('...', () => {})
})
it('should default to 1', () => {
createComponent()
expect(findSuperUsefulCounter().text()).toBe(1)
// pass
})
})
Vue 라우터#
실제(모킹되지 않은) VueRouter 객체를 사용하는 Vue Router 구성을 테스트하는 경우, 다음 가이드라인을 참고하세요. 실패의 원인 중 하나는 Vue Router 4가 라우팅을 비동기적으로 처리한다는 것이므로, 라우팅 작업이 완료될 때까지 await해야 합니다. waitForPromises 유틸리티를 사용하여 모든 프로미스가 처리될 때까지 기다릴 수 있습니다.
다음 예시에서, 테스트는 버튼 클릭 후 VueRouter가 페이지로 이동했는지 확인합니다. 버튼 클릭 후 waitForPromises를 호출하지 않으면, 라우터의 상태가 타깃 페이지로 전환되지 않아 어서션이 실패할 것입니다.
it('navigates to /create when clicking New workspace button', async () => {
expect(findWorkspacesListPage().exists()).toBe(true);
await findNewWorkspaceButton().trigger('click');
await waitForPromises();
expect(findCreateWorkspacePage().exists()).toBe(true);
});
Vue Apollo 트러블슈팅#
Apollo 뮤테이션을 실행하고 인메모리 쿼리 캐시를 업데이트하는 컴포넌트에서 단위 테스트 실패가 발생할 수 있습니다. 예를 들면:
ApolloError: 'get' on proxy: property '[property]' is a read-only and non-configurable data property on the proxy target but the proxy did not return its actual value (expected '#' but got '#')
이 오류는 writeQuery 또는 updateQuery 메서드를 호출할 때 Apollo가 Vue 반응성 객체를 수정하려고 하기 때문에 발생합니다. Apollo의 캐시를 업데이트하는 작업에서 컴포넌트의 프로퍼티를 통해 전달된 객체 사용을 피하세요. 새 객체를 생성하거나 이미 Apollo의 캐시에 존재하는 데이터를 활용해야 합니다. 최후의 수단으로, cloneDeep 유틸리티를 사용하여 타깃 객체에서 Vue의 반응성 프록시를 제거하세요.
다음 예시에서, 컴포넌트는 뮤테이션이 성공한 후 두 배열 사이에서 agent 객체를 교환하여 Apollo의 인메모리 캐시를 업데이트합니다. agent 객체는 agent 프로퍼티에서도 사용할 수 있지만, 반응성 객체입니다. 잘못된 접근 방식은 컴포넌트에 프로퍼티로 전달된 agent 객체를 참조하여 프록시 오류를 유발합니다. 올바른 접근 방식은 Apollo의 캐시에 이미 저장된 agent 객체를 찾는 것입니다.
<script>
import { toRaw } from 'vue';
export default {
props: {
namespace: {
type: String,
required: true,
},
agent: {
type: Object,
required: true,
},
},
methods: {
async execute() {
try {
await this.$apollo.mutate({
mutation: createClusterAgentMappingMutation,
update(store) {
store.updateQuery(
{
query: getAgentsWithAuthorizationStatusQuery,
variables: { namespace },
},
(sourceData) =>
produce(sourceData, (draftData) => {
const { mappedAgents, unmappedAgents } = draftData.namespace;
/*
* BAD: The error described in this section is caused by adding a Vue reactive
* object the nodes array. `this.agent` is a component property hence it is wrapped
* with a reactivity proxy.
*/
mappedAgents.nodes.push(this.agent);
unmappedAgents.nodes = removeFrom.nodes.filter((node) => node.id !== agent.id);
/*
* PREFERRED FIX: Only use data that already exists in the in-memory cache.
*/
const targetAgentIndex = removeFrom.nodes.findIndex((node) => node.id === agent.id);
mappedAgents.nodes.push(removeFrom.nodes[targetAgentIndex]);
unmappedAgents.nodes.splice(targetAgentIndex, 1);
/*
* ALTERNATIVE (LAST RESORT) FIX: Use lodash `cloneDeep` to create a clone
* of the object without Vue reactivity:
*/
mappedAgents.nodes.push(cloneDeep(this.agent));
unmappedAgents.nodes = removeFrom.nodes.filter((node) => node.id !== agent.id);
}),
);
},
});
} catch (e) {
Sentry.captureException(e);
this.$emit('error', e);
}
},
},
};
</script>
Vue 라우터 테스팅#
전체 비모킹 vue-router@4를 테스트할 때 Vue 2와의 호환성을 위해 고려해야 할 몇 가지 주의사항이 있습니다.
Window location#
vue-router@4는 window location의 변경을 감지하지 않으므로, setWindowLocation과 같은 헬퍼로 현재 URL을 설정해도 효과가 없습니다.
대신, 초기 라우트를 설정하거나 다른 라우트로 수동으로 이동하세요.
초기 라우트#
테스트의 초기 라우트를 설정할 때, vue-router@4는 기본적으로 / 라우트로 설정됩니다. 라우터 구성에 / 경로에 대한 라우트가 정의되어 있지 않으면 테스트는 기본적으로 오류가 발생합니다. 이 경우, 컴포넌트가 생성되기 전에 정의된 라우트 중 하나로 이동하는 것이 중요합니다.
router = createRouter();
await router.push({ name: 'tab', params: { tabId }})
모든 내비게이션은 항상 비동기적이므로 await가 필요합니다.
다른 라우트로 이동#
이미 마운트된 컴포넌트에서 다른 라우트로 이동하려면, 라우터의 push 또는 replace 호출을 await해야 합니다.
createComponent()
await router.push('/different-route')
push 메서드에 접근할 수 없는 경우, 예를 들어 이벤트를 통해 컴포넌트 코드 내부에서 push를 트리거하는 경우에는 await waitForPromises로 충분합니다.
다음 컴포넌트를 살펴보세요:
<script>
export default {
methods: {
nextPage() {
this.$router.push({
path: 'some path'
})
}
}
}
</script>
<template>
<gl-keyset-pagination @push="nextPage" />
</template>
$router.push 호출이 이루어지는지 테스트하려면, gl-keyset-pagination 컴포넌트의 next 이벤트를 통해 내비게이션을 트리거해야 합니다.
wrapper.findComponent(GlKeysetNavigation).vm.$emit('push');
// $router.push is triggered in the component
await waitForPromises()
내비게이션 가드#
내비게이션 가드는 내비게이션 가드를 통과하는 어떤 경로에서도 세 번째 인수인 next를 정확히 한 번 호출해야 합니다. 이는 vue-router@3과 vue-router@4 모두에서 필요하지만, 모든 내비게이션이 비동기적이고 await되어야 하는 vue-router@4에서 더 중요합니다.
next를 호출하지 않으면 디버깅하기 어려운 오류가 발생할 수 있습니다. 예를 들면:
Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout
디버깅#
대부분의 경우 아래와 같이 이해하기 어려운 오류를 만나게 될 것입니다.
Unexpected calls to console (1) with:
[1] warn: [Vue Router warn]: uncaught error during route navigation:
23 | .join('\n');
24 |
> 25 | throw new Error(
| ^
26 | `Unexpected calls to console (${consoleCalls.length}) with:\n${consoleCallsList}\n`,
27 | );
28 | };
Vue 라우터가 필요로 하는 것을 더 잘 이해하려면 jest.fn()을 사용하여 console.warn을 재정의하여 오류 출력을 볼 수 있습니다.
console.warn = jest.fn()
afterEach(() => {
console.log(console.warn.mock.calls)
})
이렇게 하면 위의 오류를 이해하기 쉬운 형태로 변환할 수 있습니다. 머지 리퀘스트를 제출하기 전에 이 코드를 제거하는 것을 잊지 마세요.
'[Vue Router warn]: Record with path "/" is either missing a "component(s)" or "children" property.'
component 및 children 프로퍼티#
Vue 라우터 3(Vue 2)과 달리, Vue 라우터 4는 component 또는 children 프로퍼티(각각의 component와 함께)가 정의되어야 합니다. 일부 시나리오에서는 router-view 없이 라우터 쿼리 변수를 관리하기 위해 Vue 라우터를 사용한 경우가 있었습니다. 예를 들어 app/assets/javascripts/projects/your_work/components/app.vue에서처럼 말이죠.
이는 안티 패턴으로, Vue 라우터는 과도한 선택이며, 예를 들어 URL searchParams를 사용하여 바닐라 JS로 쿼리 라우트를 관리하는 것이 더 나은 접근 방식입니다.
컴포넌트 재작성이 불가능한 경우, router-view 없이 애플리케이션이 렌더링하는 App 컴포넌트를 전달하면 테스트를 통과할 수 있습니다. 그러나 이는 나중에 컴포넌트에 <router-view />가 추가될 경우 의도치 않은 동작을 도입할 가능성이 있으므로 주의해서 사용해야 합니다.
격리 목록#
scripts/frontend/quarantined_vue3_specs.txt 파일은 알려진 모든 Vue 3 실패 테스트 파일로 구성되어 있습니다.
실패하는 파이프라인으로 압도되지 않도록, 이 파일들은 Vue 3 테스트 job에서 건너뜁니다.
이 내용을 읽고 있다면, 격리 job 실패로 인해 여기로 안내받았을 것입니다. 이 job은 테스트가 통과하면 실패하고, 모두 실패하면 통과하기 때문에 혼란스럽습니다. 이는 새로 통과하는 모든 테스트를 격리 목록에서 제거해야 하기 때문입니다. 이전에 실패하던 테스트를 수정한 것을 스스로 축하하고, 이 파이프라인이 다시 통과하도록 격리 목록에서 제거하세요.
격리 목록에서 제거#
vue3 check quarantined job으로 인해 파이프라인이 실패하고 있다면, 좋은 소식입니다!
이전에 실패하던 테스트를 수정했습니다!
이제 해야 할 일은 격리 목록에서 새로 통과하는 테스트를 제거하는 것입니다.
이렇게 하면 테스트가 계속 통과하고 추가적인 회귀를 방지할 수 있습니다.
격리 목록에 추가#
하지 마세요. 이 목록은 줄어들기만 해야 하며, 커져서는 안 됩니다. 머지 리퀘스트가 새 테스트 파일을 도입하거나 현재 통과 중인 테스트를 깨뜨린다면, 수정해야 합니다.
테스트 파일을 한 위치에서 다른 위치로 이동하는 경우에는 격리 목록의 위치를 수정해도 됩니다. 단, 그전에 테스트를 먼저 수정하는 것을 고려해 보세요.