지금까지 내가 어떤 서비스 개발에 참여하고, 기획서와 디자인 시안을 받으면 가장 먼저했던 것이 있다.
바로 기획서와 전체 스펙을 보고 FE에서 개발해야 할 기능 리스트를 정리하고 각 기능에 대해 머릿속이나 간단한 코드로 설계를 해보는 것이다.
테스트 코드는 개발이 완료되면 리팩터링을 시작하기 전에 작성하고는 했다.
그러나 항상 결정과 피드백* 사이의 차이점을 인식하지 못하는 경우가 발생하는데 이는 주로 QA에서 기획서 스펙과 다른 사항을 피드백받고 나서야 해결하고는 했다.
결정과 피드백* '결정'은 개발자가 목표를 구현하기 위해 설계하는 과정을 말하고, '피드백'은 기능이 정상적으로 동작하는지에 대한 성공/실패를 말한다.
TDD는 개발자가 기능을 설계하는 과정에서 목표를 잃어버리지 않게 도움을 준다. 그러므로 안정적으로 코드의 품질을 검증하고 개선해나갈 수 있으며, 이는 곧 좋은 생산성을 야기한다.
올 3월부터 착수한 프로젝트에 리액트와 jest를 기반으로 TDD로 진행한 과정을 정리해보았다.
또한 적용하기 전과 어떻게 다른지 그 느낀 점을 적었다.
과정
1. ✅ 프로덕트 코드보다 테스트 코드를 먼저 추가해야 한다.
컴포넌트 설계 후 각 컴포넌트에서 수행할 기능을 테스트 코드에 작성하는 것이다.
프로덕트 코드를 개발하기 전에 이렇게 테스트 코드를 추가하면서 어떤 기능을 수행할지 명시한다.
나는 우선 페이지 단위로 크게 크게 컴포넌트를 나누어 작성했다.
Page1.js <-> Page1.spec.js
Page2.js <-> Page2.spec.js
이 단계에서는 아직 컴포넌트에서 재활용 가능한 부분을 분리하는 등의 개선점은 찾기 어렵다.
단일 컴포넌트를 작성한다고 생각하고 한 페이지에 담긴 기능 리스트를 describe로 추가하였다.
여기서 렌더링(스냅샷) 테스트는 필수로 추가했다.
컴포넌트가 렌더링되었을 때 같이 렌더링되어야 하는 요소 혹은 초기값이 정상적으로 세팅되었는지, 함수를 호출하는지에 대한 여부를 테스트하였다.
test.todo('컴포넌트가 렌더링되면 ~~가 렌더링된다.')
test.todo('컴포넌트가 렌더링되면 ~~값이 0이다.')
test.todo('컴포넌트가 렌더링되면 ~~API가 호출된다.')
또한 초기에 작성한 테스트 케이스들은 얼마든지 다음 과정을 진행하면서 추가/수정할 수 있다.
다음 단계를 진행하더라도 기획이 변경되었거나 좀 더 테스트해야 할 사항이 생각났다면 다시 1번으로 돌아와 테스트 케이스를 추가/수정하면 된다.
2. ✨ 테스트 코드를 먼저 구현하고, 기능을 구현한다.
컴포넌트에서 수행할 기능 리스트를 정리했으면 이제 각 기능의 구체적인 설계에 들어가야 한다.
설계는 TDD와 상관없이 진행하였는데 팀에서 MobX를 사용하고 있어서 Observable과 Action을 먼저 설계하고, 이를 컴포넌트에서 옵저빙하도록 하였다.
상태 관리 아키텍쳐를 완성하고 나면 이전에 todo로 작성했던 테스트 코드를 구체적으로 작성했다. 물론 이 테스트는 Fail할 것이다.
이제 각 테스트를 PASS하게끔 기능을 구현한다.
만약 구현하기에 더 복잡한 처리가 필요하거나 더 많은 생각이 드는 경우, 일시적으로 하드코딩으로 테스트를 통과할 수 있다.
기능을 구현하기 위해서 좀 더 복잡한 처리가 필요하고, 좀 더 많은 생각이 필요한 경우에 중요하지 않은 부분은 배제하고 처음부터 완벽한 테스트를 작성하는 대신(hard coding), 테스트를 조금씩 추가해나가면서 단계별로 구현하는 것은 복잡한 기능을 구현할 때 큰 도움이 됩니다.
- 사내 강의 내용 중 발췌
(Tip) 또한 jest에서는 비슷한 로직을 갖는 테스트를 test.each로 묶어 중복을 제거할 수 있다.
test.each([
['아이디', 'kim01'],
['비밀번호', '123!@#'],
])('%s만 입력하면 확인 버튼은 비활성화된다.', async (label, value) => {
const {container} = render(<MarketLogin />)
const input = screen.getByLabelText(label)
await userEvent.type(input, value)
const button = findButton(container)
expect(button).toBeDisabled()
})
이 단계에서 나는 테스트케이스를 모두 통과하는 방향으로 구현하되, 아래 같은 예외가 발생하면 다른 단계를 먼저 진행하기도 하였다.
- 테스트 케이스를 수정해야하는 경우 : 기능 구현을 멈추고 1번으로 돌아가서 케이스를 추가한다.
- 기능 구현하다 리팩터링할 부분이 보이는 경우 : 기능 구현 및 테스트 코드 작성을 멈추고 리팩터링한다. (3번)
3. (선택) ♻️ 테스트 코드를 수정하지 않고 프로덕트 코드를 리팩터링한다.
이제 모든 테스트 코드가 통과하면 기능 구현은 완료된 것이다.
그런데 초기에 작성했던 코드를 리팩터링할 일은 거의 100%로 일어난다.
프로덕트 코드를 리팩터링할 경우에는 절대로 테스트 코드를 수정하지 말고 리팩터링한다.
그래야 리팩터링 도중 테스트가 깨지면 기존 기능이 정상 동작하지 않는다는 것을 쉽게 발견할 수 있다.
4. 모듈 간 의존 관계 테스트를 작성한다.
4번은 2번을 진행할 때 함께 병행하거나 후에 진행하였다.
테스트를 작성하다보면 다른 모듈에 의존한 기능을 테스트는 어떻게 할지 또, 상위 모듈에서 하위 모듈 테스트까지 구현해야 할지 고민이 되었다.
의존 관계 테스트는 크게 상태 기반과 행위 기반 테스트로 나눌 수 있다.
상태 기반 테스트
어떤 상호작용이 일어난 뒤, 목표 객체의 상태가 어떻게 변화되었 는지를 관찰, 즉 입력과 출력에 대해서만 테스트
행위 기반 테스트
어떤 상호작용이 발생할 때 상호작용이 어떤 방식으로 일어났는가를 관찰, 즉 구현에 대한 테스트
따라서 상위 모듈에서 하위 모듈을 Mocking해서 상위모듈에 미치는 영향을 테스트하게 된다.
Jest에서는 의존관계 테스트에 사용되는 Mocking Tool로 다음 두 함수를 제공한다.
- jest.mock : export된 모듈을 mock obj로 교체
- jest.spyOn : Object descriptor를 사용해 주어진 객체의 메서드를 spy
팀 내에서는 AxiosInstance 생성 시 mock 함수를 추가하여 아래처럼 사용하고 있다.
// in __mocks__/api.js
const instance = axios.create()
const createMockAxiosAdapter = (target) => {
// 중략
return {
async post() {
},
async get() {
},
async put() {
},
async delete() {
}
}
}
const createMockMethod = (mockInstance) => {
const mockInstance = createMockAxiosAdapter(instance)
mockInstance.mockRespondOnce = (method, path, mockResponse) => {
jest.spyOn(instance, method).mockImplementationOnce((...args) => {
const [pathFromArgs] = args
if (pathFromArgs === path) {
if (mockResponse instanceof Error) {
return Promise.reject(mockResponse)
}
return Promise.resolve(mockResponse)
}
})
}
return mockInstance
}
const extendsWithMock = (axiosInstance) => {
const inheritInstance = Object.create(axiosInstance)
inheritInstance.mock = createMockMethod(inheritInstance) // mocking 추가
return inheritInstance
}
export default extendsWithMock(instance)
jest.mock('./api')
const GET_LOGIN_URL = '/login'
const createSuccessResponse = ({errorCode = '00', errorMessage = ''}) => ({
code: 'SUCCESS',
result: {
errorCode,
errorMessage,
},
})
const mockVerifySellerApi = (data) => {
const mockResponse =
data instanceof Error
? {
data: {
code: 'ERROR',
message: '오류',
result: data,
},
}
: {data}
// AxiosInstance 생성 시 mock 메서드 추가
api.mockRespondOnce('post', GET_LOGIN_URL, mockResponse)
}
test('아이디 혹은 비밀번호가 일치하지 않으면 로그인 실패 팝업을 띄운다.', () => {
const mockedResponse = createSuccessResponse({
errorCode: '01',
errorMessage: '회원정보를 찾을 수 없습니다.<br />ID 및 비밀번호를\n확인해주세요.',
})
mockVerifySellerApi(mockedResponse)
})
결론 및 느낀점
TDD로 개발하면서 가장 먼저 달라진 건 QA 통과율에 관한 것이다.
다른 서비스에 대한 QA 결과이지만 비슷한 QA 기간과 같은 도메인임을 감안하면 수치에서 분명한 차이를 보였다.
즉, 기능의 구현체가 스펙을 잘 반영했다는 것인데 이는 테스트케이스를 미리 작성하면서 구현하고자 하는 방향을 잃지 않았던 덕분이기도 하다.
두 번째로 기능의 수정을 걱정없이 할 수 있었다. 서비스의 기획은 언제나 변경될 수 있는데 테스트 코드의 존재 여부가 수정의 위험성을 좌우한다는 것을 배웠다.
이론상으로는 TDD가 빠른 생산성에 도움이 된다는 것을 알고 있었지만 적용하기 전에는 테스트 코드를 만드는 것이 초기 개발 속도를 늦추는 것이 아닌가 내심 걱정이 되었다. 그러나 역시 이건 괜한 걱정이었다.
초반에는 TDD 스펙을 생각하느라 곧바로 개발에 착수하지 않고 정리하는 시간을 가졌지만 나중에는 결국 개발속도가 이전보다 더욱 향상되었다.
마지막으로 리팩터링이 이전보다 수월해졌다. 항상 리팩터링 이전에는 테스트 코드를 뒤늦게라도 추가하고 진행을 했는데, 뒤늦게 추가한 테스트는 구현된 기능에 초점을 맞추게 되는데 TDD에서 먼저 작성된 테스트는 기획서에 기술된 기능 자체의 목적에 초점을 맞추게 되어 테스트를 더 신뢰하면서 리팩터링할 수 있었다.
물론 단점(다른말로는 아직 모르겠는 점)도 있었다.
작성된 테스트를 통과하기 위해 기능을 구현하다보면 테스트 스펙 자체가 잘못됨을 알아차릴 때가 있었는데 그땐 기구현된 기능을 놔두고 테스트코드를 수정해야 할지 애매했다.
(이미 기능을 구현했는데 테스트 코드를 수정해야 한다니... TDD로 개발하는게 아니잖아 ㅠㅠ 하고 생각된 적이 있었다.)
아마 이것은 내가 아직 테스트 코드 작성에 미숙한 탓일 수도 있어 더 익숙해질 때까지 노력하면 나아질 수 있는 부분일거라 생각된다.
혹은 설계와 TDD는 별개이기 때문일 수도 있다. 이 글 참고
6/29 추가
프론트엔드 테스트를 처음 작성해보시는 분들이라면 이 분의 글이 잘 정리되어 있어 추가한다. 나도 두고두고 보아야겠다 ㅎ
'Today I Learn > 프론트엔드' 카테고리의 다른 글
cypress를 써보자! (2) (0) | 2021.05.23 |
---|---|
cypress를 써보자! (1) (0) | 2021.05.16 |
[MobX, mobx-react-lite] Observable 다양하게 활용하기 (0) | 2021.04.13 |
/** IGNORE*/ 주석 (0) | 2021.04.09 |
[리액트] Compound Component 활용하기 (0) | 2020.09.27 |