상태 관리 도구의 테스트 전략을 세워보자

keem·2023년 5월 19일
8
post-thumbnail

최근 진행한 프로젝트에서 상태 관리 도구로 Redux Toolkit을 선택했습니다. 그리고 이 상태 관리 도구에 대한 테스트가 필요하다고 생각했습니다. 그래서 본격적으로 테스트를 도입하기 전에, 테스트가 필요하다고 생각한 이유, 프론트엔드에게 테스트의 목적과 테스트가 필요한 코드, 그리고 구체적인 테스트 전략까지 작성해보고자 합니다.

테스트의 목적은 무엇인가?

전략이고 뭐고, 우선 테스트가 왜 필요한지부터 궁금했습니다. 프론트엔드에게 테스트란 무엇일까요? 테스트가 필요한 이유는 또 무엇일까요? 이 질문을 해소하고 싶었습니다. 아래는 제가 프론트엔드의 핵심이라고 생각하는 것들입니다.

  1. 서버의 데이터를 안전하게 받아오기
  2. 상태를 사용자에게 정확하게 보여주기
  3. 사용자의 요구 사항에 따라 상태를 변환하기
  4. 데이터를 서버로 안전하게 전송하기

프론트엔드 영역이 고도화되고 있는 것은 각 포인트 별로 '어떻게 하면 더 잘 수행할 수 있을지' 를 치열하게 고민한 결과라고 생각합니다. 예를 들어 React가 등장한 것은 점차 복잡해지는 사용자와의 상호작용과 이를 정확하게 반영해야 하는 UI 문제를 해결하기 위함입니다. React는 이를 선언형과 컴포넌트로 해결했습니다. React가 해결한 문제는 2번에 해당합니다.

장바구니 미션에서 RTK가 가지는 역할은 1번, 3번입니다. 서버와 브라우저 간 데이터의 비동기 처리, 그리고 상태 변경 처리에 대해 중대한 책임을 가지고 있는 친구였습니다. 그러므로 상태관리 도구를 테스트하는 것은 서비스의 중추를 담당하는 로직의 안정성을 높일 수 있습니다. 그럼 RTK의 '무엇'을 테스트해야 서비스의 안정성을 높일 수 있을까요?

무엇을 테스트할 수 있을까?

RTK는 Redux의 주요 단점으로 지적되던 복잡한 보일러 플레이트를 해소하기 위해 나온 도구입니다. 그래서 Redux보다 관리해야할 포인트가 상대적으로 적습니다. 몇 가지 작성해 본 테스트 포인트는 다음과 같습니다.

  1. 액션 생성자 테스트: RTK는 액션 생성자를 자동으로 생성합니다. 따라서 액션 생성자가 올바른 액션 객체를 반환하는지 테스트해야 합니다.

  2. 리듀서 테스트: RTK는 createSlice 함수를 사용해 액션 생성자와 리듀서를 동시에 생성합니다. 이 때, 리듀서가 액션에 따라 상태를 올바르게 변경하는지 테스트해야 합니다.

  3. 비동기 액션 테스트: RTK는 createAsyncThunk 함수를 사용해 비동기 액션을 생성합니다. 이런 경우, 비동기 액션이 시작되었을 때, 성공했을 때, 실패했을 때 각각 예상되는 액션이 dispatch되는지, 그리고 상태가 올바르게 업데이트되는지 테스트해야 합니다.

무엇을 테스트 해야 할까?

저는 비동기 액션 한 가지에 대해서만 테스트를 하는 것을 선택했습니다. 사실 리듀서 테스트도 해야 하나 고민했지만, 제 프로젝트에서 RTK는 비동기 액션만을 다루고 있는 상황입니다. 클라이언트에서 RTK를 사용해 다루는 전역 상태는 없습니다. 추후에 관리를 필요로 하는 전역 상태가 생겨난다면 이야기가 달라지겠지만 현재 코드에 대한 테스트는 비동기 액션 테스트만으로 충분하다고 생각했습니다.

비동기 액션의 리듀서를 테스트하는 것은 결국 'dispatch의 타입을 pending, fulfilled, rejected의 경우로 나누고 payload, error와 함께 전달했을 때 정상적으로 결과값이 나오느냐' 입니다. 예를 들면 아래와 같이 코드를 작성할 수 있을 것입니다.

 it('제품 가져오기가 fulfilled 상태일 때 정상 동작 해야 한다', () => {
    store.dispatch({ type: 'product/getProduct/fulfilled', payload: products })
    expect(store.getState().status).toEqual('succeeded')
   	expect(store.getState().products).toContainEqual(products)
  })

하지만 비동기 액션 테스트의 로딩, 성공, 실패에 대한 테스트는 이를 포함하고 있습니다. 그렇기에 비동기 액션에 대한 리듀서 테스트는 배보다 배꼽이 큰 상황이라고 판단했습니다.

비동기 액션은 서버와의 통신을 담당하는 로직입니다. 비동기 액션을 통해 데이터를 요청하고, 요청이 성공하거나 실패할 때마다 적절한 상태 변화를 수행하는지 확인하는 것은 중요합니다. 특히, 에러 처리에 대해 적절한 응답을 하고 있는지 테스트하는 것은 UX를 한층 강화시킬 수 있다고 생각합니다.

어떻게 테스트 할 것인가?

이제 상태 관리를 테스트 해야 하는 이유, 테스트할 수 있는 코드의 종류, 그 중 현재 프로젝트에서 필요한 테스트는 무엇인지까지 알아봤습니다. 그 다음은 구체적인 테스트 전략이 들어 가야 할 것입니다. 서버와의 네트워크 통신이 필요한 비동기 액션은 어떻게 테스트할 수 있을까요?

상태 관리 도구인 RTK에서는 createAsyncThunk 메서드를 사용해 API 호출을 통한 비동기 액션을 수행합니다. 하지만 테스트를 위해 API 호출을 하는 것은 권장되는 상황이 아닙니다. 외부 의존성이나 테스트 환경의 변화로 인해 안정성이 떨어질 수 있고, 불필요한 네트워크 요청에 따른 비용이 발생할 수 있습니다.

그래서 필요한 것이 Mocking API입니다. 이를 구현하기 위해 Mock Service Worker(MSW)라는 도구를 선택했습니다. MSW는 API 요청을 가로채고 미리 정의된 응답을 반환하는 방식으로 동작합니다. 이를 통해 실제 서버 없이도 API 호출의 결과를 재현할 수 있습니다.

아래는 제 프로젝트에서 테스트가 필요한 비동기 액션입니다.

  • 장바구니
    getCarts, addCart, deleteCart, deleteCarts의 로딩, 성공, 실패에 따른 상태, 데이터, 에러의 결과가 정확한가?
  • 주문
    getOrder, addOrder, updateOrder, deleteOrder, deleteAllOrder의 로딩, 성공, 실패에 따른 상태, 데이터, 에러의 결과가 정확한가?
  • 결제
    getPaymentList, addPaymentList의 로딩, 성공, 실패에 따른 상태, 데이터, 에러의 결과가 정확한가?
  • 제품
    getProduct, getProducts의 로딩, 성공, 실패에 따른 상태, 데이터, 에러의 결과가 정확한가?

예를 들어, 비동기 액션 중 하나인 getCarts를 테스트한다고 가정해봅시다. 이 getCarts 함수는 장바구니에 담긴 제품의 정보를 가져오는 기능을 수행합니다.

이를 테스트하기 위해, 먼저 MSW를 이용해 해당 API 요청을 가로채는 Mocking API를 생성할 수 있습니다. 그 다음, Mocking API가 올바른 장바구니 정보를 반환하도록 설정합니다. 이렇게 하면, 실제 서버가 아닌 Mocking API를 통해 getCarts 함수의 동작을 테스트할 수 있습니다. 아래 코드는 그 예시입니다.

const server = setupServer(...handlers)

beforeAll(() => server.listen())
afterAll(() => server.close())
afterEach(async () => server.resetHandlers())

describe('카트의 비동기 액션을 테스트한다', () => {
	const store = configureStore({ reducer: cartReducer })
	
	it('카트에 제품을 추가할 수 있다', async () => {
	await store.dispatch(addCart(products[0]))
	await store.dispatch(getCarts())
	
	const state = store.getState()
	expect(state.status).toEqual('succeeded')
	expect(state.items).toContainEqual(products[0])
	})
})

이 테스트는 getCarts가 올바른 장바구니 정보를 가져와 상태를 정확하게 업데이트하는지 확인합니다. MSW를 사용하면 API 요청의 결과를 직접 제어할 수 있으므로, 성공적인 경우뿐만 아니라 실패한 경우나 에러 처리 등의 다양한 시나리오를 테스트할 수 있습니다. 여기서는 스키마 유효성 검사가 실패했을 경우, 400 에러가 발생했을 경우에 대한 테스트 코드를 작성해보겠습니다.

it('카트의 제품 가져오기가 실패하면 에러를 보여준다', async () => {
	server.use(
		rest.get(`${API.CARTS}`, (_: RestRequest, res, ctx) => {
			return res(ctx.status(400)) // 400에러 반환
		}),
	)

	await store.dispatch(getCartItems())
	
	const state = store.getState()
	
	expect(state.status).toEqual('failed')
	expect(state.error).toEqual('Failed to fetch data')
})

it('카트의 제품에서 스키마 유효성 검증을 실패하면 에러를 보여준다', async () => {
	server.use(
		rest.get(`${API.CARTS}`, (_: RestRequest, res, ctx) => {
			return res(ctx.status(200), ctx.json({ id: 0 })) // 실패한 스키마 유효성
		}),
	)
	
	await store.dispatch(getCartItems())
	  
	const state = store.getState()
	
	expect(state.status).toEqual('failed')
	expect(state.error).toEqual('Failed schema validation')
})

이렇게 에러 처리를 위한 핸들러를 server.use 메소드에 넣어주고, 의도적으로 에러를 발생시키면 원하는 에러에 대한 처리가 동작하는지 확인할 수 있습니다. 그러면 해당 에러 처리를 바탕으로 클라이언트 단에서 적절한 UI를 보여주거나 UX를 제공하는 등의 작업을 해 줄 수 있습니다. 이제 이 전략을 바탕으로 나머지 코드들에 대한 테스트 코드만 작성하면 됩니다.

마치며

처음에는 상태 관리 도구의 테스트 전략은 고사하고 테스트의 필요성조차 제대로 느끼지 못했습니다. 그냥 console.log로 찍어보고 상태가 잘 나오면 잘 되는구나 생각했던 기간이 꽤 길었습니다. 하지만 이번 계기로 RTK를 테스트하면서 테스트의 목적에 대해서 깊게 고민해본 계기가 된 것 같습니다. 테스트라는 게 단위 테스트, 통합 테스트 등의 명칭이 중요한 게 아니라 '무엇을 점검해보고 싶은가?'에 초점을 맞춰야 한다고 생각합니다. 이번 프로젝트에서는 그 무엇이 비동기 액션이었습니다.

만약 복잡한 전역 상태를 가지고 있고, 이것이 서비스에서 매우 중요한 로직이라면? 또 다른 테스트가 필요할 것이며, 해당 로직을 확인하기 위한 최적의 테스트 도구를 찾아야 합니다. 만약 디자인팀, 기획팀 등 타 부서와 활발히 교류하면서 컴포넌트 단위, 페이지 단위로 사용자와의 상호작용을 테스트 해보거나 즉각적인 UI를 확인해보고 싶다면? 스토리북과 같은 도구가 훌륭한 선택지가 될 수 있습니다.

개인적으로 프론트엔드 테스트에 대해 요새 관심을 많이 가지게 되는 것 같은데, 저처럼 테스트에 관심이 있으신 분들에게 조금이나마 도움이 되었으면 좋겠습니다. 👋🏻

profile
본질이 뭘까?

0개의 댓글