디자인 시스템 테스트 파헤쳐보기


최근 사내에서 프론트엔드 진영에서의 테스트 코드를 주제로 스터디를 진행하고 있습니다. 이번엔 직접 오픈소스의 테스트 코드를 들여다보고 분석해보는 시간을 가지기로 했는데요, 제가 눈여겨보던 오픈소스는 Ark UIMantine 두 가지였습니다.

저는 형태를 포함한 디자인 시스템을 개발하고 있기 때문에, 처음에는 관련 인사이트를 얻기에 적합한 Mantine에 더 관심이 갔습니다. 하지만 두 디자인 시스템의 테스트 코드 작성 방식이 서로 다르고, 각기 필요한 부분만 뽑아 적용해보는 것도 의미 있겠다고 생각해 결국 두 가지 모두 살펴보기로 결정했습니다.

그래서 이번 글에서는 Ark UI와 Mantine의 테스트 코드를 분석하면서 알게 된 점과 이를 바탕으로 어떻게 더 나은 테스트 코드를 작성해보면 좋을 지 정리해보려고 합니다.

테스트하기 좋은 코드

시작하기 전에, 최근에 향로님의 테스트하기 좋은 코드 작성하기에 대한 강연을 들을 기회가 있었습니다. 강연에서는 테스트하기 어려운 코드의 특징과, 이를 해결하기 위한 방법들에 대해 많은 인사이트를 공유해주셨습니다. 본격적인 내용에 들어가기에 앞서 Mantine에서 테스트 코드를 작성하고 있는 방법이 강연 내용과 굉장히 유사하다고 느껴, 테스트하기 좋은 코드를 작성하기 위한 몇 가지 핵심 포인트를 간단히 공유하고 넘어가려고 합니다.

의존성 주입

테스트를 어렵게 하는 데에는 다음과 같은 것이 있습니다.

  • 제어할 수 없는 값에 의존하는 경우
  • 사이드 이펙트를 발생시키는 경우
  • 테스트하는 속도가 느린 경우

의존성 주입은 그 중 제어할 수 없는 값에 의존하는 경우를 개선하기 위해 가장 먼저 떠올릴 수 있는 방법입니다.

const discount = (order: Order) => {
  const now = new Date();
 
  if (now.getDay() === DayOfWeek.SUNDAY) {
    return order.price * 0.9;
  }
 
  return order.price;
};

discount라는 함수에서 사용 중인 Date 객체는 매 순간 변화하는 시간과 날짜에 관한 정보를 제공합니다. 이 때, 시간은 지금 이 순간에도 끊임없이 흘러가고 있기 때문에 내가 임의로 제어할 수 없는 값이라 할 수 있습니다. 그래서 위 함수를 테스트할 때 일요일에 대한 상황을 테스트하려 한다면, 실제 일요일이 될 때까지 기다려야 하므로 테스트하기가 굉장히 까다로울 것입니다. 더 큰 문제는 discount 함수를 사용하는 모든 다른 함수에서도 동일한 문제가 발생한다는 것입니다. 결국, 테스트의 어려움은 더 큰 영역으로 전파된다는 것을 알 수 있습니다.

이를 개선하기 위해서, 제어할 수 없는 값을 외부에서 주입해보도록 하겠습니다.

const discount = (order: Order, now: Date) => {
  if (now.getDay() === DayOfWeek.SUNDAY) {
    return order.price * 0.9;
  }
 
  return order.price;
};
 
// 테스트 코드
it('12월 22일', () => {
  const order: Order = { price: 10_000 };
 
  const now = new Date('2024-12-22');
  const result = discount(order, now);
 
  expect(result).toBe(9_000);
});

이제 now라는 값을 외부에서 주입하고 있기 때문에, 쉽게 제어할 수 있고 테스트하기에도 용이해졌습니다. 이처럼 제어하기 어려운 값에 대한 코드는 최대한 계층의 바깥 영역으로 이동시키고, 의존성을 주입하는 것이 좋습니다.

상위 계층으로 밀어내기

코드를 작성하다 보면 외부 환경에 의존해야 하는 상황이 자주 생깁니다. 대표적으로 API를 호출하거나 데이터베이스에 접근하는 경우를 예시로 들 수 있습니다.

describe('주문을 생성한 뒤 취소하면 취소 주문이 생성된다.', () => {
  const order = orderRepositary.save(createOrder()); // DB 접근
  order.cancel(); // DB 접근
 
  const result = await orderRepositary.find(orderId); // DB 접근
 
  expect(result).xxx;
});

위 코드는 주문을 생성한 뒤 취소했을 때, 취소 주문을 생성하는 경우에 대한 테스트입니다. 주문을 생성했을 때 실제로 DB에 데이터가 적재되고, 취소했을 때와 취소 주문을 찾을 때 역시 마찬가지입니다. 이처럼 테스트 할 때 실제 데이터베이스에 변경을 발생시킨다면, 이는 안정성과 속도 측면에서 좋지 못한 테스트 코드라고 할 수 있습니다.

이를 해결하기 위해 데이터베이스를 모킹하자니, 테스트할 때마다 불필요한 보일러플레이트 코드를 작성해야 한다는 번거로움이 있습니다. 게다가 모킹을 사용한다는 것은 모킹 대상에 대한 검증 책임을 완전히 대상에게 전가하는 것과 마찬가지여서, 만약 대상의 테스트가 제대로 이루어지지 않아 문제가 발생하더라도 이를 정확하게 감지하기 어렵습니다.

const createOrder = (options: Options) => {}; // 주문 생성 로직
 
const cancel = (orderId: string) => {
  const order = getOrder(orderId);
  const options = {
    amount: order.amount * -1,
    status = 'cancel',
    description = order.description,
    // ...
  };
 
  const cancelOrder = createOrder(options);
 
  return cancelOrder;
};

이 경우, 의존성을 주입하는 방식처럼 문제의 코드를 상위 계층으로 이동시킬 수 있습니다. order.cancel()에서는 취소 주문에 필요한 데이터를 생성하고, 이를 실제로 데이터베이스에 적재하는 부분은 Service 계층으로 전가하는 것입니다. 그렇게 했을 때 다양한 형태의 취소 주문 케이스를 단위 테스트로 검증할 수 있어 속도가 빠르고 유지보수도 용이합니다. 취소 주문 생성 로직은 이미 단위 테스트로 충분히 검증되었기 때문에, 통합 테스트에서는 실제 데이터가 적재되는지만 확인하면 됩니다. 이는 모든 코드를 테스트하기 용이하게 하는 것이 어렵기 때문에, 테스트하기 어려운 코드를 최소화하는 전략이기도 합니다.

검증의 책임 분리

하나의 함수가 여러 기능에 대한 책임을 지고 있다면, 이를 관심사에 맞게 분리할 수 있습니다. 특정 함수가 담당하는 테스트 개수가 적어진다면, 유지보수에도 용이하고 테스트 속도 역시 개선될 수 있습니다.

이와 관련하여 향로님께서 경험에 기반하여 세운 원칙 중 하나로 비공개 메서드 및 함수가 많아진다면 새로운 공개 인터페이스 생성에 대해 고려할 것을 말씀해주셨습니다.

Mantine / Ark UI

앞서 살펴 본 테스트하기 쉬운 코드를 작성하는 방법의 예시는 사실 서버 로직에 조금 더 가까운 내용이었습니다. 하지만 프론트엔드에서, 더 나아가 디자인 시스템에서 테스트 하기 쉬운 코드를 작성할 때도 그 본질은 달라지지 않습니다.

  • 의존성을 주입하기
  • 테스트하기 어려운 코드를 상위 계층으로 이동하기
  • 기능 별로 분리하여 검증하기

서론이 많이 길었는데, 그렇다면 Ark UI와 Mantine은 이러한 원칙을 어떻게 테스트 코드에 녹였고, 그런 테스트 코드를 작성하기 위해서 어떻게 컴포넌트를 만들고 있는지 알아보겠습니다.

확장 가능한 컴포넌트

최근, 디자인 시스템 생태계에서는 컴포넌트를 확장 가능하게 만들기 위해서 다양한 방법을 적용하고 있습니다. 그 중, 컴포넌트 합성과 관련한 가장 인기 있는 방법은 Render Delegation이라는 패턴과 Polymorphic Component 방식인 것 같습니다. 실제로 Radix UI, Mantine, Ark UI 등 많은 오픈소스 디자인 시스템 라이브러리에서는 두 가지 방식 중 하나를 사용 중에 있고, 대부분 해당 로직을 분리하여 각 컴포넌트에 적용하고 있습니다.

뜬금없이 이런 이야기를 하는 이유는, 이렇게 함수의 역할을 분리하는 것이 앞서 언급했던 테스트를 쉽게 만드는 방법 중 하나라고 생각해서입니다. 이 부분은 Ark UI에서 잘 표현되어 있는데요, 관심이 있으신 분들은 코드를 직접 확인해보시는 것도 추천드립니다. 구현체에 대한 설명은 해당 포스트의 주제와 동떨어져 있는 것 같아, 여기선 생략하겠습니다.

다시 주제로 돌아가, Ark UI에서는 Render Delegation 패턴의 컴포넌트를 factory라는 이름의 함수로 만들고 있습니다. 그리고 factory 함수에 대한 단위 테스트를 작성하여 해당 함수의 신뢰성과 안정성을 높이고 있습니다. 이제 factory 함수를 통해 생성된 다른 어떠한 컴포넌트에서도 부모의 렌더링 책임을 자식 요소에게 전가하는 기능에 대해 테스트할 필요가 완전히 사라지게 된 것입니다. 이 부분이 바로 앞서 이야기 했던 원칙 중, 역할을 분리함으로써 테스트하기 쉬운 코드를 작성한 부분이 아닐까 생각했습니다.

Mantine에서도 동일하게 Polymorphic한 컴포넌트 생성 로직을 별도의 factory 함수로 분리하고 있습니다. 다만, 궁금한 점은 해당 로직에 대한 테스트 코드가 따로 존재하지 않는다는 것입니다. 정확한 이유는 찾지 못했지만, 그들이 생각하기에 너무나도 당연한 코드이거나, 실제 UI에서 렌더링 되는 모습을 확인하는 것만으로 충분하다고 판단했을 수도 있습니다. 혹은 해당 로직에 변경이 발생하지 않을 것이라 보고 리팩토링이 필요 없다고 생각했을 수도 있겠죠. 하지만 개인적으로는, 코드가 언제 어떻게 변경될지 예측하는 것이 사실상 불가능하기 때문에 이런 작은 단위의 코드라면 더더욱 테스트 코드를 작성하는 것이 좋은 것 같습니다.

빈약한 형태 테스트

Mantine 디자인 시스템은 Box라는 다형성이 보장된 컴포넌트를 기본으로 삼고 있습니다. 이후 생성되는 다른 컴포넌트들은 이 Box에 각자의 역할에 맞는 기능을 추가하는 형태로 구성됩니다. 결국 모든 컴포넌트들에서 동일하게 적용되어야 하는 부분은 Box에서만 테스트하면 되기 때문에 테스트 코드가 더욱 간결해지면서도 안정성은 여전히 보장된다는 이점이 있습니다. 실제로도 Mantine의 Box 컴포넌트에서는 전달 받은 style prop이나, 사용자가 임의로 추가한 dataset 속성 등을 검증하고 있지만, 다른 컴포넌트들에서는 각자의 고유 기능에 대한 테스트 코드만이 존재합니다.

같은 맥락에서 형태에 관한 로직은 useStyles라는 Custom Hook이 담당하고 있습니다. 여기서는 props로 전달 받은 style 속성과 className 속성을 상황에 맞게 잘 조합하여 반환하고 있습니다.

한 가지 특이하다고 생각했던 점은, 실제로 DOM 상에서 컴포넌트에 적용된 스타일이 어떤지 검사하는 것이 아니라는 점이었습니다. 대신, 최종적으로 생성된 style 객체의 구조와 className 배열에 포함된 문자열을 검사하는 식으로 테스트가 진행되고 있었습니다. 실제 스타일을 적용하는 역할은 브라우저에게 위임하는 방식인 것 같았습니다. 다만 이런 방식으로 테스트를 진행하면 CSS 파일에서 특정 스타일 속성을 누락하는 휴먼 에러가 발생했을 때, 이를 안정적으로 검증하지 못할 가능성이 있다는 생각을 했습니다.

시각적 안정성 보장하기

이 문제를 해결하기 위해서는 시각적 회귀 테스트 방식을 도입하는 것이 좋은 것 같습니다. 시각적 회귀 테스트는 작성한 코드가 변경되면서 UI에 변화가 생겼을 때, 이를 시각적으로 확인할 수 있는 테스트 방식입니다. 코드를 변경하다 보면 내가 수정한 코드가 다른 영역에 영향을 미치는 경우가 있습니다. 단위 테스트를 잘 작성해두면 기능적인 부분에 대한 안정성은 보장할 수 있지만, 변경이 굉장히 빈번하게 발생하는 형태까지 단위 테스트로 검증하는 것은 불필요하게 리소스가 많이 드는 작업인 것 같습니다.

시각적 회귀 테스트는 기존에 찍어두었던 스냅샷과 현재 변경된 코드의 UI를 픽셀 단위로 비교해서 변경을 감지합니다. 그렇기 때문에 예상치 못하게 변경이 발생한 부분을 쉽게 찾아낼 수 있고, 어느 지점에서 변경이 발생했는지도 직관적으로 확인할 수 있기 때문에 안정성 뿐만 아니라 개발자 경험을 향상시키는 데에도 큰 도움이 될 것 같습니다.

시각적 회귀 테스트는 Cypress, Chromatic, Playwrite, BackstopJS 등의 다양한 도구를 이용할 수 있는데, 그 중 상황에 맞는 것을 선택하면 될 것 같습니다. 저는 최근 Playwrite의 사용 경험이 좋았다는 이야기를 많이 들어서, 개인적으로 공부해보고 싶은 생각이 있습니다.

형태를 테스트하는 것에 대해서는 디자인 시스템에 테스트 코드를 작성해보고 싶다고 생각했을 때부터 궁금했던 내용이기도 합니다. RTL이라는 좋은 UI 테스팅 도구가 있다곤 하나, 상황에 따라 너무나도 가변적으로 변하는 것이 바로 형태이기 때문에 이를 일일이 테스트하는 것은 오히려 비효율을 낳는 것이 아닌가 생각하기도 했습니다. 이번 기회에 Mantine을 분석하고 스스로 고민하는 과정을 거치면서, 이제는 이 부분에 대해 꽤 명확한 해답을 얻은 것 같습니다.

디자인 시스템 원칙에 대한 테스트

Mantine의 컴포넌트 테스트 파일을 보면 itSupportsSystemProps가 항상 상단에 존재합니다. 이는 Mantine에서 정의한 System Props들에 대한 테스트로, 작은 단위의 테스트 코드 집합을 모듈화 한 것입니다.

왜 Mantine이 이런 방식을 채택했을까 고민해본 결과, 다음과 같은 이유를 떠올릴 수 있었습니다.

디자인 시스템은 각기 고유한 패턴을 포함하고 있습니다. 이러한 패턴은 디자이너와 개발자의 생산성을 극대화하고, 두 집단 간의 의사소통 비용을 최소화할 수 있어야 합니다. 또한, 컴포넌트, 디자인 토큰, 가이드라인 등 디자인 시스템의 모든 요소에 일관되게 적용되어야 합니다. 따라서 모든 테스트 코드에서 이 패턴을 검증하는 것은 필수적이지만, 이는 동시에 반복적인 보일러플레이트 코드를 생성하는 등 번거로움을 초래할 수도 있습니다. Mantine은 자신들이 채택한 System Props라는 패턴을 검증하기 위해 itSupportsSystemProps라는 모듈화된 테스트 코드를 사용합니다. 이를 통해 System Props라는 중요한 패턴을 간단하고 효율적으로 검증하고, 디자인 시스템의 일관성과 원칙을 유지할 수 있게 한 것으로 보입니다.

맺음

프론트엔드 진영에서의 테스트 코드는 여전히 많은 논쟁이 이어지고 있습니다. 하지만 디자인 시스템은 비교적 독립적인 개체를 다루는 경우가 많아, 다른 제품에 비해 테스트 코드를 도입하기가 상대적으로 수월하다는 생각이 들었습니다.

또한, 디자인 시스템은 개발자를 위해 제공되는 제품인 만큼, 안정성이 보장될수록 더 많은 사용자를 유입할 수 있다고 믿습니다. 그래서 앞으로 테스트 코드를 더 깊이 공부하고 실무에도 적극적으로 적용하여, 제가 만든 제품이 더 많은 사람들에게 사랑받고 인정받을 수 있도록 노력하겠습니다.