Android/TDD

[UnitTest][ch6] 단위테스트 스타일(출력기반, 상태기반, 통신기반) 및 사용

네모메모 2023. 11. 30. 20:47
반응형

 

 

 

 

 

 

 

 

< 배울 내용 >
단위 테스트 스타일에 대해 동일한 기준틀(3가지 스타일)을 적용하고, 

-> 이 스타일 중 하나인 '출력 기반 스타일'을 사용해 테스트 작성하는 방법과
-> 이렇게 작성된 '출력 기반 테스트'와 함수형 프로그래밍의 관련성을 배움
+첨가) 함수형 프로그래밍, 함수형 아키텍처 개념 

 

 

 

 


단위테스트의 3가지 스타일

  1. 출력 기반 테스트(output-based testing) : "SUT에 입력을 넣고 생성되는 출력 결과만 검증하는 방식."
  2. 상태 기반 테스트(state-based testing) : 작업이 "완료된 후" 시스템 "최종 상태"를 검증하는 방식
  3. 통신 기반 테스트(communication-based testing) : SUT의 "협력자를 목으로 대체"하고 "SUT가 협력자를 올바르게 호출하는지"를 검증하는 방식

      =>  위 3가지 스타일들은 하나의 테스트에서 여러 개 스타일 함께 적용가능하다.

 

 


1. 출력 기반 테스트(output-based testing)

: "SUT에 입력을 넣고 생성되는 출력 결과만 검증하는 방식."
- 호출자에게 반환하는 출력만을 검증하므로 사이드 이팩트가 없다.

- '출력 기반 테스트(output-based testing)'를 함수형이라고도 함.

- 셋 중 가장 품질이 좋은 테스트 

- 주의) 순수 함수 방식으로 작성된 코드에만 적용가능하다.
   그러므로 '출력 기반 테스트로 변환'하기 위해 함수형 프로그래밍 원칙을 사용해 기반 코드가 함수형 아키텍처를 지향하게끔 재구성해야 한다!

- ex) PriceEngine은 상품 수에 1%를 곱하고 그 결과를 20%로 제한

import org.junit.Assert
import org.junit.Test

class PriceEngine {

    fun calculateDiscount(vararg products: Product): Double {
        val discount = products.size * 0.01
        return minOf(discount, 0.2)
    }
}

class PriceEngineTest {

    @Test
    fun discountOfTwoProducts() {
        val product1 = Product("Hand wash")
        val product2 = Product("Shampoo")
        val sut = PriceEngine()

        val discount = sut.calculateDiscount(product1, product2)

        Assert.assertEquals(0.02, discount, 0.0001)
    }
}

data class Product(val name: String)

 

  ㄴ> CalculateDiscount() 메서드의 결과인 반환된 할인 즉 출력 값 뿐

 

 

 


2. 상태 기반 테스트(state-based testing)

: 작업이 "완료된 후" 시스템 "최종 상태"를 검증하는 방식

- 이 테스트 스타일에서 "상태"는 {SUT협력자 중 하나, 또는 데이터베이스나 파일 시스템 등과 같은 프로세스 외부 의존성 등}의 상태 등을 의미할 수 있다.

- 셋 중 두번째로 품질이 좋은 테스트 

- ex) 추가된 상품이 목록에 제대로 추가되었는지 "최종 상태"를 확인하는 테스트 코드

import org.junit.Assert
import org.junit.Test

class Order {
    private val _products: MutableList<Product> = mutableListOf()
    val products: List<Product>
        get() = _products.toList()

    fun addProduct(product: Product) {
        _products.add(product)
    }
}

class OrderTest {

    @Test
    fun addingAProductToAnOrder() {
        val product = Product("Hand wash")
        val sut = Order()

        sut.addProduct(product)

        Assert.assertEquals(1, sut.products.size)
        Assert.assertEquals(product, sut.products[0])
    }
}

data class Product(val name: String)

 

 

 

 

 


3.통신 기반 테스트(communication-based testing)

: SUT의 "협력자를 목으로 대체"하고 "SUT가 협력자를 올바르게 호출 하는지"를 검증하는 방식

- 주의) 간헐적으로 사용해야 하는 테스트해야 한다.

- ex)

class ControllerTest {

    @Test
    fun sending_a_greetings_email() {
        val emailGateWay = mock(IEmailGateway::class.java)
        val sut = Controller(emailGateWay)

        sut.greetUser("jun@email.com")

        verify(emailGateWay, times(1))

    }

}

 

 

 

 


[ch2 + ch6]

"단위테스트의 분파(고전파, 런던파)"와 "단위 테스트의 스타일(출력기반, 상태기반, 통신기반)"는 무슨 관계?

- 고전파와 런던파 모두 1. 출력 기반 테스트(output-based testing)를 사용한다.

- 고전파 : 2. 상태 기반 테스트(state-based testing)3. 통신기반보다 선호함

- 런던파 : 3.통신 기반 테스트(communication-based testing)2. 상태기반보다 선호함

 

 

 

 

 

 

 

 

 

 


[ch4 + ch6]

a) 회귀 방지 (=오류 방지)
b) 리팩터링 방지

c) 빠른 피드백 
d) 유지 보수성

 


 

[ 'a) 회귀 방지'와  'c) 빠른 피드백'의 지표 ]로서의 "단위 테스트의 스타일"

a) 회귀 방지스타일에 따라 달라지지 않고, 아래 3가지 특성으로 결정되지만 특성도 스타일에 영향을 받지 않는다. 

a-1) 테스트 중에 실행되는 코드의 양
a-2) 코드 복잡도
a-3) 도메인 유의성

  ㄴ> 이유 : 위 3가지 특성 모두 높던 낮던지 테스트를 작성가능하므로

  ㄴ> 예외) 3.통신 기반 테스트(communication-based testing)을 남용하는 극단적 사례에서 '피상적 테스트'가 될 수 있음. ex) 작은 코드 조각을 검증하고 다른 것은 모두 목을 사용

 

 

피상적 테스트(Shallow Testing)란?

더보기

주로 소프트웨어의 외부 동작이나 표면적인 기능에 중점을 두고 테스트 수행하는 것을 나타냅니다. 이는 소프트웨어의 내부 동작이나 구현에 대한 깊은 이해보다는 주로 외부의 사용자 경험, 기능, 상호 작용에 초점을 테스트 방법 의미합니다.
때문에 버그나 코드적 이상 감지에는 큰 감지력을 갖지 못합니다.

 

 

-  c) 빠른 피드백 또한 스타일에 영향을 받지 않는다. 

  ㄴ> 이유 : 테스트가 (프로세스 외부 의존성과 떨어져) 단위 테스트 영역에 있는 한, 모든 스타일은 테스트 실행 속도가 거의 동일하기 때문이다. 
+) 굳이 느린 시간을 찾자면 => 3.통신 기반 테스트(communication-based testing)에서 목을 사용하면 런타임에 지연 시간이 생기는 편이긴 하겠지만, 이는 테스트가 수만 개 수준이 아니라면 별로 차이가 없다.

 

 

 


 

[ 리팩터링 내성(b)의 지표 ]로서의 "단위 테스트의 스타일"

- 어떤 스타일이든 캡슐화를  지키고 테스트를 식별할  있는 동작에만 결합하면 거짓 양성을 최소로 줄일  있다.
   -> 다만, 스타일별로 취약성이 달라 필요한 노력이 다를 뿐이다.

 

+) 개념 부가 설명('리팩터링 내성', '거짓양성(=허위 경보)')

더보기
'리팩터링 내성'은 리팩터링 중에 발생하는 '거짓 양성' 수에 대한 척도이다.
 ㄴ> '거짓 양성(=허위 경보)'은 식별할 수 있는 동작이 아닌 코드의 '구현 세부 사항'에 결합된 테스트의 결과이다.

 

[ 리팩터링 내성(b)의 지표]로서의 1. 출력 기반 테스트(output-based testing)

- '거짓 양성' 방지가 가장 우수하다. (테스트 대상 메서드에서만 결합되므로)

- 이 테스트는 테스트 대상 메서드가 구현 세부 사항일”만 '구현 세부 사항에 결합'된다.

 

[ 리팩터링 내성(b)의 지표]로서의 2. 상태 기반 테스트(state-based testing)

- 일반적으로 거짓 양성이 되기 쉽다. 

   ㄴ> 이유 : 확률적으로 말하면, 테스트와 제품 코드 간의 결합도가 클수록 유출되는 구현 세부 사항에 테스트가 얽매일 가능성이 커진다. 

-> 근데 이 테스트는 "테스트 대상 메서드" 외에도 "클래스 상태"와 함께 작동한다. 때문에 상태 기반 테스트는 큰 API 노출 영역에 의존하므로, 구현 세부 사항과 결합할 가능성도 더 높다.

 

더보기

상태 기반 스타일의 테스트는 객체의 상태 변화를 중심으로 진행되기 때문에, 해당 객체의 API에 의존합니다.
ㄴ>  여기서 API "Application Programming Interface" 약자로, 클래스나 모듈 등이 외부에서 사용되는 인터페이스 의미합니다. 간단히 말해서, 다른 코드에서 해당 클래스나 모듈과 상호 작용할 있게끔 제공되는 메서드나 속성 등의 집합을 가리킵니다.

 

 

[ 리팩터링 내성(b)의 지표]로서의 3.통신 기반 테스트(communication-based testing)

- '거짓 양성'에 가장 취약하다.

   때문에 리팩터링 내성을 잘 지키려면 통신 기반 테스트를 사용할 때 더 신중해야 한다.

 

- Bad Case) 테스트 대역으로 상호 작용을 확인하는 테스트는 대부분 깨지기 쉽다. (= 항상 스텁과 상호 작용하는 경우) 이러한 상호 작용을 확인해서는 안 된다.

그러나 피상적인 테스트가 통신 기반 테스트의 결정적인 특징이 아닌 것처럼, 불안정성도 통신 기반 테스트의 결정적인 특징이 아니다.

 

- Usable Case)  애플리케이션 경계를 넘는 상호 작용을 확인하고 해당 상호 작용의 사이드 이펙트가 외부 환경에 보이는 경우에"만" mock을 사용한 테스트가 괜찮다.

 


 

 

[ 'd) 유지 보수성'의 지표 ]로서의 "단위 테스트의 스타일"

- 스타일과 밀접한 관련이 있다. 그러나 리팩터링 내성과 달리 완화할 수 있는 방법이 많지 않다.

 

-  'd) 유지 보수성'단위 테스트의 유지비를 측정한다.

-  'd) 유지 보수성'다음 두 가지 특성으로 정의 한다.

  • 테스트를 이해하기 얼마나 어려운가(테스트 크기에 대한 함수)?
    ㄴ> 테스트가 크면, 파악도 변경도 어려우므로 유지 보수가 어렵다.
  • 테스트를 실행하기 얼마나 어려운가(테스트에 직접적으로 관련 있는 프로세스 외부 의존성 개수에 대한 함수)?
    ㄴ>  하나 이상의 외부 의존성과 직접 작동하는 테스트는 데이터베이스 서버 재부팅, 네트워크 연결 문제 해결등과 같이 운영하는 시간이 필요하므로 유지 보수가 어렵다.

 

 

[ 'd) 유지 보수성'의 지표]로서의 1. 출력 기반 테스트(output-based testing)

- 가장 유지 보수하기 용이하다.

  • 테스트를 이해하기 얼마나 어려운가(테스트 크기에 대한 함수)?
    ㄴ> 이 스타일 테스트는 메서드로 입력을 공급하는 것과 해당 출력을 검증하는 두 가지(몇 줄 안된다)로 요약할 수 있기 때문에 -> 항상 짧고 간결하므로 유지 보수가 쉽다.
  • 테스트를 실행하기 얼마나 어려운가(테스트에 직접적으로 관련 있는 프로세스 외부 의존성 개수에 대한 함수)?
    ㄴ> 출력 기반 테스트의 기반 코드는 전역 상태나 내부 상태를 변경할 리 없으므로 프로세스 외부 의존성을 다루지 않는다.  

 

[ 'd) 유지 보수성'의 지표]로서의 2. 상태 기반 테스트(state-based testing)

- 2. 상태 기반 테스트(state-based testing)은 종종 출력 검증보다 더 많은 데이터를 확인해야하므로 많은 공간(크기)을 차지하기 떄문에  출력 기반 테스트보다 유지 보수가 쉽지 않다. 

ㄴ> 위와 같은 테스트를 그나마 줄이는 방법은 크게 2가지가 있다.

#1. 코드를 숨기고 테스트를 단축하는 헬퍼 메서드로 문제를 완화한다.
    이러한 메서드를 작성하고 유지하는 데 상당한 노력이 필요하다.
    더불어, 여러 테스트에 이 메서드를 재사용할 때만 이러한 노력에 명분이 생기지만, 그런 경우는 드물다.

#2. 검증 대상 클래스의 동등 멤버를 정의할 수 있다.
    다만 이 방법은 본질적으로 클래스가 값에 해당하고 값 객체로 변환할 수 있을 때만 효과적이다.

ㄴㄴ> 그러나 위 방법을 적용해도 상태 기반 테스트는 출력 기반 테스트보다 공간을 더 많이 차지하므로 유지 보수성이 떨어진다.

 

 

- Ex1) 검증보다 더 많은 데이터를 확인하는 예 (테스트는 단순하고 댓글이 하나만 있지만, 검증부는 네 줄에 걸쳐 있다;;)

import org.junit.Assert
import org.junit.Test
import org.mockito.Mockito.`when`
import org.mockito.Mockito.mock
import java.util.*

class ArticleTest {

    @Test
    fun addingACommentToAnArticle() {
        val sut = Article()
        val text = "Comment text"
        val author = "John Doe"
        val now = Date(2019 - 1900, 3, 1) 

        sut.addComment(text, author, now)

        Assert.assertEquals(1, sut.comments.size)
        Assert.assertEquals(text, sut.comments[0].text)
        Assert.assertEquals(author, sut.comments[0].author)
        Assert.assertEquals(now, sut.comments[0].dateCreated)
    }

    class Article {
        val comments: MutableList<Comment> = mutableListOf()

        fun addComment(text: String, author: String, dateCreated: Date) {
            comments.add(Comment(text, author, dateCreated))
        }
    }

    // 코틀린의 data클래스가 있다.
    data class Comment(val text: String, val author: String, val dateCreated: Date)
}

 

- Ex1 + #1) 코드를 숨기고 테스트를 단축하는 헬퍼 메서드로 문제를 완화한다.

import org.junit.Test
import org.mockito.Mockito
import java.util.*

class ArticleTest {

    @Test
    fun addingACommentToAnArticle() {
        val sut = Article()
        val text = "Comment text"
        val author = "John Doe"
        val now = Date(2019 - 1900, 3, 1)

        sut.addComment(text, author, now)

        sut.shouldContainNumberOfComments(1)
            .with(Comment(text, author, now))
    }

    class Article {
        val comments: MutableList<Comment> = mutableListOf()

        fun addComment(text: String, author: String, dateCreated: Date) {
            comments.add(Comment(text, author, dateCreated))
        }

        // #2) 헬퍼 메서드 추가
        fun shouldContainNumberOfComments(expectedCount: Int): CommentsVerifier {
            return CommentsVerifier(expectedCount, comments)
        }
    }

    data class Comment(val text: String, val author: String, val dateCreated: Date)

    // CommentsVerifier 클래스를 정의하여야 합니다.

}

 

 

- Ex1 + #2) 검증 대상 클래스의 동등 멤버를 정의할 수 있다.

import org.junit.Test
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.`is`
import java.util.*

class ArticleTest {

    @Test
    fun addingACommentToAnArticle() {
        val sut = Article()
        val comment = Comment(
            "Comment text",
            "John Doe",
            Date(2019 - 1900, 3, 1)
        )

        sut.addComment(comment.text, comment.author, comment.dateCreated)

        assertThat(sut.comments[0], `is`(comment))
    }

    class Article {
        val comments: MutableList<Comment> = mutableListOf()

        fun addComment(text: String, author: String, dateCreated: Date) {
            comments.add(Comment(text, author, dateCreated))
        }
    }

    data class Comment(val text: String, val author: String, val dateCreated: Date)
}

 

ㄴ> C#의 FluentAssertions 유사한 기능을 제공하기 위해 Hamkrest 라이브러리를 사용(assertThat과 Matchers.is를 사용)하여 테스트를 작성

 

 

 

[ 'd) 유지 보수성'의 지표]로서의 3.통신 기반 테스트(communication-based testing)

- 다른 두 가지 테스트에 비해 유지보수가 가장 어렵다.

ㄴ> 이유) 3.통신 기반 테스트(communication-based testing)에는 테스트 대역과 상호 작용 검증을 설정해야 하며 이는 공간을 많이 차지한다. 
ㄴㄴ> 특히, '목 사슬(mock chain)' 형태로 있을 때 테스트는 더 커지고 유지 보수하기가 더 어려워진다.

+)  '목 사슬(mock chain)'이란?

더보기

 '목 사슬(mock chain)'이란?
: 목이 다른 목을 반환하고, 그 다른 목은 또 다른 목을 반환하는 식으로 여러 계층이 있는 목이나 스텁

 

 


 

 

단위 테스트의 스타일(출력기반, 상태기반, 통신기반) 비교하기

 

* 세 가지 스타일 모두 'a) 회귀 방지''c) 피드백'속도 지표에서는 점수가 같다.

  1. 출력 기반 2. 상태 기반 3. 통신 기반
'리팩터링 내성(b)'을 지키기 위해 필요한 노력 낮음 중간 중간
유지비 ('d) 유지 보수성') 낮음 중간 높음

 

ㄴ> 2. 상태 기반 테스트3.통신 기반 테스트는 두 지표 모두 좋지 않다.

유출된 구현 세부 사항에 결합할 가능성이 높고, 크기도 커서 유지비가 많이 든다.

 

ㄴ> 1. 출력 기반 테스트(output-based testing)가 가장 결과가 좋다.

이 스타일은 구현 세부 사항과 거의 결합되지 않으므로 리팩터링 내성을 적절히 유지하고 주의를 많이 기울일 필요가 없다.

이러한 테스트는 간결하고 프로세스 외부 의존성이 없기 때문에 유지 보수도 쉽다.

그러므로 항상 다른 것보다 1. 출력 기반 테스트를 선호하라. 하지만 행하기는 어려울 것이다.

 

ㄴ=> 결론:  1. 출력 기반 테스트는 함수형을 작성된 코드에만 적용가능(객체지향 프로그래밍 언어는 해당X)하므로, 1. 출력 기반 테스트로 변경하기 위해서 코드를 순수 함수로 만들어보자!

 

 

 

 

 

 

 

 


출처

- 책 정보 : http://www.acornpub.co.kr/book/unit-testing

 

반응형

- 피상적인 테스트https://molla4455.gitbook.io/dev-log/react/shallow-testing

반응형