본문 바로가기

7/31 넥스터즈 5주차 : 단위테스트, 디자인 시스템

 

4주차는 회사일이 급해서 회사일을 더 많이 했으므로 스킵

 

2주차에서 jest를 설치하고 jest.config.js 설정을 마쳤다면 이번주에는 jest로 단위테스트를 작성할 수 있는 Mocking을 준비하였다.

2주전에 구현한 Oauth2.0 로그인 단위테스트를 작성하면서 진행하였다. Mocking이 끝나면 앞으로의 기능은 회사에서 그랬던 것처럼 TDD로 구현할  생각이다. (TDD에 대해서 썼던 글)

 

8/26 추가

TDD로 하겠다고 호언장담했었으나 안했었다...😭

 

1. Axios Mocking

먼저 Axios Mocking이다. jest Mocking Modules 가이드에서 소개하듯이 jest.mock 으로 mocking하는 방법이 있다.

// users.test.js
import axios from 'axios';
import Users from './users';

jest.mock('axios');

test('should fetch users', () => {
  const users = [{name: 'Bob'}];
  const resp = {data: users};
  axios.get.mockResolvedValue(resp);

  // or you could use the following depending on your use case:
  // axios.get.mockImplementation(() => Promise.resolve(resp))

  return Users.all().then(data => expect(data).toEqual(users));
});

 

그런데 내가 의도하는 건 각 요청 경로 별로 응답을 다르게 설정하고 싶다. (회사처럼)

따라서 jest.spyOn 으로 axios.request를 mock하기로 했다. 이미 회사에서 저장소 분리할 때 한번 해보았기 때문에 껌이겠지 하고 시작했는데 다른 점이 존재했다.

 

const createMockMethod = (instance: AxiosInstance) => {}
export interface AxiosInstanceWithMock extends AxiosInstance {
mock: () => MockAxiosInstance
}
const extendWithMock = (instance: AxiosInstance): AxiosInstanceWithMock => {
const instanceWithMock = Object.create(instance)
instanceWithMock.mock = createMockMethod(instanceWithMock)
return instanceWithMock
}
const createAxiosInstance = () => {
const instance = axios.create()
instance.interceptors.response.use(AuthInterceptor)
return extendWithMock(instance)
}
export const globalAxiosInstance = createAxiosInstance()
export const withAxios = async <T>(request: RequestConfig): Promise<T> => {
/**
* @requires response 반드시 proxy 통해서 외부 서버와 통신합니다.
*/
const response = await globalAxiosInstance.request<T, T>({
...request,
baseURL: `${isSSR ? HOST_URL : ''}/api`,
})
return response
}
view raw withAxios.ts hosted with ❤ by GitHub

 

먼저 전역에서 axios.create 으로 인스턴스를 생성하고 mock 메소드를 추가한다.

 

mock 메소드에서 axios.request 를 mocking한다.

 

export const createMockAxiosInstance = (target) => ({
target,
async request(...args) {
return this.target && (await this.target.request(...args))
},
})
const createMockMethod = (instance: AxiosInstance | MockAxiosInstance) => () => {
if (Object.prototype.hasOwnProperty.call(instance, 'mockRespondOnce')) {
return instance as MockAxiosInstance
}
const mockInstance: MockAxiosInstance = createMockAxiosInstance(instance as AxiosInstance) as MockAxiosInstance
if (typeof jest !== 'undefined') {
mockInstance.mockRespondOnce = <T extends unknown>(path: string, data: T) => {
jest.spyOn(instance, 'request').mockImplementationOnce((...args): Promise<T> => {
const [pathFromArgs] = args
if (pathFromArgs === path || pathFromArgs.url === path) {
if (data instanceof Error) {
return Promise.reject(data)
}
return Promise.resolve(data)
}
throw new Error('path is different from pathFromArgs')
})
}
}
return mockInstance
}
view raw withAxios.ts hosted with ❤ by GitHub

이렇게 인스턴스에 mock 메소드를 추가하고 나면 mock 메소드를 호출할 withAxios를 mock할 모듈을 생성한다.

 

경로 : utils/__mocks__/fetcher/withAxios.ts

 

import {AxiosInstanceWithMock} from '$utils/fetcher/withAxios'
import {RequestConfig} from '$types/request'
const actualModule: {globalAxiosInstance: AxiosInstanceWithMock} = jest.requireActual('utils/fetcher/withAxios')
const {globalAxiosInstance: defaultAxiosInstance} = actualModule
const mockAxiosInstance = defaultAxiosInstance.mock()
export default mockAxiosInstance
export const withAxios = async <T = unknown>(req: RequestConfig): Promise<T> => {
const res = await mockAxiosInstance.request<T>({
...req,
})
return res
}
view raw withAxios.ts hosted with ❤ by GitHub

그런데 매뉴얼적으로 mocking하기 위해 utils/__mocks__/ 하단에 withAxios가 위치하던 경로 그대로 추가하면 jest.mock 이 이것을 인식한다고 알고 있었는데 mock이 먹히지 않는 것이다...😭 (참고)

 

그래서 jest.mock 의 두번째 인자로 fatory 함수를 넘겨 원래 모듈 대신에 __mock__에 위치한 모듈을 사용하도록 명시하였다.

jest.mock('utils/fetcher/withAxios', () => jest.requireActual('utils/__mocks__/fetcher/withAxios'))

 

👌 참고

jest --automock 으로 테스트를 실행하면 /__mocks__ 내의 모듈을 자동으로 사용한다. 여기서 자동으로 사용한다는 뜻은 명시적으로 jest.mock() 을 하지 않아도 자동으로 mocking된 모듈을 사용한다는 의미이다.

 

참조 : jest manual-mocks

 

사용 예시

위에서 mocking한 withAxios를 사용 예시이다.

 

jest.mock('utils/fetcher/withAxios', () => jest.requireActual('utils/__mocks__/fetcher/withAxios'))
const NAVER_LOGIN_API = '/login/naver/authorize'
const mockNaverLogin = (data: AuthorizeResponse | Error) => {
withAxios.mockRespondOnce<AuthorizeResponse | Error>(NAVER_LOGIN_API, data)
}
test('"네이버로 로그인" 버튼을 클릭하면 네이버 인증 api를 호출한다.', async () => {
window.location.replace = jest.fn()
mockNaverLogin({
redirectUrl: 'https://www.naver.com',
})
const {findByText} = render(<Login />)
let naverLoginButton: Element | null = null
const cb: MatcherFunction = (_content, element) => {
if (!element) {
return false
}
if (element.tagName === 'BUTTON' && element.textContent === '네이버로 로그인') {
naverLoginButton = element
return true
}
return false
}
await findByText(cb)
if (naverLoginButton) {
fireEvent.click(naverLoginButton)
}
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith('https://www.naver.com')
})
})
view raw Login.test.tsx hosted with ❤ by GitHub

 

2. 🚧 디자인 시스템

디자인 담당님들께서 디자인 시스템을 정리해주시고 계신것을 FE에서는 어떻게 적용할 수 있을지 고민되었다.

 

현재 tailwindCSS로 반응형 웹을 구축하려고 하고 있는데

바로 이 tailwindCSS의 config를 수정하면 이에 맞는 style을 자동으로 생성해주어 일관된 디자인을 적용할 수 있다.

 

예를 들어, ColorSet을 다음처럼 정의한다고 가정한다.

 

@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--primary-green-800: #11373e;
--primary-green-700: #5e7c75;
--primary-green-600: #818d8a;
--primary-yellow-800: #11373e;
--grey-800: #22211f;
--grey-700: #5f5d59;
--grey-600: #807c76;
--grey-500: #adaaa5;
--grey-400: #bbbbbb;
--grey-300: #dfdfdf;
--grey-200: #efefef;
--grey-100: #f8f8f8;
--grey-000: #ffffff;
--beige-500: #cbc4a8;
--beige-400: #ded9c6;
--beige-300: #eae6d7;
--beige-200: #f6f4e8;
}
view raw index.css hosted with ❤ by GitHub
module.exports = {
purge: [
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {},
colors: {
'primary-green': {
800: 'var(--primary-green-800)',
700: 'var(--primary-green-700)',
600: 'var(--primary-green-600)',
},
'primary-yellow': {
800: 'var(--primary-yellow-800)',
},
beige: {
500: 'var(--beige-500)',
400: 'var(--beige-400)',
300: 'var(--beige-300)',
200: 'var(--beige-200)',
},
grey: {
800: 'var(--grey-800)',
700: 'var(--grey-700)',
600: 'var(--grey-600)',
500: 'var(--grey-500)',
400: 'var(--grey-400)',
300: 'var(--grey-300)',
200: 'var(--grey-200)',
100: 'var(--grey-100)',
'000': 'var(--grey-000)',
},
},
},
variants: {
extend: {},
},
plugins: [],
prefix: 'tw-',
}

 

config와 index.css를 수정한 후, tailwindcss 커맨드로 static 경로에 빌드하면 /public/assets/styles/index.css 에 index.css가 생성이 된것을 확인할 수 있다. 

 

npx tailwindcss -i src/styles/index.css -o public/assets/styles/index.css

 

 

 

index.css의 내부 코드를 보면 

 

 

위에서 정의한 ColorSet이 background, gradient, color 등 Color를 적용할 수 있는 속성 클래스 모두에 적용됨을 확인할 수 있다.

위처럼 스타일이 재사용 가능한 유틸리티 클래스로 정의되는 것을 utility-first CSS라고 부른다.

 

스타일 가이드 외의 UI 컴포넌트 가이드같은 경우, 원래라면 storybook을 활용하여 관리하겠지만 프로젝트 범위가 그정도까지는 아닌거 같아서 (오히려 공수가 더 들 수 있음) /components 하단에 presenter component로만 관리할 예정이다.