💡Blob URL 방식은 Firefox 58+에서 CSP 제한이 있는 페이지에서도 스크립트를 주입할 수 있는 방법입니다. 이 방식을 사용할 때, 비동기성 때문에 발생할 수 있는 문제를 방지하려면 초기화 코드가 준비된 후 스크립트를 전달해야 합니다.
const b = new Blob([script], { type: 'text/javascript' })
const u = URL.createObjectURL(b)
const s = document.createElement('script')
s.src = u
document.body.appendChild(s)
document.body.removeChild(s)
URL.revokeObjectURL(u)vitest에서 사용자 지정 assertion을 추가하여 Zod 스키마와 Response 객체를 비교하기
import { expect } from 'vitest'
import type { ZodTypeAny } from 'zod'
expect.extend({
/**
* @param received 테스트할 Response 객체
* @param schema 검증할 Zod 스키마
*/
async toMatchSchema(received: Response, schema: ZodTypeAny) {
const response = await received.json()
const result = await schema.safeParseAsync(response)
return {
message: () => '',
pass: result.success,
} satisfies ExpectationResult
},
})
vitest.d.ts 파일에서 CustomMatchers 인터페이스를 확장하여 TypeScript와의 통합성을 유지.
import type { ZodTypeAny } from 'zod'
interface CustomMatchers<R = unknown> {
toMatchSchema(schema: ZodTypeAny): Promise<R>
}
todoResponse의 응답 데이터가 todoSchema에 정의된 Zod 스키마와 일치하는지 확인한다.
test('todo', async () => {
expect(todoResponse.ok).toBeTruthy()
expect(todoResponse).toMatchSchema(todoSchema)
})
vitest와 jest에서 가짜 타이머를 사용하는 방법. 내부적으로는 @sinonjs/fake-timers를 사용. vitest와 jest 모두 시스템 시간을 조작할 수 있도록 하여 테스트가 일관되게 실행되도록 하고 다양한 시간과 날짜를 시뮬레이션하여 다양한 조건에서 코드가 예상대로 작동하는지 확인할 수 있음.
describe('isSameDate', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime('2024-07-20')
})
afterEach(() => {
vi.useRealTimers()
})
it('현재 날짜와 동일한 날짜를 전달하면 true를 반환한다', () => {
expect(isSameDate('2024-07-20')).toBe(true)
})
it('현재 날짜와 다른 날짜를 전달하면 false를 반환한다', () => {
expect(isSameDate('2024-07-19')).toBe(false)
expect(isSameDate('2024-07-21')).toBe(false)
})
})
Flaky 테스트 방지 - Date, Math.random 같은 비순수 함수를 props로 주입
new Date(),Math.random()같은 비순수 함수는 매번 다른 결과 → 테스트 불안정- 해결: 기본 매개변수로 주입하면 프로덕션 동작 유지 + 테스트에서 제어 가능
function Date({ date = new Date() }) {
const [date, setDate] = React.useState(date)
return (
<input
type="date"
onChange={(e) => setDate(e.target.value)}
defaultValue={date}
/>
)
}
function Random({ randomizer = Math.random }) {
const [state, setState] = React.useState(randomizer())
return <div>{state}</div>
}
테스트:
describe('Date Component', () => {
it('should update state on date change', () => {
render(<Date date={new Date('2023-01-01')} />)
const input = screen.getByRole('textbox')
expect(input.value).toBe('2023-01-01')
})
})
describe('Random Component', () => {
it('should render a random number', () => {
const mockRandomizer = () => 0.5
render(<Random randomizer={mockRandomizer} />)
const div = screen.getByText('0.5')
expect(div).toBeInTheDocument()
})
})대화 상자(Dialog Box)는 사용자에게 정보를 전달하고 응답을 요청하는 그래픽 제어 요소이다. Dialog box
Modal - 대화 상자를 연 소프트웨어와의 상호작용을 차단
- System Modal - 닫기 전까지 다른 작업 불가. 과거 단일 작업 시스템에서 사용.
- Application Modal - 프로그램 일시 중단. 워크플로우 방해로 비판받음.
- Document Modal - 부모 창만 차단. macOS 시트 형태.
Modeless - 소프트웨어의 다른 부분과 상호작용 허용. 대화 상자가 열려 있어도 작업 가능. 툴바가 예시.
고려 사항:
- 모달의 문제점 - 사용자 흐름 방해. 습관적으로 확인을 누르게 되어 작업 손실 가능.
- 경고 대신 실행 취소 - 경고는 한계가 있음. 실행 취소(Undo) 기능이 더 효과적.1
- 경고를 사용하는 대신, 실행 취소를 제공하라
Footnotes
Excel View1 라이브러리를 보던 중, Excel과 Node의 상호작용에 대한 궁금증이 생겼다. 아무래도 웹개발을 하다보니 어플리케이션과 통신할 수 있는 부분에 대해서 전혀 생각을 안했었다는 걸 깨닫고 이러한 부분을 보완하기 위해 Excel과의 통신 방식을 찾아봄.
우선, 위 라이브러리 코드를 통해 ActiveXObject('Excel.Application')2로 Excel 객체를 생성하고, 이를 통해 Excel의 다양한 기능에 접근할 수 있다는 것을 확인했다. 그리고 node-activex의 문서에서 링크를 통해 추가적인 정보들을 확인할 수 있었는데 아무래도 자주 보던 영역이 아니라 일단 확인만 하는 단계에서 멈춤.
이 접근을 통해, Excel과 상호작용하는 방법에 대해 실마리를 찾을 수 있었음. 그러나 모든 과정이 순조롭지만은 않은게. ActiveXObject를 사용하는 부분에서 개념적 이해가 부족했고, 그밖에 Excel의 객체모델에 대해서도 배경지식이 많이 부족하다는 걸 알게됨.
이번 경험을 통해, 웹 개발자가 어플리케이션 레벨의 개념들도 이해하면 좋겠다는 생각을 하게 됨. 나중에 기회가 되면 살펴보고 일단 view 기능을 스프레드시트로 구현해 봐야겠다.
Footnotes
-
활성 셀의 행 전체를 별도 창에 띄워서 좌우 스크롤 없이 보게 해주는 도구 ↩
-
https://github.com/timepp/excelview/blob/master/excel.js#L70 ↩
Avoiding premature abstraction with Unstyled React Components (buildui.com)
React 컴포넌트를 작성할 때, 불필요한 추상화를 피하고 컴포넌트의 유연성을 유지하는 방법. 특히 스타일이 없는 컴포넌트를 통해 어떻게 컴포넌트의 기능에 집중할 수 있는지를 설명.
import React, { ComponentProps, FC, ReactNode } from 'react'
function Spinner() {
return (
<span className="absolute inset-0 flex items-center justify-center">
`<Spinner />`
</span>
)
}
type LabelProps = ComponentProps<'span'>
function Label({ children, ...rest }: LabelProps) {
return <span {...rest}>{children}</span>
}
type LoadingButtonProps = ComponentProps<'button'> & {
children: FC | ReactNode
}
/**
* @description 버튼이 비활성화되었을 때 로딩 스피너를 표시할 수 있는 버튼 컴포넌트입니다. 이 컴포넌트는 children 속성으로 함수나 React 노드를 받을 수 있으며, 버튼이 비활성화되면 스피너가 표시되고, 텍스트는 보이지 않게 됩니다.
*/
function LoadingButton({
disabled,
className,
children,
...rest
}: LoadingButtonProps) {
return (
<button {...rest} className={`${className} relative`} disabled={disabled}>
{typeof children === 'function' ? (
children({})
) : (
<>
{disabled && <Spinner />}
<Label className={disabled ? 'invisible' : ''}>{children}</Label>
</>
)}
</button>
)
}
LoadingButton.Spinner = Spinner
LoadingButton.Label = Label
이 패턴은 컴포넌트를 작성할 때 불필요한 스타일링이나 구조를 미리 정의하지 않고, 각 컴포넌트가 자신의 역할에 충실할 수 있도록 도와줍니다. 이를 통해 코드의 유연성을 유지하고, 필요에 따라 컴포넌트를 확장하거나 수정할 수 있는 여지를 남겨두게 됩니다.
React Router의 Link 컴포넌트 테스트하기
React 애플리케이션에서 라우팅은 중요한 부분. 특히, 사용자가 링크를 클릭할 때 적절한 페이지로 이동하는지 테스트하는 것은 중요. 다음은 React Router의 <Link /> 컴포넌트를 테스트하는 코드.
describe('Link', () => {
it('이동', () => {
const Home = () => <Link to="/about">About</Link>
const About = () => <h1>About</h1>
const { getByRole } = render(
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</MemoryRouter>
)
fireEvent.click(getByRole('link'))
expect(screen.getByRole('heading')).toHaveTextContent('About')
})
})
가끔 <Link />도 테스트해야 할 때가 있다. 위 코드는 사용자가 “About” 링크를 클릭하면 “About” 페이지로 이동하는지 테스트. fireEvent.click을 사용해 링크 클릭 이벤트를 발생시키고, screen.getByRole을 통해 헤딩 요소의 텍스트가 ’About’인지 확인. 이 테스트를 통해 라우팅이 제대로 동작하는지 확인할 수 있음.
최근에 Enzyme을 제거하고 React Testing Library로 교체하는 작업을 진행했습니다. JSX 영역은 별 문제 없이 진행되었으나, state나 props를 다루는 구현 부분에서 약간의 애매함이 있었습니다. 개인적으로는 기존 접근 방식이 나쁘지 않았다고 생각했기 때문에, 새로운 접근 방식으로 전환하는 데 주저하게 되었습니다.
결론적으로, state와 props의 JSON 결과물을 렌더링하고 getByTestId를 사용하여 이를 참조하는 방식으로 테스트를 진행하기로 했습니다. 다음은 그 예시입니다:
render() {
return <>
{process.env.NODE_ENV === 'test' && (
<pre data-testid="ThumbnailDebug">
{JSON.stringify({
props: this.props,
state: this.state,
})}
</pre>
)}
</>
}
이 방법을 통해 Enzyme을 제거한다는 목표를 달성했기 때문에, 어느 정도는 해결된 것처럼 보이며, 추후 더 나은 테스트 코드를 작성하기 위한 고민을 할 수 있을 것 같습니다.
if (!data) {
throw fetch()
}
Suspense는 던져진 promise를 캐치해서 resolve될 때까지 fallback을 렌더하고, resolve되면 다시 렌더한다. 던지는 건 예외와 같은 메커니즘이다.
- https://github.com/facebook/react/blob/eb8feb71096eec5c885b2a4c7d8d030d3622f265/packages/react/src/ReactLazy.js#L222
- https://github.com/TanStack/query/blob/b8e35598ef24855a2792414faea5721d833fa61e/packages/react-query/src/suspense.ts#L61
- https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/throw
- https://jser.pro/ddir/rie?reactVersion=18.3.1&codeKey=ud62nsxll29yy0dzba8