Post

FE테스트(4) - 전역 상태 관리

📌시작하며

기본적인 테스트 과정과 흐름에 대해 알아봤으니 이번엔 context API나 zustand와 같은 전역 상태 관리를 테스트 해보자.

✅context API

사실 처음엔 전역 상태 관리 를 테스트 한다는 거에 어떻게 하는건지 고민되었지만, 전역 상태 관리 에 초점을 맞추지 말고 사용자에게 보여지는 것 에 초점을 맞춰보면 쉽게 테스트 할 수 있다.

전체적인 흐름도 앞서 살펴본 것과 동일하다. 특별히 전역 상태 관리를 위해 API를 쓰는 것이 아니라 A에서 트리거를 발생시키고 B에 제대로 반영이 되는지 테스트하면 되기 때문이다.

➡️예제

먼저 context API를 작성해주었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import React, { createContext, useContext, useState, ReactNode } from "react"

// CountContext 타입 정의
interface CountContextType {
  count: number
  increment: () => void
}

// Context 생성
const CountContext = createContext<CountContextType | undefined>(undefined)

// Provider 컴포넌트
export const CountProvider: React.FC<{ children: ReactNode }> = ({
  children,
}) => {
  const [count, setCount] = useState(0)

  const increment = () => setCount((prev) => prev + 1)

  return (
    <CountContext.Provider value=9>
      {children}
    </CountContext.Provider>
  )
}

// Hook을 사용하여 Context 값 접근
export const useCount = () => {
  const context = useContext(CountContext)
  if (!context) {
    throw new Error("에러 발생!")
  }
  return context
}

이후 과정은 A 컴포넌트에서 increment를 호출하고 B 컴포넌트에서 숫자를 렌더링한다. 간단한 부분이라 코드는 생략한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { CountProvider } from "@/context/CountContext"
import React from "react"
import { AComponent } from "./AComponent"
import { BComponent } from "./BComponent"

const AllComponent = () => {
  return (
    <CountProvider>
      <AComponent />
      <BComponent />
    </CountProvider>
  )
}

export default AllComponent

이후 다음과 같이 AComponentBComponentCountProvider로 감싸주었다. 그리고 테스트 AllComponent.spec.tsx를 작성해 주었다.

전체 흐름은 위에서 설명한 것 처럼, 사용자 측면에서 테스트 작성해주면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { render, screen, fireEvent } from "@testing-library/react"
import { describe, it, expect } from "vitest"
import AllComponent from "./AllComponent"

describe("All Componet Context API", () => {
  it("A 컴포넌트에서 count를 증가시키면, B 컴포넌트에서도 count가 업데이트 된다.", () => {
    render(<AllComponent />)

    //aCount와 bCount가 동일하기 때문에 <p data-testid="a-count"/> 속성을 사용해 구분한다.
    //구분할 수 없을때만 사용함에 주의
    const aCount = screen.getByTestId("a-count")
    const bCount = screen.getByTestId("b-count")

    //증가 버튼을 클릭한다.
    fireEvent.click(screen.getByText(/증가/i))

    // A 컴포넌트의 count가 1로 업데이트 되었는지 확인
    expect(aCount).toHaveTextContent("Count: 1")

    // B 컴포넌트의 count도 1로 업데이트 되었는지 확인
    expect(bCount).toHaveTextContent("Count: 1")
  })
})

✅zustand

그럼 이번에는 리액트 기본 내장 전역 상태 관리가 아니라, 라이브러리를 활용했을 때의 테스트를 작성해보자. zustand 공식문서에서는 테스트에 대해 자세히 설명하고 있다.

공식문서에서 제안하는 방식에 따라 진행해보자.

먼저 Zustand처럼 전역 상태 관리 라이브러리를 테스트할 때는 A테스트에서 변경된 값이 B테스트에 영향을 미치지 않도록, 값을 초기화하는 것이 중요하다.

➡️__mocks__ 폴더 만들기

__mocks__ 폴더를 루트 경로에 만든다. 만약 src 디렉토리를 설정한 경우에는 src/__mocks__/zustand.ts에 모킹 파일을 넣는다. 나는 src 폴더를 사용하고 있으므로, src 폴더 안에 __mocks__ 폴더를 만들고 zustand.ts파일을 만들어 주었다.

필요한 코드를 공식문서에서 이미 제시하고 있어, 간단하게 테스트 환경을 만들 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// __mocks__/zustand.ts
import * as zustand from "zustand"
import { act } from "@testing-library/react"

const { create: actualCreate, createStore: actualCreateStore } =
  await vi.importActual<typeof zustand>("zustand")

// a variable to hold reset functions for all stores declared in the app
export const storeResetFns = new Set<() => void>()

const createUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
  const store = actualCreate(stateCreator)
  const initialState = store.getInitialState()
  storeResetFns.add(() => {
    store.setState(initialState, true)
  })
  return store
}

// when creating a store, we get its initial state, create a reset function and add it in the set
export const create = (<T>(stateCreator: zustand.StateCreator<T>) => {
  console.log("zustand create mock")

  // to support curried version of create
  return typeof stateCreator === "function"
    ? createUncurried(stateCreator)
    : createUncurried
}) as typeof zustand.create

const createStoreUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
  const store = actualCreateStore(stateCreator)
  const initialState = store.getInitialState()
  storeResetFns.add(() => {
    store.setState(initialState, true)
  })
  return store
}

// when creating a store, we get its initial state, create a reset function and add it in the set
export const createStore = (<T>(stateCreator: zustand.StateCreator<T>) => {
  console.log("zustand createStore mock")

  // to support curried version of createStore
  return typeof stateCreator === "function"
    ? createStoreUncurried(stateCreator)
    : createStoreUncurried
}) as typeof zustand.createStore

// reset all stores after each test run
afterEach(() => {
  act(() => {
    storeResetFns.forEach((resetFn) => {
      resetFn()
    })
  })
})

실제 테스트는 아래와 같은 과정에서 진행한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import { render, screen, act } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { describe, it, expect, beforeEach, vi } from "vitest"
import ZustandComponent from "./ZustandComponent"
import { mockReset, mockDecrement, mockIncrement } from "../stores/MockZustand"

describe("ZustandComponent 테스트", () => {
  ;("")
  beforeEach(() => {
    vi.clearAllMocks()
    // 상태 초기화
    mockReset()
  })

  it("초기 렌더링 시 count는 0이어야 한다", () => {
    render(<ZustandComponent />)
    expect(screen.getByText(/^Count: 0$/)).toBeInTheDocument()
  })

  it("+ 버튼을 클릭하면 count가 1씩 증가해야 한다", async () => {
    render(<ZustandComponent />)
    const user = userEvent.setup()

    mockIncrement() // Increment 함수 모킹

    // await act(async () => {
    //   await user.click(screen.getByTestId("plus"));
    // });

    // count 값이 증가했는지 확인
    expect(screen.getByTestId("count")).toBeInTheDocument()
  })

  it("(Zustand) - 버튼을 클릭하면 count가 1씩 감소해야 한다", async () => {
    render(<ZustandComponent />)
    const user = userEvent.setup()

    mockDecrement() // Decrement 함수 모킹

    // 버튼 클릭 이벤트를 처리하기 위해 주석을 해제해야 함
    // await act(async () => {
    //   await user.click(screen.getByTestId("minus"));
    // });

    // count 값이 감소했는지 확인
    expect(screen.getByTestId("count")).toBeInTheDocument()
  })

  it("(UI) - 버튼을 클릭하면 count가 1씩 감소해야 한다", async () => {
    render(<ZustandComponent />)
    //이벤트 핸들러 준비
    const user = userEvent.setup()

    // 상태 모킹을 사용하지 않음

    // 버튼 클릭 이벤트를 처리
    await act(async () => {
      await user.click(screen.getByTestId("minus"))
    })

    // count 값이 감소했는지 확인
    expect(screen.getByTestId("count")).toBeInTheDocument()
  })
})
This post is licensed under CC BY 4.0 by the author.