Test Code

YJoo·2023년 11월 28일
0

서버 개발 일기

목록 보기
5/5
post-thumbnail

서버 개발을 시작한 후 정말 많이 들었던 얘기 중 하나는 테스트 코드가 정말 중요하다는 얘기였다. 김영한님의 강의를 들을 때부터 테스트코드는 정말 중요하며, 대부분의 기업에서 테스트 코드를 작성하는 데 정말 많은 시간을 쏟는다는 얘기를 들었고, 소마와 다른 동아리를 거치면서도 테스트 코드의 중요성에 대해 배워왔다. 그럼 테스트 코드는 뭐고, 왜 중요한걸까?

Test Code란?

말 그대로 작성한 코드에 문제가 없는지 테스트하기 위해 작성하는 코드이다.

테스트 기본 원칙

  1. 테스팅은 결함이 없는 것이 아니라, 결함의 존재를 보여주는 것이다.
  2. 완벽한 테스트는 불가능하다.
  3. 테스트 구성은 가능한 빠른 시기에 시작한다.
  4. 결함은 군집되어 있다.(결함의 80%는 20%의 코드로 인해 발생한다.)
  5. Pesticide paradox(살충제 역설) - 비슷한 테스트가 반복되면 새로운 결함을 발견할 수 없다.
  6. 테스트는 정황에 의존적이다.(상황에 따라 다르다.)
  7. 오류 부재의 오해 - 사용되지 않는 시스템이나 사용자의 기대에 부응하지 않는 기능의 결함을 찾고 수정하는 것은 의미가 없다.

테스트 코드는 왜 작성해야 할까?

디버깅 비용 절감

애플리케이션은 항상 내적 결함을 가지고 있고, 좋은 코드와 설계를 하더라도 결함의 존재 자체를 부정할 수 없다.
때문에 버그는 항상 발생하고, 개발자는 디버깅을 통해 이를 해결한다.

하지만 문제를 해결하다보면 실제 해결하는 시간보다 문제를 찾기 위한 시간이 더 많다는 것을 깨닫게 된다.

테스트 코드 작성 습관화는 이 문제를 찾는 과정에 드는 시간을 줄여줌으로써 디버깅 비용을 줄이고, 개발자가 비즈니스 로직에 집중할 수 있게 해준다.

코드 변경에 대한 불안감 해소

회귀 버그

이전에 잘 작동하던 기능에 문제가 생기는 것을 가리킨다.

개발을 하다 보면 회귀 버그가 정말 많이 발생하게 된다.

왜 발생할까?

사실 이유는 간단한데, 애플리케이션은 단일 요소가 아닌 함수, 객체, 도메인 등 여러 요소가 상호작용하며 이루어지기 때문이다. 때문에 하나의 기능을 수정하더라도 수정된 요소가 다른 기능에 영향을 줄 수 있기 때문에 회귀 버그가 발생하게 된다.

회귀 테스트가 필요한 이유

회귀 테스트는 기능 추가나 오류 수정으로 인해 새롭게 유입되는 오류가 없는지 겁증해준다.
테스트 코드는 당시의 기능을 만들기 위해서만 필요한 코드가 아니다.
그 이후

  • 기능 변경을 위해 기존 코드를 수정하거나
  • 더 나은 코드를 위해 리팩토링을 하거나
    서비스가 지속 가능하게 발전하기 위해 필요한 코드이다.

더 나은 문서 자료

문서의 방치

코드를 이해하기 편하게 하기 위해 문서화를 진행하지만, 쉽게 방치되기도 한다.
이는 문서와 코드를 같은 유지 보수 대상으로서 가져가는 것이 어렵기 때문인데, 이 때문에 신뢰하기가 어려워진다.

코드와 가장 가까운 문서

Behavior spec 스타일의 테스트코드를 보면 given(todo 저장), when(id가 중복되면), then(저장에 실패한다) 처럼 코드가 어떤 역할을 갖는지 명세를 작성하게 된다. 이는 기존 코드를 이해하기 위한 문서의 역할을 해 도움을 주게 된다.

좋은 코드는 테스트하기 쉽다.

좋은 코드는 "변경하기 쉬운"이라는 형용사를 내포한다.
이는 약한 결합을 가지고 있는 코드를 뜻하며 강한 결합을 갖는 코드는 당연히 테스트하기 어렵다.

좋은 코드의 지표

이것이 의미하는 것은 만약 내가 작성한 코드가 테스트하기 어려운 코드라면 안좋은 코드일 가능성이 높다는 것이다.

테스트 자동화

개발자의 기도메타

본인이 작성한 코드가 실제 운영 환경에 배포됐을 때 불안감에 휩싸여 기도하는 것을 말한다. 특히 실제 운영중인 서비스라면 더욱 더 큰 불안감에 휩싸일 수 밖에 없다.

CI를 통한 테스트 자동화

CI를 통해 우리가 작성한 코드의 병합 순간 우리가 작성한 테스트 코드를 통해 버그가 배포되는 것을 막을 수 있다.
이를 통해 안정감 있는 프로젝트를 진행할 수 있고, 우리도 안정감이 생기게 된다.

테스트 코드의 종류

단위 테스트(Unit Test)

  • 가장 작은 단위의 테스트이며, 모든 테스트의 시작점이다.
  • 개별적인 코드 단위(메소드 등)이 의도한 대로 작동하는지 확인하는 과정
  • F.I.R.S.T 원칙을 갖는다.
    • Fast: 유닛 테스트는 빨라야 한다
    • Isolated: 다른 테스트에 종속적인 테스트는 작성하지 않는다
    • Repeatable: 테스트는 실행할 때마다 같은 결과를 내야 한다.
    • Self-validating: 테스트는 스스로 결과물이 옳은지 그른지 판단할 수 있어야 한다.
    • Timely: 유닛 테스트는 프로덕션 코드가 테스트를 성공하기 직전에 구성되어야 한다.

통합 테스트(Integration Test)

  • 각각 시스템들이 서로 어떻게 상호작용하고 제대로 작동하는지 테스트하는 것을 의미한다 .
  • 유닛 테스트는 다른 컴포넌트와 독립적이지만, 통합 테스트는 그렇지 않다. 유닛 테스트에서 데이터베이스에 접근하는 코드는 실제 데이터베이스와 통신하는 것은 아니지만, 통합 테스트는 실제 통신해야 한다.
  • 통합 테스트는 서로 다른 시스템끼리 잘 소통하고 있는지(예를 들어 어플리케이션과 데이터베이스가 잘 상호작용하고 있는지..) 테스트할 때 사용된다.
  • 때문에 유닛 테스트를 작성하는 것보다 복잡하고 오랜 시간이 걸리게 된다.

기능 테스트(Function Test)

  • E2E 테스트(End-to-end Test) 혹은 브라우저 테스트(Browser Test)라고도 불린다.
  • 어떤 어플리케이션이 제대로 동작하는지 완전한 기능을 테스트하는 것을 의미한다.
  • 최종 사용자의 흐름에 대한 테스트이며, 외부로부터의 요청부터 응답까지 기능이 잘 동작하는 지에 대한 테스트이다.

Test Double

테스트 목적으로 실제 객체 대신 사용되는 모든 종류의 가상 객체를 뜻한다. xUnit Test Patterns의 저자인 Gerard Meszaros는 이를 Dummy, Fake, Stub, Spy, Mock 5가지 종류로 분류했다.

Dummy

전달되지만 실제로는 사용되지 않는다. 일반적으로 매개변수 목록을 채우는 데만 사용한다.

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 객체를 전달한다.

Fake

일의 능률을 향상시켜주지만 일반적으로 프로덕션에 적합하지 않다.(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 객체)를 사용해 기능을 테스트하는 방식이다.

Stub

테스트할 동안 준비된 대답을 제공해준다. 테스트를 위해 프로그래밍된 것 외에는 응답하지 않는다.

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을 반환한다.

Spy

어떻게 부름을 받았는지에 따라 일부 정보를 기록하는 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)을 기록하는 것이 핵심이고 검증 단계에서 이 정보를 활용한다.

Mock

수신할 것으로 예상되는 호출의 사양을 형성하는 기댓값으로, 미리 프로그래밍된 객체이다.

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 구문을 이용해서 정의했다.

Stubbing vs Mocking

  • Stub: 테스트에 필요한 호출에 대해 미리 준비된 답을 제공하는 객체
  • Mock: 예상된 동작을 가진 객체

의미만 놓고 보면 같아 보이지만, 테스트 코드를 작성하는 관점에서 바라보면 크게 2가지 차이가 있다.

서로 다른 스타일로 작성된다

  • Stub: 실제 객체처럼 동작하는 클래스를 직접 구현하는데, 테스트에 필요한 구현에 집중하고 부가적인 기능은 구현하지 않는다.
  • Mock: 다양한 Mock Framework를 통해서 Mock 객체를 생성하고 특정 액션에 대한 출력을 정의한다.

상태 검증(state verification)과 행동 검증(behavior verification)

  • Stub: 상태 검증을 사용한다. 어떤 입력에 대해서 어떤 출력이 발생하는지 검증한다.
  • Mock: 행동 검증을 사용한다. 입력과 상관없이 출력을 어떻게 만들어 내는지에 집중한다(위에 Mock 예시 코드에서 every — returns 구문을 사용한 부분 참고).

Mockist Testing vs Classical Testing (취향)

  • Mockist Testing
    • 동작하는 모든 객체에 대해 항상 Mock을 사용한다.
  • Classical Testing
    • 가능하면 실제 객체를 사용하고, 실제 객체를 사용하는 것이 어색할 때 Mock이나 Test Double을 사용하고, 되도록 Mock 사용을 지양한다.

검증하고 싶은 대상에 따라 구분해 사용한다.

  • 내가 검증하고 싶은 대상이 입력에 관계없이 어떤 행동을 했을때 내가 원하는 출력이 나오기만 해도 상관없다면 Mock을 사용하면 된다.
  • 내가 검증하고 싶은 대상에 상태 검증이 필요하다면 Stub을 사용하면 된다.

Test Fixture

중복 발생되는 무언가를 고정시켜 한곳에 관리하도록 하겠다는 개념

Test Fixture 메소드 사용

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를 사용하는 방식. 각 메소드 시작 전/ 종료 후 혹은 테스트 메소드 시작 전 / 종료 후에 실행된다.

Fixture 분리

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
    ...
profile
https://github.com/Y-Joo

0개의 댓글