서버 개발을 시작한 후 정말 많이 들었던 얘기 중 하나는 테스트 코드가 정말 중요하다는 얘기였다. 김영한님의 강의를 들을 때부터 테스트코드는 정말 중요하며, 대부분의 기업에서 테스트 코드를 작성하는 데 정말 많은 시간을 쏟는다는 얘기를 들었고, 소마와 다른 동아리를 거치면서도 테스트 코드의 중요성에 대해 배워왔다. 그럼 테스트 코드는 뭐고, 왜 중요한걸까?
말 그대로 작성한 코드에 문제가 없는지 테스트하기 위해 작성하는 코드이다.
애플리케이션은 항상 내적 결함을 가지고 있고, 좋은 코드와 설계를 하더라도 결함의 존재 자체를 부정할 수 없다.
때문에 버그는 항상 발생하고, 개발자는 디버깅을 통해 이를 해결한다.
하지만 문제를 해결하다보면 실제 해결하는 시간보다 문제를 찾기 위한 시간이 더 많다는 것을 깨닫게 된다.
테스트 코드 작성 습관화는 이 문제를 찾는 과정에 드는 시간을 줄여줌으로써 디버깅 비용을 줄이고, 개발자가 비즈니스 로직에 집중할 수 있게 해준다.
이전에 잘 작동하던 기능에 문제가 생기는 것을 가리킨다.
개발을 하다 보면 회귀 버그가 정말 많이 발생하게 된다.
사실 이유는 간단한데, 애플리케이션은 단일 요소가 아닌 함수, 객체, 도메인 등 여러 요소가 상호작용하며 이루어지기 때문이다. 때문에 하나의 기능을 수정하더라도 수정된 요소가 다른 기능에 영향을 줄 수 있기 때문에 회귀 버그가 발생하게 된다.
회귀 테스트는 기능 추가나 오류 수정으로 인해 새롭게 유입되는 오류가 없는지 겁증해준다.
테스트 코드는 당시의 기능을 만들기 위해서만 필요한 코드가 아니다.
그 이후
코드를 이해하기 편하게 하기 위해 문서화를 진행하지만, 쉽게 방치되기도 한다.
이는 문서와 코드를 같은 유지 보수 대상으로서 가져가는 것이 어렵기 때문인데, 이 때문에 신뢰하기가 어려워진다.
Behavior spec 스타일의 테스트코드를 보면 given(todo 저장), when(id가 중복되면), then(저장에 실패한다) 처럼 코드가 어떤 역할을 갖는지 명세를 작성하게 된다. 이는 기존 코드를 이해하기 위한 문서의 역할을 해 도움을 주게 된다.
좋은 코드는 "변경하기 쉬운"이라는 형용사를 내포한다.
이는 약한 결합을 가지고 있는 코드를 뜻하며 강한 결합을 갖는 코드는 당연히 테스트하기 어렵다.
이것이 의미하는 것은 만약 내가 작성한 코드가 테스트하기 어려운 코드라면 안좋은 코드일 가능성이 높다는 것이다.
본인이 작성한 코드가 실제 운영 환경에 배포됐을 때 불안감에 휩싸여 기도하는 것을 말한다. 특히 실제 운영중인 서비스라면 더욱 더 큰 불안감에 휩싸일 수 밖에 없다.
CI를 통해 우리가 작성한 코드의 병합 순간 우리가 작성한 테스트 코드를 통해 버그가 배포되는 것을 막을 수 있다.
이를 통해 안정감 있는 프로젝트를 진행할 수 있고, 우리도 안정감이 생기게 된다.
테스트 목적으로 실제 객체 대신 사용되는 모든 종류의 가상 객체를 뜻한다. xUnit Test Patterns의 저자인 Gerard Meszaros는 이를 Dummy, Fake, Stub, Spy, Mock 5가지 종류로 분류했다.
전달되지만 실제로는 사용되지 않는다. 일반적으로 매개변수 목록을 채우는 데만 사용한다.
test("FROM 계좌의 잔액이 부족하면 Failure 리턴") {
// arrange
val bankPortStub = object : BankPort {
override fun getBalance(bankCode: String, accountNumber: String): Long {
return 1000L
}
override fun withdraw(bankCode: String, accountNumber: String, amount: Long): BankPort.Result = TODO("Not yet implemented")
override fun deposit(bankCode: String, accountNumber: String, amount: Long): BankPort.Result = TODO("Not yet implemented")
}
val sut = TransferBank(
transferHistoryRepository = mockk(), // Dummy 객체
bankPort = bankPortStub,
emailPort = mockk(), // Dummy 객체
)
// act
val actual = sut.invoke(
from = TransferBankUseCase.BankAccount("088", "1212121212"),
to = TransferBankUseCase.BankAccount("088", "4242424242"),
amount = 100_000L,
)
// assert
(actual is TransferBankUseCase.Result.Failure) shouldBe true
}
emailPort와 TransferHistoryRepository는 사용되지는 않지만 전달되야 하는 값으로, mockk를 이용해 생성된 Dummy 객체를 전달한다.
일의 능률을 향상시켜주지만 일반적으로 프로덕션에 적합하지 않다.(In-memory같은 방식)
import org.jetbrains.exposed.sql.Database
// H2 In-memory database에 접속
val h2Database = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver")
// mysql database에 접속
val mysqlDatabase = Database.connect("jdbc:mysql://localhost/test", driver = "com.mysql.jdbc.Driver")
프로덕션에서는 운영중인 MySQL 서버에 접속해서 데이터를 저장하고 조회하지만, 테스트코드에서는 In-memory Database(Fake 객체)를 사용해 기능을 테스트하는 방식이다.
테스트할 동안 준비된 대답을 제공해준다. 테스트를 위해 프로그래밍된 것 외에는 응답하지 않는다.
test("FROM 계좌의 잔액이 부족하면 Failure 리턴") {
// arrange
val bankPortStub = object : BankPort {
override fun getBalance(bankCode: String, accountNumber: String): Long {
return 1000L
}
override fun withdraw(bankCode: String, accountNumber: String, amount: Long): BankPort.Result = TODO("Not yet implemented")
override fun deposit(bankCode: String, accountNumber: String, amount: Long): BankPort.Result = TODO("Not yet implemented")
}
val sut = TransferBank(
transferHistoryRepository = mockk(),
bankPort = bankPortStub, // Stub 객체
emailPort = mockk(),
)
...
}
잔액을 테스트하는 함수에서 입력된 계좌번호에 상관 없이 1000을 반환한다.
어떻게 부름을 받았는지에 따라 일부 정보를 기록하는 stub이다. ex) 전송된 메세지 수를 기록하는 이메일 서비스
test("송금을 성공하면 이메일을 한 번 발송") {
// arrange
val transferHistoryRepositoryStub = object : TransferHistoryRepository {
override fun findById(id: Long): TransferHistory = TODO("Not yet implemented")
override fun save(history: TransferHistory): TransferHistory {
return history
}
}
val bankPortStub = object : BankPort {
override fun getBalance(bankCode: String, accountNumber: String): Long {
return 100_000L
}
override fun withdraw(bankCode: String, accountNumber: String, amount: Long): BankPort.Result {
return BankPort.Result("success")
}
override fun deposit(bankCode: String, accountNumber: String, amount: Long): BankPort.Result {
return BankPort.Result("success")
}
}
val emailPortSpy = object : EmailPort {
var emailCount = 0
override fun sendEmail(content: String) {
emailCount++
}
fun countSentEmail(): Int {
return emailCount
}
}
val sut = TransferBank(
transferHistoryRepository = transferHistoryRepositoryStub,
bankPort = bankPortStub,
emailPort = emailPortSpy,
)
// act
val actual = sut.invoke(
from = TransferBankUseCase.BankAccount("088", "1212121212"),
to = TransferBankUseCase.BankAccount("088", "4242424242"),
amount = 100_000L,
)
// assert
check(actual is TransferBankUseCase.Result.Success)
emailPortSpy.countSentEmail() shouldBe 1
}
Spy는 내가 확인하고자 하는 대상(emailCount)을 기록하는 것이 핵심이고 검증 단계에서 이 정보를 활용한다.
수신할 것으로 예상되는 호출의 사양을 형성하는 기댓값으로, 미리 프로그래밍된 객체이다.
test("송금 성공") {
// arrange
val transferHistoryRepositoryMock = mockk<TransferHistoryRepository>()
val bankPortMock = mockk<BankPort>()
val emailPort = mockk<EmailPort>()
val sut = TransferBank(
transferHistoryRepository = transferHistoryRepositoryMock,
bankPort = bankPortMock,
emailPort = emailPort,
)
every { bankPortMock.getBalance(any(), any()) } returns 100_000L
every { bankPortMock.withdraw(any(), any(), any()) } returns BankPort.Result("success")
every { bankPortMock.deposit(any(), any(), any()) } returns BankPort.Result("success")
every { transferHistoryRepositoryMock.save(any()) } returns TransferHistory(
id = 1L,
fromBankCode = "088",
fromBankAccountNumber = "1212121212",
toBankCode = "088",
toBankAccountNumber = "4242424242",
amount = 100_000L,
)
every { emailPort.sendEmail(any()) } returns Unit
// act
val actual = sut.invoke(
from = TransferBankUseCase.BankAccount("088", "1212121212"),
to = TransferBankUseCase.BankAccount("088", "4242424242"),
amount = 100_000L,
)
// assert
(actual is TransferBankUseCase.Result.Success) shouldBe true
}
Mock을 사용하면 내가 어떤 호출을 기대하고 그 호출에 대한 결과가 무엇인지 명세(specification)를 만들어놔야 한다. 위 코드에서 행동(behavior)에 대한 명세(specification)를 MockK에서 제공하는 every — returns 구문을 이용해서 정의했다.
의미만 놓고 보면 같아 보이지만, 테스트 코드를 작성하는 관점에서 바라보면 크게 2가지 차이가 있다.
중복 발생되는 무언가를 고정시켜 한곳에 관리하도록 하겠다는 개념
class LottoTicketsTest {
private List<LottoTicket> ascendingLottoTickets;
private List<LottoNumber> ascendingLottoNumbers;
private LottoTickets lottoTickets;
@BeforeEach
void setUp() {
ascendingLottoNumbers = IntStream.rangeClosed(1, 6)
.mapToObj(LottoNumber::new)
.collect(Collectors.toList());
ascendingLottoTickets = IntStream.range(0, 2)
.mapToObj(ticketCount -> new LottoTicket(ascendingLottoNumbers))
.collect(Collectors.toList());
lottoTickets = new LottoTickets(ascendingLottoTickets);
}
@Test
@DisplayName("여러장의 로또 생성")
void create() {
assertThat(lottoTickets).isEqualTo(new LottoTickets(ascendingLottoTickets));
}
@Test
@DisplayName("일치 번호 개수 리스트 반환")
void numberOfMatches() {
List<LottoNumber> winningLottoNumbers = Arrays.asList(new LottoNumber(1), new LottoNumber(2), new LottoNumber(3), new LottoNumber(4), new LottoNumber(5), new LottoNumber(45));
List<LottoRank> lottoRanks = lottoTickets.lottoRanks(winningLottoNumbers);
assertThat(lottoRanks).isEqualTo(Arrays.asList(LottoRank.SECOND, LottoRank.SECOND));
}
}
@BeforeAll, @AfterAll, @BeforeEach, @AfterEach를 사용하는 방식. 각 메소드 시작 전/ 종료 후 혹은 테스트 메소드 시작 전 / 종료 후에 실행된다.
public class LottoTicketsFixtures{
public static LottoTicket createLottoTickets(){
ascendingLottoNumbers = IntStream.rangeClosed(1, 6)
.mapToObj(LottoNumber::new)
.collect(Collectors.toList());
ascendingLottoTickets = IntStream.range(0, 2)
.mapToObj(ticketCount -> new LottoTicket(ascendingLottoNumbers))
.collect(Collectors.toList());
return new LottoTickets(ascendingLottoTickets);
}
}
Fixture 클래스로 분리하는 방식
public class LottoTicketTests{
private LottoTickets lottotickets = LottoTicketsFixtures.createLottoTickets();
@Test
...