트러블이슈/이약저약

테스트코드 작성기

Ahyeon, Jung 2024. 5. 16. 00:58

컴포넌트를 작성하다가 여러 util 함수를 만들었는데, 뭔가 다른 개발자분들도 쉽게 쓸 수 있게 인자랑 결과를 포함해서 알려주고 싶었다. 물론 다른 사람의 코드를 열심히 읽는게 중요하긴 하지만, 그냥 편하게 가져다 쓰게 하고 싶은 util 함수도 꽤나 많았다. JSDoc으로 어떤 함수인지, 어떤 인자를 받는지 알려주고는 있었지만, 부족함이 있었고 구구절절 다 쓰면 주석이 너무 커졌다. 그래서 처음에는 주석으로 따로 빼야하나 생각했다. 공통 컴포넌트를 스토리북으로 공유하면서 이해가 편해졌던 것처럼, 코드만으로 설명을 해주고 싶었다. 사실 스토리북도 결국 테스트를 위한건데, 이게 테스트라는 것을 인지하지 못해서 꽤나 고민을 많이 했다. 어쨌든 util 함수를 테스트 코드를 통해 상황을 알려주고 해당 상황의 결과값을 알려줘서 해당 함수의 기능을 알려주기 편했다. 물론 찾아보니, 이게 테스트 코드의 궁극적인 목적도 아니고 기초적인 로직을 테스트하는게 전부였지만, 테스트 코드에 대한 감을 얻어갈 수 있는 기회였다. 특히 TDD나 Jest 등에 대해서는 알고는 있었지만, 왜 프론트에 도입해야하는지 납득이 가지 않아서 별로 알아보려는 의지가 없었는데, 테스트 코드의 중요성을 알게 된 것 같다. 그동안 테스트 코드에 대한 질문이나 프론트엔드 테스팅과 설계 강연을 들으면서 나중에 보겠다고 그냥 모아만놨었는데, 그걸 다 열어보면서 왜 그동안 나에게 일단 경험해보라고 들이밀었는지 알게 되었다..


TDD(Test-Driven Development)

실제 코드를 작성하기 전에, 테스트 코드를 먼저 작성하는 개발 프로세스이다. 테스트를 먼저 작성함으로써, 개발자는 구현하려는 동작에 대해 먼저 생각하게 되어 특정한 요구사항에 맞는 코드를 작성할 수 있다. 또한 코드의 유지보수성을 향상시키고, 시간이 지나도 프로젝트의 유지 관리가 쉬워진다. 그리고 코드 수정 시 미처 생각지 못한 부분에 대한 문제점을 미리 파악할 수 있으며, 애플리케이션에 대한 신뢰성이 높아진다. 또한 테스트 코드가 동작 방식 혹은 결과를 설명하기 때문에 테스트 파일 자체를 문서로 활용할 수 있다.

특히 테스트 코드를 작성해두고, 코드를 커밋하기 전에 항상 CI를 설정해두면, 새로운 로직이 기존의 로직을 침법했거나 에러를 일으키지 않았는가에 대해 미리 방지할 수 있다. 그리고 여러가지 테스트 케이스를 먼저 만들어둔 뒤에 로직을 구성하면 QA 또는 실전 배포에서 생각지 못한 버그르 마주할 확률을 낮출 수 있다.

한편으로, 좋은 엔지니어링이란 인력과 시간 등의 비용 대비 창출해내는 가치의 크기를 극대화하는 것이다. 따라서 테스트에 들어가는 비용 대비 실익이 클수록 좋기 때문에, 예상이 쉬워 간단한 동작을 하기 보다는 결제 등의 중요한 로직의 경우에 테스트 코드를 도입하는 것이 좋다. 그리고 테스트 코드는 처음에 한번 도입하는 것이 아니라 꾸준히 지속 가능해야할 것이다. 이는 테스트 코드를 작성하면서 확장 가능성, 인터페이스의 중요성, 모듈의 의도 등을 이해하며 경험할 수 있다.

  1. 테스트 코드 작성: 우선 구현하려는 동작을 설명하는 테스트 코드를 작성한다. 이때, 아직 동작을 구현하는 코드를 작성하지 않아야 한다.
  2. 테스트 실행: 테스트를 실행한다. 위에 언급했듯이, 코드를 작성하지 않았으므로 실패해야한다.
  3. 코드 작성: 1번에 작성했던 설명에 부합하는 코드를 작성한다. 이때 테스트를 통과하기 위한 목적으로만 작성해야 한다.즉 아진 존재하지 않은 문제들을 생각하여 코드를 작성하기보다는 테스트 목표에 집중하여 작성하는 것이 중요하다.
  4. 테스트 실행: 테스트를 다시 실행한다.리팩토링: 테스트를 통과하면 코드를 리팩토링한다.

범위에 따른 분류

단위 테스트 Unit Test

단위 테스트는 함수나 메서드와 같은 작은 단위의 코드를 테스트하는 데 중점을 두는 테스트 유형이다. 시스템의 전체적인 동작에 중점을 두며, 단위 테스트의 목적은 코드의 각 단위가 예상대로 작동하고 요구 사항을 충족하는지 확인하는 것이다.

통합 테스트 Integration Test

통합 테스트는 시스템의 여러 단위 또는 구성 요소 간의 상호작용을 검증하는 테스트 유형이다. 서로 다른 단위가 함께 동작하면서 흐름에 맞게 잘 동작하고, 예상한 결과를 생성하는지를 테스트 한다.

E2E 테스트 End-To-End Test

E2E 테스트는 시스템의 시작부터 끝까지 전체 흐름을 확인하는 테스트 유형으로, 시스템이 예상대로 작동하고 사용자의 요구 사항을 충족하는지를 확인하기 위해 모든 구성 요소와 해당 구성 요소의 상호작용을 테스트한다. 또한 사용자의 입장에서 전체 flow가 정상적으로 동작을 하는지 확인한다.

 

  • E2E 테스트로 각 단위와 단위의 통합을 테스트 하면 안될까?

유닛 테스트, 통합 테스트에서 테스트했던 내용이 E2E 테스트로 확인가능하며 중복될 수 있지만, 유닛 테스트가 E2E 테스트보다 훨씬 빠르며, 세분화할 수록 어떤 에러가 발생했는지도 빠르게 체크할 수 있다.

대상에 따른 분류

기능 테스트

제품의 요구사항, 갖춰야하는 기능들을 검증한다.

비기능 테스트

보안, 접근, 성능과 같은 비기능적인 측면을 검증한다.

방식에 따른 분류

명세 기반 테스트 Black box test

내부가 어떻게 구현되어 있는지는 모르지만 테스트를 수행한다.

구조 기반 테스트 White box test

인프라, 코드 구조를 검증하는 테스트를 수행한다.

크기에 따른 분류

작은 테스트

단위 테스트와 유사하게 작은 범위의 테스트를 수행한다.

중간 테스트

통합 테스트와 유사하게 중간 규모의 범위를 테스트한다.

큰 테스트

E2E 테스트와 유사하게 시스템 전체를 테스트하는 큰 규모의 테스트를 수행한다.


테스트 중인 코드에 대한 생각보다는 코드가 지원하는 Use Case에 대해 더 많이 생각하라

 

테스트 코드를 작성할 때는, 구현 세부 사항을 테스트하는 것이 아니라 Use Case를 생각함으로써, 사용자가 애플리케이션을 사용하는 방식과 유사한 방식으로 테스트를 작성할 수 있다. 코드 내부의 동작 방식에 초점을 맞추는게 아닌 코드가 제공하는 기능을 테스트하는 것에 초점을 맞춰야 한다. 의존성을 식별하고 어떻게 관리할지 정의하면 좀 더 쉽게 이해할 수 있다.

 

즉, 개발자는 기능을 테스트해야 하며 구현이 드러나면 안 된다


 

내가 작성한 테스트

 테스트 코드에 대해서 조사는 거창하게 했지만, 아직 사이드 프로젝트인 단계에서 크게 무조건 도입되어야한다는 테스트 코드는 없었다. 도입하게 된 계기와 같이 내가 쓴 코드를 테스트 코드를 통해 설명해준다는 정도였다. 그럼에도 테스트 케이스를 작성하면서, 상황을 다양하게 작성할 수록 코드가 안정된다는 것을 느꼈다. 타입스크립트가 아니었다면 타입별로 상황을 잡아줬을것이기 때문에 타입스크립트의 유용함을 체감하기도 했다. 필요성을 느끼거나 납득을 해야만 해보는 것도 좋지만, 장단점을 정확히 알고 일단 한번 해보는 것도 나쁘지 않은 것 같다.

 

그리고 아직 테스트 코드에 대한 감이 제대로 잡히지 않았으며, 테스트 코드 작성의 목표가 함수에 대한 설명이 었기 때문에 함수를 열심히 찾아볼 수 있었다. 이 과정에서 충분히 외부에서 모듈로 불러와도 괜찮았는데 컴포넌트 안에서 정의하는 경우를 분리해낼 수 있었고, 여기서 비즈니스 로직에 대한 이해를 할 수 있었다. 얼떨결에 시작한 테스트 코드였고 어설픈 코드인게 느껴지지만 나름 만족한다.

import { calculateDday } from ".";

describe("나의 영양제 디데이 계산 테스트", () => {
	test("유통기한이 미래 시점인 경우", () => {
		const startDate = new Date("2024-04-23");
		const endDate = new Date("2024-04-28");
		const expectedDifference = 5;

		const difference = calculateDday(startDate, endDate);

		expect(difference).toBe(expectedDifference);
	});

	test("유통기한이 과거 시점인 경우", () => {
		const startDate = new Date("2024-04-23");
		const endDate = new Date("2024-04-18");
		const expectedDifference = -5;

		const difference = calculateDday(startDate, endDate);

		expect(difference).toBe(expectedDifference);
	});

	test("유통기한이 없는 경우", () => {
		const startDate = new Date("2024-04-23");
		const endDate = null;
		const expectedDifference = "유통기한없음";

		const difference = calculateDday(startDate, endDate);

		expect(difference).toBe(expectedDifference);
	});
});

 

처음에는 단순한 미래 시점에 대한 계산뿐이었으나, 과거 시점인 케이스와 유통기한이 없는 케이스를 통해 코드를 좀 더 안정되게 변경할 수 있었다.

import { countStar } from ".";

describe("getStarRating 함수 테스트 in StarRating", () => {
	it("올바른 채워진 별, 반 별, 빈 별의 수를 객체로 반환한다.", () => {
		const testCases = [
			{
				star: 4.5,
				totalStars: 5,
				expected: { fullStars: 4, halfStar: true, emptyStars: 0 },
			},
			{
				star: 3.7,
				totalStars: 5,
				expected: { fullStars: 3, halfStar: true, emptyStars: 1 },
			},
			{
				star: 2.2,
				totalStars: 5,
				expected: { fullStars: 2, halfStar: false, emptyStars: 3 },
			},
		];

		testCases.forEach((test) => {
			const { star, totalStars, expected } = test;
			const { fullStarsCount, halffilledStar, emptyStarsCount } = countStar(
				star,
				totalStars,
			);

			expect(fullStarsCount).toEqual(expected.fullStars);
			expect(halffilledStar).toEqual(expected.halfStar);
			expect(emptyStarsCount).toEqual(expected.emptyStars);
		});
	});
});

 

해당 함수에서 잘못된 빈 별 값을 제공할 수 있음을 확인하여 수정할 수 있었다. 테스트 코드의 로직을 한번에 이해하기 어려웠고 해당 케이스에 대한 추가적인 설명을 원한다는 의견이 있어서 아래와 같이 수정하였다.

import { countStar } from ".";

describe("getStarRating 함수 테스트 in StarRating", () => {
	test("0.5 단위 평점인 경우", () => {
		expect(countStar(4.5, 5)).toStrictEqual({
			emptyStarsCount: 0,
			fullStarsCount: 4,
			halffilledStar: true,
		});
	});
	test("반올림이면 올림 단위 평점인 경우", () => {
		expect(countStar(3.7, 5)).toStrictEqual({
			emptyStarsCount: 1,
			fullStarsCount: 3,
			halffilledStar: true,
		});
	});
	test("반올림이면내림 단위 평점인 경우", () => {
		expect(countStar(2.2, 5)).toStrictEqual({
			emptyStarsCount: 3,
			fullStarsCount: 2,
			halffilledStar: false,
		});
	});
});

 

  • jest의 매처 차이

toBe

정확한 객체나 원시 값의 일치 여부를 확인한다. 자바스크립트에서는 객체나 배열 같은 참조형 값은 참조가 같은지를 확인하는 것이 아니라 값 자체가 같은지를 확인한다.

toEqual

객체 또는 배열 등의 참조형 값에 대해 재귀적으로 비교하여 값을 확인ㅇ한다. 이는 매처의 객체의 내용이 동일한지 확인하므로, 객체의 구조가 중요한 경우에 유용하다.

toStrictEqual

toEqual과 유사하지만, 객체나 배열 등의 참조형 값의 타입도 엄격하게 비교한다. 즉, 값과 타입 모두가 일치해야만 테스트를 통과한다.

Reference

테스트 코드 도입은 정말 비효율적일까?. 실전 사례로 보는 테스트 코드 도입 | by Soo | DelightRoom | Medium

프론트엔드 테스팅과 설계 - 한재엽