Valtio로 분리된 비즈니스 로직을 테스트해보자

신석진( Seokjin Shin)·2024년 3월 7일
2
post-thumbnail

지난 글에서는 Valtio를 통해서 투두 리스트를 만들어 보았다. 그 코드에 덧붙여서 테스트를 진행해보자.

준비

프로젝트의 기술스택은 다음과 같다.
런타임: Bun.js
개발환경: Vite

vitest를 설치해준다.
bun add -D vitest

vite.config.ts 파일을 수정한다.

/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import tsConfigPaths from "vite-tsconfig-paths";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), tsConfigPaths()],
  test: {
    globals: true, //전역에서 import 없이 사용하기 위함
  },
});

typescript가 전역 vitest를 인식할 수 있도록 tsconfig.json 파일을 수정한다.

{
  "compilerOptions": {
    ...
    "types": ["vitest/globals"],
    ...
  },
}

테스트를 돌릴 수 있도록 스크립트를 작성해준다.

{
 ...
  "scripts": {
   ...
    "test": "vitest"
  },
 ...
}

테스트 코드 작성

src/__test__/ 폴더 하위에 *.test.ts로 명명되는 파일을 만들어나갈 것이다.

이제 투두리스트에 항목을 추가하는 로직을 테스트 해보자. 테스트를 진행할 코드는 아래와 같다.

// 기존 코드
import { Filter, Todo } from "@/types/todo/todoType";
import { proxy } from "valtio";
import { proxyMap } from "valtio/utils";

export class TodoState {
  filter;
  todos;

  constructor(params: { filter: Filter; todos: Map<string, Todo> }) {
    const { filter, todos } = params;
    this.filter = filter;
    this.todos = todos;
  }

  add(params: { title: string; completed: boolean }) {
    const { title, completed } = params;

    if (!title) {
      return;
    }

    const id = crypto.randomUUID();
    this.todos.set(id, { id, title, completed });
  }
}

기존에는 return 값이 없어서 실제로 객체에 잘 들어갔는지 테스트 하기 어려웠다. 때문에 넣어준 todo를 return 하도록 변경하였다.

// 변경 코드
add(params: { title: string; completed: boolean }) {
  const { title, completed } = params;

  if (!title) {
    return;
  }

  const id = crypto.randomUUID();
  const todo = { id, title, completed };

  this.todos.set(id, todo);

  return todo;
}

변경한 후에 테스트 코드를 작성해보자.
describe는 여러개의 테스트를 모아놓는 것이고 test가 실제 테스트를 진행하는 함수이다. describe에 선언된 TodoState의 인스턴스를 가지고 서비스를 호출했을 때 기대되는 값을 적으면 된다. 물론 테스트마다 객체를 각각 선언해줄 수도 있고 describe에 가변 변수를 선언하여 beforeEach나 beforeTest시마다 초기화해주는 방법도 있을 수 있다.

Todo의 add 함수를 호출하면 매개변수로 넘겨준 제목과 완료 여부의 값으로 맵에 id를 생성하여 set해준다. 이 과정을 그대로 코드를 옮기면 다음과 같다.

import { TodoState } from "@/state/todo/todoState";

describe("Test Todo state", () => {
  const state = new TodoState({ filter: "all", todos: new Map() });

  test("Add todo", () => {
    const title = "title1";
    const completed = false;
    
    // 제목과 완료 여부를 토대로 todo를 생성.
    const todo = state.add({ title, completed })!;

    // 생성된 todo가 있어야하며
    expect(todo).toBeDefined();
    
    // 이때 todo의 갯수는 1개이다.(초기값은 비어있는 맵이므로)
    expect(state.todos.size).toBe(1);

    // 반환된 todo는 변경되지 않은 처음 넘겨준 그대로의 값이여야 한다.
    expect(todo.title).toBe(title);
    expect(todo.completed).toBe(completed);
  });
});

TDD 방식

지금은 기존 코드가 있음을 가정하고 테스트를 작성하였다. 그럼 TDD 방식으로 투두를 삭제하는 것을 해보자.
remove 테스트를 추가해주기 전에 아까의 테스트를 리팩토링할 필요성을 느꼈다. 각각의 테스트가 고유의 역할만을 수행할 수 있도록 각 테스트마다 객체를 생성해주도록 변경해주겠다. 그 후 remove를 추가하면 다음과 같다.

import { TodoState } from "@/state/todo/todoState";

describe("Test Todo state", () => {
  test("todo add", () => {
    const state = new TodoState({ filter: "all", todos: new Map() });

    const title = "title1";
    const completed = false;

    const todo = state.add({ title, completed })!;

    expect(todo).toBeDefined();
    expect(state.todos.size).toBe(1);

    expect(todo.title).toBe(title);
    expect(todo.completed).toBe(completed);
  });

  test("todo remove", () => {
    // TodoState에 넣어줄 맵을 선언
    const todos = new Map();
    
    // id, title, completed를 가상으로 생성 후 todo에 넣어준다.
    const id = crypto.randomUUID();
    const title = "title1";
    const completed = false;
    todos.set(id, { id, title, completed });

    // todos에 하나의 값을 가진채 객체를 생성
    const state = new TodoState({ filter: "all", todos });
    const prevSize = state.todos.size;

    // 함수가 없으므로 오류가 발생한다.
    state.remove();

    // 이전의 크기보다 한개가 줄어들어야한다.
    expect(state.todos.size).toBe(prevSize - 1);
  });
});

이를 만족하는 remove 함수를 만들어주면 그것이 TDD이다.
우선 state.remove라는 것이 없을 것이므로 이를 해결해준다.

// todoState.ts
export class TodoState {
  ...
  remove(){}
  ...
}

remove를 작성하려다보니 자료구조로 map을 사용하기에 요소를 삭제하려면 id가 필요하다는 것을 알 수 있다. 테스트를 수정해보자.

test("todo remove", () => {
   ...
   state.remove({id});
   ...
});

그러면 다시 파라미터를 받을 수 없다며 오류가 발생할 것이다. 이제 테스트에 남은 문제들을 모두 해결하도록 로직을 작성해보자.

// todoState.ts
export class TodoState {
  ...
  remove(params: { id: string }){
    const { id } = params;
    this.todos.delete(id);
  }
  ...
}

위와 같이 작성하면 테스트가 통과하는 것을 볼 수 있다. 앞서 해본 것은 간단한 예시긴 하지만 TDD의 흐름이다. 먼저 테스트를 작성하면 발생하는 오류들을 하나씩 해결해나가다 보면 기능이 완성되는 것이다.

여담

개인적으로 기능 개발을 하면서 TDD 방식을 고수할 필요는 없다고 생각한다. 모든 기능에 테스트가 필요하다고 생각하지도 않는다 왜냐하면 결국 테스트 코드도 코드이기에 유지보수가 필요하기에 비용의 득실을 따질 필요가 있기 때문이다. 필자는 복잡한 로직이거나 버그가 발생하면 테스트 코드에 추가하는 편이다. 복잡한 로직일 때에는 후에 협업할 사람과 미래의 나를 위해서 작성하고 버그가 발생하면 동일한 작업을 두번하지 않기 위해 작성한다.
테스트 코드를 작성한지 1년이 되지 않았기에 다른분들은 어떻게 하시는지 그리고 어떤 생각을 가지고 테스트를 작성하시는지 궁금해진다.

0개의 댓글