AuthContext 테스트 패턴

// localStorage 함수 테스트
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => {
  if (key === 'auth_token') return 'test_token'
  return null
})

// AuthProvider 상태 테스트
const TestComponent = () => {
  const { token, login, logout } = useAuth()

  return (
    <div>
      <p data-testid="token">{token || 'null'}</p>
      <button onClick={() => login('test_token')}>Login</button>
      <button onClick={logout}>Logout</button>
    </div>
  )
}

// useAuth 훅 테스트
it('AuthProvider 외부에서 호출하면 에러', () => {
  expect(() => renderHook(() => useAuth())).toThrowError()
})

it('AuthProvider 내부에서 정상 동작', () => {
  const wrapper = ({ children }) => <AuthProvider>{children}</AuthProvider>
  const { result } = renderHook(() => useAuth(), { wrapper })
  expect(result.current).toHaveProperty('token', null)
})
#328

타이머를 제어하기 위해 vi.useFakeTimersvi.advanceTimersByTime을 사용하기.

describe('Countdown 컴포넌트', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

  afterEach(() => {
    vi.useRealTimers()
  })

  test('타이머가 동작하며 시간을 업데이트한다.', () => {
    const endTime = new Date(Date.now() + 60000).toISOString()
    const renderChild = vi.fn(({ targetRef }) => (
      <div ref={targetRef} data-testid="countdown" />
    ))

    render(<Countdown end_at={endTime}>{renderChild}</Countdown>)

    expect(screen.getByTestId('countdown')).toBeEmptyDOMElement()

    act(() => {
      vi.advanceTimersByTime(10000)
    })

    expect(screen.getByTestId('countdown')).toHaveTextContent('00:00:50')

    act(() => {
      vi.advanceTimersByTime(50000)
    })

    expect(screen.getByTestId('countdown')).toHaveTextContent('00:00:00')
  })
})

#327

폼 제출 시 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()
})
#326

거리 만km 포맷팅

function formatToManKm(distance: number): string {
  const manKm = (distance / 10000).toFixed(1)

  return `${manKm}만km`
}

formatToManKm(104335) // "10.4만km"
#325

ARIA 속성 적용 패턴

// 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 사용
#324

전화번호 포맷팅

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{3})(\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{3})/, '$1-$2-$3')
  }
  return 'Invalid phone number'
}

formatPhoneNumber('01012341234') // 010-1234-1234
formatPhoneNumber('021234567') // 02-123-4567
#323

Map으로 localStorage 래핑

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 직렬화 필수
  • 용량 제한 5~10MB 주의
#322
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)

#321

변수의 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* vs default* - initial은 초기 상태, 재설정 가능성 적음. default는 기본값, 교체 가능.
  • base* vs original* - base는 기준값(비교/계산용). original은 원래 상태 보존/복구용.
#320
💡

이 패턴이 “상태 머신”처럼 들린다면, 그리 놀랄 일도 아닙니다. 결국, 선택의 문제는 상태 머신을 구축할지 말지가 아니라, 그것을 암시적으로 구축할지 명시적으로 구축할지에 달려 있습니다.

stateDiagram-v2
  [*] --> Mounting

  Mounting --> AwaitingEmailInput
  Mounting --> AwaitingCodeInput

  AwaitingEmailInput --> SubmittingEmail

  SubmittingEmail --> AwaitingCodeInput
  SubmittingEmail --> AwaitingEmailInput

  AwaitingCodeInput --> SubmittingCode

  SubmittingCode --> Success
  SubmittingCode --> AwaitingCodeInput

  Success --> [*]

#319
31 중 15페이지