Component 구성 기준

April 03, 2022

TL;DR

  • 리엑트의 특징을 가지는 Component
    • 단일책임원칙 - 한가지의 기능/역할을 가진다
  • 의미있는 테스트를 할 정도의 Component
    • UI라면, Ul 기준
    • 기능이라면, 기능 기준

0. 글을 작성하는 이유?

  • Q. 컴포넌트는 어떤 단위로 작성되는게 좋을까?
    • A. 테스트를 할 정도의 크기라는 답변을 받았다.
  • 의미있는 테스트를 할 정도의 크기 -> UI / 기능
    • 리엑트 컴포넌트의 특징을 가지는 컴포넌트 단위(재사용)

1. 리엑트의 특징

  • Declarative
  • Component-Based

1) Imperative vs Declarative (명령형 vs 선언형)

- 예제

// 식당
명령형: 12번 테이블이 비어 있습니다. 나는 저 자리로 걸어가서 않을 겁니다.
선언형: 1명이요!

// 대리운전
명령형: 주차장 북쪽출구로 나와 좌회전. 12번가 출구가 나올 때까지 I-15 North를 타십시오.
      이케아에 가듯이 출구에서 우회전하세요. 직진하여 첫 번째 신호등에서 우회전하십시오.
      다음 신호등을 지나 계속해서 다음 신호등에서 좌회전하세요. 내 집은 # 298입니다
선언형: 내 주소는 ㅇㅇ 아파트입니다
  • Declarative 같은 경우, Imperative 방식(구현)이 추상화 된것을 알 수있다.
    • 식당 : 식당의 직원은 빈 자리를 찾아 인원수에 맞는 자리를 안내할것
    • 대링운전: 해당 목적이를 GPS or 경험을 통해 갈것
  • Declarative을 수행/사용하기 위해서는 먼저 Imperative이 구성되어 있어야한다.
    • 단, 어떻게 구현되었는지, 작동하는지 모를뿐이다

- Code Base 예제

  • 명령형
function double(arr) {
    let results = []
    for (let i = 0; i < arr.length; i++) {
        results.push(arr[i] * 2)
    }
    return results
}

function add(arr) {
    let result = 0
    for (let i = 0; i < arr.length; i++) {
        result += arr[i]
    }
    return result
}

$('#btn').click(function () {
    $(this).toggleClass('highlight')
    $(this).text() === 'Add Highlight'
        ? $(this).text('Remove Highlight')
        : $(this).text('Add Highlight')
})
  • 선언형
function double(arr) {
    return arr.map((item) => item * 2)
}

function add(arr) {
    return arr.reduce((prev, current) => prev + current, 0)
}

;<Btn
    onToggleHighlight={this.handleToggleHighlight}
    highlight={this.state.highlight}
>
    {this.state.buttonText}
</Btn>
  • Declarative을 통해 코드는 더 읽기 쉬어졌다
    • map/ruduce가 어떻게 동작하는지 모른다
    • react가 state가 변경되면 어떻게 UI를 바꿔줄지는 모른다.
  • Declarative 프로그래밍의 또 다른 장정은 프로그램의 컨텍스트가 독립적이라는 것이다.
    • 명령형 같은 경우 현재 프로그램의 컨텍스트를 의존하는 경우가 많기 때문이다.
  • 함수형 프로그랭이 선언적 프로그래밍의 하위집합니다.

2) Component?

- 프로그래밍

CBD

  • 하나 이상 함수를 모아 하나의 특정한 기능을 수행하는 작은 기능적 단위를 말한다.
  • 특징은 응집도는 높게 결합도를 낮게 → 관심사 분리를 강조 (종속 X, 재사용/교체가 가능)
  • 컴포넌트는 제공된 인터페이스를 통해 사용될뿐, 어떻게 작동/구현되는지 알 필요가 없다(캡슐화)
  • 이러한 컴포넌트를 활용한 프로그래밍 방법론을 CBSE / CBD (Component-based software engineering / component-based development)라고 부른다.

- React

React Component

  • 컴포넌트란 독립적이며, 재활용가능한 코드의 조각 ⇒ Component - Based
  • 응집도는 높게 결합도를 낮게 → 관심사 분리를 강조 (종속 X, 재사용/교체가 가능)
  • 컴포넌트는 제공된 인터페이스를 통해 사용될뿐, 어떻게 작동/구현되는지 알 필요가 없다(캡슐화)
    • State, Props를 사용하여 UI를 보여준다.
    • 상태가 변경되면, 해당 UI도 변경된다.
  • 자바스크립트의 함수 역할이 동일하지만, JSX(HTML)를 리턴한다.

2. Test를 통해 컴포넌트 단위를 생각해보자

  • 컴포넌트를 테스트하는 이유
    • 확장 가능성 있는 코드 작성 및 코드의 질을 올리기 위해
    • 리펙토링을 했는데 기존 기능이 정상 작동하는지 확인하기 위해
    • 컴포넌트 크기를 조절하기 위해 → 의미있는 Test를 할 정도
  • CRA는 기본적으로 React Testing Library 를 지원하며, 이를 활용해 UI Test를 진행한다.

React

function ProductCategoryRow({ category }: ProductCategoryRowProps) {
    return (
        <tr>
            <th colSpan={2}>{category}</th>
        </tr>
    )
}

function ProductRow({ product }: ProductRowProps) {
    const name = product.stocked ? (
        product.name
    ) : (
        <span style={{ color: 'red' }} data-testid="stocked-element">
            {product.name}
        </span>
    )

    return (
        <tr>
            <td>{name}</td>
            <td>{product.price}</td>
        </tr>
    )
}

function ProductTable({
    products,
    filterText,
    inStockOnly,
}: ProductTableProps) {
    const rows: ReactNode[] = []
    let lastCategory: string

    products.forEach((product) => {
        if (product.name.indexOf(filterText) === -1) {
            return
        }
        if (inStockOnly && !product.stocked) {
            return
        }
        if (product.category !== lastCategory) {
            rows.push(
                <ProductCategoryRow
                    category={product.category}
                    key={product.category}
                />
            )
        }
        rows.push(<ProductRow product={product} key={product.name} />)
        lastCategory = product.category
    })

    return (
        <table>
            <thead>
                <tr>
                    <th>Name</th>
                    <th>Price</th>
                </tr>
            </thead>
            <tbody>{rows}</tbody>
        </table>
    )
}

function FilterableProductTable() {
    const [filterText, setFilterText] = useState('')
    const [inStockOnly, setInStockOnly] = useState(false)

    return (
        <div>
            <ProductTable
                products={PRODUCTS}
                filterText={filterText}
                inStockOnly={inStockOnly}
            />
        </div>
    )
}

describe('fullTest', () => {
    test('ProductTable', () => {
        render(
            <ProductTable
                products={PRODUCTS}
                filterText={''}
                inStockOnly={false}
            />
        )
        const category = screen.getByText(PRODUCTS[0].category)
        expect(category).toBeInTheDocument()
    })

    test('ProductRow', () => {
        const product = PRODUCTS[0]
        render(<ProductRow product={product} />)
        const price = screen.getByText(product.price)
        expect(price).toBeInTheDocument()
    })

    test('StockedProductRow', () => {
        const stockedProduct = PRODUCTS[2]
        render(<ProductRow product={stockedProduct} />)
        const stockElement = screen.getByTestId('stocked-element')
        expect(stockElement).toBeInTheDocument()
    })

    test('ProductCategoryRow', () => {
        const category = PRODUCTS[0].category
        render(<ProductCategoryRow category={category} />)
        const element = screen.getByText(category)
        expect(element).toBeInTheDocument()
    })
})

3. API, CustomHook, Redux Test 방법

  • api 관련 테스트(msw을 활용 - Mock API)

    // msw/index.ts
    
    import {rest} from 'msw'
    import {setupServer} from 'msw/node'
    
    const server = setupServer(
        rest.get('/greeting', (req, res, ctx) => {
            return res(ctx.json({greeting: 'hello there'}))
        })
    )
    
    export default server
    
    // index.tsx
    const initialState = {
        error: null,
        greeting: null,
    }
    
    function greetingReducer(state: typeof initialState
        , action: any) {
        switch (action.type) {
            case 'SUCCESS': {
                return {
                    error: null,
                    greeting: action.greeting,
                }
            }
            case 'ERROR': {
                return {
                    error: action.error,
                    greeting: null,
                }
            }
            default: {
                return state
            }
        }
    }
    
    type Props = {
        url: string
    }
    
    export default function Fetch({url}: Props) {
        const [{error, greeting}, dispatch] = useReducer(
            greetingReducer,
            initialState,
        )
        const [buttonClicked, setButtonClicked] = useState(false)
    
        const fetchGreeting = async (url: string) =>
            axios
                .get(url)
                .then(response => {
                    const {data} = response
                    const {greeting} = data
                    dispatch({type: 'SUCCESS', greeting})
                    setButtonClicked(true)
                })
                .catch(error => {
                    dispatch({type: 'ERROR', error})
                })
    
        const buttonText = buttonClicked ? 'Ok' : 'Load Greeting'
    
        return (
            <div>
                <button onClick={() => fetchGreeting(url)} disabled={buttonClicked}>
                    {buttonText}
                </button>
                {greeting && <h1>{greeting}</h1>}
                {error && <p role="alert">Oops, failed to fetch!</p>}
            </div>
        )
    }
    
    // index.test.tsx
    beforeAll(() => server.listen())
    afterEach(() => server.resetHandlers())
    afterAll(() => server.close())
    
    test('loads and displays greeting', async () => {
        render(<Fetch url="/greeting" />)
    
        fireEvent.click(screen.getByText('Load Greeting'))
    
        await waitFor(() => screen.getByRole('heading'))
    
        expect(screen.getByRole('heading')).toHaveTextContent('hello there')
        expect(screen.getByRole('button')).toBeDisabled()
    })
    
    test('handles server error', async () => {
        server.use(
            rest.get('/greeting', (req, res, ctx) => {
                return res(ctx.status(500))
            }),
        )
    
        render(<Fetch url="/greeting" />)
    
        fireEvent.click(screen.getByText('Load Greeting'))
    
        await waitFor(() => screen.getByRole('alert'))
    
        expect(screen.getByRole('alert')).toHaveTextContent('Oops, failed to fetch!')
        expect(screen.getByRole('button')).not.toBeDisabled()
    })
  • CustomHooks Test

    // usePage.ts
    const usePage = (url: string) => {
        const [data, setData] = useState('')
        const [isLoading, setLoading] = useState(true)
    
        useEffect(() => {
            axios
                .get(url)
                .then((response) => {
                    const { data } = response
                    const { greeting } = data
                    setLoading((prev) => !prev)
                    console.log({ isLoading })
                    setData(greeting)
                })
                .catch((error) => {
                    console.log('erroe')
                    setLoading(false)
                    setData(error)
                })
        }, [])
    
        return [data, isLoading]
    }
    
    // usePage.test.ts
    const setupRenderCustomHook = () => {
        return renderHook(({ url }) => usePage(url), {
            initialProps: {
                url: '/greeting',
            },
        })
    }
    
    describe('usePage', () => {
        it('usePage를 새로운 pageId와 호출하면 isLoading은 true이다.', async () => {
            const { result, waitForNextUpdate, rerender } =
                setupRenderCustomHook()
            await waitForNextUpdate()
            const [data1, isLoading2] = result.current
            console.log(result.current)
    
            rerender()
            const [data, isLoading] = result.current
    
            console.log({ data1, isLoading2, data, isLoading })
    
            expect(data).toBe('hello there')
            expect(isLoading).not.toBe(true)
            rerender()
            expect(isLoading).not.toBe(true)
        })
    })
  • Store Test

    // store/index.ts
    export const initialState = [
        {
            text: 'Use Redux',
            completed: false,
            id: 0,
        },
    ]
    
    const todosSlice = createSlice({
        name: 'todos',
        initialState,
        reducers: {
            todoAdded(state, action: PayloadAction<string>) {
                state.push({
                    id:
                        state.reduce(
                            (maxId, todo) => Math.max(todo.id, maxId),
                            -1
                        ) + 1,
                    completed: false,
                    text: action.payload,
                })
            },
        },
    })
    
    // store/index.test.ts
    
    test('should return the initial state', () => {
        const foo = reducer(undefined, {})
        console.log(foo)
    
        expect(foo).toEqual([
            {
                text: 'Use Redux',
                completed: false,
                id: 0,
            },
        ])
    })
    
    test('should handle a todo being added to an empty list', () => {
        const previousState: typeof initialState = []
        expect(reducer(previousState, todoAdded('Run the tests'))).toEqual([
            {
                text: 'Run the tests',
                completed: false,
                id: 0,
            },
        ])
    })
    
    test('should handle a todo being added to an existing list', () => {
        const previousState = [
            {
                text: 'Run the tests',
                completed: true,
                id: 0,
            },
        ]
        expect(reducer(previousState, todoAdded('Use Redux'))).toEqual([
            {
                text: 'Run the tests',
                completed: true,
                id: 0,
            },
            {
                text: 'Use Redux',
                completed: false,
                id: 1,
            },
        ])
    })

참고


© 2023, Customized by Joon