본문 바로가기

기타

단위 테스트에서 테스트 코드를 조직화하는 방법

요즘 "자바와 JUnit을 활용한 실용주의 단위 테스트"라는 책을 읽고 있는데 그동안 프로그램을 개발하는 여러 도서를 읽어봤지만 이런 소프트웨어 공학적인 내용은 처음이라 차근차근 읽으면서 많은 것을 배우고 있다. 지난 학기(2019-2)에는 소프트웨어 공학 과목을 배우면서 단위 테스트, 통합 테스트 같은 내용도 배웠기 때문에 기억하고 있었는데 객체지향 프로그래밍 과목에서 프로그램을 설계하면서 기능을 테스트하는 용도로 JUnit을 사용하는 것을 보고 안그래도 안드로이드 프로그래밍 등으로 익숙해진 자바 언어를 사용하는 JUnit을 이용해 단위 테스트를 한번 경험해보자는 목적으로 읽게 되었다.

도서에서 설명하는 JUnit은 버전 4였지만 요즘 이클립스에는 5버전까지 포함되어 있다.

JUnit으로 어떻게 테스트를 진행하고 어떤 기능이 있는지는 추후 도서를 다 읽은 다음에 쓰는 게 맞다고 생각하지만 읽던 중 중간에 어떻게 테스트 코드를 잘 조직하고 구조화할 수 있는지에 대해 다루는 내용이 있어 기록할 만한 가치가 있다고 생각하여 포스트를 작성하게 되었다.

  • 준비(Arrange) - 실행(Act) - 단언(Assert)을 사용하여 테스트를 가시적이고 일관성 있게 만드는 방법
  • 메서드를 테스트하는 것이 아니라 동작을 테스트하여 테스트 코드의 유지 보수성을 높이는 방법
  • 테스트 이름의 중요성
  • @Before와 @After 애너테이션을 활용하여 공통 초기화 및 정리 코드를 설정하는 방법
  • 거슬리는 테스트를 안전하게 무시하는 방법

AAA로 테스트 일관성 유지

AAA는 다음 단계의 약자를 딴 것이다.

  1. 준비(Arrange): 테스트 코드(@Test)를 실행하기 전 시스템을 적절한 상태로 초기화하는 것.
  2. 실행(Act): 실제로 테스트 코드를 실행하는 것
  3. 단언(Assert): "실행"한 코드가 실제로 동작하는지 확인하는 것.

추가로 사후(After) 단계가 붙기도 한다. 이때는 할당된 자원의 해제 등을 수행한다.

이를 코드로 보이면 다음과 같다.

public class AccountTest {
	// -------------------- [ Arrange ] -----------------------
	Account account;
	
	@Before
	public void init() {
		account = new Account("Jason"); 
	}
	// --------------------------------------------------------
    
	@Test
	public void insertTest() {
    	// -------------------- [ Act ] -----------------------
		account.insert(1000);
        // ----------------------------------------------------
        
        // -------------------- [ Assert ] -----------------------
		assertTrue(account.getAmount() == 1000);
        // -------------------------------------------------------
	}
}

위와 같이 세 단계로 나누었는데(주관적이므로 정확하지 않음) 은행 계좌 클래스의 인스턴스를 가지고 테스트해야 하므로 은행 계좌의 인스턴스를 생성하는 것이 모든 테스트의 기반이 된다. 그러므로 테스트 클래스에 은행 계좌의 인스턴스를 선언하였으며 @Before 애너테이션을 사용하여 인스턴스를 생성함으로써 준비(Arrange)작업을 수행하였다.

 

실제로 테스트를 하려면 어떤 작업을 수행한 후에 그 결과를 비교해야 하므로 생성한 인스턴스에 대해서 메소드를 호출하거나 다른 라이브러리등을 사용하여 실행(Act)한다. 여기서는 은행 계좌에 insert() 메소드를 통해 예금을 1000원 입금하였다.

 

이제 확인해야 할 것은 은행 계좌에 입금한 만큼의 돈이 들어갔는가다. 그러므로 기본적인 단언(Assert) 메소드인 assertTrue()를 사용하여 은행 계좌의 잔액이 1000원인지 확인하는 단언(Assert)과정을 거쳤다.

 

이렇게 세 단계로 나눔으로써 여러 테스트 메소드 간 중복되는 작업(객체 생성 등)을 간편하게 준비시킨 후 각 테스트 메소드에서 이를 실행, 검증하는 과정을 거치며 가시적이고 일관성 있는 테스트를 수행할 수 있게 된다.

동작 테스트 vs 메소드 테스트

위의 테스트 메소드에서는 단순하게 계좌에 돈을 입금하는 메소드(insert())만 확인하고 있다. 하지만 단순히 메소드 하나를 테스트 하기 위해 단위 테스트를 활용하는 것은 부적절하다. 테스트를 작성할 때는 이 클래스가 어떻게 동작하는지, 잘 동작하는지 검증할 수 있도록 클래스의 동작을 테스트하는 전체적인 시각을 가져야 한다.

	@Test
	public void accountTest() throws NotEnoughAmountException {
		account.insert(500);
		account.withdraw(320);
		assertTrue(account.getAmount() == 180);
		
		account.insert(10);
		account.insert(50);
		account.insert(100);
		account.withdraw(account.getAmount());
		assertTrue(account.getAmount() == 0);
	}

예시로 사용한 은행 계좌 클래스는 단순히 입금, 출금 메소드밖에 존재하지 않지만 여러번의 입금, 출금 동작을 테스트하는 메소드를 작성하여 클래스가 정상적으로 동작하는 것을 확인하였다.

테스트와 프로덕션 코드의 관계

검증 대상인 실제 소스 코드를 프로덕션 코드(또는 SUT, System Under Test, 테스트 대상 시스템)라 할 때 JUnit 테스트 코드는 프로덕션 코드와 같은 프로젝트에 위치할 수 있다. 보통 Eclipse에서 JUnit Test Case를 생성하면 프로젝트 내부에 test 폴더를 만들어서 그곳에 생성하곤 한다. 그러나 테스트 코드는 프로덕션 코드와 분리되어야 하며 프로덕션 코드와 테스트 코드는 일방향성 의존 관계를 가져야 한다.

일방향적 관계

간단히 말하면 테스트 코드는 프로덕션 코드의 존재를 알고 작성된 클래스의 메소드를 활용하면서 테스트를 진행하기 때문에 이에 의존하는 관계지만 그 반대는 해당하지 않는다는 것이다. 그렇지만 테스트를 작성하는 행위가 프로덕션 시스템의 설계에 영향을 주지 않는다는 것은 아니며 오히려 설계와 단위 테스트를 둘 다 신경써서 갈수록 테스트 친화적으로 시스템을 설계하는 것이 더 바람직하다.

 

그렇다면 이 코드들을 어떻게 분리해야 할까? 이 도서에서도 사용하고 권장하는 방법은 테스트 코드를 프로덕션 코드와 같은 패키지에 넣지만 별도의 디렉터리로 분리하는 것이다.

분리된 테스트 코드와 프로덕션 코드

JUnit-Chapter3라는 프로젝트 아래에 src, test 폴더가 존재하며 이들 각각 bankaccount라는 패키지를 가지고 있다. 프로덕션 코드는 src, 테스트 코드는 test 폴더가 해당할 것이다. 이때 test 디렉터리 구조는 src 디렉터리 구조를 반영하기 때문에 각 테스트는 검증하고자 하는 대상 클래스와 동일한 패키지(bankaccount)에 포함되며 자바 특성상 패키지 수준의 접근 권한을 가지는데 아래에서 설명하겠다. 아무튼 이렇게 테스트 코드와 프로덕션 코드를 분리하면 배포 시에도 테스트 코드 때문에 attack surface(인증받지 않은 사용자가 데이터를 조회하거나 빼낼 수 있는 지점)를 줄일 수 있다.

 

이 외에도 그냥 프로덕션 코드와 같은 디렉토리, 패키지에 넣거나 별도의 디렉터리에 유사한 패키지(test패키지 등)에 넣는 방법도 있다. 전자는 권장되지 않으며(배포 시 테스트 코드를 제거해야 하기 때문) 후자의 경우 위에서 언급했던 패키지 수준의 접근 권한을 테스트 코드가 가지지 못하도록 일부러 패키지를 바꾸는 것이다. 즉 의도적으로 공개된(public) 인터페이스만 활용하여 테스트 코드를 작성할 수 있도록 하는 것이다.

package bankaccount;

public class Account {
	private int amount;
	private String name;
	int serial;
	
    ...
}

예를 들어 위와 같은 계좌 클래스가 있다고 할 때, 접근지정자가 지정되지 않은 int형 변수 serial는 같은 패키지 내에서 접근 가능하다. 아래는 위의 이미지처럼 test 폴더의 bankaccount 패키지에 있는 AccountTest.java의 일부이다.

package bankaccount;

import static org.junit.Assert.*;
...

public class AccountTest {
	Account account;
	
	@Before
	public void init() {
		account = new Account("Jason"); 
	}
	
	@Test
	public void accessPrivateFieldTest() {
		account.serial = 11111;
	}
    
    ...
}

하지만 아래와 같은 구조의 테스트 코드를 작성하여 동일하게 수행해보면 가시성 문제가 발생한다.

서로다른 패키지의 동일한 테스트 코드
package 접근제어가 불가능하기 때문에 에러 발생

기존에는 같은 패키지에 포함되어 있던 테스트 코드가 test패키지의 하위 패키지(bankaccount)로 포함되었기 때문에 기존처럼 같은 bankaccount 패키지에 포함되어 있지 않아 접근할 수 없는 것이다(자바의 접근 지정자)

 

도서에 따르면 이런 점을 활용하여 몇몇 개발자들은 테스트를 수행할 때 프로덕션 코드의 공개 인터페이스, 다시 말하면 public 메소드만 사용해서 테스트 코드를 작성할 수 있도록 한다고 한다. 공개가 아닌 인터페이스를 호출한다면 이는 정보 은닉 원칙을 깨뜨리는 것이며 이런 메소드를 호출하는 테스트 코드는 리팩토링 등에 의해 클래스의 세부 사항이 변경될 때 테스트가 깨질(정상적으로 작동하지 않을) 수 있고 극단적인 경우 리팩토링을 꺼리게 되는 악영향을 끼칠 수 있다는 점에서 이 같은 주장을 하는 것이라 하였다.

 

테스트 코드와 프로덕션 코드 간 결합성의 문제도 있는데 테스트를 위해 시스템의 내부 데이터(동작이 아닌)를 노출하거나 이는 객체지향의 원칙 중 하나인 "단일 책임 원칙(SRP, Single Responsibility Problem)"을 위배하므로 바람직하지 않다는 것이다.

집중적인 단일 목적 테스트의 가치

테스트의 고립, 즉 다수의 케이스를 별도의 JUnit 테스트 메소드로 분리한다면 다음과 같은 효과를 얻을 수 있다.

  • 단언(Assert)이 실패했을 때 어느 동작에 문제가 있는지 빠르게 파악할 수 있음. 테스트 메소드의 이름을 잘 지었다면 어느 기능에서 문제가 발생했는지 파악하기 쉬울 것이다.
  • 실패한 테스트의 해독에 걸리는 시간을 줄일 수 있음. 각 테스트는 독립적(별도의 인스턴스)이기 때문에 현재 실패한 테스트가 다른 부분에서 영향을 받지 않았다는 것을 확신할 수 있다.
  • 모든 케이스가 실행되었음을 보장할 수 있음. 단언이 실패한다면 java.lang.AssertionError을 throw하며 JUnit에서는 이를 catch하여 테스트가 실패하였다고 판단, 더이상의 테스트 케이스를 실행하지 않기 때문이다.

문서로의 테스트

테스트는 어떻게 보면 해당 클래스에 대한 지속적이고 믿을 수 있는 문서 역할을 수행하기도 한다. 테스트는 코드 자체로는 설명하기 어려운 여러 가능성(런타임 에러 등)을 알려주며 주석의 내용을 대신할 수도 있다. 이렇게 테스트 코드가 하나의 문서(docs)로 동작하려면 어떻게 해야 할까?

 

가장 기본적인 것은 위에서 언급하였듯이 테스트 메소드의 이름을 잘 짓는 것이다. JUnit Test Case를 처음 생성하면 fail() 메소드를 가진 test()라는 테스트 메소드가 존재하는데 이렇게 단순한 이름을 가진 테스트 메소드는 그냥 보기에는 어떤 행동을 수행하는지 알 수 없다. 그렇기 때문에 하나의 테스트에서 너무 넓은 범위를 커버하기보다는 작은 테스트로 나누어 하나의 분명한 행동에 집중한다면 테스트 메소드의 작명도 한결 쉬워질 것이다.

 

다른 팁은 테스트 메소드의 이름에 테스트하려는 맥락을 제안하기보다 어떤 맥락에서 일련의 행동을 호출했을 때 어떤 결과가 나오는지 명시하는 것이다. 예를 들어 예금 계좌에서 잔액 이상의 돈을 인출하려고 하는 테스트 메소드의 이름을 "attemptToWIthdrawTooMuch" 라고 짓는 것보다 "withdrawalOfMoreThanAvailableFundsGenerateError" 처럼 행동에 의해 예상되는 결과를 명시할 수 있을 것이다. 하지만 너무 길거나 단어가 많아진다면 가독성이 떨어지므로 도서에서는 다음과 같은 양식을 제안하고 있다(카멜 형식으로 표기).

"doingSomeOperationGeneratesSomeResult"

"someResultOccursUnderSomeCondition"

"givenSomeContextWhenDoingSomeBehaviorThenSomeResultOccurs"

마지막은 너무 길기 때문에 "givenSomeContext"는 종종 제거되기도 한다.

 

이것만으로도 파악이 어렵다면 주석을 추가하거나 지역 변수의 이름 개선, 의미 있는 상수 도입, hamcrest 라이브러리 사용 등을 통해 테스트를 좀 더 의미있게 만들 수 있다. 이런식으로 테스트 코드를 작성한다면 코드가 하나의 문서처럼 다른 사람에게 의미있게 읽힐 수 있을 것이다.

@Before와 @After(공통 초기화와 정리) 더 알기

중복된 초기화 코드를 제거하는 데 사용할 수 있는 @Before 애너테이션, 혹은 셋업(setup) 메소드는 유지보수에 큰 도움이 된다. 이들 역시 여러 메소드로 분할될 수 있는데 단순히 @Before 애너테이션을 가진 메소드를 여러개 정의함으로써 가능하다.

@Before createAccount
@Before resetAccountLogs
@Test
...

이때 분할된 메소드들은 특정 순서를 가지지 않기 때문에 선수관계가 필요하다면 하나의 메소드에 정의해야 한다. 분할되었더라도 이 메소드들은 모든 테스트 케이스에 적용되기 때문에 모든 테스트에 공통적으로 필요한 기능만 구현해야 한다.

 

비슷한 애너테이션으로 @After가 있는데 이는 테스트가 실패하든 성공하든 실행된다. 주로 테스트에서 발생하는 부산물(생성된 임시파일, 연결된 데이터베이스 등)을 정리하는 데 사용된다. 만약 단 한번만 실행되어야 하는 선수작업 혹은 후수작업이 필요하다면 @BeforeClass, @AfterClass 애너테이션을 사용할 수 있다.

녹색이 좋다: 테스트를 의미 있게 유지

녹색이 뭔가 하면 아마 JUnit에서 모든 테스트를 통과했을 때 나타나는 초록색 막대를 의미하는 것일 것이다.

모든 테스트가 통과한 모습

만약 테스트가 실패한다면 다음과 같은 빨간 막대가 나타나게 된다.

6개 중 하나의 테스트가 실패한 모습

이처럼 테스트가 실패했다면 프로덕션 코드를 작성하는 것을 멈추고 곧바로 수정하여 모든 테스트가 항상 통과하도록 해야 한다. 즉 항상 녹색(모두 통과)을 지향하여야 하는 것이다. 하지만 빨간 막대가 무서워 자신이 원하는 테스트만 수행하거나 제한된 테스트를 수행한다면 애플리케이션의 숨겨진 문제를 발견하지 못할 것이므로 단위 테스트를 수행할 때는 가급적 전체 테스트 스위트(suite)에서 피드백을 받을 수 있도록 해야 한다.

 

당연히 애플리케이션이 외부와 통신하거나 데이터베이스를 사용하는 등 비용이 드는(costly) 테스트를 수행한다면 테스트의 수행 속도는 느려질 것이다. 이는 JUnit의 Categories 기능으로 특정 카테고리에 해당하는 테스트만 별도로 실행하거나 목 객체(Mock Object)를 활용할 수 있다. 단위 테스트가 항상 매우 빠르게 수행되어야 하는 것은 아니지만 대부분의 경우 그래야 한다. 통합(integration) 테스트가 아닌 단위(unit) 테스트기 때문에 빠르게 피드백을 얻는 것이 중요하기 때문이다.

 

코드를 작성하던 중간에 단위 테스트를 수행하다 보면 불가피하게(주로 업데이트되지 않은 부분으로 인해) 실패하는 테스트가 발생할 수 있는데 거슬린다면 @Ignore 애너테이션을 사용하여 무시할 수 있다. 이는 주석 처리보다는 더 효과적인 게 JUnit 인터페이스에서 무시된 테스트들은 따로 표시해주기 때문이다.

insertTest()를 @Ignore 하였다.
insertTest()가 무시(skip)된 테스트

마치며

이제 막 3분의 1정도를 읽은 책이지만 도서관에서 우연히 꺼내든 그 순간이 다행이라고 생각될 정도로 유익한 내용을 많이 얻고 있다. 애플리케이션 개발을 위한 코딩이 아니라 시스템 설계나 유지보수를 생각하는 코딩을 하는 건 처음이라 신기하기도 하고, 추후 소프트웨어 공학 개론서에 있는 내용과 비교하며 단위 테스트에 관하여 포스팅을 올리면 더 많은 지식을 얻을 수 있을 것 같다. 나중에 코딩을 할때도 일일히 System.out.println() 하는 것보다는 좀 더 유용하게 쓸 수 있을 것 같다.