(번역) 좋은 코드, 테스트 용이한 코드

원문 : Good Code, Testable Code

테스트 용이성이란 무엇인가요?

소프트웨어 “테스트 용이성”에 대한 정의는 많은 곳에서 찾아볼 수 있습니다. 안타깝게도, 대부분의 정의는 “시스템을 테스트하는 것이 얼마나 쉬운가” 또는 “코드를 어느 정도까지 테스트할 수 있는가” 같은 표현으로 귀결됩니다. 저는 이러한 표현이 안타깝다고 생각하는데, “쉽다”와 “어렵다” 같은 용어는 본질적으로 상대적이며 주관적이기 때문입니다.

자동화된 테스트 작성의 용이성은 여러분의 기술이 성장함에 따라 커질 것입니다. 하루가 걸렸던 테스트가 이제는 한 시간 만에 끝날 수도 있습니다. 그렇다고 해서 그 코드의 테스트 용이성이 향상된 것일까요? 아닙니다, 그저 여러분이 향상된 것입니다.

저는 소프트웨어 테스트 용이성에 대한 보다 확실한 정의를 제안하고 싶습니다. 이 정의는 테스트 여정을 막 시작한 사람에게도, 오랜 소프트웨어 테스트 경험을 가진 전문가에게도 똑같이 적용될 수 있을 것입니다.

테스트 용이성은 코드의 복잡성과 테스트 설정의 복잡성 간의 관계를 설명하는 소프트웨어의 특성입니다.”

코드만으로는 테스트 용이성을 관찰할 수 없다는 점을 강조하는 것이 중요합니다. 테스트 용이성은 항상 코드와 그 복잡성이 테스트 설정의 복잡성과 맺고 있는 관계에 달려 있습니다.

아래 함수를 살펴보세요.

import { toAbsolutePath, cleanUrl } from './utils.ts';

export function normalizePath(path: string | RegExp) {
  if (path instanceof RegExp) {
    return path;
  }

  const absolutePath = toAbsolutePath(path);

  return cleanUrl(absolutePath);
}

normalizePath 함수의 구현만을 가지고 이 함수의 테스트 용이성을 판단할 수 있을까요?

저는 그렇게 판단하기 어렵다고 생각합니다.

코드를 기반으로 판단할 수 있는 유일한 것은 코드의 복잡성뿐입니다. 예를 들어, path 인자가 유니언 타입이어야 하는지, 아니면 간단하게 문자열만 받아서 정규 표현식을 미리 체크하여 함수를 더 간단하게 만들 수 있는지 생각해 볼 수 있습니다. 또한, 이 함수가 얼마나 잘 의도를 구현하고 있으며, 엔지니어링의 모범 사례를 얼마나 충실하게 따르고 있는지도 확인할 수 있습니다.

이런 과정이 틀린 것은 아닙니다. 하지만, 이 과정은 함수의 테스트 용이성과는 직접적으로 관련이 없으며, 그에 의해 영향을 받아서도 안 됩니다.

복잡성 간의 관계

시스템 복잡성과 테스트 설정 복잡성 사이의 관계는 정의하기 까다로운 문제입니다. 코드가 복잡하다고 해서 테스트 용이성이 낮은 코드를 의미하지 않으며 그 반대의 경우도 마찬가지입니다.

이 두 함수를 예로 들어보겠습니다.

export function add(a: number, b: number) {
  return a + b;
}
export async function updatePost(postId: string, payload: Post) {
  const existingPost = await db.posts.findFirst({ id: postId });

  if (!existingPost) {
    throw new InputError(`Cannot find post with id "${postId}"`);
  }

  const nextPost = await existingPost.update(payload);
  return nextPost;
}

객관적으로 보면 add 함수는 updatePost 함수보다 덜 복잡합니다. 더 테스트하기 쉽다는 뜻일까요? 모든 함수를 add처럼 순수하고 단순하게 만들어야 할까요?

아니요, 그럴 필요는 없다고 생각합니다.

소프트웨어 또는 소프트웨어의 일부가 해야 할 일을 기반으로 소프트웨어 설계를 결정해야 한다고 생각합니다. 정당한 이유만 있다면 소프트웨어의 복잡성은 나쁘지 않습니다. 테스트의 복잡성도 마찬가지입니다.

위의 예시들을 따르면, add 함수를 신뢰성 있게 테스트하는 데는 별다른 준비가 필요하지 않습니다. updatePost의 경우 테스트 결과에 영향을 미치지 않도록 데이터베이스 연결을 어떤 식으로든 모킹(Mock)해야 할 것입니다. 두 함수 모두 복잡성은 다르지만 완벽하게 테스트할 수 있습니다.

하지만 테스트 용이성이 더 나쁜 경우는 어떨까요?

좋은 질문입니다. 또 다른 간단한 함수의 예를 들어보겠습니다.

export async function isLegacyUser(userId: string) {
  const user = await db.users.findFirst({ id: userId });
  return user?.type === 'legacy';
}

표면적으로 보면 isLegacyUser 함수는 사용자의 type'legacy'인지 아닌지를 확인하는 간단한 함수이기 때문에 그다지 복잡하지 않습니다.

그러나 이 함수를 테스트해 보면 테스트 설정의 복잡성이 함수 자체의 복잡성보다 더 크다는 것을 금방 알게 될 것입니다.

// is-legacy-user.test.ts
import { db } from './db.js';
import { isLegacyUser } from './is-legacy-user.js';

afterEach(() => {
  vi.resetAllMocks();
});

afterAll(() => {
  vi.restoreAllMocks();
});

test('returns true for a legacy user', async () => {
  vi.spyOn(db.users, 'findFirst').mockResolvedValue({
    id: 'abc-123',
    type: 'legacy',
  });
  await expect(isLegacyUser('abc-123')).resolves.toBe(true);
});

test('returns false for a regular user', async () => {
  vi.spyOn(db.users, 'findFirst').mockResolvedValue({
    id: 'abc-123',
  });
  await expect(isLegacyUser('abc-123')).resolves.toBe(true);
});

함수가 db에 의존하고 있기 때문에, 테스트에서는 갑자기 데이터베이스 쿼리를 모킹해야 합니다. isLegacyUser의 의도는 사용자를 가져오는(fetch) 것이 아니라, 단순히 일부 속성만 확인하는 것이었습니다. 이 구현은 불필요하게 복잡하며, 그 복잡성이 테스트 설정에도 영향을 미치고 있음을 알 수 있습니다.

이것은 또 다른 중요한 점을 시사합니다.

암시적 테스트로서의 테스트 용이성

“코드의 테스트 용이성은 그 자체로 암시적 테스트입니다.”

이러한 사고방식은 받아들일 가치가 있습니다. 잘 작성된 코드는 항상 테스트하기가 더 쉬운 반면, 잘못 작성된 코드는 복잡한 테스트를 통해 그 이면의 잘못된 결정들을 되새기게 할 것입니다.

테스트 용이성을 활용하여 코드의 실수를 발견할 수 있습니다. 코드가 너무 많은 일을 하고 있다면, 범위를 재고해 봐야 할 것입니다. 또는, 그것이 디미터의 법칙을 위반하여 본래 참조해서는 안 되는 것을 참조하고 있을 수도 있습니다. 코드와 테스트의 복잡성 사이의 관계는 종종 선형적이지 않기 때문에 테스트와 특히 테스트 설정에서 이러한 문제들이 자주 드러납니다.

그렇다고 해서 테스트가 설계 결정을 좌우하도록 해서는 안 됩니다.

테스트 용이성은 시스템을 분석하는 데 추가적으로 수행할 수 있는 좋은 점검 사항이지만, 그것이 구현의 원동력이 되어서는 안 됩니다. 테스트와 구현 모두는 어떤 목적을 달성하기 위한 의도에서 비롯된 것입니다. 전자는 코드를 통해 그 의도를 설명하고, 후자는 그것을 구현합니다. 테스트의 진정한 목적에서 언급했듯이, 간단한 논리 단위의 경우 두 가지가 동일할 수 있지만, 수십 줄에서 수백 줄에 달하는 코드가 단 하나의 목적을 달성하는 경우도 흔합니다.

게다가, 테스트 용이성을 기준으로 구현을 결정하면 코드의 품질이 저하될 수 있습니다.

그 좋은 예로는, 의존성 주입을 통해 코드의 테스트 용이성을 높이는 방법에 대한 흔한 조언 중 하나입니다.

export function query(address: string) {
  sql.open(address);
}

이 예제에서는 query 함수의 테스트 용이성을 향상시키기 위해 sql.open 함수를 인자로 받아 테스트에서 모킹 함수로 대체할 수 있다고 주장합니다.

// 이제, 모든 사용자(테스트 포함)는 모든
// 일치하는 함수를 `open` 인수로 전달할 수 있습니다.
export function query(open: (address: string) => void) {
  open();
}

사실, 다른 언어에서도 이러한 관행은 흔히 볼 수 있는데, 개인적으로 코드의 문제라기보다는 의존성 모킹 도구가 부족하기 때문이라고 생각합니다.

이 예시에서 일어나고 있는 일은, 여러분이 테스트를 더 쉽게 하기 위해 함수의 호출 시그니처를 변경하고 있는 것입니다. 그러나 코드는 여러분을 위해 작성하는 것이 아니라, 코드를 사용하는 사용자(웹사이트 사용자나 다른 개발자 등)를 위해 작성하는 것입니다. 테스트를 위한 것이 아니라, 사용자에게 훌륭한 API 경험을 제공하기 위해 코드를 작성해야 합니다.

테스트는 시스템의 의도를 설명하기 위해 작성된 것입니다. 구현 세부 사항이 테스트에 누출되지 않도록 하는 것이 권장되는 것처럼, 테스트가 코드에 영향을 미치는 것도 피해야 합니다.

이 예시에서, 원래의 query(address: string) 호출 시그니처를 그대로 유지하고, 테스트에서는 sql 객체에 대한 의존성을 처리할 수 있습니다(예: sql 객체를 모킹으로 처리). 이렇게 하면 함수 설계를 테스트와 분리할 수 있으며, 구현과 테스트가 각자의 제약 내에서 목적을 달성할 수 있습니다.

의존성 주입은 테스트에서 유효한 기법이며, 시스템이 이를 수용하도록 설계되어 있다면 반드시 활용해야 합니다. 단, 테스트를 위한 설계가 아닌, 사용자를 위한 설계를 해야 합니다.

테스트 용이성 향상

여러 이야기를 했지만, 코드를 더 테스트하기 쉽게 만들려면 어떻게 해야 할까요?

우선, 코드를 작성할 때 모범 사례를 따르는 것부터 시작하세요. 이것은 여전히 중요하며 여러 면에서 도움이 될 수 있습니다. 좋은 코드는 항상 테스트하기가 더 쉽기 때문에, 언어, CTO 또는 본인이 생각하는 “좋은” 코드에 대해 노력하세요.

하지만 좋은 코드를 작성하는 것만으로 테스트 용이한 코드를 얻을 수 있었다면, 지금 이 글을 읽고 있지는 않았을 것입니다. 테스트 용이성은 단순히 모범 사례를 따르는 것만으로는 충분하지 않다는 것입니다.

테스트 용이성은 시간이 지나면서 코드가 변경될 때 지속적으로 관찰해야 합니다. 코드의 복잡성과 테스트의 복잡성 사이의 관계를 분석해야 합니다. 모든 불일치를 코드의 설계 결정을 재검토할 기회로 삼고, 이를 변경하면 복잡성 비율이 개선될지 확인하세요.

테스트 설정에 유틸리티 함수들을 제공함으로써 테스트 환경에 투자하세요. 테스트 설정 단계에서는 코드의 복잡성에 대한 대가를 가장 많이 치르게 됩니다. 데이터베이스 모킹을 단일 함수 호출로 처리하세요. 반복적인 작업을 작은 함수로 캡슐화하세요. 동일한 테스트 스위트를 로컬과 스테이징 환경 모두에서 환경 변수를 통해 실행할 수 있도록 하세요. 시스템에 적절한 주의를 기울인다면 시스템은 부족한 설정이 무엇인지 안내할 것입니다. 이러한 모든 것들이 테스트 설정에서 느껴지는 복잡성을 제거하는 데 결정적인 역할을 할 것입니다.


🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!


Written by@[Ykss]
고이게 두지 않고 흘려보내는 개발자가 되자.

GitHubInstagramLinkedIn