Jun 개발노트

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
            }
        ])
    })

참고


Written by Junho You 배운것을 기록하자