폼 제출 시 FormData 테스트
test('폼 제출 시 formData 확인', () => {
const handleSubmit = jest.fn((e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData.entries())
expect(data).toEqual({ username: 'testuser', password: 'password' })
})
render(<LoginForm onSubmit={handleSubmit} />)
fireEvent.change(screen.getByPlaceholderText('아이디'), {
target: { value: 'testuser' },
})
fireEvent.change(screen.getByPlaceholderText('비밀번호'), {
target: { value: 'password' },
})
fireEvent.click(screen.getByRole('button', { name: '로그인' }))
expect(handleSubmit).toHaveBeenCalled()
})
거리 만km 포맷팅 — toFixed(1) 은 반올림하고 0 까지 채운 문자열을 준다(100000 → 10.0만km)1
function formatToManKm(distance: number): string {
const manKm = (distance / 10000).toFixed(1)
return `${manKm}만km`
}
formatToManKm(104335) // "10.4만km"
Footnotes
-
Number.prototype.toFixed() - MDN: “The number is rounded if necessary, and the fractional part is padded with zeros if necessary so that it has the specified length.” ↩
ARIA 속성 적용 패턴 — aria-labelledby 는 공백으로 구분한 id 목록을 받아 그 순서대로 텍스트를 이어 붙여 이름을 만든다. aria-label 보다 우선한다1
// region + labelledby
<div role="region" aria-labelledby={titleId}>
<h2 id={titleId}>제목</h2>
</div>
// list + listitem
<div role="list" aria-labelledby="list-title">
<div role="listitem" aria-labelledby={`${label}-label ${label}-value`}>
<span id={`${label}-label`}>{label}</span>
<span id={`${label}-value`}>{value}</span>
</div>
</div>
aria-labelledby- 연결된 요소의 텍스트를 레이블로aria-describedby- 추가 설명이 필요할 때- 중복 제거: aria-label과 화면 텍스트 중복 시 aria-labelledby 사용
Footnotes
-
aria-labelledby - MDN: “the content from each referenced element is combined in the order that they are referenced in the
aria-labelledbyvalue.” · “If an element has both attributes set,aria-labelledbywill be used.” ↩
전화번호 포맷팅 — replace 는 매치된 부분만 바꾸고 나머지는 뒤에 그대로 남긴다. 그래서 그룹 자릿수 합이 길이와 같아야 한다1
function formatPhoneNumber(phoneNumber) {
const cleaned = phoneNumber.replace(/\D/g, '')
if (cleaned.length === 11) {
return cleaned.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3')
} else if (cleaned.length === 10 && cleaned.startsWith('02')) {
return cleaned.replace(/(\d{2})(\d{4})(\d{4})/, '$1-$2-$3')
} else if (cleaned.length === 10) {
return cleaned.replace(/(\d{3})(\d{3})(\d{4})/, '$1-$2-$3')
} else if (cleaned.length === 9 && cleaned.startsWith('02')) {
return cleaned.replace(/(\d{2})(\d{3})(\d{4})/, '$1-$2-$3')
}
return 'Invalid phone number'
}
formatPhoneNumber('01012341234') // 010-1234-1234
formatPhoneNumber('021234567') // 02-123-4567
formatPhoneNumber('0212345678') // 02-1234-5678
Footnotes
-
String.prototype.replace() - MDN: “returns a new string with one, some, or all matches of a
patternreplaced by areplacement.” ↩
Map으로 localStorage 래핑 — JSON.stringify(map) 은 {} 라서 [...map](엔트리 배열)으로 저장하고 new Map(entries) 로 되살린다1
class LocalStorageMap {
constructor(storageKey) {
this.storageKey = storageKey
this.map = this.loadFromStorage()
}
loadFromStorage() {
const data = localStorage.getItem(this.storageKey)
return data ? new Map(JSON.parse(data)) : new Map()
}
saveToStorage() {
localStorage.setItem(this.storageKey, JSON.stringify([...this.map]))
}
set(key, value) {
this.map.set(key, value)
this.saveToStorage()
}
get(key) {
return this.map.get(key)
}
delete(key) {
const result = this.map.delete(key)
this.saveToStorage()
return result
}
clear() {
this.map.clear()
localStorage.removeItem(this.storageKey)
}
}
- localStorage는 문자열만 저장 → JSON 직렬화 필수
- 출처당 5MiB — 넘으면
setItem이QuotaExceededError를 던진다2
Footnotes
-
JSON.stringify() - MDN: “Only enumerable own properties are visited. This means Map, Set, etc. will become “{}”.“ ↩
-
Storage quotas and eviction criteria - MDN: “Browsers can store up to 5 MiB of local storage, and 5 MiB of session storage per origin.” · “Once this limit is reached, browsers throw a
QuotaExceededErrorexception” ↩
const color =
(isUnchecked && 'palette.label.alternative') ||
(isChecked && 'palette.primary.normal') ||
'palette.label.normal'
중첩 삼항/논리 연산자는 간결하지만 상태가 늘어나면 유지보수 어려움.
핵심: status 모델을 어떻게 정의하고 참조할 것인가?
class StatusManager {
static Status = {
UNCHECKED: 'UNCHECKED',
CHECKED: 'CHECKED',
DEFAULT: 'DEFAULT',
} as const
static getColor(status: keyof typeof this.Status) {
switch (status) {
case this.Status.UNCHECKED:
return 'palette.label.alternative'
case this.Status.CHECKED:
return 'palette.primary.normal'
default:
return 'palette.label.normal'
}
}
}
const color = StatusManager.getColor(StatusManager.Status.CHECKED)
변수의 prefix는 역할과 의미를 명확히 하기 위해 사용된다.
/**
* 초기값을 나타내며, 상태나 값이 처음 설정될 때 사용된다.
* 적합한 상황: 프로그램이 시작되거나 객체가 처음 생성될 때의 값을 정의한다.
*/
const initialCount = 0 // 카운터의 초기값
const initialState = { loggedIn: false, user: null } // 초기 상태
const initialPosition = { x: 0, y: 0 } // 초기 좌표
/**
* 기본값으로 널리 사용되는 표준 값이다.
* 적합한 상황: 값이 없을 경우 사용할 기본값을 정의한다.
*/
const defaultTheme = 'light' // 기본 테마
const defaultUser = { name: 'Guest', role: 'viewer' } // 기본 사용자
const defaultPageSize = 20 // 페이지당 기본 항목 수
/**
* 기준값 또는 다른 값의 참조점이 되는 값이다.
* 적합한 상황: 값을 계산하거나 파생할 때 기준이 되는 값을 정의한다.
*/
const baseSalary = 3000 // 기준 급여
const baseUrl = 'https://api.example.com' // API의 기준 URL
const baseColor = '#FFFFFF' // 기준 색상
/**
* 변경되기 전 원래 상태 또는 초기 상태를 강조한다.
* 적합한 상황: 데이터를 변경하기 전에 원래 값을 유지해야 할 때 사용한다.
*/
const originalText = 'Hello World' // 변경 전 텍스트
const originalSettings = { theme: 'dark', notifications: true } // 원래 설정값
const originalImage = image.clone() // 원본 이미지 복사
initial*vsdefault*- initial은 초기 상태, 재설정 가능성 적음. default는 기본값, 교체 가능.base*vsoriginal*- base는 기준값(비교/계산용). original은 원래 상태 보존/복구용.
💡이건 그냥 상태 머신 아닌가? 그렇다! … “불리언 수프”에 기대는 컴포넌트도 마찬가지다. 선택은 상태 머신을 만드느냐 마느냐가 아니라, 암시적으로 만드느냐 명시적으로 만드느냐다.
stateDiagram-v2
[*] --> Mounting
Mounting --> AwaitingEmailInput
Mounting --> AwaitingCodeInput
AwaitingEmailInput --> SubmittingEmail
SubmittingEmail --> AwaitingCodeInput
SubmittingEmail --> AwaitingEmailInput
AwaitingCodeInput --> SubmittingCode
SubmittingCode --> Success
SubmittingCode --> AwaitingCodeInput
Success --> [*]
💡Blob URL 방식은 Firefox 58+에서 CSP 제한이 있는 페이지에서도 스크립트를 주입할 수 있는 방법입니다. 이 방식을 사용할 때, 비동기성 때문에 발생할 수 있는 문제를 방지하려면 초기화 코드가 준비된 후 스크립트를 전달해야 합니다.
src 로 넣은 스크립트는 내용을 가져온 뒤에야 돈다(비동기).1
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)
Footnotes
-
Inject scripts with Blob URLs: “Because the
textContentway is synchronous while thesrcway is asynchronous.” ↩
vitest에서 사용자 지정 assertion을 추가하여 Zod 스키마와 Response 객체를 비교하기
// vitest 5.0 기준
import { expect } from 'vitest'
import type { MatcherResult } 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 MatcherResult
},
})
vitest.d.ts 에서 vitest 모듈의 Matchers 인터페이스를 확장한다. 비동기 매처라 반환 타입은 R 이 아니라 Promise<void>1.
import 'vitest'
import type { ZodTypeAny } from 'zod'
declare module 'vitest' {
interface Matchers<R, T> {
toMatchSchema: (schema: ZodTypeAny) => Promise<void>
}
}
todoResponse의 응답 데이터가 todoSchema에 정의된 Zod 스키마와 일치하는지 확인한다. 매처가 async라 테스트에서 await 한다1.
test('todo', async () => {
expect(todoResponse.ok).toBeTruthy()
await expect(todoResponse).toMatchSchema(todoSchema)
})
Footnotes
-
Extending Matchers | Guide | Vitest: “If a matcher implementation is asynchronous, declare its return type as
Promise<void>instead ofRand don’t forget toawaitit in the test” ↩ ↩2