좋은 Test란?

HEETAE HEO·2022년 7월 19일
0
post-thumbnail

Why should i write testCode?

(개인)

  • 코딩 생산성 증대 : 확신을 갖고 시스템을 수정할 수 있습니다. -> TDD

  • 효율적으로 버그를 잡을 수 있습니다.

  • 코드 변경(특히 refactoring)을 쉽게 할 수 있습니다.

  • modular한 걸계에 도움 : Single responsible

Why should we write testCode?

(팀)

  • QA doesn't scale : 구글 개발팀에서는 qa를 사람 손으로 하는 것은 없다고 합니다. 왜냐하면 인력으로는 모든 앱을 테스트 할 수 없기에 테스트 자동화를 중요시한다고 합니다.

  • 협업을 위해 반드시 필요로 합니다.
    -> 여름 휴가중에 내 코드에 장애가 발생한다면 or 몇 개월 뒤의 내가 갑자기 코드를 보게 된다면
    -> 협업을 촉진 : code owner가 아니더라도 코드를 보면 수정이 가능하게 됩니다.
    문서로서의 테스트 코드, 테스트 케이스만 보면 특정 시스템의 기능과 의도 올바른 사용법을 단번에 파악가능합니다. 또한 원래 의도와 어긋나게 수정했다고 해도 테스트가 실패하므로 금방 알아챌 수 있습니다.

Google의 테스트 정책

높은 Test Coverage : 여기서 테스트 커버리지는 오직 small-sized test로만 측정합니다.
Beyonce Rule : if you liked it, then you should put a test on it -> 코드를 짠 사람이 테스트 코드 또한 작성하는 것이 예의이다 이러한 뜻이라고 합니다.

When to use real device for testing

  • DO : 안전성/ 호환성 테스트, 성능 테스트

  • DON'T : Unit tests : 할 수 있는 한 JVM에서 실행해야 합니다. 그리고 되지 않는 것은 simulator에서 실기기 테스트를 합니다. 테스트를 하기전에 먼저 고려해야 할 것들이 있습니다. 바로 다음과 같습니다.

  • 격리된 테스트 환경 구축(Hermetic testing)
    -> Protocol testing for client-server compatiblity

Test 코드를 처음 작성한다면?

1 단계

  • 작은 독립적인 부분부터 시작합니다.
    -> 계산 / 변환 로직을 가진 클래스 , 테스트 주도 개발

  • 실제로 (더) 도움이 되는 테스트 코드 작성
    -> 실제/의사 디버깅 과정에서 테스트 코드를 작성

구글의 경우 장애가 발생한다면 다음과 같이 대처한다고 합니다.

  1. 문제가 되는 PR을 찾아서 롤백합니다.

  2. 문제 PR에 장애를 재현하기 위한 테스트 코드를 작성 - 물론 그 테스트는 fail입니다.

  3. 테스트가 성공하도록 코드를 수정합니다.

2 단계

  • 실제와 가까운 단위 테스트 만들기 입니다.

의존성은 어떻게 해결하나??
-> 예 SQLite, REST/gRPC call
-> Real code > Fake > Mock/Spy/Stub
-> 1순위 : 의존성 관계이 있는 진짜 코드를 사용 - 예 : in - memory DB, prefer realism over isolation
-> 2순위 : 라이브러리에 의해 제공되는 표준 fake를 사용
-> 3순위 : 위의 방법이 불가능할 때 mock을 사용
-> hilt를 이용한 testing

Mocking Best Pratice

-> type-safe한 matcher를 활용할 것(hamcrest, truth 등 + built-in)
-> interaction보다 state를 체크할 것(appendix 참조)
-> 필요시 shared code를 적절히 사용할 것
-> Android API를 mocking하지 말 것
-> Robolectric via androidx.test
-> Fragmet 독립 생성, Life Cycle 제어 등

3 단게 (패자 부활전)

  • 테스트 코드의 유지보수가 너무 어렵다
  • 이전에 잘 돌아갔던 테스트가 어느 순간 fail이 된다.
  • 이유는 Brittle test 때문입니다.

-> 테스트가 견고하지 못하면 잘 깨지게 되고 처음부터 테스트 코드를 잘 작성해야지 깨지지 않습니다.

대원칙 : Unchanging Test

  • Test should not be changed by following reasons :
    -> pure refactorings
    -> New features
    -> Bug fixes
  • Exception : behavior changes

코드

Best Practice #1 Test via public APIs

// 송금을 하는 메서드

fun processTransaction(transaction : Transaction) { 
	if(isValid(transaction)) {
    saveToDatabase(transaction)
    }
}
private fun isValid(t: Transaction) : Boolean =
	return t.amount < t.sender.balance
    
private fun saveToDatabase(t: Transaction) { 
	val s = "${t.sender}, ${t.recipient()}, ${t.amount()}"
    database.put(t.getId(),s)
}

isValid를 어떻게 테스트를 할 수 있을까요?

잘못된 코드를 먼저 보겠습니다.

test를 빨리하기 위해 private한 코드를 public으로 변경하는 것은 잘못된 방식입니다.

@Test
fun emptyAccountShouldNotBeValid() { 
	assertThat(processor.isValide(new Transaction().setSender(EMPTY_ACCOUNT)))
    .isFalse()
}

@Test
fun shouldSaveSerializedData(){
	processor.saveToDatabase(new Transaction()
    .setId(123)
    .serSender("me")
    .setReciptient("you")
    .setAmount(100)
    aseerThat(database.get(123)).isEqualTo("mt,you,100")
}

private 경우 다음과 같이 만들어줍니다.

좋은 코드

@Test
fun shouldTransferFunds() { 
	processor.setAccountBalance("me",150)
    processor.setAccountBalance("you",20)
    
    processor.processTransaction(newTransaction()
    .setSender("me")
    .setRecipient("you")
    .setAmount(100))
    
asserThat(processor.getAccountBalance("me")).isEqualTo(50)
asserThat(processor.getAccountBalance("you")).isEqualTo(120)

다음과 같이 me의 통장에서 100을 you에 송금을 해서 통장에 남은 금액이 50원과 120이 맞는지 확인합니다.

테스트코드는 완결성을 가져야하고 간결성을 가져야합니다.

잘못된 코드

@Test
fun shouldPerformAddition() { 
	// 덧셈에 대한 함수임에도 불구하고 calculusEngine과 같은 덧셈이라는 동작과 상관없는 설정을 해주는 것은 잘못된 
    // 테스트 코드의 작성입니다. 
    
    val calculator = Calculator(RoundingStrategy() , "unused" , ENABLE_COSINE_FEATURE,0.01, calculusEngine, false)
    
    //newTestCalulation()라는 함수를 helper를 통해 불러오는데 이렇게 작성한다면 어떠한 동작을 하는지 모르는데
 	// 결과값을 5라는 값과 비교하기 때문에 본인이 작성한 개발 코드가 아니라면 이해할 수가 없습니다. 
    
    val result = calculator.calculate(newTestCalculation())
    assertThat(result).isEqualTo(5) // where did this number come from

좋은 코드

  • 테스트 본문은, 테그트 하고자 하는 것을 정확히 알 수 있는 정보를 모두 갖고 있어야하고 반대로 불필요한 내용은 감춰야합니다.
@Test
fun shouldPerformAddition() { 
	val calclator = newCalculator()
    val result = calculator.calculate(newCalculator(2,operation.PLUS,3))
    assertThat(result).isEqualTo(5)
}

다음과 같이 본문만 봐도 어떻게 동작하는지 바로 이해가 됩니다. 2에 3을 더하면 결과가 5인지 확인하는 그러한 Test코드 입니다.

Test Behaviors Not Methods

fun displayTransactionResult(user : User , transaction : Transaction) { 
	ui.showMessage("You bought a " + transaction.itemName)
    if(user, balance < LOW_BALANCE_THRESHOLD) { 
    ui.showMessage("Warning : your balance is low")
  	}
}

잘못된 코드

@Test
fun testDisplayTransactionResults(){
	transactionProcessor.displayTransactionResult(
    newUserWithBalance(
    LOW_BALANCE_THRESHOLD.plus(dollar(2))),
    Transaction("Some Item", dollars(3)))
    
assertThat(ui.getText()).contains("You bought a Some Item")
assertThat(ui.getText()).contains("your balance is low")
}

다음 코드에서는 결과값을 "" text 값을 넣어 비교를 하게 됩니다. 이렇게 text를 작성한다면 추후에 개발과정에서 text 결과값이 변경된다면 테스트 코드에서의 결과값들도 변경해줘야하는 문제가 발생하게 됩니다.

좋은 코드

@Test
fun displayTransactionResult_showsItemName() { 
	transactionProcessor.displayTransactionResults(
    User(),Transaction("Some Item"))
asserThat(ui.getText()).contains("You bought a Some Item")
}

@Test
fun displayTransactionResult_showsLowBalanceWarning() { 
	transactionProcessor.displayTransactionResult(
    newUserWithBalance(
    LOW_BALANCE)THRESHOLD.plus(dollar(2))),
    Transaction("Some Item", dollars(3)))
assertThat(ui.getText()).contains("your balance is low")
}

잘못된 코드

@Test
fun shouldNavigateToAlbumsPage() {
	val baseUrl = "http://photos.google.com/"
	val nav = navigator(baseUrl)
	nav.goToAlbumPage()
	// 예측이 안되는 결과값을 비교하는 것보다는
assertThat(nav.getCurrentUrl()).isEqaulTo(baseUrl + "/albums")
}

@Test
fun shouldNavigateToPhotosPage() 
	val nav = navigator("http://photos.google.com/")
	nav.goToAlbumPage()
assertThat(nav.getCurrentUrl()).isEqaulTo("http://photos.google.com//albums")
// 다음과 같이 하드코딩을 하더라도 정확환 결과값을 가지고 있는 값을 비교해주는 것이 좋습니다. 
// 값을 모르는 상태에서 테스트를 한다면 테스크 코드에서 에러가 발생할 가능성이 있기 때문입니다. 
}

DAMP , Not DRY

  • DAMP : Descriptive And Meaningful Pharases
  • DRY : Don't Repeat Yourself

잘못된 코드

테스트가 비슷한 종류의 테스트를 작업한다면
반복하는 것들이 많이 생성되는데 반복을 없애기 위해 테스트를 위해 헬퍼함수를 만들어서
작성을 했지만 작성된 것을 봐서는 테스트가 무엇을 목적으로 하는지를 잘 모르게되고
validateFroumAndusers가 어떠한 동작을 하는지 알 수 없습니다.

@Test
fun shouldAllowMultipleUsers() {
	val users = createUsers(false, false)
	val forum = createForumAndRegisterUsers(users)
	validateForumAndUsers(form, users)
}

@Test
public void shouldNotAllowBannedUsers(){
	val users = createUsers(true)
	val forum = createForumAndRegisterUsers(users)
	validateForumAndUsers(forum, users)
}
// Lots more tests.... 

private fun createUsers(boolean ... banned) : List<User> {
	// ....
}

좋은 코드

중복코드들이 생성되지만 일반유저를 생성하는 것을 알 수 있으며
true인지 false인지를 통해서 알겠다 이러한 것들을 할 수 있는 장점이

@Test
fun shouldAllowMultipleUsers() {
val user1 = newUser().setState(State.NORMAL).build()
val user2 = newUser().setState(State.NORMAL).build()

val forum = Forum()
forum.register(user1)
forum.register(user2)

assertThat(forum.hasRegisteredUser(user1)).isTrue()
assertThat(forum.hasRegisteredUser(user2)).isTrue()
}

@Test
위와 같이 banned가 설정된다면 등록이 False가 되어있는지를 확인한다는 것을 코드만 보고도 이해할 수 가 있습니다.
fun shouldNotRegisterBannedUsers() {
val user = newUser().setState(State.BANNED).build()

val forum = Forum()
try{
 forum.register(user)
} catch(ignored : BannedUserException) {}

assertThat(forum.hasRegisteredUser(user)).isFalse()
}

다음과 같이 좋은 테스트 코드를 작성하는 방법에 대해서 알아보았습니다.
읽어주셔서 감사합니다.

references

https://speakerdeck.com.saryong
https://testing.googleblog.com/

profile
Android 개발 잘하고 싶어요!!!

0개의 댓글