-
관심사의 분리를 잘 하면 테스트 코드 작성이 수월해진다 (레벨 테스트 5일차 작업 회고)Today I Learned 2022. 10. 7. 22:23
테스트가 잘 만들어지지 않는 이유는 관심사의 분리가 잘 되지 않아서였다
요 며칠 동안 계속 프론트엔드 테스트 코드에 대한 이야기만 하고 있는 것 같다. 하지만 지금 안 하고 넘어간다 한들 언젠가는 결국 고통받을 것이기 때문에 할 수 있을 때 잘 하고 넘어가고 싶었고, 트레이너님께서 찾아오셨을 때 여쭤봤던 것들도 대부분 프론트엔드 테스트 코드에 대한 내용이었다.
오늘은 트레이너님께서 테스트 코드가 잘 작성되지 않을 때에는 기존 소스코드의 관심사가 분리되어 있지 않을 가능성이 있기 때문에 소스코드의 관심사를 분리해야 할 필요가 있다는 말씀을 해 주셨는데, 인상이 깊게 남아 내용을 정리해보고자 한다.
다음은 상품 목록을 렌더링하는 페이지 내 컴포넌트의 예시 소스코드이다. 최종적으로 구현되어야 할 컴포넌트의 UI는 다음의 형태이다.
상품 목록을 렌더링하는 페이지 URL로 이동하면 다음의 페이지 컴포넌트가 호출된다.
// ProductsPage.jsx import { useEffect } from 'react'; import useProductStore from '../hooks/useProductStore'; import Products from '../components/Products'; export default function ProductsPage() { const productStore = useProductStore(); useEffect(() => { const page = 1; productStore.fetchProducts(page); }, []); return ( <Products /> ); }
페이지 컴포넌트에서는 먼저 백엔드로부터 상품 목록들을 받아와 productStore의 products 배열에 객체 형태로 저장해 놓은 뒤 Products 컴포넌트를 호출한다.
Products 컴포넌트는 products에서 상품 객체를 하나씩 꺼내와 화면에 배치하고, 필요한 경우 페이징 버튼들도 화면에 렌더링한다.
// Products.jsx import { useNavigate } from 'react-router-dom'; import useProductStore from '../hooks/useProductStore'; export default function Products() { const navigate = useNavigate(); const productStore = useProductStore(); const { products, pagesCount } = productStore; const handleProductClick = (productId) => { navigate(`/products/${productId}`, { state: { productId, }, }); }; const handlePageClick = (page) => { productStore.fetchProducts(page); }; const renderPageButtons = () => { // Render page buttons }; return ( // elements ); }
해당 페이지 컴포넌트에서 테스트하고 싶었던 것은 각각 상품이 존재하지 않을 때, 상품이 1페이지 이내로 존재할 때, 상품이 2페이지 이상 존재할 때 렌더링을 어떻게 될 것인가에 대한 부분이었다. 해당 컴포넌트의 동작만에 집중하고 싶었기 때문에 의존하고 있는 hook이나 Store는 정해진 값만 반환하도록 Mocking시켜주려 했다.
문제는 테스트 케이스마다 Mocking되는 내용이 달라야 했고, 트레이너님의 도움을 받아 각 context마다 beforeEach에서 Mocking된 값을 다르게 부여하여 it이 각기 다르게 Mocking된 store의 내용을 볼 수 있도록 했다.
// Products.test.jsx /* eslint-disable max-len */ import { render, screen } from '@testing-library/react'; import context from 'jest-plugin-context'; import Products from './Products'; jest.mock('react-router-dom', () => ({ useNavigate: jest.fn(), })); let products = []; let pagesCount = 0; jest.mock('../hooks/useProductStore', () => () => ({ products, pagesCount, })); describe('Products', () => { context('상품이 1페이지 이내로 존재할 때', () => { beforeEach(() => { // products 재할당 // pagesCount 재할당 }); it('상품 제조사, 이름, 가격 표출', () => { render(<Products />); // 검증 }); }); context('상품이 2페이지 이상 존재할 때', () => { beforeEach(() => { // products 재할당 // pagesCount 재할당 }); it('상품 제조사, 이름, 가격 표출', () => { render(<Products />); // 검증 }); }); context('상품이 존재하지 않을 때', () => { beforeEach(() => { // products 재할당 // pagesCount 재할당 }); it('예외 메세지 출력', () => { render(<Products />); // 검증 }); }); });
거의 3일 간 발목잡혀 있던 문제를 해결했다는 기쁨보다 더 귀를 울렸던 이야기는 그 다음이였다. 컴포넌트의 테스트를 작성할 때 무엇을 어떻게 Mocking해야 할지 너무 어렵게 느껴진다면, 테스트 코드가 어렵게 작성될 수밖에 없게끔 컴포넌트 구조가 잘못 짜여져 있는 것은 아닌지 다시 한번 살펴봐야 한다는 것이었다.
Product 컴포넌트의 역할을 다시 한 번 생각해보면, Product 컴포넌트는 단순히 페이지가 보여줘야 할 상태값을 보여주는 컴포넌트에 지나지 않는다. 그러나 지금의 Product 컴포넌트는 주체적으로 다른 hook들이나 Store를 직접 바라보고 있는 곳이 많았다. 그렇기 때문에 '상태값을 보여준다'에 집중하기 위해서는 관련된 hook들이나 Store의 동작을 직접 Mocking해줘야 했다.
기존의 Product.jsx를 다시 살펴보면, Product 컴포넌트의 동작만을 검증하기 위한 테스트를 짜려면 react-router-dom의 useNavigate를, ProductStore의 products와 pagesCount를 Mocking해줘야 한다. Mocking이 잘 되는지의 여부는 차치하고 자칫하다가는 배보다 배꼽이 더 큰 상황이 될 수도 있는 것이었다.
// Products.jsx import { useNavigate } from 'react-router-dom'; import useProductStore from '../hooks/useProductStore'; export default function Products() { const navigate = useNavigate(); const productStore = useProductStore(); const { products, pagesCount } = productStore; const handleProductClick = (productId) => { navigate(`/products/${productId}`, { state: { productId, }, }); }; const handlePageClick = (page) => { productStore.fetchProducts(page); }; const renderPageButtons = () => { // Render page buttons }; return ( // elements ); }
그렇다면 Products 컴포넌트에서 주체적으로 바라보고 있는 useNavigate나 useProductStore를 상위 컴포넌트로 옮겨서 Products가 필요한 값들을 상위에서 prop 형태로 받아오게 한다면, 테스트 코드도 훨씬 쉽게 작성될 수 있을 것임을 트레이너님께서 말씀해주셨다.
하나의 대상에 지나치게 몰려 있는 관심사를 분리해 각 대상의 책임을 명확하게 하고, 이제 그 복잡하지 않고 명확해진 책임만을 테스트하면 되는 것이었다.
// ProductsPage.jsx import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import useProductStore from '../hooks/useProductStore'; import Products from '../components/Products'; export default function ProductsPage() { const navigate = useNavigate(); const productStore = useProductStore(); const { products, pagesCount } = productStore; useEffect(() => { productStore.fetchProducts(1); }, []); const navigateToProduct = (productId) => { navigate(`/products/${productId}`, { state: { productId, }, }); }; const switchPage = (page) => { productStore.fetchProducts(page); }; return ( <Products products={products} pagesCount={pagesCount} onClickProduct={navigateToProduct} onClickPage={switchPage} /> ); }
// Products.jsx export default function Products({ products, pagesCount, onClickProduct, onClickPage, }) { const handleProductClick = (productId) => { onClickProduct(productId); }; const handlePageClick = (page) => { onClickPage(page); }; const renderPageButtons = () => { // Render page buttons }; return ( // elements ); }
Products의 구조가 굉장히 간결해졌다. 이제는 테스트 코드에서 상품이 존재하지 않을 때, 상품이 1페이지 이내로 존재할 때, 상품이 2페이지 이상 존재할 때에 대한 각 테스트 케이스를 작성하기 위해 고려하는 케이스에 맞게 세팅되어 있는 배열을 <Products />에 prop으로 전달하기만 하면 되게 되었다.
버튼을 눌렀을 때의 특정 함수를 수행하는지 검증하는 방법도 상위로부터 전달되는 함수 몸체가 호출되었는지만을 확인하면 되는 구조로 바뀌었다.
// Products.test.jsx /* eslint-disable max-len */ import { render, screen } from '@testing-library/react'; import context from 'jest-plugin-context'; import Products from './Products'; describe('Products', () => { const handleClickPage = jest.fn(); const handleClickProduct = jest.fn(); function renderProducts({ products, pagesCount }) { render(( <Products products={products} pagesCount={pagesCount} onClickPage={handleClickPage} onClickProduct={handleClickProduct} /> )); } context('상품이 1페이지 이내로 존재할 때', () => { // const products = Each case 1 // const pagesCount = Each case 1 it('상품 제조사, 이름, 가격 표출', () => { renderProducts({ products, pagesCount }); // 검증 }); }); context('상품이 2페이지 이상 존재할 때', () => { // const products = Each case 2 // const pagesCount = Each case 2 it('상품 제조사, 이름, 가격 표출', () => { renderProducts({ products, pagesCount }); // 검증 }); }); context('상품이 존재하지 않을 때', () => { // const products = Each case 3 // const pagesCount = Each case 3 it('예외 메세지 출력', () => { renderProducts({ products, pagesCount }); // 검증 }); }); });
사실 이것들은 모두 강의에서 봤던 구조들이였다. 12주차에서 했었던 강의반복과제와 마카오 페이에서 상위 컴포넌트에 함수를 정의해 하위 컴포넌트에 함수의 이름을 전달하는 부분이 그제서야 다시 생각났다. 강의에서 배운 것을 내가 얼마나 잘 활용하느냐가 앞으로의 차이를 만들어 낼 것이라는 트레이너님의 말이 더욱 뼈저리게 와닿았다.
앞으로도 모르는 것은 계속 만나야 하기 때문에
이번 레벨 테스트를 임하는 자세로 요구사항을 구현해내는 것은 이제는 기본이고 모르는 것들, 이해가 안 되는 것들을 계속해서 공부해서 내 것으로 만들 수 있는 능력을 계속 갖춰나가는 데에도 힘써야 한다고 했다. 그래서 다른 동료들보다 진도가 조금 늦는 것을 감수하더라도 막히는 부분이 있을 때 계속 기록하고, 찾고, 기록한 것과 찾은 것을 다시 비교해보는 과정을 반복하고 있다.
오늘 새벽에는 동료분이 홀맨님으로부터 받은 질문에 대한 답을 구하기 위해 혼신의 힘을 다해가며 동작의 원리를 찾고, 정리하고, 자신의 코드에 개념이 어떻게 사용되는지 정리하는 모습을 지켜봤었다. 조각을 맞추기 위해 사방에 흩어진 파편을 찾고, 파편들이 이어져 논리가 갖춰지는 구조의 내용이 만들어지는 것을 지켜보는 과정은 정말 놀라웠다.
개발자로써의 삶을 마치는 순간까지 프론트엔드를 다루든, 백엔드를 다루든, 아니면 또 다른 분야를 다루든 모르는 개념과 모르는 문제와의 싸움은 끝나지 않으리라 생각한다. 살아남는 자가 강한 것이라고 했다. 우리는 모르겠는 문제들 속에서도 살아남아야 하고, 모르는 문제를 겁내지 않고 찾아보고 계속 도전해보겠다는 마음가짐이 필요할 것이라 생각된다.
'Today I Learned' 카테고리의 다른 글
성능의 최적화가 오히려 코드 가독성에 좋지 않은 영향을 미칠 수 있다 (0) 2022.10.09 @SpyBean, @MockBean (0) 2022.10.08 문서와 테스트는 어느 정도로 심도있게 작성하는 게 맞는 걸까...? (레벨 테스트 4일차 작업 회고) (0) 2022.10.06 프론트엔드 단위 테스트에 하루종일 고통받다 (레벨 테스트 3일차 작업 회고) (0) 2022.10.05 실험의, 실험에 의한, 실험을 위한 하루 (레벨 테스트 2일차 작업 회고) (0) 2022.10.04