- Kent C. Dodds ๋์ Static vs Unit vs Integration vs E2E Testing for Frontend Apps์ ์ฝ๊ณ ์ถ๊ฐ์ ์ผ๋ก ๋ ํ์ํ ๋ถ๋ถ๊ณผ ๊ฐ์ด ์ ๋ฆฌํ ๊ธ์ ๋๋ค. ์ ์ ์ดํด๋๋ฅผ ๋์ด๊ธฐ ์ํด ๋ณด์ถฉ ์ค๋ช ์ด ๋์ด ์๋ ๋ถ๋ถ๋ค๋ ์์ผ๋ฏ๋ก ์ ํํ ์์๋ฅผ ์ฐธ๊ณ ๋ถํ๋๋ฆฝ๋๋ค.
์ด ํ ์คํธ ํธ๋กํผ๊ฐ ๋ฌด์์ธ์ง, ์ ์ด ๋ถ๋ฅ๊ฐ ์ค์ํ์ง, ์๋๋ฉด ์ค์ํ์ง ์์์ง์ ๋ํด ์ด์ผ๊ธฐ ํฉ๋๋ค.
ํ ์คํธ ํธ๋กํผ์๋ 4๊ฐ์ง ์ ํ์ ํ ์คํธ๊ฐ ์์ต๋๋ค.
- End to End test : ์ค์ง ์ฌ์ฉ์ ์ฒ๋ผ ์ฑ ์ฃผ๋ณ์ ํด๋ฆญํ๊ณ ์ ๋๋ก ์๋ํ๋์ง ํ์ธํ๋ ๋์ฐ๋ฏธ ๋ก๋ด์ ๋๋ค. "๊ธฐ๋ฅ ํ ์คํธ" ๋๋ e2e๋ผ๊ณ ๋ ํฉ๋๋ค.
- ํตํฉ : ์ฌ๋ฌ ๋จ์ ํ ์คํธ๋ค์ด ์กฐํ๋กญ๊ฒ ํจ๊ป ์๋ํ๋์ง ํ์ธํฉ๋๋ค.
- ๋จ์ : ๊ฐ๋ณ์ ์ผ๋ก ๋ ๋ฆฝ๋ ์กฐ๊ทธ๋งํ ๋จ์๋ก ์์ฑ๋๋ฉฐ ์์๋๋ก ์๋ํ๋์ง ํ์ธํฉ๋๋ค.
- ์ ์ : ์ฝ๋๋ฅผ ์์ฑํ ๋ ์คํ ๋ฐ ์ ํ ์ค๋ฅ๋ฅผ ํฌ์ฐฉํฉ๋๋ค.
๊ฐ ๋จ๊ณ ์์์ ํธ๋กํผ์ ์ฌ์ด์ฆ(๋ฉด์ )๋ ํ ์คํธ๋ฅผ ํ๋๋ฐ ์ผ๋ง๋ ์ด์ ์ ๋ง์ถ๋์ง์ ๋ํ ๋ถ๋ถ์ ๋ํ๋ ๋๋ค.
ํธ๋กํผ์ ์๋๋ก๋ถํฐ ํ ์คํ ํผ๋ผ๋ฏธ๋ ํ๋ฆ์ ๋ฐ๋ผ๊ฐ ๋ณด๊ฒ ์ต๋๋ค.
Static
์ ํธ๋กํผ ๊ทธ๋ฆผ์ Martin Fowler์ ํ ์คํธ ํผ๋ผ๋ฏธ๋๋ฅผ ๋ฐํ์ผ๋ก ์ถ๊ฐ๋ก ์ ์ ๋ถ์ ํด์ด ์ค์ํ๋ค๊ณ ์๊ฐํด Kent.C๋ถ์ด ํ ์คํธ ํธ๋กํผ๋ก ์๋ก ๋ง๋ค์์ต๋๋ค.
TypeScript, ESLint, Flow์ ๊ฐ์ ์ ์ ๋ถ์ ํด์ ์ค์ ๋ฐํ์๋ณด๋ค ์ด๋ฅธ ์์ ์ ๋ฒ๊ทธ๋ฅผ ์ฐพ์๋ผ ์ ์์ต๋๋ค.
๊ทธ๋ฆฌ๊ณ ๊ทธ๋งํผ ์ค๋ฅ๋ฅผ ์ฐพ๋ ๋ฐ๋ ๋นจ๋ฆฌ ์ฐพ๊ธฐ ๋๋ฌธ์ ๋น์ฉ์ด ๋ค์ง ์์ต๋๋ค.
๋ฒ๊ทธ ์ ๋ฐ์ ์ค์ฌ ์ฃผ๊ธด ํ์ง๋ง ๋น์ฆ๋์ค ๋ก์ง์ ๋ํ ํ์ ์ ๋ณด์ฅํ ์ ์์ต๋๋ค. ๋ฐ๋ผ์ ์ข์ ํ ์คํธ ์ค์ํธ๋ก ์ฝ๋์ ๋ํ ํ์ ์ ๋์ผ ์ ์์ต๋๋ค.
// can you spot the bug?
// I'll bet ESLint's for-direction rule could
// catch it faster than you in a code review ๐
for (var i = 0; i < 10; i--) {
console.log(i)
}
const two = '2'
// ok, this one's contrived a bit,
// but TypeScript will tell you this is bad:
const result = add(1, two)
Unit
์ด ์ค๋ช
์ ์ถ๊ฐ๋ก ์๋ฌธ์๋ ์๋ ๋ถ๋ถ์
๋๋ค. ๋ฆฌ์กํธ์์์ ๋จ์ ํ
์คํธ์
๋๋ค. ๋จ์ ํ
์คํธ์๋ ์ฃผ๋ก ๋ธ๋ผ์ฐ์ ์ DOM๊ณผ ์ฐ๊ด๋ ์ฝ๋๊ฐ ์๋ ์์ํจ์๋ก ์ฐ์ด๋ ๊ฒ๊ณผ๋ ๋ค๋ฅด๊ฒ ๋ฆฌ์กํธ์์๋ render()
ํจ์๋ก DOM์ ์ฌ์ฉ ํ์ฌ ์ปดํฌ๋ํธ๊ฐ ๋ ๋๋ง ๋๋ ๊ฒ์ด ๋จ์ํ
์คํธ์ ํ ์๋ก ํฌํจ๋ฉ๋๋ค.
import '@testing-library/jest-dom/extend-expect'
import * as React from 'react'
import { render, screen } from '@testing-library/react'
import ItemList from '../item-list'
test('renders "no items" when the item list is empty', () => {
render(<ItemList items={[]} />)
expect(screen.getByText(/no items/i)).toBeInTheDocument()
})
Integration
Expedia์ ๊ธฐ์ ๋ธ๋ก๊ทธ Integration Testing in React๋ฅผ ์ธ์ฉํ์๋ฉด, ํตํฉ ํ ์คํธ๋ ์ดํ๋ฆฌ์ผ์ด์ ์ ๋ค์ํ ๊ตฌ์ฑ ์์ ๊ฐ์ ์ํธ ์์ฉ์ ํ ์คํธํฉ๋๋ค. React ์ดํ๋ฆฌ์ผ์ด์ ์ ๊ฒฝ์ฐ ์ด๋ ํ ์คํธ๋ฅผ ์๋ฏธํฉ๋๋ค.
- ์ผ๋ฐ์ ์ผ๋ก ๋ค์๊ณผ ๊ฐ์ prop ํจ์๋ฅผ ํธ์ถํ์ฌ ์ํ๋๋ React ๊ตฌ์ฑ ์์ ๊ฐ์ ์ํธ ์์ฉ
<Component onClick={onClickHandler}>
- ๊ตฌ์ฑ ์์ ์ํ ์กฐ์
- React ๋ผ์ดํ์ฌ์ดํด ๋ฉ์๋์์ DOM์ ์ง์ ์กฐ์
Kent C. Dodds๋ ํตํฉํ ์คํธ์ ํฌ์๋ฅผ ๋ง์ด ํด์ผ ํ๋ค๊ณ ํฉ๋๋ค. ๊ทธ ์ด์ ๋ก๋ ํตํฉํ ์คํธ๋ ์ฝ๋์ ๋ํ ํ์ /์์ ๊ฐ๊ณผ ์๋/๋น์ฉ ๊ฐ์ ๊ท ํ์ ํ๋ฅญํ๊ฒ ์ ์งํ๊ธฐ ๋๋ฌธ์ ๋๋ค. ๊ทธ๊ฐ ์ ์ํ๋ ๋ ๋ง์ ํตํฉ ํ ์คํ ์ ์์ฑํ๋ ๋ฐฉ๋ฒ์ผ๋ก๋,
- mock ์ ๋ง์ด ์ฐ์ง ๋ง ๊ฒ.
- React๋ฅผ ์ํํ๋ ๊ฒฝ์ฐ, ์์ ๋๋๋ง์ ๋ง์ด ์ฐ์ง ๋ง๊ฒ์ ์ ์ํฉ๋๋ค. Kent C. Dodds ๋์ ์์ ๋ ๋๋ง์ ์ฌ์ฉํ์ง ์๋ ์ด์
์๋์ ํ
์คํธ๋ ๋ก๊ทธ์ธ ๊ณผ์ ์ ํ
์คํธ ํ๋ ๊ณผ์ ์ด๋ฉฐ ์ ์ฒด์ฑ์ ๋ ๋๋งํ๋ ๊ฒ์ ํตํฉ ํ
์คํธ์ ์๊ตฌ์ฌํญ์ ์๋์ง๋ง ์ฌ๊ธฐ์๋ ๊ทธ๊ฐ ๋ง๋ react-test-library์ ๋ด์ฅ ํจ์์ธ render()
๋ฅผ ์ฌ์ฉํ์ฌ ์ ์ฒด ์ฑ์ ๋ ๋๋ง ํฉ๋๋ค. ๋ํ ๋ก๊ทธ์ธ ๋๋ Registration ์ด ํตํฉํ
์คํธ์ ์ ํฉํ ์๋ผ๊ณ ํฉ๋๋ค.
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 tests typically only mock HTTP requests via MSW
const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterAll(() => server.close())
afterEach(() => server.resetHandlers())
test(`logging in displays the user's username`, async () => {
// The custom render returns a promise that resolves when the app has
// finished loading (if you're server rendering, you may not need this).
// The custom render also allows you to specify your initial 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 whatever you need to verify the user is logged in
expect(screen.getByText(username)).toBeInTheDocument()
})
End to End
- ์ข
๋จ๊ฐ ํ
์คํธ๋ ํ๋ก ํธ์๋์ ๋ฐฑ์๋๋ฅผ ํฌํจํ ์ ์ฒด ์ดํ๋ฆฌ์ผ์ด์
์ ์คํํ๋ฉฐ, ์ค์ง์ ์ธ ์ผ๋ฐ ์ฌ์ฉ์ ์ฒ๋ผ ์ฑ๊ณผ ์ํธ์์ฉ์ ํฉ๋๋ค. ์๋์ ์ฝ๋๋ Cypress๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ก๊ทธ์ธ ํ๋ ๊ณผ์ ์
๋๋ค.
visitApp()
์ด๋ ํจ์๋ฅผ ์ฌ์ฉํ์ฌ ์ค์ ์ฌ์ฉ์์ ํ๋์์์ฒ๋ผ ์ฑ์ ๋ฐฉ๋ฌธํฉ๋๋ค.
import { generate } from 'todo-test-utils'
describe('todo app', () => {
it('should work for a typical user', () => {
const user = generate.user()
const todo = generate.todo()
// here we're going through the registration process.
// I'll typically only have one e2e test that does this.
// the rest of the tests will hit the same endpoint
// that the app does so we can skip navigating through that experience.
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...
// My E2E tests typically behave similar to how a user would.
// They can sometimes be quite long.
})
})
ํธ๋ ์ด๋ ์คํ
๊ฐ ๋จ๊ณ์์์ ํธ๋ ์ด๋ ์คํ์ ๋ํด ์ด์ผ๊ธฐ๋ฅผ ํ์๋ฉด,
๋น์ฉ, ์๋, ํ์ ์ฑ ์ธ๊ฐ์ง ์์๊ฐ ์์ต๋๋ค.
๋น์ฉ : ์์ ํธ๋กํผ ์ด๋ฏธ์ง์์ ํธ๋กํผ์ ์๋จ๊ณ๋ก ์ฌ๋ผ๊ฐ ์๋ก ํ ์คํธ ๋น์ฉ์ด ๋ ๋ง์ด ๋ญ๋๋ค. ์์ง๋์ด๋ค์ด ๊ฐ ๊ฐ๋ณ ํ ์คํธ๋ฅผ ์์ฑํ๊ณ ์ ์งํ๋๋ฐ ๊ฑธ๋ฆฌ๋ ์๊ฐ์ด๊ธฐ๋ ํฉ๋๋ค. ๋ํ ํธ๋กํผ๊ฐ ์๋ก ์ฌ๋ผ๊ฐ์๋ก ํ ์คํธ์์ ๋ ๋ง์ ์คํจ์ง์ ๋ค์ด ์์ต๋๋ค. ๊ทธ๋์ ํ ์คํธ๋ฅผ ๋ถ์ํ๊ณ ์์ ํ๋๋ฐ ๋ ๋ง์ ์๊ฐ์ด ์์ ๋ฉ๋๋ค.
์๋ : ํธ๋กํผ ์๋จ๊ณ๋ก ์ฌ๋ผ๊ฐ ์๋ก ํ ์คํธ๋ ์ผ๋ฐ์ ์ผ๋ก ๋๋ฆฌ๊ฒ ์คํ๋ฉ๋๋ค. ๊ทธ ์ด์ ๋ ํธ๋กํผ์์ ๋ ๋์์๋ก ํ ์คํธ์์ ๋ ๋ง์ ์ฝ๋๊ฐ ์คํ๋๊ธฐ ๋๋ฌธ์ ๋๋ค. ์๋์ ์๋ ๋จ์ ํ ์คํธ๋ ์ผ๋ฐ์ ์ผ๋ก ์ข ์์ฑ์ด ์๊ฑฐ๋ ํด๋น ์ข ์์ฑ์ mocking ํ๋ ์์ ๋จ์๋ฅผ ํ ์คํธํฉ๋๋ค.
ํ์ ์ฑ: ๋น์ฉ๊ณผ ์๋๊ฐ ํธ๋กํผ์ ์๋ก ์ฌ๋ผ๊ฐ ์๋ก ๋ ๋ง์ ๋น์ฉ์ด ๋ค๊ณ ๋ ํ ์คํธ ์์ฑ/์คํ ๋ถํฐ ์ ์ง๋ณด์ ๊น์ง ๋ง์ ์๋น ๋ถ๋ถ์ด ์์๋ฉ๋๋ค. ํ์ง๋ง ํผ๋ผ๋ฏธ๋ ์๋ก ์ฌ๋ผ๊ฐ ์๋ก ํ ์คํธ ํ์์ ์ ๋ขฐ๋๊ฐ ์ฆ๊ฐํฉ๋๋ค. E2E ํ ์คํธ๋ ๋จ์ ํ ์คํธ๋ณด๋ค ๋๋ฆฌ๊ณ ๋น์ฉ์ด ๋ง์ด ๋ค์ง๋ง ์ ํ๋ฆฌ์ผ์ด์ ์ด ์๋ํ ๋๋ก ์๋ํ๋ค๋ ํ์ ์ ํจ์ฌ ๋ ๋ง์ด ์ค๋๋ค.
์ ๋ฆฌ
๋ชจ๋ ๋ ๋ฒจ์์ ๊ฐ์ง๊ณ ์๋ ํธ๋ ์ด๋ ์คํ๋ฅผ ์ธ๊ธํ๋ฉฐ E2E ํ ์คํธ์ ๊ฒฝ์ฐ๋ฅผ ์ด์ผ๊ธฐ ํ๋๋ฐ, E2Eํ ์คํธ๋ ์คํจ์ผ์ด์ค๋ฅผ ์ผ์ผํค๋ ๋ฌธ์ ์ ์ฝ๋๋ฅผ ์ฐพ๊ธฐ๊ฐ ํ๋ค์ง๋ง (์ฆ, ๋น์ฉ๊ณผ ์๊ฐ์ด ๋ง์ด ๋ ๋ค๋ ์ด์ผ๊ธฐ ์ธ ๊ฒ ๊ฐ์ต๋๋ค.), ์ด ํ ์คํธ๊ฐ ๋ ๋ง์ ํ์ ์ ์ค๋ค๊ณ ๋งํฉ๋๋ค. ๊ทธ๋์ ์ด๊ฒ์ ์๊ฐ์ด ๋ง์ง ์์ ๊ฒฝ์ฐ์ ํนํ ์ ์ฉ ํฉ๋๋ค. ๋ํ, ์์ ๊ฐ/ํ์ ๊ฐ์ง๊ณ ์ฒ์๋ถํฐ ํ ์คํธ ์คํจ ์ด์ ๋ฅผ ์ถ์ ํ๋๋ฐ ์ง๋ฉดํ๋ ๊ฒ์ด ๋ฌธ์ ๋ฅผ ์ฐพ์ง ์์ผ๋ ค๊ณ ํ๋ ๊ฒ ๋ณด๋ค ๋ ๋ซ์ต๋๋ค.
์ฌ๋๋ค์ด ๊ทธ์ ๋จ์ํ ์คํธ๋ฅผ ๋ณด๊ณ ํตํฉํ ์คํธ ํน์ E2E ํ ์คํธ๋ผ ๋ถ๋ฅด๋ ๊ฒ์ ๋ํด ์ฆ, ๊ตฌ๋ถ์ ๋ํด ๋ณ ๊ด์ฌ์ ๊ฐ์ง์ง ์์ต๋๋ค. ํ์ง๋ง ๋ณ๊ฒฝ์ฌํญ์ด ์๋ ๊ทธ์ ์ฝ๋๊ฐ ๋น์ฆ๋์ค ์๊ตฌ ์ฌํญ์ ์ถฉ์กฑํ๊ณ ๊ทธ ๋ชฉํ๋ฅผ ๋ฌ์ฑํ๊ธฐ ์ํด ๋ค์ํ ํ ์คํธ ์ ๋ต์ ์ฌ์ฉํ๋ค๊ณ ํฉ๋๋ค.
์ฐธ๊ณ ์๋ฃ