Test Code & TDD

테스트 코드가 필요한 이유

코드의 변경 사항이 생겼을 때 문제가 발생하더라도 정의해놓은 테스트에서 에러가 발생하기 때문에 어디서 문제가 생겼는지 파악하기가 수월하다. 즉, 유지보수와 확장성 측면에서 장점을 가질 수 있고, 테스트 코드를 통해 해당 기능의 다양한 사용 케이스를 볼 수 있으므로 일종의 문서 역할을 한다.

또한 자동화된 배포에서도 테스트 코드가 있다면 자동으로 전체 테스트 코드를 실행하여 성공 시에만 배포하여 안정성을 가질 수 있다.

 

물론 단점도 존재하는데, 테스트 코드 작성하는 법을 따로 공부해야 한다는 점과 테스트 코드를 짜는 시간이 추가적으로 소요된다는 점이다.

하지만 테스트 코드 작성법을 공부했을 때 가져갈 수 있는 이점과 초반에 시간을 투자 했을 때 이후에 얻을 수 있는 시간 단축 효과가 더 크기 때문에 단점을 상쇄시킬 수 있다고 생각한다.

 

 

단위 테스트와 통합 테스트

Unit Test

단위 테스트는 테스트가 가능한 가장 작은 단위를 테스트하는 것을 말한다.

최대한 작고 간단하게 테스트를 진행해야 단위의 복잡성이 낮아지져 디버깅이 쉬워지고, 리팩토링이 수월해지며, 테스트의 동작을 더 잘 표현하여 이해도를 높일 수 있다.

단위 테스트의 크기가 정해져있지는 않지만, 일반적으로 클래스나 메소드 단위로 테스트한다.

단위 테스트는 소프트웨어의 내부 구조나 구현 방법을 고려하여 개발자 관점에서 작성하기 때문에 TDD에서는 단위 테스트 위주로 개발을 진행한다.

Integration Test

통합 테스트는 단위 테스트 보다 큰 범위에서 여러 모듈들이 의도대로 실행되는지를 파악하기 위한 테스트이다.

단위 테스트와 달리 개발자가 변경할 수 없는 외부 라이브러리, API, DB 등의 외부 요인들까지 묶어서 검증한다.

즉, 단위 테스트로 각각의 기능들에 대해 문제를 파악했더라도 다른 요인들이 포함된, 실제 실행에서 발생할 수 있는 문제를 파악할 수 있다.

단위 테스트 보다 한번에 많은 내용을 테스트하기 때문에 테스트의 신뢰성과 에러의 위치 파악, 유지 보수가 조금 더 어렵다.

 

 

TDD란?

Test Driven Development: 테스트 주도 개발이라는 뜻으로 개발하기 전에 테스트 케이스를 먼저 작성하여 테스트에 맞춰 개발을 진행하는 방식이다.

Red Green Refactor이라고도 하는데 Red(실패 케이스)를 먼저 작성하고, Green(성공)이 되도록 구현을 한 다음 Refactoring 하는데,즉, 시나리오를 먼저 만들어서 그 시나리오대로 흘러가도록 만들고 만들어진 코드를 리팩토링해서 코드 품질을 높이는 것이다.

위 과정을 아래 그림처럼 반복하면 된다.

 

TDD 이전의 개발

TDD 이전의 개발에서는 어떤 클래스를 만들지, 어떤 메서드를 만들지 고만하고 개발한 다음 직접 실행해보면서 테스트하고 디버깅을 진행했다.

이런식으로 기능 한번에 다 구현하고나서 테스트를 하면 직접 원하는 지점에서 로그 메세지를 출력해야 하고, IDE에서 break point를 걸어 확인해야 하기 때문에 프로젝트가 커지거나, 확인하고 싶은 기능이 호출되는 곳까지 가는데 시간이 많이 소요된다. 만약 입력 과정까지 있다면 너무 귀찮다.

기능이 호출되기 이전 과정을 생략하기 위해 주석 처리를 하거나 정적으로 값을 넣는다면 프로덕션 코드의 품질을 떨어뜨릴 수 있으며, 기능을 구현한 개발자가 퇴사하고 없다면 다른 사람이 짠 코드를 테스트해야 하기 때문에 시간이 더 많이 소요된다.

정리해보면 TDD 도입 이전에는 코드를 한번에 다 구현하다 보니 디버깅과 유지보수가 더 어려웠다.

 

 

테스트 코드 작성 시 고려사항

작성 순서와 범위

  • 테스트 코드를 작성할 때는 작성 순서에 따라 구현 복잡도가 달라질 수 있기 때문에 가장 쉬운 것부터 작성해야한다.
  • 예외 케이스를 먼저 고려하여 구현하는 것이 이후에 예외상황으로 인해 코드를 수정하는 횟수를 줄일 수 있다.
  • 지금 당장의 테스트를 통과시킬 정도의 최소한만 구현하는 것이 좋다.
    • TDD에서는 최소한의 기능으로 구현된 테스트가 통과하도록 작성하고 이후에 다른 케이스를 추가하면서 리팩토링 하는 과정을 반복하여 테스트 코드를 완성 시킨다.
    • 지금 자신이 생각한 내용이 다 맞지 않을 수 있다. 그렇기 때문에 최소한으로 차근차근 수정해나가는 것이 더 안정적이다.
    • 지금 당장 필요없는 기능을 구현하지 않음으로써 오버 엔지니어링을 방지해준다.

 

유지 보수

테스트 코드도 프로덕션 코드와 마찬가지로 유지보수 대상이기 때문에 유지보수하기 좋은 코드를 작성하는 것이 중요하다.

(테스트 코드를 유지보수하지 않는다면, 점점 실패하는 테스트가 늘어날 것이고 결국 테스트를 무시하게 되어 코드 품질이 저하될 것이다.)

  • Test 코드는 기능 명세의 역할도 하기 때문에 이름을 잘 작성해야한다.  테스트 메서드 네이밍 규칙
  • 테스트 코드는 실행 순서에 의존하지 않아야한다. (항상 일관된 결과를 가져와야 믿을 수 있는 테스트 코드가 된다.)
  • 실행 결과가 랜덤해서는 안된다. (신뢰성이 떨어진다.)
  • 검증 코드를 조건부로 실행하지 않는다. (조건 검사를 검증 코드로 구현하는 것이 더 낫다.)
    • if(pets.contain("cat") + 검증코드 => assertTrue(pets.contain("cat"));  + 검증 코드
  • 의존 객체를 직접 생성하지 않고, 외부 주입을 통해 생성해야 테스트 코드를 작성하기 편리하다.
    • A 클래스에서 B 객체를 사용한다면, 클래스 내부의 필드 변수 선언에서 바로 값을 할당하는 것 보다 A 클래스를 생성할 때 의존성을 주입하도록 설계하면 A에 대한 테스트 코드 작성 시 B 객체를 컨트롤 할 수 있다. 
  • 기대값을 변수나 필드로 표현하지 않고 직접 값을 넣어 직관적이게 표현해야한다.
    • assertEquals(Object.getField()), expectedValue) 보다 assertEquals("기대 결과", expectedValue)가 더 직관적이다.
  • 하나의 테스트 코드가 어려 기능을 테스트하지 않는다. 또는 여러 검증을 하지않는다.
    • 테스트를 실행했을 때 정확한 위치와 원인 파악을 위해 하나의 테스트 메서드가 하나의 기능만을 검증하는 것이 좋다.
  • 과도한 검증을 구현하지 않아야한다. (충분히 검증된 코드에 검증 코드를 더 추가하면 변경 시 쉽게 테스트가 깨질 수 있다.)
  • 테스트에 필요한 데이터만 정의하여야한다.
    • new Car()로 생성해도 충분한 코드를 new Car("람보르기니")와 같이 필요없는 데이터를 정의한다면 테스트가 깨질 가능성만 높아지게된다.
  • 모의 객체를 정확하게 일치하는 값으로 설정하지 않는다. (변경 시 테스트가 깨질 위험만 커진다.)
//정확하게 일치시킨 코드
BDDMockito.given(mockPasswordChecker.checkPasswordWeak("pw"))
			.willReturn(true);
            
//일치시키지 않은 코드
BDDMockito.given(mockPasswordChecker.checkPasswordWeak(Mockito.anyString())
			.willReturn(true);
            
/*위 검증 코드는 pw가 약한 pw인지 검증하는 것이지 pw가 "pw"인지 검증하는 코드가 아니다.
  따라서 일치하는 값을 넣어줄 필요는 없다.
*/

통합 테스트에서의 유지보수 고려사항

  • 특정 메서드에만 적용되는 데이터를 공유하지 않기
  • 여러 테스트 메서드에 중복되는 코드는 보조 클래스로 활용
  • 실행 환경이 다르다고 실패해서는 안된다.
    • 경로 같은 변수는 절대 경로로 하드코딩 하면 실행 컴퓨터에 따라 다른 결과가 나오기 때문에 상대 경로로 지정
    • 특정 OS에서만 테스트해야 하는 경우에는 @EnabledOnOs, @DisabledOnOs와 같은 기능을 사용해서 OS에 따른 실행 여부를 지정하면 된다.
  • 통합 테스트는 테스트 범위가 넓기 때문에 필요한 범위까지만 연동한다.

 

 

Test Double

테스트 코드를 작성할 때 외부 상황에 대한 테스트는 매우 힘들다.

예를 들어 REST API로 응답을 받아올 때 응답시간이 5초 이상이라면 예외를 발생시키고 싶다면? REST API 서버에서 5초 정지 시키기는 힘들다.

이런 경우에는 대역(Test Double)을 사용하여 해결할 수 있으며, Stub, Fake, Spy, Mock 4가지가 존재한다.

Stub

외부 의존성을 가져 테스트를 위한 원하는 결과를 가져오기 힘든 상황, 특정 상황을 시뮬레이션하고 싶은 상황에서 사용할 수 있다.

테스트 할 클래스를 상속하여 오버라이딩 하거나, 인터페이스를 구현하여 내가 원하는 상황을 재현하도록 구현한다.

public class Lotto{
	private int number;

	public int genetateRandomNumber(){
    	//랜덤 값을 반환하는 로직
    }
}

public class LottoStub extends Lotto{
    @Override
    public int genetateRandomNumber(){
    	//내가 원하는 테스트에서는 3이라는 값이 나와야한다.
        return 3;
    }
}

Fake

제품에는 적합하지 않지만, 실제 동작하는 구현을 제공한다.

예를 들어 DAO 클래스에서 Connect로 실제 연결된 DB 대신 가짜 메모리 DB를 구현해서 테스트에 사용할 수 있다.

public CarDaoFake implement CarDao {
	//HashMap<> car...
    
    public saveCar(String name, int price){
    	//실제 CarDao는 insert 쿼리로 작성되어있지만, Fake에서는 컬렉션으로 값을 테스트한다.
    	car.put(name, price);
    }
}

Spy

호출된 내역을 기록하며, 기록한 내용으로 원하는 기능이 정상적으로 호출됐는지 검증할 수 있다.

@Test
    public void testFetchDataCalls() {
        // Spy 객체 생성
        DataSpy spy = new DataSpy();
        
        // fetchData 메서드 호출
        spy.fetchData();
        spy.fetchData();
        spy.fetchData();
        
        // fetchData 호출 횟수 검증
        assertEquals(3, spy.getFetchDataCallCount());
    }
}

Mock

실제 객체의 동작을 시뮬레이션하고 호출된 메서드, 파라미터, 호출 횟수 등 Stub과 Spy의 기능을 모두 수행할 수 있다.

하지만 Mock 객체는 대역 클래스를 만들지 않아도 된다는 장점이 있지만, 검증 코드 자체는 더 길어지기 때문에 무조건 Mock 방식만 사용하면 테스트 코드가 복잡해질 수도 있다.

//Stub 검증 예시
@Test
public void testAddition() {
    // Mock 객체 생성
    CalculatorService calculatorService = mock(CalculatorService.class);

    // 테스트 대상 객체 생성 및 Mock 의존성 주입
    Calculator calculator = new Calculator(calculatorService);

    // Mock 객체 동작 설정
    when(calculatorService.add(2, 3)).thenReturn(5);

    // 메서드 호출
    int result = calculator.add(2, 3);

    // Mock 객체 동작 검증
    verify(calculatorService).add(2, 3);

    // 결과 검증
    assertEquals(5, result);
}

//Spy 검증 예시
@Test
public void testCreateUser() {
    UserRepository userRepository = mock(UserRepository.class);

    UserService userService = new UserService(userRepository);

    User user = new User("John");
    userService.createUser(user);

    verify(userRepository).save(user);
}