OOPY에서 보시면 더욱 편하게 보실 수 있습니다!

 

테스트에 대해 알아보자 With 단위, 통합, E2E

개요

cabi.oopy.io

개요

새롭게 Java Spring으로 프로젝트 코드를 작성하면서, 테스트가 왜 필요한지, 어떻게 작성해야 하는지 궁금해졌다.

TDD(Test-Driven Development, 테스트 주도 개발)라는 말이 있을 정도로 프로그래밍에서 테스트는 중요한 것 같은데 왜인지, 어떤게 있는지 한번 알아보고자 한다.

이 글에서 다루는 테스트는 소프트웨어 애플리케이션의 테스트이다.


테스트란?

프로그래밍에서 테스트(Test)는 개발한 소프트웨어의 동작을 검증하는 과정을 말한다. 테스트는 프로그램의 요구사항(동작)을 명세하고, 품질을 확보하고, 버그를 찾아내고 수정함으로써 소프트웨어의 신뢰도를 높인다.

개인적인 생각으로 테스트는, **‘요구사항에 따른 동작과 그 작용에 대한 명세’**라고 생각한다.


왜 테스트를 해야 하는가

“어떤 것을 이해하는 좋은 방법 중 하나는, 그것의 반대의 경우에 대해서 생각해보는 것이다 - 교수님"

테스트가 없을 때와 있을 때를 생각해서, 왜 필요한지에 대해서 생각해보자.

테스트가 없다면?

테스트가 실패하지 않는다..?

  1. 코드가 정상 작동하는지 확인하기 힘들다. → 원하는 결과를 제대로 뱉는지, 버그가 있는지 없는지를 알기 힘들다. → 무한 print의 반복, 동작 확인을 위한 코드에 이리저리 추가 코드 심기..
  2. 작성한 코드가 무슨 일을 하는지 이해하기 어렵다. → 명시된 사항이 없으므로, 그 내부에서 어떤 일이 일어나는지, 어떤 동작을 하는지 알기 어렵다. → 반환 값을 일일이 찍어보고.. 변경된 값이 반영됐는지 DB를 파보고.. ⇒ 협업을 함에 있어서 다른 사람이 내 코드가 무얼 하는지 이해하기 힘듦을 의미한다.
  3. 코드의 변경 사항을 반영하기가 어렵다(⭐️⭐️⭐️). → 기존의 동작을 보장하는 코드(테스트)가 없기 때문에, 변경 사항이 온전히 이전과 같게 동작하는지 알 수 없다. → 리팩터링을 하더라도, 그 동작이 정상적인지를 보장해주는 것(테스트)이 없다. → 기능을 추가하더라도, 해당 기능이 다른 것들에 영향을 주는지 명확히 알 수 없다. ⇒ 변경사항을 반영하기 어려워지고, 반영하더라도 안전성을 보장할 수 없다.

테스트가 있다면?

기도는 테스트에게 하면 된다.

  1. 코드가 어떤 요구사항을 반영했고, 각 케이스에서 어떻게 작동하는지 확인할 수 있다. → 정해진 결과(요구사항)에 대한 검증을 수행하기 때문에, 이에 대한 명세처럼 작성된다.
  2. 다른 동료가 내 코드에 대해 이해하기가 수월해진다. → 어떤 케이스에 어떻게 동작하는지 명시되어 있으므로, 코드가 하는 일을 쉽게 이해할 수 있다.
  3. 간접적으로 내 코드에 대한 문제점을 파악할 수 있다. → 테스트가 이해하거나 작성하기 어렵다면, 본인의 코드가 잘못 됐을 수 있다. → 심사위원의 마음가짐으로 바라보면, 놓친 예외 사항들이 떠오를 수 있다.
  4. 코드의 변경에 어려움이 적어진다(⭐️⭐️⭐️). → 변경한 코드가 기존의 동작과 부합하는지 바로 확인할 수 있다. → 새 기능을 추가했을 때, 다른 코드들의 동작에 영향을 미치는지 확인할 수 있다.

물론, 위 내용은 테스트 코드가 잘 작성되었을 때의 얘기다.

테스트 작성의 단점

테스트를 잘 테스트 하는지에 대한 테스트를 테스트해보겠다.

  1. 테스트 코드 작성도 결국 추가적인 개발이다. → 또 작성해? → 유지보수..
  2. 완벽한 코드는 없다. 그리고 그것은 테스트도 마찬가지!
  3. 테스트 작성에 매몰될 수 있다.
  4. 테스트 작성을 위한 라이브러리와 작성법의 러닝커브..

하지만 이 모든 것을 커버할 수 있는 효용성..!을 가지고 있기에, 우리는 테스트를 해야한다.

테스트의 중요도

백엔드와 프론트엔드가 협업할 때, API 명세가 정말 중요하듯이, 테스트 또한 중요하다. API 명세가 백엔드와 프론트엔드 간의 요구사항과 그에 대한 약속이라면, 테스트는 클라이언트와 소프트웨어 간의 요구사항과 약속을 담아내기 때문이다.

즉, 테스트는 부수적인 것이 아니라 소프트웨어의 비즈니스 로직을 검증하는 것이다. → 필요하다면 코드의 구조가 바뀔 수 있는 것이다.

결론적으로, 안전하고 좋은 프로그래밍을 위해서라면, 테스트는 반드시 수반되어야 하는 것이다.


테스트 방법론

그렇다면 어떤 방식으로 테스트를 해야하는 걸까?

주로 다음과 같이 말하는 방법들이 있다.

단위(Unit)

**단위 테스트(Unit Testing)**는 개별적인 코드 단위(보통 함수 또는 메서드)가 의도한 대로 정확하게 작동하는지를 확인하는 테스트다.

단위 테스트는 독립적으로 테스트하므로 다른 부분(특히 외부 의존성)의 영향을 받지 않고 코드의 품질을 검증할 수 있고, 그렇게 해야한다.

각 단위 테스트는 다른 테스트와 독립적으로 실행되어야 한다.

이는 특정 코드가 예상대로 작동하는지를 확인하기 위해 다른 부분에 의존하지 않는 것을 의미하는 것이다.

계산기와, 계산기의 덧셈을 생각해보자.

// 테스트 할 클래스
public class Calculator {
    public int sum(int a, int b) {
        return a + b;
    }
}
/*-------------------------------*/
// 단위 테스트
public class CalculatorUnitTest {

    Calculator calculator = new Calculator();    

    @Test
    void 의존하지_않는_덧셈() {
        int result = calculator.sum(2, 3);
        assertEquals(5, result);
    }

		@Test
		void 다른_함수에_의존하는_곱셈() {
				Calculator calculator = new Calculator();
        int result = calculator.multiply(calculator.sum(2, 3), 4);
				/* 이런 식으로 테스트에 다른 함수(calculator.add)가 낑기면, 이 함수가 바뀌었을 때 이 테스트도 영향을 받는다. */
				/* 물론 지금 상황에서는 sum이 반드시 덧셈만을, multiply가 곱셈만을 잘 해내야하는 것이 암묵적으로 제시되고 있다.*/
        assertEquals(20, result);
		}
}

다른 테스트와 의존하지 않고, 본인(클래스)자체의 주어진 기능만을 테스트하므로, 자체적인 변화가 생기지 않는다면 테스트가 변하지 않는다 - 만약 변화가 있다면 그것은 테스트가 의존성을 갖거나, 코드 구조가 잘못되었음을 나타낸다.

다른 테스트, 코드와 의존성을 갖지 않게끔 작성하려고 노력하다보면, 기존 코드의 문제점을 발견할 수 있고, 특히 유닛 테스트의 이러한 특성을 잘 활용하면 각 단위(유닛)들이 순수 함수(동일한 입력에 대해 항상 동일한 출력)와 같이 구성될 수 있게끔 간접적인 도움을 받을 수 있다.

만약 가장 작은 단위부터 단위 테스트를 잘 작성한다면, 그것들을 이용하는(주입 받는) 더 큰 클래스들의 유닛 테스트를 작성할 때, 더욱 걱정이 없을 것이다. → 테스트가 실패한다면 그 책임은 반드시 그 클래스와 테스트에 있는 것이 확정적이므로.

통합(Integration)

**통합 테스트(Integration Testing)**는 여러 구성 요소 또는 모듈을 결합한 동작을 테스트하는 것이다. 개별적인 컴포넌트가 상호작용하는 방식, 데이터 흐름, 의존성과의 상호작용 등을 검증하여 시스템이 예상대로 작동하는지 확인하는 것을 목표로 한다.

이는 개별 모듈이 독립적으로 테스트(단위 테스트)된 후, 서로 연결된 상태에서 테스트되는 단계다.

즉, 단위 테스트는 해당 클래스가 갖는 메서드들의 로직 자체에서 행위에 대한 케이스들을 얘기했다면, 통합 테스트는 상위의 케이스들(여러 의존성과 외부 자원들과 엮여 있는)을 통해 상태, 특히 데이터 동기화를 검증하는 것이다.

계산기를 이용해서 상품들의 총 가격을 계산하는 캐셔를 생각해보자. (간소화해서 표현한 코드이므로 실제 구현과 다를 수 있음)

// Calculator에 의존하는 Class
public class Cashier {

	// 캐셔는 계산기가 필요하다.
	private final Calculator calculator;

	public int sumTotalItemsPrice(List<Item> items) {
		int total = 0;
		for (Item item : items) {
			total = this.calculator.sum(total, item.getPrice());
		}
		return total;
	}
}
/*-------------------------------*/
public class Item {
	private final String name;
	private final int price;
}
/*-------------------------------*/
class CashierIntegrationTest {

	Cashier cashier;
	List<Item> items;

	// 캐셔에게 계산기를 쥐어준다.
	@BeforeEach
	void setUp() {
		Calculator calculator = new Calculator();
		cashier = new Cashier(calculator);
	}

	// 천원짜리 개껌, 2천원짜리 딸랑이의 총합은 3000원이다.
	@Test
	void test() {
		items = List.of(
							Item.of("개껌", 1000),
							Item.of("딸랑이", 2000));
		int result = cashier.sumTotalItemsPrice(items);
		assertEquals(3000, result);
	}
}

위 코드에서 개별적인 컴포넌트는 [Calculator, Cashier]다.

이 통합 테스트는 Cashier가 Calculator에 의존하는 상황에서(연관관계), 캐셔가 계산기를 들고 금액을 잘 계산하는지를 체크하는 것, 즉 의존성과 상호작용이 예상한대로 잘 이뤄지고 있는 지를 검사하는 것이다.

만약 Calculator에 대한 단위 테스트가 잘 작성되지 않아서 로직에 문제점을 내포하고 있을 경우, Cashier 또한 영향을 받게되므로, Calculator의 단위 테스트가 선행되어야 한다.

통합 테스트가 단위 테스트와 다른 점은, 클래스의 메서드 하나하나의 순수한 작용을 확인하는 것이 아닌, 상호작용이 이뤄지는 부분에서 객체 간 서로 주고 받는 데이터 혹은 상태가 원하는대로 표현되는지를 본다는 점이다.

Cashier에 대한 단위 테스트를 작성하려고 한다면, Calculator에 의존적이지 않은 캐셔의 행위자체(메서드)에 초점을 맞추어서 검증할 수 있도록 Calculator 동작을 Mock(흉내내기)를 해줘야할 것이다. → 그렇지 않다면, Calculator가 바뀔 때마다 Cashier의 단위 테스트가 영향을 받게되고 테스트 코드를 수정하게 될 것이다.

위처럼 통합 테스트와 단위 테스트를 구분하여 작성한다면, 우리가 작성한 Calculator의 sum이 더하는 것이 아니라 빼는 로직으로 바뀌는 경우에 Cashier의 행위를 검증하는 단위 테스트는 예전처럼 성공하고, 통합 테스트는 실패할 것이다. 왜냐하면 우리가 예상하는 sum의 동작은 더하기이지 빼기가 아니기 때문이다. 이는 테스트 코드가 요구 사항에 대한 명세로서 잘 작동하고 있는 것을 의미한다.

E2E(End-Point to End-Point)

E2E, 종단 간 테스트는 소프트웨어 시스템이나 애플리케이션의 전체적인 동작을 사용자의 관점에서 완전히 시험하는 종합적인 테스트 방법을 말한다.

이는 실제 사용자의 행동을 모방하고, 사용자 인터페이스와 다른 컴포넌트 간의 통합과 상호작용을 테스트하며, 전체 시스템이 예상대로 동작하는지를 검증하는 과정이다.

예를 들어, ‘구매시 포인트 적립’을 E2E로 테스트한다고 해보자.

구매에 대한 요청이 들어왔다면, Cashier를 거쳐 구매가 이뤄지고, 이 총 금액에 대한 포인트를 계산하고, 실제 적립을 수행(DB에 포인트에 대한 정보 저장)이 수행되는지를 확인하는 것이 E2E 테스트이다.

이 부분은, 웹 앱의 경우 프론트엔드의 요청을 통한 백엔드의 컨트롤러(주로 HTTP 요청을 받아서 처리하는 계층)부터, DB까지 연결되어 있는 하나의 큰 서비스 흐름을 확인하는 것이다.

통합과 E2E, 뭐가 다를까?

이 단락에서 작성하는 내용은 여러 글들을 읽어보고 달랐던 뉘앙스들에서 헤매이다가 내가 잠정적으로 내린 결론이다.

통합 테스트보다, E2E 테스트가 더 상위의 흐름을 갖는다고 생각한다. 단위 테스트를 끌고와서 그 단계로 생각해보면, 다음과 같다.

E2E > 통합 > 단위

즉, 단위 테스트를 통해 각 함수와 메서드들의 동작이 잘 이뤄지는지 확인하고, 통합 테스트를 통해 내부 요소들의 연관관계와 상호작용이 원하는대로 동작하는지 확인한 다음, E2E 테스트를 통해 사용자 - 서비스(프론트-백-DB)의 작용이 의도한 바대로 작동하는지 테스트한다는 것이다.

통합 테스트는 주로 애플리케이션 내부의 요소들(계층별로 나누어진 클래스들, 객체와 객체)의 작용을 테스트하는 것이고, E2E는 외부 서비스를 포함한 더 넓은 범위의 테스트라는 점에서 다르다.


정리

단위, 통합, E2E 테스트에 대해서 나름대로의 기준을 가지고 구분해보았다.

테스트의 필요성은 여러번 강조해도 부족하지 않을 것 같다.

테스트를 작성하는 습관, 그리고 테스트를 잘 작성하는 방법에 대해서 고민해보고, 그 효용에 대해서 더 고민해봐야 할 것 같다.

다음 글 : JUnit을 이용한 테스트 작성해보기(작성 예정)

참고 자료

[TIL] Unit test, Integration test, e2e test 그리고 TDD

인수 테스트와 E2E 테스트 차이

E2E 테스트 도입 경험기 | 카카오엔터테인먼트 FE 기술블로그

테스트 코드는 왜 만들까? | 요즘IT

End-to-end vs integration tests: what's the difference?

복사했습니다!