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?
- 프로그래밍
- 하나 이상 함수를 모아 하나의 특정한 기능을 수행하는 작은 기능적 단위를 말한다.
- 특징은 응집도는 높게 결합도를 낮게 → 관심사 분리를 강조 (종속 X, 재사용/교체가 가능)
- 컴포넌트는 제공된 인터페이스를 통해 사용될뿐, 어떻게 작동/구현되는지 알 필요가 없다(캡슐화)
- 이러한 컴포넌트를 활용한 프로그래밍 방법론을 CBSE / CBD (Component-based software engineering / component-based development)라고 부른다.
- React
- 컴포넌트란 독립적이며, 재활용가능한 코드의 조각 ⇒ Component - Based
- 응집도는 높게 결합도를 낮게 → 관심사 분리를 강조 (종속 X, 재사용/교체가 가능)
-
컴포넌트는 제공된 인터페이스를 통해 사용될뿐, 어떻게 작동/구현되는지 알 필요가 없다(캡슐화)
- State, Props를 사용하여 UI를 보여준다.
- 상태가 변경되면, 해당 UI도 변경된다.
- 자바스크립트의 함수 역할이 동일하지만, JSX(HTML)를 리턴한다.
2. Test를 통해 컴포넌트 단위를 생각해보자
-
컴포넌트를 테스트하는 이유
- 확장 가능성 있는 코드 작성 및 코드의 질을 올리기 위해
- 리펙토링을 했는데 기존 기능이 정상 작동하는지 확인하기 위해
- 컴포넌트 크기를 조절하기 위해 → 의미있는 Test를 할 정도
-
CRA는 기본적으로 React Testing Library 를 지원하며, 이를 활용해 UI Test를 진행한다.
- data-testId를 통해 DOM에 접근하여 테스트를 진행한다 하지만 이러한 test 속성이 production 코드를 들어가는걸 방지하기 위해 바벨 플러그인을 제공한다
- cypress 같은 경우 React Testing Library 처럼 data-cy 속성을 사용 (링크)
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 } ]) })