이 글은 Kent C. Dodds의 Static vs Unit vs Integration vs E2E Testing for Frontend Apps 포스트를 번역한 글입니다.
Thank you Kent C. Dodds. Because of your posts, I can continue to grow as an engineer.
TestingJavaScript.com에 있는 "Testing Practices with J.B. Rainsberger" 인터뷰에서 그는 내가 정말 좋아하는 비유를 들려주었다. 그는 다음과 같이 말했다.
"You can throw paint against the wall and eventually you might get most of the wall, but until you go up to the wall with a brush, you'll never get the corners. 🖌️"
"벽에 페인트를 던진다면 결국 대부분의 벽을 페인트칠할 수 있지만, 붓을 들고 벽에 올라가지 않는 한 절대 모서리를 페인트칠할 수 없다.🖌️"
내가 테스트를 적용하는 관점에서 이 비유를 좋아하는 이유는, 올바른 테스트 전략을 선택하는 것은 벽에 그림을 그릴 때 붓을 고르는 것과 똑같기 때문이다.
벽 전체에 가느다란 붓을 사용해야 할까? 물론 아니다. 그렇게 하면 시간이 너무 오래 걸리고 최종 결과물이 균일하지 않을 것이기 때문이다. 200년 전 고조할머니가 바다 건너 가져온 가구 주변까지 롤러로 칠할 수 있을까? 불가능하다.
경우에 따라 사용하는 브러쉬가 다르듯이, 테스트에도 똑같이 적용된다.
이것이 바로 내가 트로피를 만든 이유다. 그 후 Maggie Appleton (egghead.io의 뛰어난 아트/디자인을 만든 사람)이 TestingJavaScript.com을 위해 이 트로피를 만들었다.
테스팅 트로피에는 4가지 타입의 테스트가 있다. 위의 설명은 화면 리더기를 사용하는 사용자(그리고 이미지가 로드되지 않는 경우)를 위해 아래 자세하게 설명하려고 한다.
- End to End: 사용자처럼 행동하는 도우미 로봇이다. 앱을 클릭하고, 올바르게 작동하는지 확인한다. 'functional testing' 또는 e2e라고도 한다.
- Integration: 여러 장치가 함께 상호 작용하여 잘 동작하는지 확인한다.
- Unit: 기능들이 각각 독립적으로 잘 동작하는지 확인한다.
- Static: 코드를 작성할 때 오타와 타입에러를 확인한다.
트로피가 보여주는 이러한 테스트 형식의 크기는 애플리케이션을 테스트할 때 얼마나 집중해야 하는지에 대한 상대적인 크기이다. (일반적으로) 이제 이런 다양한 형태의 테스트에 대해 자세히 살펴보고, 실질적으로 어떤 의미가 있는지, 그리고 테스트 비용을 최대한 활용하기 위해 무엇을 최적화할 수 있는지 알아보자.
테스트 타입
몇 가지의 예를 들어 어떤 종류의 테스트가 있는지 살펴보자.
End to End
일반적으로 전체 애플리케이션(프론트엔드 및 백엔드 모두)을 실행하며, 테스트는 실제 사용자가 사용하는 것처럼 앱과 상호 작용한다. 아래 테스트 코드는 cypress로 작성되었다.
import {generate} from 'todo-test-utils'
describe('todo app', () => {
it('should work for a typical user', () => {
const user = generate.user()
const todo = generate.todo()
// 여기서는 등록 절차를 진행한다.
// 일반적으로 이 작업을 수행하는 e2e 테스트는 하나만 존재한다.
// 나머지 테스트들은 동일한 endpoint를 호출할 것이다.
// 애플리케이션이 인간과 같은 방식으로 동작하기 때문에,
// 우리는 수작업으로 테스트를 수행하지 않아도 된다.
cy.visitApp()
cy.findByText(/register/i).click()
cy.findByLabelText(/username/i).type(user.username)
cy.findByLabelText(/password/i).type(user.password)
cy.findByText(/login/i).click()
cy.findByLabelText(/add todo/i)
.type(todo.description)
.type('{enter}')
cy.findByTestId('todo-0').should('have.value', todo.description)
cy.findByLabelText('complete').click()
cy.findByTestId('todo-0').should('have.class', 'complete')
// etc...
// 나의 E2E 테스트는 일반적으로 사용자가 하는 것과 비슷하게 동작한다.
// 때로는 상당히 길어질 수 있다.
})
})
Integration
아래 테스트는 전체 앱을 렌더링한다. 이는 Integration 테스트의 `필수 조건은 아니며`, 대부분의 Integration 테스트에서는 전체 앱을 렌더링 하지 않는다. 하지만 모듈의 렌더링 메서드에서 사용하는 모든 제공자들과 함께 렌더링 될 것이다. (가상의 `test/app-test-utils` 모듈의 렌더링 메서드가 하는 일이다.)
Integration 테스트의 기본 개념은 가능한 한 적게 모의(mock)을 하는 것이다. 나는 거의 다음 것들만 모의(mock)한다.
- 네트워크 Requests(MSW 사용)
- 애니메이션을 담당하는 컴포넌트 (테스트 중에 애니메이션을 기다릴 사람은 없을 테니까)
import * as React from 'react'
import {render, screen, waitForElementToBeRemoved} from 'test/app-test-utils'
import userEvent from '@testing-library/user-event'
import {build, fake} from '@jackfranklin/test-data-bot'
import {rest} from 'msw'
import {setupServer} from 'msw/node'
import {handlers} from 'test/server-handlers'
import App from '../app'
const buildLoginForm = build({
fields: {
username: fake(f => f.internet.userName()),
password: fake(f => f.internet.password()),
},
})
// integration 테스트는 일반적으로 MSW를 통한 HTTP Request만 모의(mock) 테스트한다.
const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterAll(() => server.close())
afterEach(() => server.resetHandlers())
test(`logging in displays the user's username`, async () => {
// 커스텀 렌더링은 애플리케이션이 로딩을 완료할 때까지 resolve하는
// Promise를 리턴한다. (서버 렌더링의 경우 필요하지 않을 수 있다.)
// 또한 커스텀 렌더링은 초기 route를 지정(특정 페이지를 렌더링)할 수 있도록 해준다.
await render(<App />, {route: '/login'})
const {username, password} = buildLoginForm()
userEvent.type(screen.getByLabelText(/username/i), username)
userEvent.type(screen.getByLabelText(/password/i), password)
userEvent.click(screen.getByRole('button', {name: /submit/i}))
await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i))
// 사용자가 로그인했는지 확인할 필요가 있는 모든 것을 assert 하라.
expect(screen.getByText(username)).toBeInTheDocument()
})
이를 위해 일반적으로 테스트 사이에 모든 모의 테스트를 자동으로 재설정하는 등 몇 가지 사항을 전역적으로 설정한다.
React Testing Library 설정 문서에서 위와 같이 테스트 유틸리티 파일을 설정하는 방법을 배울 수 있다.
Unit
import '@testing-library/jest-dom/extend-expect'
import * as React from 'react'
// 위의 통합 테스트 예제에서와 같은 Integration 테스트 유틸리티 모듈이 있다면,
// @testing-library/react대신 해당 모듈을 사용해도 된다.
import {render, screen} from '@testing-library/react'
import ItemList from '../item-list'
// 몇몇 사람들은 이것들을 unit 테스트라고 부르지 않는다.
// 왜냐하면 리액트로 DOM에 렌더링을 하기 때문이다.
// 그들은 얕은(shallow) 렌더링을 사용하라고 할 것이다.
// 그들이 당신에게 이런 말들을 한다면, 이 링크를 보여주면 된다. https://kcd.im/shallow
test('renders "no items" when the item list is empty', () => {
render(<ItemList items={[]} />)
expect(screen.getByText(/no items/i)).toBeInTheDocument()
})
test('renders the items in a list', () => {
render(<ItemList items={['apple', 'orange', 'pear']} />)
// note: 이렇게 간단한 경우에는 스냅샷을 사용하는 것도 고려해볼 수 있지만, 다음 조건이
// 모두 충족될 때만 사용하는 것이 좋다.
// 1. 스냅샷이 작을 때
// 2. toMatchInlineSnapshot() 메서드를 사용할 때
// Read more: https://kcd.im/snapshots
expect(screen.getByText(/apple/i)).toBeInTheDocument()
expect(screen.getByText(/orange/i)).toBeInTheDocument()
expect(screen.getByText(/pear/i)).toBeInTheDocument()
expect(screen.queryByText(/no items/i)).not.toBeInTheDocument()
})
누구나 아래의 코드를 unit 테스트라고 부르고, 그 말이 맞다.
// 순수 함수(pure functinos)는 unit 테스트에 가장 적합하며,
// 그들에 대해 jest-in-case 라이브러리를 사용하는 것을 좋아한다.
import cases from 'jest-in-case'
import fizzbuzz from '../fizzbuzz'
cases(
'fizzbuzz',
({input, output}) => expect(fizzbuzz(input)).toBe(output),
[
[1, '1'],
[2, '2'],
[3, 'Fizz'],
[5, 'Buzz'],
[9, 'Fizz'],
[15, 'FizzBuzz'],
[16, '16'],
].map(([input, output]) => ({title: `${input} => ${output}`, input, output})),
)
Static
// 버그를 발견할 수 있니?
// ESLint의 for-direction 규칙을 사용하면
// 코드 리뷰보다 더 빨리 발견할 수 있을 것이다. 😉
for (var i = 0; i < 10; i--) {
console.log(i)
}
const two = '2'
// ok, 이건 좀 인위적이야,
// 하지만 타입스크립트는 이것이 나쁘다는 것을 알려 줄 것이다:
const result = add(1, two)
다시, 왜 우리는 테스트를 해야하는가?
나는 우리가 처음부터 왜 테스트를 작성해야 하는지를 기억하는 것이 중요하다고 생각한다.
왜 당신은 테스트를 작성하는가? 내가 당신에게 테스트하라고 말했기 때문인가? PR시 테스트가 포함되지 않으면 reject 될까 봐 그런가? 테스트가 당신의 workflow를 개선해서 그런가?
내가 테스트를 작성하는 가장 중요한 이유는 신뢰성이다. 나는 앞으로 추가될 코드가 현재 운영 중인 앱을 망치지 않을 것이라는 신뢰를 갖고 싶다.
그래서 나는 어떤 일을 하든 작성하는 테스트가 애플리케이션에 대해 최대한 신뢰성을 얻을 수 있도록 하고, 테스트할 때 어떤 trade-off가 있는지 잘 알아 본다.
이 그림(my sliedes에서 발췌)에서 강조하고 싶은 테스팅 트로피에는 몇 가지 중요한 요소가 있다:
이미지 화살표는 자동화된 테스트를 고려할 때 생각해야 할 세 가지 trade-off를 나타 낸다:
Cost: ¢ heap ➡ 💰🤑💰
테스팅 트로피가 올라갈수록, 더 많은 비용이 든다. 이는 지속적 통합 환경(Continuous integration environment)에서 테스트를 실행하는 실제 비용뿐만 아니라 엔지니어가 각 개별 테스트를 작성하고 유지 관리하는 데 걸리는 시간도 포함된다.
트로피의 위로 올라갈수록 실패 포인트가 많아지므로 테스트가 중단될 가능성이 높아져 테스트를 분석하고 수정하는데 더 많은 시간이 소요된다. 중요한 #복선이므로 기억하라.
Speed: 🏎💨 ➡ 🐢
테스트 트로피가 위로 올라갈수록 테스트는 일반적으로 느리게 실행된다. 이는 테스트 트로피에서 더 높은 단계로 올라갈수록 테스트가 실행하는 코드가 더 많기 때문이다.
Unit테스트는 일반적으로 의존성이 없거나 해당 의존성을 모킹(mocking)할 수 있는 작은 단위의 기능을 테스트한다.(수천 라인의 코드를 단 몇 줄로 효과적으로 대체). 중요한 #복선이므로 기억하라
Confidence: Simple problems 👌 ➡ Big problems 😖
일반적으로 사람들이 테스트 피라미드 🔺에 대해 이야기할 때 비용과 속도의 trade-off를 언급한다.
하지만 이것이 유일한 trade-off라면 테스팅 피라미드를 고려할 때 Unit 테스트에만 100% 집중하고, 다른 형태의 테스트를 완전히 무시할 것이다. 물론 이렇게 해서는 안되며, 그 이유는 내가 이전에 말한 적이 있는 매우 중요한 원칙이 있기 때문이다:
"The more your tests resemble the way your software is used, the more confidence they can give you."
테스트가 소프트웨어 사용 방식과 유사할수록, 더 많은 신뢰를 줄 수 있다.
위의 문장이 무엇을 의미 하는가? 즉, Marie 이모가 세무 소프트웨어를 사용하여 세금을 할 수 있도록 하려면 실제로 마리 이모가 해보는 방법보다 더 좋은 방법이 없다는 뜻이다.
하지만 Marie 이모가 버그를 찾아줄 때까지 기다릴 수는 없지 않은가? 그것은 시간이 너무 오래 걸리고 테스트해야할 일부 기능을 놓칠 수 있다. 여기에 정기적으로 소프트웨어 업데이트 하고 있다는 사실을 고려하면, 아무리 많은 사람들이 있더라도 이를 따라잡을 방법이 없다.
그럼 어떻게 해야할까? 우리는 trade-off를 고려해야한다. 어떻게? 소프트웨어를 테스트하는 소프트웨어를 작성한다. 테스트할 때 항성 trade-off를 고려하는 것은 이제 우리의 테스트가 Marie 이모가 소프트웨어를 테스트할 때만큼 안정적으로 소프트웨어가 사용되는 방식이과 비슷하지 않다는 것이다.
하지만 이런 접근 방식을 통해 실제 문제를 해결할 수 있기 때문에 테스트를 한다. 이것이 바로 모든 테스팅 트로피 레벨에서 우리가 하고 있는 일이다.
테스팅 트로피가 위로 올라갈 수록 "신뢰도 계수"라는 것이 높아진다. 이는 각 테스트 수준에서 상대적인 신뢰성 수준을 나타낸다.
트로피 위에는 수동 테스트가 있다고 상상할 수 있다. 수동 테스트는 매우 높은 신뢰성을 얻을 수 있지만, 테스트 비용이 매우 비싸고 속도도 느리다.
앞서 두가지 #복선을 기억하라고 했다:
트로피 위로 올라갈수록 실패 포인트가 많아지므로 테스트가 중단될 가능성이 높아져 테스트를 분석하고 수정하는데 더 많은 시간이 소요된다.
Unit테스트는 일반적으로 의존성이 없거나 해당 의존성을 모킹(mocking)할 수 있는 작은 단위의 기능을 테스트한다.(수천 라인의 코드를 단 몇 줄로 효과적으로 대체).
이 말들은 테스팅 피라미드의 아래쪽으로 갈수록 테스트하는 코드의 양이 적어진다는 것을 의미한다. 낮은 수준에서 작동하는 경우, 더 많은 테스트가 필요하며, 테스팅 피라미드의 상위 단계에서는 하나의 테스트로 더 많은 코드 라인을 커버할 수 있다.
사실, 테스팅 피라미드의 아래쪽으로 내려갈수록, 테스트가 불가능한 몇 가지 요소가 있다.
특히 정적 분석 도구로 비즈니스 로직에 대한 신뢰성을 얻을 수 없다. Unit 테스트는 의존성 호출이, 적절하게 이루어졌는지 보장할 수 없다. (호출 방법에 대한 Assertion은 할 수 있지만 Unit 테스트로 제대로 호출되는지는 보장할 수 없다.).
UI Integration 테스트는 백엔드에 올바른 데이터를 전달하고, 에러를 올바르게 처리하고 파싱하는지 보장할 수 없다.
End to End는 매우 훌륭하지만, 일반적으로 non-Production(운영 환경에 가깝지만 실제 운영 환경이 아닌 환경)에서 실행하여 신뢰성 대비 실용성의 trade-off가 있다.
이제 다른 방향으로 가보자. 테스팅 트로피의 맨 위에서 Form과 URL 생성의 edge case에 대해 E2E 테스트를 사용하여 특정 필드를 입력하고 제출 버튼을 클릭하는 것을 확인하기 위해 E2E 테스트를 사용하려고 하면, 전체 애플리케이션(백엔드 포함)을 실행하여 많은 설정 작업을 하게 된다. 이는 Integration 테스트에 좀 더 적합하다.
쿠폰 코드 계산기의 edge case를 테스트하기 위해 Integration 테스트를 사용하려고 한다면, 쿠폰 코드 계산기를 사용하는 컴포넌트를 렌더링할 수 있는지 확인하기 위해 설정 함수(setup function)에서 상당의 양을 작업을 할 것이다.
이런 edge case는 Unit 테스트에서 하는 것이 더 낫다.
만약 숫자 대신 문자열을 사용하여 add 함수를 호출했을 때의 결과를 검증하기 위해 Unit 테스트를 사용하려 한다면, TypeScript와 같은 정적 타입 체크 도구를 사용하는 것이 훨씬 더 효과적일 수 있다.
결과
트로피의 각 수준마다 각각의 trade-off를 갖고있다. E2E테스트는 실패 포인트가 더 많아서 어떤 코드가 문제를 일으켰는지 추적하기가 어려울 수 있지만, 이것은 더 많은 신뢰성을 가질 수 있다는 것을 의미한다.
이는 테스트를 작성할 시간이 충분하지 않은 경우 특히 유용하다.
애초에 테스트를 통해 문제를 발견하지 못하는 것보다는 자신감을 가지고 실패의 원인을 추적하는 것이 더 낫다.
결국 나는 그 구분에 별로 신경쓰지 않는다.
만약 내 Unit 테스트를 Integration 혹은 E2E라고 생각한다면(어떤 사람들은 그렇게 부르기도 한다 🤷♂️) 그렇게 생각해라.
나는 내 코드가 비즈니스 요구 사항을 충족하는 것이 더 중요하다. 그 목표를 달성하기 위해 다양한 테스트 전략을 혼합하여 사용할 것이다.
Good luck!