#5: Context API를 Recoil로 변경하기 (recoil test)

Song-Minhyung·2023년 5월 25일
0
post-thumbnail

목차

  1. useState를 사용해 ToDo-List TDD 적용해보기
  2. Context API(TodoProvider) Test 해보기
  3. useState로 관리하던 상태를 ContextAPI로 변경 + 기능추가
  4. ToDo App에 삭제, 토글, 수정 기능 추가하기
  5. Context API를 Recoil로 변경하기 (recoil test) 👈 NOW

TODO:

이번에는 Context API로 작성해둔 상태들을 Recoil로 바꿀것이다.
굳이 useState -> Context API -> Recoil로 세번씩이나 상태관리 하는 방법을 바꿔보는 이유는
확실하게 테스트코드 작성하는 법을 이해하고 싶기 떄문이다!!!
그리고 실제로도 많은 도움이 됐다.

Context API를 테스트 할때는 따로 만든 커스텀 훅이 없어서
testing library의 renderHook으로 테스트 하지를 못했었다.

TodoContext를 테스트 할 때 테스트를 하기위한 테스트 컴포넌트를 작성하고
제대로 돌아가는지 테스트 코드를 작성해줬었다.

이 방법은 너어어어어어어어 무 비효울적이다.
그래서 이번에는 Recoil의 값을 조작하는 커스텀 훅을 작성후 해당 훅을 테스트 할것이다.

근데 어차피 해당 훅을 사용하는 Component에서 테스트를 진행 하는데
이렇게 구현 상세인 커스텀 훅을 테스트 하는게 맞는지는 아직 잘 모르겠다.
이부분은 어떤게 좋은 방법인지 앞으로 계속 생각해봐야 할것같다.

Recoil Atom 작성하기

// TodoAtomState.tsx
import { atom } from "recoil";
export interface Todo {
  id: number;
  value: string;
  done: boolean;
  modifyMode: boolean;
}

const TodoAtomState = atom<Todo[]>(
  {
    key: 'TodoAtomState', 
    default: [],
  }
);

export default TodoAtomState;

TodoAtom은 간단하게 Todo의 배열을 갖도록 작성해주었다.
그리고 이제 해당 Atom을 기반으로 커스텀 훅을 작성 해 줄것이다.

useTodoState Custom Hook

// useTodoState.tsx
const useTodoState = () => {
  const [todoState, setTodoState] = useRecoilState(TodoAtomState);
  const nextId = useRef(1);

  const addTodo = (value: string) => {
    setTodoState(prev => ([...prev, 
      {
        id: nextId.current++, 
        done: false, 
        modifyMode: false, 
        value
      }
    ]));
  };

  const deleteTodo = (id: number) => {
    setTodoState(prev => prev.filter( todo => todo.id !== id ));
  };

  const toggleTodo = (id: number) => {
    setTodoState(prev => 
      prev.map( todo => 
        todo.id === id 
        ? {...todo, done: !todo.done}
        : todo
      )
    );
  };

  const changeTodo = (id: number, value: string) => {
    setTodoState(prev => 
      prev.map( todo => 
        todo.id === id 
        ? {...todo, value}
        : todo
      )
    );
  };

  const changeModifyMode = (id: number) => {
    setTodoState(prev => 
      prev.map( todo => 
        todo.id === id 
        ? {...todo, modifyMode: !todo.modifyMode}
        : todo
      )
    );
  };

  return {todoState, addTodo, deleteTodo, toggleTodo, changeTodo, changeModifyMode};
}

export default useTodoState;

함수 이름만 봐도 어떤 동작을 하는지 알 수 있다.

useTodoState 테스트 하기

component를 테스트 할때랑은 반대로 커스텀 훅을 작성 후에 -> 테스트를 진행 해주었다.
TDD 원칙에는 어긋나지만 커스텀훅을 굳이 그렇게 테스트 해줄 필요는 없어보인다.
이부분도 어느 방법이 좋은지 계속 생각해보자.

import { renderHook } from "@testing-library/react";
import useTodoState from "./useTodoState";
import { RecoilRoot } from "recoil";
import { act } from "react-dom/test-utils";

describe('useTodoState', () => {
  const setup = () => {
    const { result } = renderHook(()=> useTodoState(), {wrapper: RecoilRoot});
    act(()=>{
      result.current.addTodo("newTodoInit");
    })
    return { result };
  }

  it('초기 상태 = todo 1개', () => {
    const { result } = setup();
    expect(result.current.todoState).toHaveLength(1);
  });

  it('addTodo(value) 호출시 todoState에 값이 들어감', async () => {
    const { result } = setup();

    act(()=>{
      result.current.addTodo("newTodo");
    });

    expect(result.current.todoState[1].value).toBe("newTodo");
  });

  it('deleteTodo(2) 호출시 id가 2인 요소 없음',()=>{
    const { result } = setup();

    act(()=>{
      result.current.deleteTodo(2);
    });

    expect(result.current.todoState.every(todo => todo.id !== 2)).toBeTruthy();
  });

  it('toggleTodo(1) 호출시 해당 id의 의 값은 바뀌어야함.', () => {
    const { result } = setup();

    act(()=>{
      result.current.toggleTodo(1);
    });
    let todo = result.current.todoState.find( todo => todo.id === 1);
    expect(todo?.done).toBeTruthy();


    act(()=>{
      result.current.toggleTodo(1);
    });
    todo = result.current.todoState.find( todo => todo.id === 1);
    expect(todo?.done).toBeFalsy();
  });

  it('changeTodo(1, "changedTodo") 호출시 해당 id의 todo가 변경되어야 함', () => {
    const { result } = setup();

    act(()=>{
      result.current.changeTodo(1, "changedTodo")
    });
    const todo = result.current.todoState.find( todo => todo.id === 1);
    expect(todo?.value).toBe("changedTodo");
  });

  it('changeModifyMode 호출시 해당 id의 ModifyMode 값은 바뀌어야함', () => {
    const { result } = setup();

    act(()=>{
      result.current.changeModifyMode(1);
    });
    let todo = result.current.todoState.find( todo => todo.id === 1);
    expect(todo?.modifyMode).toBeTruthy();

    act(()=>{
      result.current.changeModifyMode(1);
    });
    todo = result.current.todoState.find( todo => todo.id === 1);
    expect(todo?.modifyMode).toBeFalsy();
  })
});

renderHook의 wrapper에 RecoilRoot를 넣어서 Recoil이 정상적으로 작동하게 해줬다.
그리고 테스트 내용은 커스텀 훅에서 작성한 함수들이 제대로 작동하는지에 대한 내용이다.

아 그리고 실행후에 돔에 반영 되어야 하는 부분들은 act() 안에 함수를 넣어줘야 한다.
act로 감싸주지 않고 실행해보면 함수만 실행되며, 테스트가 FAIL이 되는걸 볼 수 있다.

사실 testing-library/react 의 콜백 함수들도 뜯어보면 act() 함수로 감싸져있다.
그래서 userEvent등을 사용할때 act() 를 사용하지 않아도 된다.

공식문서이곳 에 act에 대해 자세히 설명되어 있으니 한번 읽어보면 좋을것같다.
내용을 요약하면 이렇다.

  • act는 React가 업데이트를 처리하도록 보장한다.
  • act를 사용하면 React가 상태 업데이트와 효과 실행을 제대로 처리하므로 UI를 예상한 대로 - 업데이트할 수 있습니다.
  • act는 사용자 상호작용, API 호출, 이벤트 핸들러 및 구독 등과 같이 UI에 변화를 줄 수 있는 코드 부분을 경계로 지정한다.
  • act를 사용하여 코드 상호작용을 그룹화하면 보다 정확한 코드를 작성할 수 있다.
  • jest.useFakeTimers()를 사용하여 타이머를 모의(mock)할 수 있다.
  • 비동기 작업을 테스트할 때는 async act(() => ...) 또는 await act(async () => - ...)를 사용하면된다.
  • act를 통합하여 테스트 라이브러리와 함께 사용하면 보일러플레이트 코드를 줄일 수 있다.

TodoItem.test 수정

renderWithRecoil

Recoil을 쓸 때도 다른곳에서 아래처럼 중복되게 코드를 작성하지 않도록 헬퍼함수를 먼저 작성해줬다.

// renderWithRecoil.tsx
const renderWithRecoil = (
  renderTarget: React.ReactElement<any, string |React.JSXElementConstructor<any>>
) => {
  const wrapper = ({children}: {children: React.ReactNode}) => (
    <RecoilRoot 
      initializeState={({set}) => set(TodoAtomState, mockTodos)}
    >
      {children}
    </RecoilRoot>
  );
  const options =  render(renderTarget, {wrapper});
  return {options};
}

export default renderWithRecoil

TodoItem 테스트코드

우선 TodoItem 설계를 조금 바꿔줬다.
기존에는 TodoItem 안에서 바로 useContext를 사용해줬는데
이러면 나중에 만약에 코드를 수정해야할 일이 있을 때 재사용성이 떨어질 것이라 생각됐다.
그래서 props로 내려받도록 수정 해 주었따.

setup, clickModifyButton 수정

// TodoItem.test.tsx
const deleteTodo = jest.fn();
const toggleTodo = jest.fn();
const changeTodo = jest.fn();
const changeModifyMode = jest.fn();

const props: TodoItemProps = {
  item: mockTodo,
  deleteTodo,
  toggleTodo,
  changeTodo,
  changeModifyMode,
};

const setup = () => {
  const {options}= renderWithRecoil(<TodoItem {...props}/>);

  const deleteButton = screen.getByTestId("DeleteButton") as HTMLButtonElement
  const modifyButton = screen.getByTestId("ModifyButton") as HTMLButtonElement;
  const modifyInput = screen.queryByTestId("ModifyInput") as HTMLInputElement;
  const itemSpan = screen.queryByText(mockTodo.value) as HTMLSpanElement;

  return { 
    ...options, props, itemSpan, deleteButton, modifyButton, modifyInput
  };
}
const clickModifyButton = async() => {
  const setups = setup();

  await userEvent.click(setups.modifyButton);
  
  expect(setups.props.changeModifyMode).toBeCalledWith(mockTodo.id);

  setups.rerender(<TodoItem {...props} item={{...mockTodo, modifyMode: true}}/>);

  const modifyInput = screen.queryByTestId("ModifyInput") as HTMLInputElement;

  return {...setups, modifyInput};
}

테스트 코드 수정

테스트 코드는 테스트 목록은 변경되지 않았지만
TodoItem의 구조가 변경되었으므로 해당 부분을 건드려 주었다.

describe('<TodoItem>', () => {
  it('최초 렌더링시 item.value값을 가진 span과 수정버튼이 존재함', () => {
    const {itemSpan, modifyButton} = setup();

    expect(itemSpan.innerHTML).toBe(mockTodo.value);
    expect(modifyButton.innerHTML).toBe("수정");
  });

  it('수정버튼 클릭시 item.value값을 가진 input과 완료버튼이 존재함', async () => {
    const {modifyButton, modifyInput} = await clickModifyButton();
    
    expect(modifyButton.innerHTML).toBe("완료");
    expect(modifyInput.value).toBe(mockTodo.value);
  });

  it('완료버튼 클릭시 값이 있다면 changeTodo 함수를 호출함', async () => {
    const {modifyButton, modifyInput} = await clickModifyButton();

    await userEvent.clear(modifyInput);
    await userEvent.type(modifyInput, "new Todo");
    await userEvent.click(modifyButton);

    expect(changeTodo).toBeCalledWith(mockTodo.id, "new Todo");
  });

  it('완료버튼 클릭시 값이 없다면 alert(입력값이 없습니다.) 를 출력함', async () => {
    const {modifyInput, modifyButton} = await clickModifyButton();
    window.alert = jest.fn();

    await userEvent.clear(modifyInput);
    await userEvent.click(modifyButton);

    expect(window.alert).toBeCalledWith("입력값이 없습니다.");
  });

  it('삭제버튼 클릭시 deleteTodo(id)가 호출됨', async ()=>{
    const {props: {deleteTodo}, deleteButton} = setup();

    await userEvent.click(deleteButton);

    expect(deleteTodo).toBeCalledWith(mockTodo.id);
  });

  it('span 클릭시 가운데 줄이 생기고toggleTodo(id)가 호출됨, 다시한번 클릭시 줄이 없어짐', async ()=>{
    const {props, itemSpan, rerender} = setup();

    await userEvent.click(itemSpan);
    rerender(<TodoItem {...props} item={{...mockTodo, done: true}}/>);

    expect(toggleTodo).toBeCalledWith(mockTodo.id);
    expect(itemSpan).toHaveClass("line-through");


    await userEvent.click(itemSpan);
    rerender(<TodoItem {...props} item={{...mockTodo, done: false}}/>);

    expect(toggleTodo).toBeCalledWith(mockTodo.id);
    expect(itemSpan).not.toHaveClass("line-through");
  });
  
});

ToDo-List에 TDD 적용하기 끝

투두리스트에 TDD를 적용하는건 이번 글로 끝이다 ! ! ! !
다음 글은 reqct-query를 사용해서 테스트코드를 작성 해 볼것같다.

앞으로 커스텀 훅을 테스트하는게 무조건 좋은지
커스텀 훅 코드를 작성하고 테스트 코드를 작성하는게 좋은지
에 대해서 천천히 생각해 봐야겠다.

그리고 상태관리 하는 방법을 몇번 바꾸며 느낀점은
TDD를 적용해 코드를 작성하려면 초기에 설계가 잘 되어 있어야 할것 같았다.
그렇지 않으면 테스트 코드를 변경하는데도 작성할때 만큼 시간이 많이 소요됐기 때문이다.

체감상 이랬다
테스트 코드를 변경할 때 걸리는 시간 >>> 테스트 할 코드를 변경할 때 걸리는 시간
그럼에도 불구하고 미래의 디버깅 할 때 고통을 현재로 가져오기 때문에 TDD를 적용한다고 생각한다.
끗.

profile
기록하는 블로그

0개의 댓글