🎯 μ»΄ν¬λ„ŒνŠΈλ₯Ό λ§Œλ“€κ³  ν…ŒμŠ€νŠΈ μ½”λ“œλ₯Ό 톡해 κ²€μ¦ν•©λ‹ˆλ‹€.


πŸ“— Today I Learned

Title μ»΄ν¬λ„ŒνŠΈ μž‘μ„±

// πŸ“„ Title.tsx
import { styled } from 'styled-components';
import { ColorKey, HeadingSize, Theme } from '../../style/theme';

interface Props {
  children: React.ReactNode;
  size: HeadingSize;
  color?: ColorKey;
}

export default function Title({ children, size }: Props) {
  return <TitleStyle size={size}>{children}</TitleStyle>;
}

const TitleStyle = styled.h1<Omit<Props, 'children'>>`
  font-size: ${({ theme, size }) => (theme as Theme).heading[size].fontSize};
  color: ${({ theme, color }) =>
    color ? (theme as Theme).color[color] : (theme as Theme).color.primary};
`;
  • Title은 <h1> νƒœκ·Έλ‘œ λ Œλ”λ˜λ©°, styled둜 μŠ€νƒ€μΌμ΄ μ§€μ •λ©λ‹ˆλ‹€.

  • font-size : 전달받은 size 킀에 따라 ν…Œλ§ˆμ—μ„œ ν•΄λ‹Ή 폰트 μ‚¬μ΄μ¦ˆ 값을 κ°€μ Έμ˜΅λ‹ˆλ‹€.

  • color : colorκ°€ μžˆλ‹€λ©΄ ν•΄λ‹Ή 색상을 μ‚¬μš©ν•˜κ³ , μ—†μœΌλ©΄ primary 색상을 μ‚¬μš©ν•©λ‹ˆλ‹€.


Test μ½”λ“œ μž‘μ„±

ν…ŒμŠ€νŠΈλŠ” @testing-library/reactλ₯Ό μ‚¬μš©ν•˜μ—¬ λ Œλ”λ§ κ²°κ³Όλ₯Ό ν™•μΈν•©λ‹ˆλ‹€.

// πŸ“„ Title.spec.tsx
import { render, screen } from '@testing-library/react';
import Title from './Title';
import { BookStoreThemeProvider } from '../../context/ThemeContext';

describe('Title μ»΄ν¬λ„ŒνŠΈ ν…ŒμŠ€νŠΈ', () => {
  it('λ Œλ”λ§ 확인', () => {
    render(
      <BookStoreThemeProvider>
        <Title size='large'>제λͺ©</Title>
      </BookStoreThemeProvider>
    );

    expect(screen.getByText('제λͺ©')).toBeInTheDocument();
  });

  it('size props 적용', () => {
    const { container } = render(
      <BookStoreThemeProvider>
        <Title size='large'>제λͺ©</Title>
      </BookStoreThemeProvider>
    );

    expect(container?.firstChild).toHaveStyle({ fontSize: '2rem' });
  });

  it('color props 적용', () => {
    const { container } = render(
      <BookStoreThemeProvider>
        <Title size='large' color='primary'>
          제λͺ©
        </Title>
      </BookStoreThemeProvider>
    );

    expect(container?.firstChild).toHaveStyle({ color: 'brown' });
  });
});
  • describe : ν…ŒμŠ€νŠΈν•  그룹을 λ¬ΆλŠ” μš©λ„μž…λ‹ˆλ‹€.

    • [ ν…ŒμŠ€νŠΈ 1 ] 'λ Œλ”λ§ 확인'

      • render(...): Title μ»΄ν¬λ„ŒνŠΈλ₯Ό 화면에 λ Œλ”λ§ μ‹œν‚΅λ‹ˆλ‹€.

      • <BookStoreThemeProvider> : styled-componentsμ—μ„œ ν…Œλ§ˆ 값을 μ“°κΈ° μœ„ν•΄ κ°μ‹Έμ€λ‹ˆλ‹€.

      • screen.getByText('제λͺ©') : λ Œλ”λœ ν™”λ©΄μ—μ„œ '제λͺ©'μ΄λΌλŠ” ν…μŠ€νŠΈλ₯Ό κ°€μ§„ μš”μ†Œλ₯Ό μ°ΎμŠ΅λ‹ˆλ‹€.

      • expect(...).toBeInTheDocument() : 찾은 μš”μ†Œκ°€ 화면에 μ‘΄μž¬ν•˜λŠ”μ§€λ₯Ό ν™•μΈν•©λ‹ˆλ‹€.

    • [ ν…ŒμŠ€νŠΈ 2 ] 'μ‚¬μ΄μ¦ˆ 확인'

      • render(...) : size='large'λ₯Ό 넣은 Title μ»΄ν¬λ„ŒνŠΈλ₯Ό 화면에 λ Œλ”λ§ μ‹œν‚΅λ‹ˆλ‹€.

      • container.firstChild : λ Œλ”λœ μ»΄ν¬λ„ŒνŠΈμ˜ κ°€μž₯ λ°”κΉ₯ μš”μ†Œ (<h1>)λ₯Ό κ°€μ Έμ˜΅λ‹ˆλ‹€.

      • expect(...).toHaveStyle({ fontSize: '2rem' }) : 찾은 μš”μ†Œκ°€ font-size: 2rem μŠ€νƒ€μΌμ„ κ°€μ§€κ³  μžˆλŠ”μ§€ ν™•μΈν•©λ‹ˆλ‹€.

    • [ ν…ŒμŠ€νŠΈ 3 ] '색깔 확인'

      • render(...) : color='primaryλ₯Ό 넣은 Title μ»΄ν¬λ„ŒνŠΈλ₯Ό 화면에 λ Œλ”λ§ μ‹œν‚΅λ‹ˆλ‹€.

      • container.firstChild : λ Œλ”λœ μ»΄ν¬λ„ŒνŠΈμ˜ κ°€μž₯ λ°”κΉ₯ μš”μ†Œ (<h1>)λ₯Ό κ°€μ Έμ˜΅λ‹ˆλ‹€.

      • expect(...).toHaveStyle({ color: 'brown' }) : 찾은 μš”μ†Œκ°€ color.primary = 'brown' μŠ€νƒ€μΌμ„ κ°€μ§€κ³  μžˆλŠ”μ§€ ν™•μΈν•©λ‹ˆλ‹€.




Button μ»΄ν¬λ„ŒνŠΈ μž‘μ„±

// πŸ“„ Button.tsx
import { styled } from 'styled-components';
import { ButtonScheme, ButtonSize, Theme } from '../../style/theme';

interface Props {
  children: React.ReactNode;
  size: ButtonSize;
  scheme: ButtonScheme;
  disabled?: boolean;
  isLoading?: boolean;
}

export default function Button({
  children,
  size,
  scheme,
  disabled,
  isLoading,
}: Props) {
  return (
    <ButtonStyle
      size={size}
      scheme={scheme}
      disabled={disabled}
      isLoading={isLoading}
    >
      {children}
    </ButtonStyle>
  );
}

const ButtonStyle = styled.button<Omit<Props, 'children'>>`
  font-size: ${({ theme, size }) => (theme as Theme).button[size].fontSize};
  padding: ${({ theme, size }) => (theme as Theme).button[size].padding};
  color: ${({ theme, scheme }) => (theme as Theme).buttonScheme[scheme].color};
  background-color: ${({ theme, scheme }) =>
    (theme as Theme).buttonScheme[scheme].backgroundColor};
  border: 0;
  border-radius: ${({ theme }) => (theme as Theme).borderRadius.default};
  opacity: ${({ disabled }) => (disabled ? 0.5 : 1)};
  pointer-events: ${({ disabled }) => (disabled ? 'none' : 'auto')};
  cursor: ${({ disabled }) => (disabled ? 'none' : 'pointer')};
`;
  • ButtonStyle은 <button> νƒœκ·Έλ‘œ λ Œλ”λ˜λ©°, propsλ₯Ό λ°›μ•„ μŠ€νƒ€μΌμ„ μž…ν˜€μ€λ‹ˆλ‹€.

  • theme.button[size] β†’ 크기별 font-size, padding을 μ§€μ •ν•©λ‹ˆλ‹€.

  • theme.buttonScheme[scheme] β†’ 색상 μŠ€νƒ€μΌλ³„ color, backgroundColorλ₯Ό μ§€μ •ν•©λ‹ˆλ‹€.

  • disabled β†’ opacity, pointer-events, cursor 속성을 μ§€μ •ν•©λ‹ˆλ‹€.


Test μ½”λ“œ μž‘μ„±

// πŸ“„ Button.spec.tsx
import { render, screen } from '@testing-library/react';
import Button from './Button';
import { BookStoreThemeProvider } from '../../context/ThemeContext';

describe('Button μ»΄ν¬λ„ŒνŠΈ ν…ŒμŠ€νŠΈ', () => {
  it('λ Œλ”λ§ 확인', () => {
    render(
      <BookStoreThemeProvider>
        <Button size='large' scheme='primary'>
          λ²„νŠΌ
        </Button>
      </BookStoreThemeProvider>
    );

    expect(screen.getByText('λ²„νŠΌ')).toBeInTheDocument();
  });

  it('κΈ€μž 크기 확인', () => {
    render(
      <BookStoreThemeProvider>
        <Button size='large' scheme='primary'>
          λ²„νŠΌ
        </Button>
      </BookStoreThemeProvider>
    );

    expect(screen.getByRole('button')).toHaveStyle({ fontSize: '1.5rem' });
  });

  it('색상 확인', () => {
    render(
      <BookStoreThemeProvider>
        <Button size='large' scheme='primary'>
          λ²„νŠΌ
        </Button>
      </BookStoreThemeProvider>
    );

    expect(screen.getByRole('button')).toHaveStyle({ color: 'white' });
  });
});
  • [ ν…ŒμŠ€νŠΈ 1 ] 'λ Œλ”λ§ 확인'

    • screen.getByText('λ²„νŠΌ') : λ Œλ”λœ ν™”λ©΄μ—μ„œ 'λ²„νŠΌ'μ΄λΌλŠ” ν…μŠ€νŠΈλ₯Ό κ°€μ§„ μš”μ†Œλ₯Ό μ°ΎμŠ΅λ‹ˆλ‹€.

    • expect(...).toBeInTheDocument() : 찾은 μš”μ†Œκ°€ 화면에 μ‘΄μž¬ν•˜λŠ”μ§€λ₯Ό ν™•μΈν•©λ‹ˆλ‹€.

  • [ ν…ŒμŠ€νŠΈ 2 ] 'κΈ€μž 크기 확인'

    • screen.getByRole('button') :

    • expect(...).toHaveStyle({ fontSize: '1.5rem' }): 찾은 μš”μ†Œκ°€ font-size: 1.5rem μŠ€νƒ€μΌμ„ κ°€μ§€κ³  μžˆλŠ”μ§€ ν™•μΈν•©λ‹ˆλ‹€.

  • [ ν…ŒμŠ€νŠΈ 3 ] '색상 확인'

    - screen.getByRole('button') :

    • expect(...).toHaveStyle({ color: 'white' }): 찾은 μš”μ†Œκ°€ color: 'white' μŠ€νƒ€μΌμ„ κ°€μ§€κ³  μžˆλŠ”μ§€ ν™•μΈν•©λ‹ˆλ‹€.



Input μ»΄ν¬λ„ŒνŠΈ μž‘μ„±

// πŸ“„ InputText.tsx
import React, { ForwardedRef } from 'react';
import styled from 'styled-components';
import { Theme } from '../../style/theme';

interface Props {
  placeholder?: string;
}

const InputText = React.forwardRef(
  ({ placeholder }: Props, ref: ForwardedRef<HTMLInputElement>) => {
    return <InputTextStyle placeholder={placeholder} ref={ref} />;
  }
);

const InputTextStyle = styled.input.attrs({ type: 'text' })`
  padding: 0.25rem 0.75rem;
  border: 1px solid ${({ theme }) => (theme as Theme).color.border};
  border-radius: ${({ theme }) => (theme as Theme).borderRadius.default};
  font-size: 1rem;
  line-height: 1.5;
  color: ${({ theme }) => (theme as Theme).color.text};
`;

export default InputText;
  • InputText은 forwardRefλ₯Ό μ΄μš©ν•΄ μ™ΈλΆ€ μ»΄ν¬λ„ŒνŠΈμ—μ„œ 전달받은 refλ₯Ό <input> νƒœκ·Έμ— μ—°κ²°ν•΄μ£ΌλŠ” 역할을 ν•©λ‹ˆλ‹€.

  • styled.input.attrs({ type: 'text' }) : type: 'text'인 input μš”μ†Œλ₯Ό λ Œλ”λ§ν•˜κ²Œ λ©λ‹ˆλ‹€.

πŸ€” μ™œ forwardRefκ°€ ν•„μš”ν•œ 걸까?

기본적으둜 ReactλŠ” ν•¨μˆ˜ν˜• μ»΄ν¬λ„ŒνŠΈμ— refλ₯Ό 직접 μ „λ‹¬ν•˜μ§€ λͺ»ν•©λ‹ˆλ‹€.
refλŠ” 일반 props와 λ‹€λ₯΄κ²Œ 처리되기 λ•Œλ¬Έμ—, μžμ‹ μ»΄ν¬λ„ŒνŠΈμ— λ„˜κΈ°κ³  μ‹Άλ‹€λ©΄, React.forwardRef둜 κ°μ‹Έμ€˜μ•Όν•©λ‹ˆλ‹€.

// λΆ€λͺ¨
function Parent() {
  const inputRef = useRef(null);

  return <ChildInput ref={inputRef} />;
}

// μžμ‹
const ChildInput = React.forwardRef((props, ref) => {
  return <input ref={ref} />;
});

Test μ½”λ“œ μž‘μ„±

// πŸ“„ InputText.spec.tsx
import { render, screen } from '@testing-library/react';
import InputText from './InputText';
import { BookStoreThemeProvider } from '../../context/ThemeContext';
import React from 'react';

describe('InputText μ»΄ν¬λ„ŒνŠΈ ν…ŒμŠ€νŠΈ', () => {
  it('λ Œλ”λ§ 확인', () => {
    render(
      <BookStoreThemeProvider>
        <InputText placeholder='여기에 μž…λ ₯ν•˜μ„Έμš”' />
      </BookStoreThemeProvider>
    );

    expect(
      screen.getByPlaceholderText('여기에 μž…λ ₯ν•˜μ„Έμš”')
    ).toBeInTheDocument();
  });

  it('forwardRef ν…ŒμŠ€νŠΈ', () => {
    const ref = React.createRef<HTMLInputElement>();

    render(
      <BookStoreThemeProvider>
        <InputText placeholder='여기에 μž…λ ₯ν•˜μ„Έμš”' ref={ref} />
      </BookStoreThemeProvider>
    );

    expect(ref.current).toBeInstanceOf(HTMLInputElement);
  });
});
  • [ ν…ŒμŠ€νŠΈ 1 ] 'λ Œλ”λ§ 확인'

    • screen.getByPlaceholderText('여기에 μž…λ ₯ν•˜μ„Έμš”') : placeholder 속성을 κΈ°μ€€μœΌλ‘œ 인풋 μš”μ†Œλ₯Ό μ°Ύμ•„ λ°˜ν™˜ν•©λ‹ˆλ‹€.

    • expect(...).toBeInTheDocument() : 찾은 μš”μ†Œκ°€ 화면에 μ‘΄μž¬ν•˜λŠ”μ§€λ₯Ό ν™•μΈν•©λ‹ˆλ‹€.

  • [ ν…ŒμŠ€νŠΈ 2 ] 'forwardRef ν…ŒμŠ€νŠΈ'

  • ref.current : forwardRefλ₯Ό 톡해 μ „λ‹¬λœ ref 객체둜, ν˜„μž¬ μ—°κ²°λœ DOM μš”μ†Œλ₯Ό μ°Έμ‘°ν•©λ‹ˆλ‹€.

  • expect(...).toBeInstanceOf(HTMLInputElement) : 찾은 μš”μ†Œκ°€ <input> μš”μ†ŒμΈμ§€ ν™•μΈν•©λ‹ˆλ‹€.




✏️ 회고

ν…ŒμŠ€νŠΈ μ½”λ“œ 자체λ₯Ό μ§œλŠ” κ²ƒμ—λŠ” 어렀움이 μ—†μ—ˆμ§€λ§Œ, styled-components와 νƒ€μž…μ— λŒ€ν•΄ μ΅μˆ™μΉ˜ μ•Šλ‹€λ³΄λ‹ˆ μ–΄λ €μ› λ˜ κ±° κ°™λ‹€.

profile
🌱개발 기둝μž₯

0개의 λŒ“κΈ€