[다담다] Jotai로 모달 관리하는 테스트코드 작성하기

한낱·2023년 10월 15일
0

오늘 작성할 내용은 이번 프로젝트에서 가장 만족스러운 코드이다.

Jotai를 왜 썼는가

Jotai란 일본어로 상태라는 뜻으로 프론트엔드의 상태관리 도구 중 하나이다. 사실, 수많은 프론트엔드 상태관리 도구 중 Jotai는 점유율이 높거나 많이 사용되는 도구는 아니다. 당시 프로젝트에는 context API를 사용하고 있었는데 Jotai가 context API와 사용방법이 유사하다는 평가를 듣고 코드를 변경하기 쉬울 것이라고 판단하여 선택하였다. (추가로, 요즘 흔히들 보이는 Recoil에 영향을 많이 받아 만들어졌다고 하여 여차하면 Recoil로 바꾸기도 좋을 것 같다는 판단도 한 몫 했다.) 그리고, (다른 상태관리 도구를 사용해본 적 없어서 그런가) 여태까지 큰 불만은 없다.

제 모달은요...

사실 이 부분이 가장 하고 싶은 얘기라 Jotai에 대한 내용은 짧게 끊었다.
이 작업을 할 당시 막 디자이너님께 디자인을 받았을 때여서 기존에 작업한 모달을 새로운 디자인으로 업데이트 해야했다. 또한, 기존 모달은 모달의 배경인 overlay 부분을 클릭해도 사라지지 않는 문제가 있어 모든 모달에 해당 기능을 추가해야했다. 추가로, 모달의 타이틀 위치나 닫기 버튼의 위치 및 기능 등은 동일한데 이를 반복해서 코드를 작성하는 것이 낭비처럼 느껴져 개선할 방법이 없을까 고민을 했었다.

방법 설명

app.tsx에서 만약 modal이 존재해야하는 상황이면 modal을 그리도록 한다. 이 모달은 modal wrapper와 modal element로 이루어져, modal wrapper에서는 동일한 모달의 타이틀이나 닫기 버튼, 오버레이, 크기 등을 담당하고, modal element는 메모를 추가하는 상황, 스크랩을 수정하는 상황 등의 상황에 맞추어 필요한 내용으로 바꿔끼우도록 한다.

Jotai 설치

jotai를 설치한다.

npm i jotai

jotai 공식 문서

modal에 대한 atom 생성

interface ICustomModalInfo {
    title: string,
    isOpen: boolean,
    element: React.ReactNode,
    callback?: () => void,
}

export type {ICustomModalInfo};
import { atom } from "jotai";

import { ICustomModalInfo } from "@/types/IModalsInfoAtom";

const modalAtom = atom<ICustomModalInfo>(
    {
        title: '',
        isOpen: false,
        element: '',
    }
);

export default modalAtom;

jotai는 atom을 기반으로 동작한다. ICustomModalInfo를 통해 필수적/부차적으로 모달을 만들기 위해 필요한 정보를 명시해놓았다. 이를 바탕으로 모달의 타이틀, 보이는 여부, 내용을 필수적으로 가지는 modal atom을 만든다.

useModal hook 만들기

import { useAtom } from "jotai";
import { useCallback } from "react";

import modalAtom from "@/state/modalAtom";

export const useModal = () => {
    const [modal, setModal] = useAtom(modalAtom);

    const closeModal = useCallback(() => {
        setModal((prev) => { return { ...prev, isOpen: false } })
    }, [setModal]);

    const modalTypeMatching = {
        memoCreate: {
            title: '메모 추가하기',
            element: <MemoCreateModalElement />,
        },
        login: {
            title: '소셜 로그인하기',
            element: <LoginModalElement />,
        },
        userDelete: {
            title: '회원 탈퇴하기',
            element: <UserDeleteModalElement />,
        },
        scrapDelete: {
            title: '스크랩 삭제하기',
            element: <ScrapDeleteElementModal />,
        },
        scrapEdit: {
            title: '스크랩 편집하기',
            element: <ScrapEditModalElement />,
        },
        scrapCreate: {
            title: '스크랩 추가하기',
            element: <ScrapCreateModalElement />,
        },
    }

    const openModal = useCallback((
        type: string
    ) => {
        setModal((prev) => {
            return {
                ...prev,
                ...modalTypeMatching[type as keyof typeof modalTypeMatching],
                isOpen: true,
            }
        })
    }, [setModal]);

    return { modal, closeModal, openModal };
}

modal을 export해줌으로써 useModal hook을 사용하는 다른 컴포넌트들에서 전역상태를 사용할 수 있다.

  • closeModal을 모달을 닫는 데에 사용된다.
  • openModal은 모달을 여는 데에 사용된다.
  • modalTypeMatching을 통해 모달에 해당하는 title과 element를 매칭해준다.
  • useModal('원하는 모달을 표현하는 string')표현을 통해 사용할 수 있다.

모달의 위치 = 최상단

//App.tsx
<~Provider>
  	{modal.isOpen && <ModalWrapper />}
  	<라우팅들/>
</~Provider>

다른 요소들이나 라우팅 중 무엇이 그려지고 있든지 상관 없이 모달과 모달을 덮는 overlay가 가장 상단에 그려지도록 하고 싶었다. 또한, 이 서비스에서의 모달은 항상 한 가지만 존재함을 보장할 수 있어 최상단에서 modal atom의 isOpen 상태에 따라 모달을 그리도록 설정하였다.

(코드가 너무 길어져서 styling에 대한 코드는 제거하였습니다.)

import { Box, Modal, Typography } from '@mui/material';

import { useModal } from '@/hooks/useModal';
import theme from '@/assets/styles/theme';
import { CloseIcon } from '@/components/atoms/Icon';

function ModalWrapper() {
    const { modal, closeModal } = useModal();

    return (
        <Modal
            open={modal.isOpen}
            onClose={closeModal}
            aria-labelledby="parent-modal-title"
            aria-describedby="parent-modal-description"
        >
            <Box>
                <Box>
                    <Box
                        onClick={closeModal}
                    >
                        <CloseIcon width='24' height='24' fill={theme.color.Gray_070} />
                    </Box>
                    <Typography
                        variant='h1'
                        color={theme.color.Gray_090}
                    >
                        {modal.title}
                    </Typography>
                </Box>
                {modal.element}
            </Box>
        </Modal>
    );
}

export default ModalWrapper;

프로젝트 전반적으로 사용하고 있던 MUI를 활용하여 모달의 껍데기 역할을 하는 Modal Wrapper 클래스를 만들었다. modal atom의 isOpen을 통해 Modal Wrapper가 보이는 상태를 제어할 수 있고, closeModal을 통해 모달의 '닫힘'을 구현할 수 있다. 또한, atom에 저장된 title과 element를 wrapper에 그려줌으로써 모든 모달이 별도의 노력없이도 동일한 styling을 가질 수 있게 되었다.

모든 modal element를 가져올 수 없어 스크랩을 추가할 때 사용되는 modal element를 예시로 가져왔다.

import { useState, useEffect, ChangeEvent } from 'react';
import { Box, Button, FormControl, FormHelperText, OutlinedInput } from '@mui/material';

import { usePostCreateScrap } from '@/api/scrap';
import theme from '@/assets/styles/theme';
import { useModal } from '@/hooks/useModal';

import { LinkIcon } from '@/components/atoms/Icon';

function ScrapCreateModalElement() {
    const [textAreaValue, setTextAreaValue] = useState('');
    const [token, setToken] = useState<string | null>(null);
    const { closeModal } = useModal();

    useEffect(() => {
        setToken(localStorage.getItem('token'));
    }, []);

    const handleSetValue = (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
        e.preventDefault();
        setTextAreaValue(e.target.value);
    };

    const { mutate } = usePostCreateScrap();

    return (
        <FormControl>
            <Box>
                <OutlinedInput
                    placeholder="추가할 스크랩 주소를 입력하세요."
                    onChange={(e) => handleSetValue(e)}
                    startAdornment={
                        <LinkIcon width='24' height='24' fill={theme.color.Gray_090} color={theme.color.Gray_060} />
                    }
                    autoFocus
                />
                <Button
                    variant='contained'
                    onClick={
                        () => {
                            (token) && mutate({ token, textAreaValue });
                            closeModal();
                        }
                    }
                >
                    추가
                </Button>
            </Box>
        </FormControl>
    );
}

export default ScrapCreateModalElement;

모달에서 버튼을 누르면 해당 모달은 사라져야 한다. 당연한 동작이지만, atom을 통해 모달을 구현하기 전에는 props로 계속 전달해주어야 해서 힘들었다. 이제는 return문 이전에 useModal을 통해 closeModal을 불러와줌으로써 쉽게 모달 닫기 기능을 연결할 수 있어졌다.

모달 사용

<Button
  color='primary'
  variant='contained'
  onClick={() => openModal('scrapCreate')}
>
  + 스크랩 추가
</Button>

모달의 사용도 훨씬 간편해졌다. 원하는 부분에서 useModal을 통해 openModal을 가져오고, 관련된 버튼에 해당 모달을 불러오는 string을 통해 modal을 열면 된다.

이 과정을 역으로 거슬러 올라가보면,
1. openModal: 모달의 isOpen을 true로 변경해준다. 또한, 'scrapCreate'에 매칭되는 모달의 타이틀과 내용을 찾아 모달 atom에 채워준다.
2. app.tsx: 모달의 isOpen 상태가 true이므로 Modal Wrapper를 그리도록 한다.
3. Modal Wrapper: 모달의 atom에 저장된 타이틀과 내용을 렌더링한다. 오버레이를 클릭하면 모달 atom의 close Modal에 의해 isOpen 상태가 false가 되어 모달이 닫힌다.
4. Modal Element: 모달의 내용을 그린다. 만약 모달의 버튼이 클릭되면 해당하는 동작을 수행한 후, 모달 atom에 저장된 close Modal을 통해 (modal wrapper와 마찬가지로) 모달을 닫는다.
로 정리할 수 있다.

테스트 코드 작성해보기

위에서 원하는 모달을 불러오는 버튼을 클릭하면 atom의 isOpen 상태가 변화하는지와 이를 통해 원하는 모달이 불러와졌는지를 확인할 수 있다.

describe('스크랩 추가하기 모달 테스트', () => {
    it('스크랩 추가하기 버튼을 누르면 모달 atom의 isOpen 상태가 변경된다.', () => {
        const { result } = renderHook(() => useModal());
        expect(result.current.modal.isOpen).toBe(false);

        act(() => {
            render(<ScrapListHeader count={1} type={'list'} />)
        });

        act(() => {
            userEvent.click(screen.getByText('+ 스크랩 추가'));
        });

        expect(result.current.modal.isOpen).toBe(true);
    });
})

expect(result.current.modal.isOpen).toBe(false);를 통해 버튼을 클릭하기 전에 모달의 열림 상태가 false였음을 확인할 수 있고, 이후 스크랩 추가 버튼이 있는 ScrapListHeader 컴포넌트를 렌더링하고, 이를 통해 스크랩 추가 버튼을 찾아 클릭하여 스크랩 추가 모달을 여는 작업을 수행할 수 있다. 이 결과로 이전과 다르게 modal의 isOpen 상태가 true로 변경되었다. 즉, 특정 모달이 열림상태로 변경되었음을 알 수 있다.

만약 이 때 열린 모달이 스크랩 추가하기에 대한 모달이 맞는지 확인하고 싶다면 마지막줄을 다음과 같이 변경하면 된다.

expect(result.current.modal.title).toBe('스크랩 추가하기');

이 모든 과정을 거쳐 탄생한 다담다 프로젝트가 궁금하다면? 다담다 서비스 체험해보기

profile
제일 재밌는 개발 블로그(희망 사항)

0개의 댓글