수동적인 테스팅, 즉 개발자가 코드를 작성해서 브라우저에서 시험적으로 테스트하는 것을 의미합니다. 이는 개발자로서 항상하는 것입니다. 하지만 이런 수동적인 앱 테스팅은 오류 발생이 쉽습니다. 이는 가능한 모든 조합과 시나리오를 테스트하기 어렵기 때문입니다.
그렇기 때문에 자동화된 테스팅을 사용하는 것입니다. 즉, 기존처럼 수동적인 테스팅을 하면서 추가적으로 자동화된 테스팅을 하여 오류를 더 일찍 잡을 수 있고 더 나은 코드를 작성할 수 있습니다.
자동화된 테스팅은 추가적인 코드를 작성해서 이 코드가 실행되면서 다른 코드를 테스트합니다. 자동화된 테스팅의 장점은 아래와 같습니다.
전체 애플리케이션을 자동으로 테스트하는 코드를 작성하기 때문에 항상 모든 것을 테스트할 수 있다는 점입니다.
코드가 변경될 때마다 서로 다른 개별적인 구성 요소에 대해 테스트를 진행한 다음 모든 개별 구성요소들을 다같이 테스트합니다.
모든 것을 상시 테스트 가능하며 수동 테스팅과 함께하면 오류를 더 일찍 잡을 수 있고 더 나은 코드를 작성할 수도 있습니다.
자동화 테스트에 여러 다른 종류가 존재합니다. 예를 들어 Unit Test, Integration Tests, End-to-End(E2E) Tests 등이 존재합니다.
단위 테스트는 애플리케이션의 가장 작은 단위에 대한 테스트를 작성하는 것입니다. 즉, 애플리케이션에서 사용되는 독립적인 개별 함수, 메서드, 클래스 등의 동작들을 테스팅하는 것입니다.
따라서 프로젝트에는 일반적으로 많은 단위 테스트가 포함됩니다. 애플리케이션을 구성하는 모든 함수 및 컴포넌트를 테스트하기 때문입니다. 그렇기 때문에 단위 테스트는 가장 일반적이고 중요한 테스트입니다.
단위 테스트의 아이디어는 모든 개별 단위를 자체적으로 테스트하면 전체 애플리케이션도 동작한다는 것에서 나왔습니다. 하지만 전체 애플리케이션도 실제로 동작하는지 확인하기 위해 이 모든 단위들을 모아서 통합 테스트를 해볼 수 있습니다.
통합 테스트는 여러 개의 구성 요소의 조합을 테스트합니다. 즉, 2개 이상의 모듈들을 결합하여 잘 작동하는지에 대한 테스트입니다.
예를 들어, 컴포넌트가 정상적으로 렌더링되는지에 대한 개별 컴포넌트에 대한 유닛 테스트이지만, 이 컴포넌트가 Redux와 같은 상태 라이브러리와 통합했을 때 최종적으로 의도한 결과를 도출하는지에 대한 테스트를 통합 테스트로 볼 수 있습니다.
전 구간 테스트는 애플리케이션의 전체 워크플로우, 전체 시나리오를 테스트하는 것입니다. 이는 실제 사용자가 애플리케이션을 사용하는 것과 유사한 환경을 구축한 후 실제 사용자의 동작을 흉내내어 테스트하는 것을 의미합니다.
이는 실제 사용자의 동작 흐름을 그대로 모방하여 테스트할 수 있다는 장점을 갖고 있지만 환경을 구축해야하며, 사용자의 행동 시나리오를 구축해야하기에 비싼 테스트가 됩니다.
프론트엔드에서 E2E 테스트는 실제 브라우저와 유사한 환경을 구축한 뒤 거기서 실제로 여러가지 이벤트들을 발생시킨 후 일련의 과정들을 테스트하는 방식으로 진행됩니다.
Jest는 기본적으로 *.test.*
형태를 가진 파일을 테스트 파일로 인식하며 해당 파일안에 있는 코드를 실행합니다.
일반적으로 소프트웨어를 테스트하는 과정을 생각해보면 아래와 같은 과정을 거치게 됩니다.
특정한 동작을 수행한다.
동작을 수행한 결과가 기대한 결과와 일치하는지 판단한다.
테스트 코드도 동일하게 테스트하고자하는 동작을 수행한 뒤 그 결과가 기대한 상황과 일치하는지 검증하는 코드를 작성합니다.
Jest에서는 이를 기대한 상황과 일치하는지 판단하는 함수들을 "matchers"라고 부릅니다. 따라서 Jest의 코드는 아래와 같은 형태를 띄게 됩니다.
특정한 동작을 수행한다.
matcher를 통해 실제 결과와 기대한 결과가 일치한지 검증한다.
Jest에서는 하나의 특정한 동작을 수행하기 위해서 test()
또는 it()
함수를 제공합니다.
test('two plus two is four', () => {
expect(2 + 2).toBe(4);
});
it('two plus two is four', () => {
expect(2 + 2).toBe(4);
});
위 코드처럼 test("테스트 이름", callback)
의 형태로 작성하며, callback 안에는 원하는 동작을 수행하고 expect(실제 결과).matcher()
의 형태로 결과를 판단하게 됩니다.
하나의 콜백 내 여러 expect
를 수행할 수 있으며 그 중 하나라도 기대값과 일치하지 않으면 해당 테스트는 실패한 테스트로 간주됩니다.
Jest에서 주로 사용되는 matcher들은 아래와 같습니다.
.toBe(원시값)
: 인수로 전달한 원시값과 일치시 테스트 성공
.toEqual(객체)
: 인수로 전달한 객체의 구조와 일치한 경우 테스트 성공
.toBeTruthy()
: truthy 값인 경우 테스트 성공
.toBeFalsy()
: falsy 값인 경우 테스트 성공
.toBeInTheDocument()
: jsdom에 엘리먼트 존재시 테스트 성공
.toContain(값)
: 인수로 전달된 값이 이터러블한 객체 내 존재하는 경우 테스트 성공
.toMatch(정규표현식)
: 인수로 전달된 정규표현식의 패턴과 매치하는 경우 테스트 성
.not
: Matcher의 기대값을 반대로 변경
.toHaveLength(숫자)
: 배열의 length 프로퍼티값과 인수로 전달된 값 일치시 테스트 성공
애플리케이션의 규모가 커질수록 수십 개 혹은 수천 개의 테스트 파일을 갖게 될텐데 이러한 서로 관련된 테스트들을 그룹화하여 정리할 수 있습니다.
그룹핑은 jest의 describe()
함수로 가능합니다.
describe
함수는 두 개의 인수를 전달받습니다.
첫 번째 인수로는 테스트 그룹의 설명을 전달합니다. 이는 테스트 그룹화의 식별자로서도 사용되는 값입니다.
두 번째 인수로는 함수를 전달하는데 이 함수에는 자체 테스팅 코드를 작성하지 않고 그룹화될 test 호출문들을 작성해줍니다.
describe('그룹 식별자', () => {
test(,,,);
test(,,,);
,,,
};
Jest를 통해 순수한 자바사크립트 코드를 테스트할 수 있게되었지만 리액트는 UI 라이브러리기에 리액트의 동작을 순수 Jest만으로 테스트하기에는 다소 어려움이 있습니다.
따라서 UI를 렌더링하는 부분을 책임지는 react-dom
라이브러리에서 제공해주는 별도의 기능과 결합하여 테스트를 수행해야 합니다. 이러한 과정을 테스트할 때마다 수행하기에는 번거롭기 때문에 리액트 컴포넌트를 테스트할 때 사용할 수 있는 라이브러리들이 존재합니다.
컴포넌트의 UI와 동작을 테스트할 때 많이 사용되는 라이브러리로 Enzyme와 React-Testing-Library(RTL)가 존재합니다.
Enzyme는 구현을 테스트하는 것에 초점이 맞춰진 라이브러리이며 RTL는 결과를 테스트하는 것에 초점이 맞춰진 라이브러입니다.
RTL은 리액트 컴포넌트를 테스트할 때 내부에서 어떤식으로 세부적인 구현이 이루어졌는지를 테스트하는 것이 아니라 행위에 대해서 어떤 결과가 나와아햐는지에 초점을 두고 있습니다. 이로인해 내부적인 구현이 어떻게 바뀌든 결과만 동일하다면 테스트 코드를 변경할 필요가 없습니다.
RTL은 이러한 철학에 기반을 두었기에 리액트 컴포넌트를 렌더링하고, 특정 요소에 접근할 수 있는 기능을 제공해줍니다. 그리고 특정 요소에서 이벤트를 발생시키는 기능도 제공해줍니다.
컴포넌트를 렌더링하거나, 요소에 접근 기능들은 @testing-library/react
가 제공하고, 요소에 이벤트를 발생시키는 기능은 @testing-library/user-event
가 제공합니다.
위 그림은 CRA으로 생성한 프로젝트의 package.json 파일입니다.
이때 dependencies에 "@testing-library/react", "@testing-library/jest-dom", "@testing-library/user-event" 가 기본적으로 존재하는 것을 확인할 수 있습니다.
RTL은 통상 jest-dom
라이브러리와 함께 사용됩니다. RTL은 렌더링, 요소 접근 등의 기능을 수행해줍니다. 하지만 테스트를 위해서 이 요소들이 DOM상에 존재하는지, 그리고 특정 프로퍼티를 갖고 있는지 등을 검사할 수 있어야 하지만 Jest는 이러한 기능을 수행할 수 있는 matcher들을 기본적으로 포함하고 있지 않습니다.
이러한 DOM과 관련된 matcher들을 추가적으로 포함하기 위해서는 jest-dom
라이브러리를 사용해야 합니다(CRA에 포함되어 있음).
예를 들어, .toBeInDocument()
나 .toBeDisabled()
와 같은 matcher들은 jest-dom 라이브러리를 통해 추가적으로 포함시킬 수 있습니다.
CRA로 프로젝트를 생성한 경우 setupTests.js 파일 내부에서 @testing-library/jest-dom
을 import하고 있는 것을 확인할 수 있습니다.
setupTests.js 파일은 모든 테스트를 시작하기 전에 실행되는 파일입니다.
RTL은 jsdom에 테스트하고자하는 컴포넌트를 @testing-library/react
가 제공하는 render
함수로 컴포넌트를 렌더링 후, jsdom에 렌더링된 내용을 기반으로 테스트를 진행합니다.
이때 렌더링된 특정 요소의 텍스트나 내용을 검사하기 위해서 특정 요소를 탐색할 수 있는 기능을 @testing-library/react
의 screen
객체의 쿼리 메서드로 제공됩니다.
CRA로 생성한 경우 App.test.js" 파일에 테스트 코드 일부가 포함되어 있으며, 즉시 사용할 수도 있습니다.
App.test.js 파일은 App 컴포넌트를 테스트하기 위한 파일입니다.
일반적으로 테스팅 파일과 컴포넌트 파일의 이름을 "일치"시키는 것이 관례이며, 테스팅 파일명 뒤에 ".test"만 덧붙여 테스팅 파일명을 작성합니다.
jsdom에 렌더링된 요소를 찾는 메서드를 쿼리 메서드라고 하며, @testing-library/react
의 screen
객체의 메서드로 제공됩니다.
import { screen } from '@testing-library/react';
const element = screen.queryMethod(value);
쿼리 메서드는 3가지 섹션으로 나누어져 있습니다.
쿼리 타입
get
: 동기적으로 엘리먼트 검색, 타겟 못찾을시 에러
find
: 비동기적으로 돔 엘리먼트 검색, 기본값 1000ms(1s) 이후에도 타겟 못찾을시 에러 발생
find 타입은 검색된 타켓을 resolve한 프로미스 객체를 반환
query
: 동기적으로 돔 엘리먼트 검색, 타겟 못찾을시 null 반환
타겟 개수
All
"을 작성, 다수의 엘리먼트를 요소로 갖는 배열을 반환. findAll*
사용시 배열을 resolve한 프로미스 객체를 반환타겟 유형(사용 우선순위)
ByLabelText
: 인수로 label이 있는 input 의 label 값으로 input 엘리먼트를 선택
ByPlaceholderText
: 인수로 placeholder 값으로 input 및 textarea 엘리먼트를 선택
ByText
: 인수로 엘리먼트가 가지고 있는 텍스트 값으로 선택합니다.
ByDisplayValue
: 인수로 input, textarea, select가 지니고 있는 현재 값(value 값)을 가지고 엘리먼트를 선택합니다.
ByAltText
: 인수로 alt 속성값을 가지고 있는 엘리먼트(주로 img) 를 선택합니다.
ByTitle
: 인수로 title 속성값을 가지고 있는 엘리먼트 혹은 title 엘리먼트를 지니고있는 SVG 를 선택 할 때 사용합니다.
ByRole
: 인수로 특정 role 값을 지니고 있는 엘리먼트를 선택합니다.
role 종류
ByTestId
: 다른 방법으로 못 선택할때 사용하는 방법입니다. test 할 때 사용할 id 를 달아서 선택하는 것을 의미합니다. 즉, 인수로 data-testid
어트리뷰트 값을 통해 엘리먼트를 선택합니다.
쿼리 메서드를 사용할 때는 위 그림처럼 위에서 아래 순서로 결정하여 사용합니다.
만약 특정 요소에 이벤트를 발생시키고자 하는 경우 @testing-library/user-event
가 export default한 객체를 통해서 이벤트를 발생시킬 수 있습니다.
객체에는 이벤트 타입과 동일한 이름의 메서드가 존재하며 인수로 요소 전달시 이벤트가 발생됩니다.
import userEvent from '@testing-library/user-evnet';
userEvent.이벤트타입(요소);
HTTP 요청을 보내는 코드를 테스트할 때 실제로 서버에 HTTP 요청을 보내지 않습니다. 이는 아래와 같은 이유로 요청을 보내지 않습니다.
많은 네트워크 트래픽을 일으키기 때문에 서버가 요청들에 의해 과부화될 것입니다. 특히 많은 테스트에서 요청을 보내는 경우가 있습니다.
데이터를 가져오지는 않지만 일부 컴포넌트가 서버로 POST 요청을 전송한다면 테스트로 인해 데이터베이스에 데이터가 추가될 것입니다. 혹은 서버의 내용이 변경될 수도 있습니다.
이러한 이유로 인해 테스트 코드를 작성할 때는 실제로 HTTP 요청을 전송하지 않거나 혹은 일종의 테스팅 서버로 요청을 전송하는 것입니다.
Jest는 "mock" 기능을 지원합니다. mock이란 유닛 테스트를 실행할 때 테스트되는 코드가 의존하는 부분을 더미 코드로 제공하는 기능입니다.
Http 요청을 보내는 경우 fetch API를 사용하여 보낼 수 있지만 실제로 요청을 보내지 않기 위해서 fetch API를 mock으로 설정하여 실제로 요청을 보내지 않도록 할 수 있습니다.
Jest는 mock 함수를 jest.fn
함수를 호출하여 생성할 수 있습니다.
생성된 mock 함수는 일반 함수처럼 인수를 전달하여 호출하고 반환하는 함수이지만 비어있는 더미 함수입니다.
const mockFunction = jest.fn();
mockFunction(...args);
생성된 더미 함수인 mock 함수를 설정하기 위해서 아래와 같은 메서드를 호출하여 설정할 수 있습니다.
jest.fn().mockReturnValue(value)
: 함수가 호출될 때마다 반환될 값을 지정
jest.fn().mockReturnValueOnce(value)
: 함수가 한 번 호출될 때 반환될 값 지정
jest.fn().mockImplemetation(() => { ... })
: 함수를 즉석으로 구현할 수 있다. 인수로 구현될 함수를 전달한다.
jest.fn().mockImplementationOnce(() => { ... })
: 함수가 한 번 호출될 때 실행될 함수 전달
jest.fn().mockResolvedValue(value) / jest.fn().mockRejectedValue(value)
: 비동기 함수에서 resolve 값/reject 값을 인수로 전달한다. 이때 mock 함수는 프로미스 객체를 반환합니다.
jest.fn().mockResolvedValueOnce(value) / jest.fn().mockRejectedValueOnce(value)
: 비동기 함수에서 호출시 한 번 resolve 값/reject 값을 인수로 전달한다. 이때 mock 함수는 프로미스 객체를 반환합니다.
Async 컴포넌트는 useEffect
훅을 이용하여 서버에게 HTTP 요청을 전송하고 응답으로 데이터를 받은 뒤 상태 변경 함수를 호출하고 있습니다.
이때 요청을 보낼 때 Fetch API를 사용하여 HTTP 요청을 전송하고 있습니다.
Async.test.js 테스트 파일의 테스트 코드 내부 Arrange 단계에서 fetch
함수를 재정의하는 것입니다.
즉, window.fetch
에 jest.fn()
의 반환값을 할당합니다.
그리고 mockResolvedValueOnce
메서드를 호출하는데 이는 resolve될 값을 인수로 전달합니다. 여기서는 응답 객체(response)의 역할을 하는 객체를 mockResolvedValueOnce
메서드의 인수로 전달합니다. 이때 전달되는 객체 내부에서는 json이라는 이름의 메서드도 정의해주어야 합니다. 이는 Async 컴포넌트에서 response.json()을 호출하기 때문입니다.
이렇게 우리는 테스트를 할 때 실제로 HTTP 요청을 보내지 않고 HTTP 요청을 보내는 fetch 함수를 HTTP 요청을 보내지 않는 함수로 재정의하였습니다.
테스팅을 진행하려면 package.json의 scripts에 작성된 명령어인 "test"를 이용합니다.
즉, 터미널에 "npm test"를 입력하여 테스트를 진행할 수 있습니다.
npm test 명령어를 입력하면 위 그림과 같은 화면이 보이게 됩니다. 실제로 명령어를 입력하는 즉시 테스트를 실행하지는 않습니다.
모든 테스트를 실행하려면 "a"를 입력합니다.
"a" 옵션을 선택하면 ".test.js" 확장자를 가진 모든 파일을 찾아 test 함수를 실행하여 모든 테스트를 실행합니다.
"a"를 입력하면 위 그림처럼 테스트를 실행하고 결과를 보여줍니다. 현재는 1개의 테스트를 실행하였으며 통과했다는 결과를 보여줍니다.
만약 테스트에 실패하게 된다면 아래와 같은 결과가 표시됩니다.
이때 왜 테스트에 실패했는지에 대한 설명도 추가적으로 표시됩니다.
"a" 옵션을 사용하게 되면 watch 모드가 적용되어 추가적인 명령어를 실행하지 않아도 파일이 수정되면 자동적으로 테스트를 다시 진행하게 됩니다.