TDD의 필요성
프로덕션 코드를 먼저 생성하고 이후 테스트 코드를 작성하는 방식에는 다음과 같은 문제점이 존재합니다.
- 요구사항을 명확히 규정하기 어렵습니다.
- 스스로 작성한 코드가 편향적일 수 있습니다.
- 모든 요구사항이 테스트 되었는지 확인할 수 없습니다.
- 지루합니다.
이러한 단점을 극복하기 위한 개발 방식이 테스트 주도 개발입니다. 테스트 주도 개발은 이름에서 느껴지는 것처럼, 테스트 코드를 먼저 작성하고 이를 만족하기 위한 프로덕션 코드를 생성하는 개발 과정을 의미하며, 이전 포스팅까지 우리는 입력을 여러 대표군으로 나누고, 이를 만족할 수 있는 테스트를 나열한 후, 모든 테스트를 만족하는 최소한의 코드를 작성하는 방식으로 TDD를 연습해왔습니다. 이러한 방식은 요구사항을 명확히 하고, 편향성을 극복할 수 있으나, 실수로 인해 모든 요구사항을 테스트 하지 않는 경우가 발생할 수 있고, 어느 정도 지루합니다. 여기, 좀더 나은 방안이 있습니다.
로버트 C. 마틴 TDD 3원칙
클린 코드를 작성한 저명한 프로그래머 로버트 C. 마틴은 세 가지 원칙을 통한 테스트 주도 개발 방식을 주장하였습니다. 다음이 그가 주장한 TDD 3원칙입니다.
- 유닛 테스트를 통과할 목적이 아니라면, 프로덕션 코드를 작성할 수 없습니다.
- 유닛 테스트가 실패한다면, 더이상 테스트 코드를 작성할 수 없습니다. 컴파일 실패도 실패입니다.
- 실패한 유닛 테스트를 통과하게 만든다면, 더이상 프로덕션 코드를 작성할 수 없습니다.
예시
다음과 같이, 핑 서버에 요청을 보내 EndpointResult
중 하나의 반환값을 받는 동작을 수행하는 인터페이스가 있다고 가정합니다.
public interface PingServerHttpEndpointSync {
enum EndpointResult {
SUCCESS,
GENERAL_ERROR,
NETWORK_ERROR
}
EndpointResult pingServerSync();
}
위 3가지 원칙을 따라 핑 서버에 요청을 보내고 반환값을 받는 PingServerUseCaseSync
클래스를 작성해봅시다. 먼저 클래스를 생성합니다.
public class PingServerSyncUseCase {
}
1 원칙에 따르면, 우리는 유닛 테스트를 통과시킬 목적의 코드 이외에는 작성할 수 없습니다. 현재 테스트가 없기 때문에 코드를 작성할 수 없죠. 따라서 테스트 코드를 작성해줍니다.
@RunWith(MockitoJUnitRunner.class)
public class PingServerSyncUseCaseTest {
PingServerSyncUseCase systemUnderTest;
@Before
public void setUp() {
systemUnderTest = new PingServerSyncUseCase();
}
@Test
public void pingServerSync_success_successReturned() {
// Arrange
// Act
// Assert
}
}
이제 요청이 success
하는 상황을 가정하여 Arrange 부를 구현합니다. success
하는 상황을 디폴트로 설정하기 위해 해당 작업은 setUp
함수에서 수행해주도록 합시다.
@RunWith(MockitoJUnitRunner.class)
public class PingServerSyncUseCaseTest {
PingServerSyncUseCase systemUnderTest;
@Mock
PingServerHttpEndpointSync mPingServerHttpEndpointSync;
@Before
public void setUp() {
systemUnderTest = new PingServerSyncUseCase();
endpointSuccess();
}
@Test
public void pingServerSync_success_successReturned() {
// Arrange
// Act
// Assert
}
private void endpointSuccess() {
when(mPingServerHttpEndpointSync.pingServerSync())
.thenReturn(PingServerHttpEndpointSync.EndpointResult.SUCCESS);
}
}
다음으로 Act 부를 구현합니다.
@Test
public void pingServerSync_success_successReturned() {
// Arrange
// Act
systemUnderTest.pingServerSync();
// Assert
}
그런데 pingServerSync()
메소드는 현재 구현되어 있지 않죠. 이때 2원칙이 적용됩니다. pingServerSync()
를 구현합시다.
public class PingServerSyncUseCase {
public void pingServerSync() {
}
}
세부 구현을 추가하고 싶지만, 3원칙이 이를 금지합니다. 컴파일 실패로 인해 코드를 작성하게 되었고, 컴파일 실패가 해소되었으니 코드를 작성할 수 없습니다. 다시 1원칙에 따라 테스트 코드를 작성할 차례입니다. 함수의 반환값을 검증하는 테스트이나 반환값을 변수에 담지 않았죠. 수정해봅시다.
@Test
public void pingServerSync_success_successReturned() {
// Arrange
// Act
PingServerSyncUseCase.UseCaseResult result = systemUnderTest.pingServerSync();
// Assert
}
반환값의 자료형은 임의로 UseCaseResult
자료형을 설정하였습니다. 컴파일 에러가 나네요. 다시 2원칙에 따라 프로덕션 코드를 작성하러 갑시다.
public class PingServerSyncUseCase {
enum UseCaseResult {}
public void pingServerSync() {
}
}
result
의 타입이 될 enum
을 생성해줍니다. 3원칙으로 인해 세부 구현은 비워둡니다. 테스트로 돌아와보면 여전히 에러가 발생하고 있을 것입니다. pingServerSync()
의 반환형이 void
이기 때문입니다. 반환형을 수정하고, 세부 구현은 비워둡니다.
public class PingServerSyncUseCase {
enum UseCaseResult {}
public UseCaseResult pingServerSync() {
return null;
}
}
다시 1원칙에 따라 실패하는 테스트 코드를 생성해봅시다. Assert 부를 구현해야겠죠? 테스트 이름처럼 SUCCESS
가 반환되어야 할 것입니다.
@Test
public void pingServerSync_success_successReturned() {
// Arrange
// Act
PingServerSyncUseCase.UseCaseResult result = systemUnderTest.pingServerSync();
// Assert
assertEquals(PingServerSyncUseCase.UseCaseResult.SUCCESS, result);
}
SUCCESS
가 정의되어 있지 않아 컴파일 에러가 발생하네요. 수정해줍시다.
enum UseCaseResult {
SUCCESS,
}
3원칙으로 인해 이 이상의 구현은 금지된 상태입니다. 이제 테스트를 실행할 수 있게 되었네요. 실행해보면 테스트가 실패하는 모습을 볼 수 있습니다.
2원칙에 따라 더이상 테스트를 작성할 수 없네요. 프로덕션 코드를 다음과 같이 변경합니다.
public class PingServerSyncUseCase {
enum UseCaseResult {
SUCCESS,
}
public UseCaseResult pingServerSync() {
return UseCaseResult.SUCCESS;
}
}
테스트는 통과하지만 코드가 허술합니다. 최소한의 코드만 작성했으니 당연한 일입니다. 1원칙에 따라 모든 테스트를 만족한 이상 더는 프로덕션 코드를 작성할 수 없으니 새로운 테스트 코드를 생성해보도록 합시다.
@Test
public void pingServerSync_generalError_failureReturned() {
// Arrange
endpointGeneralError();
// Act
PingServerSyncUseCase.UseCaseResult result = systemUnderTest.pingServerSync();
// Assert
assertEquals(PingServerSyncUseCase.UseCaseResult.FAILURE, result);
}
다음 테스트는 FAILURE
가 존재하지 않아 실패합니다. enum
에 FAILURE
를 추가한 후 실행하면 테스트 결과가 실패합니다. 코드를 고쳐봅니다.
public class PingServerSyncUseCase {
enum UseCaseResult {
SUCCESS,
FAILURE,
}
private final PingServerHttpEndpointSync mPingServerHttpEndpointSync;
public PingServerSyncUseCase(PingServerHttpEndpointSync pingServerHttpEndpointSync) {
mPingServerHttpEndpointSync = pingServerHttpEndpointSync;
}
public UseCaseResult pingServerSync() {
PingServerHttpEndpointSync.EndpointResult endpointResult = mPingServerHttpEndpointSync.pingServerSync();
if (endpointResult == PingServerHttpEndpointSync.EndpointResult.GENERAL_ERROR) {
return UseCaseResult.FAILURE;
} else {
return UseCaseResult.SUCCESS;
}
}
}
순서가 이해가 가시나요? 1원칙에 따라, 프로덕션 테스트를 만들기 위한 목적으로 테스트 코드를 생성하고, 2원칙, 3원칙을 번갈아 수행하며 최소한의 코드를 작성하는 과정으로 프로그램을 완성할 수 있습니다.
결론
로버트 C. 마틴의 3원칙을 따르면 테스트 코드를 작성하며 대두되었던 네 가지 문제점을 모두 해결할 수 있으며, 추가적으로 다음과 같은 이점이 있습니다.
- 설계에 필요한 노력이 줄어듭니다.
- 컨디션에 상관없이 일정한 코드 퀄리티를 유지할 수 있습니다.
반면 다음과 같은 한계도 존재합니다.
- 빌드, 테스트 속도가 빠르지 않으면 집중력을 잃게 됩니다.
'Android > TDD(Test Driven Development, 테스트 주도 개발)' 카테고리의 다른 글
kotest Project Config 다른 모듈과 공유 (0) | 2022.04.14 |
---|---|
Turbine 없이 kotest에서 StateFlow, SharedFlow 테스트 하기, Unit Test (1) | 2022.04.12 |
Unit Test(유닛 테스트) 팁 5가지 - TDD(테스트 주도 개발) (0) | 2021.12.16 |
Mockito 프레임워크 - TDD(테스트 주도 개발) (0) | 2021.12.14 |
Test Double(테스트 더블) - TDD(테스트 주도 개발) (0) | 2021.12.14 |