테스트 코드 작성하시나요?

 

저는 얻는 효용에 비해 들어가는 비용이 더 크다고 생각해 커버리지라고 할 만큼 작성하지는 않습니다. 그래도 코드를 작성하면서 '아 이거는 작성하는게 더 이득이다' 싶었던 일부 경우를 공유하려 합니다.

 

1. 리팩토링을 위한 통합 테스트

간단한 투두리스트로 예시를 들어보겠습니다.

export default function App() {
  const [todos, setTodos] = useState([])
  const [input, setInput] = useState('')

  const addTodo = (todo) => {
    setTodos([...todos, todo]);
  }

  const toggleTodo = (id) => {
    setTodos(todos.map(todo => 
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ))
  }
  
  const onChange = (e) => {
    setInput(e.target.value)
  }

  return (
    todos 렌더링 UI 
    toggleTodo 버튼 UI
    todo 추가하는 input UI
    todo 추가하는 버튼 UI
  )

 

 

 

App 컴포넌트에 대한 통합테스트를 작성해줍니다

describe('App 통합테스트', () => {
  it('전체 투두리스트 플로우가 정상 작동한다', async () => {
    const user = userEvent.setup()
    render(<App />)

    // 1. 초기 상태 확인
    expect(screen.getByText('투두리스트')).toBeInTheDocument()

    // 2. 할일 추가
    const input = screen.getByPlaceholderText('새 할일 입력')
    await user.type(input, '첫 번째 할일')
    await user.click(screen.getByText('추가'))

    expect(screen.getByText('첫 번째 할일')).toBeInTheDocument()
    expect(screen.getByText('전체: 1, 완료: 0')).toBeInTheDocument()

    // 3. 할일 완료 처리
    const checkboxes = screen.getAllByRole('checkbox')
    await user.click(checkboxes[0])

    expect(screen.getByText('전체: 2, 완료: 1')).toBeInTheDocument()
  })

  ~~~~ 기타 테스트 코드
})

 

 

App 컴포넌트는 잘 작성되었고 테스트 코드를 통해 모든 기능들이 정상 동작하는지도 확인했습니다🎉

그런데 App 컴포넌트가 너무 많은 기능을 하는 것 같습니다.

코드를 리팩토링 해봅니다.

export default function App() {
	return (
    	<>
            <Todos />
            <TodoInput />
            <AddTodoButton />
        </>
    )
}

export default function Todos() {
  const [todos, setTodos] = useState([])

  const toggleTodo = (id) => {
    setTodos(todos.map(todo => 
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ))
  }

  return (
    todos 렌더링 UI
    toggleTodo 버튼 UI
  )
}
  
export default function TodoInput() {
  const [input, setInput] = useState('')

  const onChange = (e) => {
    setInput(e.target.value)
  }

  return (
    todo 추가하는 input UI
  )
}

export default function AddTodoButton() {
  const addTodo = (todo) => {
    setTodos([...todos, todo]);
  }

  return (
    todo 추가하는 버튼 UI
  )
}

 

리팩토링 후에는 기존에 동작했던 기능들이 동일하게 동작하는지 테스트를 하는 번거로운 작업을 진행해야됩니다.

하지만 저희는 App 컴포넌트에 대해 테스트 코드를 미리 작성해놨습니다. 테스트를 돌리니 모든 테스트코드가 통과했습니다!

덕분에 각 기능들이 정상적으로 동작하는지 별도로 확인할 필요 없이 테스트 코드를 통해 검증이 완료되었습니다 🎉

 

2. 에러 시나리오 테스트

API 엔드 포인트에 따라 적절한 UI처리를 해주어야 하는데, 에러를 내달라고 백엔드에 요청하기는 번거롭습니다

그럴 때 MSW로 의도적으로 에러를 발생시키고 적절한 UI가 나오는지 테스트하기 좋습니다.

 

유저정보를 가져오는 API를 패칭합니다.

export default function UserList() {
  const [users, setUsers] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        setUsers(data)
        setLoading(false)
      })
      .catch(err => {
        setError(err.message)
        setLoading(false)
      })
  }, [])

  if (loading) return <div>Loading...</div>
  if (error) return <div>Error: {error}</div>

  return (
    <div>
      <h1>Users</h1>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name} - {user.email}</li>
        ))}
      </ul>
    </div>
  )
}

 

API 호출을 모킹해 일부러 에러를 발생하고 적절한 UI가 나오는지 테스트 해보겠습니다.

import { server } from '../mocks/server'

it('API 에러 시 에러 메시지를 표시한다', async () => {
    server.use(
      http.get('/api/users', () => {
        return new HttpResponse(null, { status: 500 })
      })
    )

    render(<UserList />)

    await waitFor(() => {
      expect(screen.getByText(/Error:/)).toBeInTheDocument()
    })
})

 

백엔드에게 일부러 에러를 내달라고 요청하지 않고도 에러 발생 시 적절한 UI 가 나오는지 테스트 할 수 있게되었습니다🎉

(비슷한 느낌으로 delay를 걸어서 api 요청이 늦게오는 테스트를 진행할 수도 있습니다.)

 

3. 요구사항이 복잡한 비즈니스 함수의 단위 테스트

 

계산이 복잡한 함수의 경우에 테스트 코드를 작성하면 코드 안정성과 신뢰성 그리고 자신감 까지 올라갑니다🙎‍♂️

저의 경우엔 mongoDB Aggregation 파이프라인에 대한 검증에 테스트 코드를 작성했습니다.

 

Aggregation 메서드는 $match, $group, $project, $unwind, $sort, $limit 등이 있습니다.

SQL 구문과 비교해보면 아래와 같습니다.

$match WHERE 조건에 따라 필터링
$group GROUP BY 조건에 따라 그룹화하고 집계 함수를 적용
$project SELECT 특정 필드만 선택
$unwind UNNEST 배열 요소 평면화 (flatMap 기능)
$sort ORDER BY 결과를 정렬
$lookup JOIN 컬렉션 간의 관계형 데이터를 연결

 

 

$match: 50만원 초과 제품 필터

$project: name, price 필드만 선택 

 const products = [
        {name: '노트북', price: 1200000, manufacturer: '삼성'},
        {name: '스마트워치', price: 350000, manufacturer: '삼성'},
        {name: '스마트폰', price: 980000, manufacturer: '애플'},
        {name: '무선이어폰', price: 250000, manufacturer: '애플'},
    ];
 
 test('$match: 조건에 맞는 문서 필터링', async () => {
    const expensiveProducts = await productsCollection.aggregate([
      { $match: { price: { $gt: 500000 } } },
      { $project: { _id: 0, name: 1, price: 1 } }
    ]).toArray();
    
    // 검증
    expect(expensiveProducts).toEqual([{"name": "노트북", "price": 1200000}, {"name": "스마트폰", "price": 980000}]);
  });

 

간단한 함수에는 테스트 코드까지 작성할 필요를 못느꼈지만, 복잡한 집계 파이프라인을 작성하니 코드에 검증이 필요했습니다.

아래 예시를 보겠습니다.

test('복합 집계 파이프라인: 카테고리별 평균 평점', async () => {
    // 카테고리별 평균 평점 계산
    const categoryRatings = await productsCollection.aggregate([
        { $unwind: '$reviews' },
        {
            $group: {
                _id: {
                    category: '$category',
                    product: '$name'
                },
                avgRating: { $avg: '$reviews.rating' },
                reviewCount: { $sum: 1 }
            }
        },
        {
            $group: {
                _id: '$_id.category',
                avgCategoryRating: { $avg: '$avgRating' },
                products: {
                    $push: {
                        name: '$_id.product',
                        rating: '$avgRating',
                        reviews: '$reviewCount'
                    }
                }
            }
        },
        { $sort: { avgCategoryRating: -1 } }
    ]).toArray();

    // 검증
    expect(categoryRatings[0]).toHaveProperty('_id');
    expect(categoryRatings[0]).toHaveProperty('avgCategoryRating');
    expect(categoryRatings[0]).toHaveProperty('products');
    expect(categoryRatings[0].products.length).toBeGreaterThan(0);
});

 

파이프라인이 조금 복잡해지니 검증 필요성이 느껴저 테스트코드를 작성해서 문제를 해결했습니다.

 

서두에 말씀드렸듯이 저도 테스트코드를 많이 작성하지는 않습니다. 기능 쳐내기 바쁜데 테스트 코드는 사치 아닌가 싶은 생각도 많이듭니다.. 그래도 위의 경우엔 꽤 효용이 있다고 생각해 글로 공유해봤습니다😊 그리고 테스트 코드를 작성하면서 뭔가 코드를 입체적으로 보는 시각이 조금 길러지는 장점도 있는 것 같습니다 (Typescript를 배울때와 유사한 느낌적인 느낌입니다..)

+ Recent posts