토이 프로젝트할 때와 달리 회사 프로젝트는 상시 켜두기 때문에, 그리고 누구일지 모르는 사람들이 다수가 사용하기 때문에
어떤 일이 벌어질지 예측을 할 수가 없다.
최대한 모든 상황을 담아서 테스트를 해야되고 프로젝트가 커질수록 미들웨어 간의 관계 등에 의해서 예상치 못한 부분에서 에러가 발생할 수 있다.
따라서 테스트 코드를 작성해야 돼서 공부를 시작했다.
테스트 코드는 방금 작성한 코드를 당장 테스트 하기 위해서만 작성되는 것이 아니다.
이후 다른 변경 사항으로 인해 발생 가능한 결함을 찾아내는 역할을 한다.
직접 손으로 테스트하는 것보다 명령어 한 줄(jest test 등)으로 자동화시키는 것이 더 안정적이고 실수가 줄어든다.
물론 처음에는 오래 걸리겠지만, 프로젝트가 커질수록 해당 시간은 보잘 것 없게 된다.
즉, 투자할 가치가 있는 시간인 것이다.
QA만으로 모든 테스트를 커버할 수 없고, 개발자 입장에서 테스트를 하고 넘겨줘야 QA 프로세스가 감소할 수 있다.
UI 테스트가 쉽지는 않지만 불가능한 부분도 아니다.
서버에 대한 테스트가 중요하겠지만 클라이언트 테스트도 무시할 부분이 아니다.
개발한 모듈(프로그램의 기본 단위)이 의도대로 동작하는가에 초점이 맞춰져 있다. 일반적으로 Class나 Method 범위로 테스트를 진행한다.
의존성이 있는 코드와 함께 테스트하는 Sociable 테스트(자식 컴포넌트까지 포함해서 렌더링)와 모듈에 의해 실행되는 코드를 테스트 더블로 대체하는 Solitary 테스트(자식 컴포넌트를 mocking해서 렌더링)가 있다.
단위 크기가 작을수록 복잡성이 낮아지므로 동작을 표현하기 더 쉬워진다.
+) 테스트 더블
테스트를 진행하기 어려운 경우 이를 대신해 테스트를 진행할 수 있도록 만들어주는 객체.
단위 테스트가 끝난 모듈과 외부 라이브러리 또는 DB와 같이 개발자가 변경할 수 없는 모듈까지 함께 진행되는 테스트로 모듈 간 상호작용이 정상적으로 수행되는지에 초점이 맞춰져 있다.
통합 테스트를 통해 단위 테스트에서 발견하기 어려운 버그를 찾을 수 있다.
사용자 스토리(시나리오)에 맞춰 수행하는 테스트이다.
앞선 두 테스트들과 달리 비즈니스 쪽에 초점을 둔다.
프로젝트에 참여하는 사람들(기획자, 클라이언트 대표, 개발자 등)이 토의해서 시나리오를 만들고 개발자는 이에 의거해서 코드를 작성한다.
개발자 혼자서 직접 시나리오를 제작할 수 있지만 고객 관점 측면에서 놓치는 부분이 생길 수 있다.
따라서 직접 고객과 대면하는 팀으로부터 시나리오와 피드백을 받아 개발할 수 있는 테스트이다.
핸드폰의 주요 부품인 배터리와 SIM 카드에 대해 테스트한다고 가정하면
단위 테스트는 배터리의 수명, 용량 및 기타 매개 변수를 확인하며 SIM 카드가 활성화되었는지 확인하는 것이다.
통합 테스트는 배터리와 SIM 카드가 일체화되어 휴대폰을 시작하기 위해 조립하는 것이다.
기능 테스트는 휴대폰의 기능 및 배터리 사용량은 물론 SIM 카드 설비 등을 확인하는 것이다.
참고)
유닛 -> E2E로 갈 수록 테스트의 단위 크기가 커지고 테스트를 위한 비용이 증가한다.
Google Test Automation Conference에서는 테스트의 비율로서 E2E는 10%, 통합은 20%, 유닛은 70%로 테스트 비율을 제안했다.
~꼭 따라야 할 필요는 없을 것 같지만,~
TDD는 애자일처럼 다음 순서를 무한 반복하는 개발 주기를 가진다.
단위 테스트는 가장 작은 단위의 테스트이며, 모든 테스트의 시작점이다.
FIRST는 Fast, Isolated, Repeatable, Self-validating, Timely의 약자이다.
배포할 때는 필요없는 패키지이므로 개발용으로 설치한다.
이에 예외는 없을 것 같다.
npm install -D jest
package.json에 "test"를 "jest"로 수정한다.
npm test로 jest를 실행할 수 있다.
global 옵션을 줘서 npx로 실행할 수도 있지만 일반적으로는 이렇게 많이 실행한다.
"scripts: {
"test": "jest"
}
test('description), () => {
expect(어떤 행위).toBe(기댓값)
})
위와 같은 방식이 기본 형식이다.
여러 테스트들을 하나의 그룹으로 묶는데 사용한다.
아래는 그 예시다.
describe('/', () => {
test('/readiness :GET', async () => {
const { status } = await request.get('/healthz/readiness')
expect(status).toEqual(200)
})
test('/liveness :GET', async () => {
const { status } = await request.get('/healthz/liveness')
expect(status).toEqual(200)
})
})
각각은 모든 테스트 전,
모든 테스트 후,
describe로 묶인 각각의 테스트 전에,
describe로 묶인 각각의 테스트 후에
실행하고자 있는 것이 있을 때 사용한다.
아래와 같이 사용할 수 있다.
beforeEach(() => {
console.log('123')
})
afterAll(() => {
sequelize.sync(force: true)
}
toBe
vs toEqual
primitive types는 무엇을 쓰든 상관 없이 같은 결과가 나온다.
하지만 Object는 아니다.
toBe
는 같은 메모리를 가리키고 있는지 확인하는 것이고, toEqual
은 같은 값을 가지고 있는지를 확인하는 것이다.
describe("toBe", () => {
test("같은 메모리를 가리키면 같다고 판단한다.", () => {
const obj1 = {}
const obj2 = obj1
expect(obj1).toBe(obj2) // true
})
test("다른 메모리를 가리키면 값이 같더라고 다르다고 판단한다.", () => {
const obj1 = {}
const obj2 = {}
expect(obj1).toBe(obj2) // false
})
})
describe("toEqual", () => {
test("같은 메모리를 가리키면 같다고 판단한다.", () => {
const obj1 = {}
const obj2 = obj1
expect(obj1).toEqual(obj2) // true
})
test("같은 값을 가지고 같다고 판단한다.", () => {
const obj1 = {}
const obj2 = {}
expect(obj1).toEqual(obj2) // true
})
})
테스트 코드도 유지보수의 대상이다.
최대한 사람의 직접적인 수정이 덜 필요하게 만들어야 하기 때문에 다음과 같이 반복문적인 부분을 제공한다.
test.each([[999], [0], [-123], [NaN], ['string'], [12.34]])(
`옵션이 ~~이 아니라면 에러를 반환한다.`,
(command) => {
expect(() => {
// 함수 실행
}).toThrow()
}
)
MissionUtils
는 우테코에서 제공하는 모듈이다.사용자 입력이나 랜덤값 등 테스트를 진행하기 위해 필요한 동작을 하는 가짜(mock) 함수로 만들어준다.
// 원래는 콘솔의 입력을 받는 함수
const mockQuestions = (answers) => {
MissionUtils.Console.readLine = jest.fn()
answers.reduce((acc, input) => {
return acc.mockImplementationOnce((_, callback) => {
callback(input)
})
}, MissionUtils.Console.readLine)
}
// 원래는 범위 안에 있는 숫자 중에서 랜덤하게 선택해서 반환하는 함수
const mockRandoms = (numbers) => {
MissionUtils.Random.pickNumberInRange = jest.fn()
numbers.reduce((acc, number) => {
return acc.mockReturnValueOnce(number)
}, MissionUtils.Random.pickNumberInRange)
}
test('횟수 1번만에 성공한다.', () => {
mockRandoms([1, 0, 1])
mockQuestions(['3', 'U', 'D', 'U'])
const app = new App()
app.play()
// ...
})
어떤 객체에 속한 함수의 구현을 가짜로 대체하지 않고, 해당 함수의 호출 여부와 어떻게 호출되었는지만을 알아내야 할 때가 사용한다.
아래와 같이 콘솔에 출력된 내용을 확인하는 용도로 사용할 수 있다.
const getLogSpy = () => {
const logSpy = jest.spyOn(MissionUtils.Console, 'print')
logSpy.mockClear()
return logSpy
}
const expectLogContains = (received, logs) => {
logs.forEach((log) => {
expect(received).toEqual(expect.stringContaining(log))
})
}
test('횟수 1번만에 성공한다.', () => {
const logSpy = getLogSpy()
const app = new App()
app.play()
const log = getOutput(logSpy)
expectLogContains(log, [
'최종 게임 결과',
'[ O | | O ]',
'[ | O | ]',
'게임 성공 여부: 성공',
'총 시도한 횟수: 1',
])
})
import
를 인식하지 못해서 검색해보니 require
을 사용하거나 babel 관련 모듈을 다운받으라고 했다. require
로 바꾸면 프로젝트 전체 문법을 바꿔야 하니 포기했고 babel을 사용해도 해결이 되지 않았다. (지금 생각해보니 1번 문제였는데 babel 문제로 착각했던 것 같다).설정에 대한 설명은 https://www.typescriptlang.org/ko/docs/handbook/tsconfig-json.html 에 자세히 나와 있다.
{
"compilerOptions": {
"target": "es2021",
"module": "commonjs",
"moduleResolution": "node",
"types": ["node", "jest"],
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"removeComments": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"exclude": ["node_modules", "dist"]
}
module.exports = {
testEnvironment: 'node',
testMatch: ['**/__tests__/*.test.js'],
testTimeout: 120000,
}
const port = ~~
const app = initApp()
const serve = async () => {
try {
checkRequiredEnvs()
await connectMongoDb()
const server = http.createServer(app)
server.listen(port, () => {
console.log('서버가 실행되었습니다.')
})
} catch(err) {
console.error(err)
}
}
serve()
export default app // 테스트 코드에서 이 app 변수를 import 하기 위함.
// http.createServer가 진행된 app을 import해야 테스트 코드(supertest)가 제대로 동작한다고 함.
// app.address is not a function 에러가 발생할 시 아래 주소 참고.
// https://stackoverflow.com/questions/33986863/mocha-api-testing-getting-typeerror-app-address-is-not-a-function 참고
unit test 시 좋은 테스트 코드를 만들기 위한 패턴 중 하나이다.
expect
등).it(`버튼을 클릭하고 5초 뒤에는, p 태그 안에 "버튼이 눌리지 않았다." 라고 쓰여진다.`, () => {
// Arrange
jest.useFakeTimers();
const { getByText } = render(<Button />);
const buttonElement = getByText("button");
// Act
fireEvent.click(buttonElement);
act(() => {
// state 변경 시 감싸지 않으면 warning 발생
jest.advanceTimersByTime(5000); // 5초가 흘렀다고 가정. 실제 테스트 시간이 5초가 걸리진 않음.
});
// Assert
const p = getByText("버튼이 눌리지 않았다.");
expect(p).not.toBeNull();
expect(p).toBeInstanceOf(HTMLParagraphElement);
});
AAA의 또다른 이름이라고 생각해도 된다.
AAA가 TDD의 용어이며, 개발자 지향적인 용어라면,
GWT는 BDD의 용어이며, 비즈니스 지향적인 용어이다.
브라우저 환경 => headless 브라우저를 사용하여 개발이 완료된 후 배포할 때 CI와 연동해서 테스트하는 방식 권장됨.
Node.js 환경 => jest와 같은 테스트 도구에서 DOM을 가상으로 구현하는 라이브러리 활용
~현재 프로젝트에서 당장 사용하기 보다는, 유닛 테스트와 통합 테스트가 자리를 잡고 유의미하다고 생각이 들면 도입을 제안해야겠다.~
개인적으로 생각하는 E2E 테스트의 가장 큰 장점은 QA 과정에서도 발견하지 못할 버그를 찾을 수 있으며 QA 전인 개발 과정에서 버그를 찾는 것이 시간과 비용 측면에서 많은 세이브가 된다는 것이다.
많은 회고록들을 보면 E2E는 어렵다고 하며 심지어 도입했다가 걷어내는 팀도 있다고 한다.
왜 그런 일들이 발생했을까 고민해보면 다음과 같은 이유가 있을 것이다.
카카오엔터테인먼트 팀에서는 이러한 문제에도 불구하고(다만 아직 QA팀과 협업은 진행하지 않았다고 함) E2E 테스트에서 시나리오를 작성함으로써 전체적인 프로젝트 흐름을 파악하고, 다른 사람의 코드를 수정할 때 부담이 되는 점을 보완하고자 도입을 시도했다.
이 덕분에 프론트엔드에서 예상치 못한 버그를 찾아내거나 API에서 발생한 사이드 이펙트를 발견하기도 했다고 한다.
그 팀이 지킨 원칙은 다음과 같다.
Sorry-Cypress
) 사용.+) 시간이 오래 거릴기 때문에 CI/CD 중에 테스트를 돌리는 것도 하나의 방법.
Right-BICEP는 무엇을 테스트할지에 대한 가이드를 제공한다.
입력-실행-결과 식으로 짤 수 있도록 집중해야 한다.
다음은 프론트엔드에서 테스트할 수 있는 대상이다.
크게 3가지로 구분하자면 시각적 요소, 사용자 이벤트 처리, API 통신이다.
+) 테스트 코드도 코드이다.
즉, 선언적으로, 반복적으로 작성되는 부분은 함수로 구현하면 효율적이다.
it("뭔가 수행한다.", async () => {
const onSubmit = jest.fn();
const onCancel = jest.fn();
const result = render(
<ComplexForm onSubmit={onSubmit} onCancel={onCancel} />
);
expect(result.getByLabelText("First Name")).toBeInTheDocument();
expect(result.getByLabelText("Last Name")).toBeInTheDocument();
await act(async () => {
userEvent.click(result.getByLabelText("Over 21?"));
});
expect(result.getByLabelText("Favorite Drink?")).toBeInTheDocument();
});
위 코드보다는 아래 코드가 시나리오를 모르는 누군가가 봐도 이해하기 쉬울 것이다.
it("뭔가 수행한다.", async () => {
const { FirstNameInput, LastNameInput, clickIsOver21, FavoriteDrinkInput } =
renderComplexForm();
expect(FirstNameInput()).toBeInTheDocument();
expect(LastNameInput()).toBeInTheDocument();
await clickIsOver21();
expect(FavoriteDrinkInput()).toBeInTheDocument();
});
render
vs screen
아래 두 코드는 모두 동작하고 기능적으로 잘못된 부분은 없다.
import React from 'react'
import { render, screen } from '@testing-library/react'
describe("렌더링", () => {
test("Foo가 있는 컴포넌트가 렌더링된다1.", () => {
const { getByText } = render(<div>Foo</div>)
expect(getByText('Foo')).toBeInTheDocument()
})
test("Foo가 있는 컴포넌트가 렌더링된다2.", () => {
render(<div>Foo</div>)
expect(screen.getByText('Foo')).toBeInTheDocument()
})
})
해당 모듈 컨트리뷰터의 블로그에 따르면 screen
을 사용하는 것이 권장된다.
구조 분해 할당을 해서 몇 개의 함수를 뽑아 써야 한다면 여러 테스트 코드가 번잡해지기 때문이다.
The benefit of using screen is you no longer need to keep the render call destructure up-to-date as you add/remove the queries you need. You only need to type screen. and let your editor's magic autocomplete take care of the rest.
The only exception to this is if you're setting the container or baseElement which you probably should avoid doing (I honestly can't think of a legitimate use case for those options anymore and they only exist for historical reasons at this point).
에러 메시지가 알려주는 자세함이 다르기 때문이다.
const button = screen.getByRole('button', {name: /disabled button/i})
// ❌
expect(button.disabled).toBe(true)
// error message:
// expect(received).toBe(expected) // Object.is equality
//
// Expected: true
// Received: false
// ✅
expect(button).toBeDisabled()
// error message:
// Received element is not disabled:
// <button />
aria-
, role
의 잘못된 또는 불필요한 접근자를 쓰지 않는다.button
, nav
, main
등 시맨틱 태그는 기본적으로 본인의 속성을 가지고 있다.
해당 내용은 다음 링크에서 확인할 수 있다.
Slapping accessibility attributes willy nilly is not only unnecessary (as in the case above), but it can also confuse screen readers and their users. The accessibility attributes should really only be used when semantic HTML doesn't satisfy your use case (like if you're building a non-native UI that you want to make accessible like an autocomplete).
query*
로 element를 찾는 것은 DOM에 존재하지 않는 element에 대해서만 쓴다.get*
, find*
등은 DOM에 존재하지 않는 element에 접근할 때 에러를 던진다.
그리고 그 에러 메시지는 query*
로 찾은 element를 toBeInTheDocument()
로 확인하는 것보다 더 유용한 에러 정보를 제공한다.
// ❌
expect(screen.queryByRole('alert')).toBeInTheDocument()
// ✅
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
waitFor
보다는 find*
를 쓴다. // ❌
const submitButton = await waitFor(() =>
screen.getByRole('button', {name: /submit/i}),
)
// ✅
const submitButton = await screen.findByRole('button', {name: /submit/i})
더 간결하고 유용한 에러 정보를 제공하기 때문이다.
참고로 위 쿼리들은 당장에 존재하지 않을 수 있는 element를 찾을 때 사용한다.
waitFor
내부에서 사이드 이펙트를 수행하지 않는다.// ❌
await waitFor(() => {
fireEvent.keyDown(input, {key: 'ArrowDown'})
expect(screen.getAllByRole('listitem')).toHaveLength(3)
})
// ✅
fireEvent.keyDown(input, {key: 'ArrowDown'})
await waitFor(() => {
expect(screen.getAllByRole('listitem')).toHaveLength(3)
})
waitFor
는 non-deterministic한 callback을 처리하기 위한 함수이다.
따라서 waitFor
내부에서 예상치 못하게 side-effect가 여러 번 실행될 수 있다.
jest-dom
에서 제공하는 matchers를 활용한다.
const button = screen.getByRole('button')
// ❌
expect(button.disabled).toBe(true)
// ✅
expect(button).toBeDisabled();
다음과 같은 컴포넌트가 있다고 하자.
function SubmitButton() {
return (
<button className="submit-button" data-testid="submit-button-testid">
등록
</button>
)
}
위 컴포넌트의 button
element에 접근할 수 있는 방법은 다양하다.
document.querySelector('.submit-button'); // querySelector를 사용할 수 있음. react testing library는 class로 query하는 방식을 제공하지 않음.
screen.getByTestId('submit-button-testid');
screen.getbyRole('button', { name: /등록/ });
사용자는 SubmitButton
을 누를 때 class를 보거나 testid를 보는 것이 아니다.
byRole
은 class나 testid 변경 등, 개발단에서만 변경이 적용되는 내용에 영향을 받지 않는다(받아도 덜 받는다).
또한byRole
쿼리를 사용한다면 "등록"이라는 명확한 단어가 필요한 부분에서 "등럭" 같은 오타가 발생하는 말도 안 되는 경우를 미리 발견하고 에러를 해결할 수도 있다.
비슷한 의미에서 class로 쿼리해서 가져오는 것을 지양하자.
class 네이밍을 변경할 경우 불필요하게 테스트 코드까지 변경해야 되는 경우가 있기 때문이다.
class에 대해 반드시 테스트해야 되면 toHaveClass
assertion으로 테스트가 가능하다.
*byRole
성능 참고
(항상 같은 결과는 아니겠지만) 위 세 가지 쿼리 방법 중 *byRole
로 쿼리하는 테스트가 가장 느리다.
성능 관련 깃헙 이슈1, 성능 관련 깃헙 이슈2
https://ko.myservername.com/differences-between-unit-testing
https://tecoble.techcourse.co.kr/post/2021-05-25-unit-test-vs-integration-test-vs-acceptance-test/
https://hijuworld.tistory.com/80?category=1097105
https://codechacha.com/en/unittest-aaa-pattern/
https://velog.io/@fkszm3/testing-unit-test%EA%B8%B0%EC%B4%88-AAA-Pattern
https://martinfowler.com/bliki/GivenWhenThen.html
https://softwareengineering.stackexchange.com/questions/308160/differences-between-given-when-then-gwt-and-arrange-act-assert-aaa
https://fe-developers.kakaoent.com/2023/230209-e2e/
https://brunch.co.kr/@jiwonleeqa/241
https://medium.com/delivus/e2e-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B5%AC%EC%B6%95%EA%B8%B0-used-aws-step-functions-2fccb930218c
https://hyperconnect.github.io/2022/01/28/e2e-test-with-playwright.html
https://ui.toast.com/posts/ko_20210818
https://ui.toast.com/posts/ko_20210630
https://www.freecodecamp.org/news/testing-react-hooks/
https://team.modusign.co.kr/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C%EC%97%90%EC%84%9C-%EC%9D%98%EB%AF%B8%EC%9E%88%EB%8A%94-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EC%9E%91%EC%84%B1%ED%95%98%EA%B8%B0-4992409c7f2d
https://blog.mathpresso.com/%EB%AA%A8%EB%8D%98-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A0%84%EB%9E%B5-1%ED%8E%B8-841e87a613b2
https://blog.mathpresso.com/%EB%AA%A8%EB%8D%98-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A0%84%EB%9E%B5-2%ED%8E%B8-de069e271b3d
https://learn-react-test.vlpt.us/#/
https://yrnana.dev/post/2021-08-15-testing-library/
https://kentcdodds.com/blog/common-mistakes-with-react-testing-library
https://velog.io/@velopert/react-testing-library
https://webtips.dev/solutions/classes-in-react-testing-library