hcn1519 / TILMemo

블로그 초안 저장소
10 stars 1 forks source link

Unit Test - Test Stub #79

Closed hcn1519 closed 2 years ago

hcn1519 commented 3 years ago

Test Stub xUnit

How It Works

When to Use it

  1. 적절한 input을 구성할 수 없어서 테스트할 수 없는 코드가 발생하는 경우 - Test Stub을 통해 다양한 indirect input을 구성하고 이를 통해 SUT의 동작을 컨트롤 할 수 있다.
  2. 테스트 환경에서는 사용이 어려운 소프트웨어를 사용하는 모듈을 테스트하고자 하는 경우 - 해당 소프트웨어를 통해 제공받는 값을 Test Stub을 통해 값을 대신 주입할 수 있도록 한다.
    • 사용하능 indirect input을 검증해야 하는 경우, stub 대신 mock이나 spy를 활용한다.

Variation: Test Fixture

In xUnit , a test fixture is all the things we need to have in place in order to run a test and expect a particular outcome. Some people call this the test context .

Variation: Responder

Variation: Saboteur

Variation: Temporary Test Stub

Variation: Entity Chain Snipping

Implementation Note

hcn1519 commented 3 years ago
hcn1519 commented 3 years ago

이 글은 UnitTest를

UnitTest는 흐름

UseCase

일반적인 UnitTest

SUT(System Under Test), DOC(Depended On Component) - 두 가지 용어는 Martin Fowler의 글이나, xunit patterns와 같은 저서에서 지속적으로 나오는 용어입니다. - [SUT - System Under Test](http://xunitpatterns.com/SUT.html) - 테스트의 주요 대상이 되는 객체 - [DOC - Depended On Component](http://xunitpatterns.com/DOC.html) - SUT가 의존하고 있는 객체, UnitTest 과정에서 SUT를 테스트하는데에 있어서 real DOC(production에서 사용하는 객체)를 사용하기 어려운 경우 DOC를 [Test Double](https://martinfowler.com/bliki/TestDouble.html)(Mock, Stub 등)로 대체하여 UnitTest를 진행합니다. > Testing-oriented people like to use terms like object-under-test or system-under-test to name such a thing. Either term is an ugly mouthful to say, but as it's a widely accepted term I'll hold my nose and use it. Following Meszaros I'll use System Under Test, or rather the abbreviation SUT. - [Martin Fowler - Mocks Aren't Stubs](https://martinfowler.com/articles/mocksArentStubs.html)

1. Setup phase

Verify Phase

진행 단계와 Fixture

전통적인 UnitTest는 4단계 진행 과정으로 수행 됩니다. 각 단계에 대해 간략히 알아보도록 하겠습니다.

  1. setup exercise verify teardown

Test Stub - Step

Test Double과 Stub

Test Double is a generic term for any case where you replace a production object for testing purposes.

테스트 목적으로 만드는 객체 산출물은 모두 Test Double로 부를 수 있습니다.

hcn1519 commented 3 years ago

Test Double - Stub

Test Double is a generic term for any case where you replace a production object for testing purposes.

테스트 목적으로 만드는 객체 산출물은 모두 Test Double로 부를 수 있다.

용어 - Stub

용어 - SUT(System Under Test), DOC(Depended On Component)

Testing-oriented people like to use terms like object-under-test or system-under-test to name such a thing. Either term is an ugly mouthful to say, but as it's a widely accepted term I'll hold my nose and use it. Following Meszaros I'll use System Under Test, or rather the abbreviation SUT.

Stub 기반 UnitTest

Test Double

Stub을 사용하는 시점


참고자료

hcn1519 commented 3 years ago

The Difference Between Mocks and Stubs

Spies are stubs that also record some information based on how they were called. One form of this might be an email service that records how many messages it was sent.

hcn1519 commented 2 years ago

Test Stub

  1. What is Stub?
  2. Stub을 통한 UnitTest 수행 과정
  3. Stub을 사용하는 시점

1. What is Stub?

이번 글에서는 대표적인 Test Double 중 하나인 Stub에 대해 정리합니다.

Stub은 개발자가 테스트하고자 하는 대상(SUT, System Under Test)에게 indirect input을 제공하기 위해 사용되는 객체입니다. 대부분의 객체나 함수(Uint)는 많은 경우 주입된 input에 맞추어 output을 리턴하는 형태로 구성됩니다. 그리고, 우리가 작성하는 테스트는 자연스럽게 "내가 주입한 input에 맞추어 output이 나오는가"를 검증합니다. 이 때, 테스트 작성자는 테스트가 Unit을 적절히 검증하는지 확인하기 위해 최대한 다양한 input을 구성합니다. 이러한 경우 제공하는 input을 indirect input이라고 부릅니다. 또한, SUT에 indirect input을 제공하는 것은 Unit에 직접 주입하는 형태로 구성되기도 하지만, 또 다른 객체에서 제공하기도 합니다. 이 또 다른 객체는 SUT가 의존하는 객체라는 의미에서 DOC(Depended on Component)라고 부르고, Stub은 이 DOC에서 제공하는 input을 대신 제공하는 객체입니다.

2. Stub을 통한 UnitTest 수행 과정

알려진 것처럼 일반적인 테스트는 4단계로 진행됩니다.(setup, exercise, verify, teardown)

  1. 테스트 작성자는 이 중 Setup 단계에서 SUT에 input을 제공하는 시점에서 Stub을 사용할지 여부를 결정합니다. 즉, SUT가 의존하고 있는 DOC의 인터페이스를 구현합니다.
  2. Exercise 단계에서는 Stub은 SUT가 input을 요구하는 시점에서 적절한 indirect input을 제공합니다.
  3. Verify 단계에서는 SUT의 상태가 적절한지 판별합니다.

이를 좀 더 자세히 살펴보기 위해 예시를 살펴보겠습니다. 여기서는 사용자의 프로그램 구독을 on/off하는 HTTP API을 처리하는 SubscriptionWorker를 생각해보겠습니다. 해당 API는 다음과 같은 json 응답을 제공합니다.

{
    "subscribed": true,
    "subscriptionCount": 27038
}

이 json을 처리하기 위해 다음과 같은 형태로 SubscriptionWorker를 구성해볼 수 있습니다.

상세 코드 보기 ```swift protocol RequestConvertible { var urlRequest: URLRequest { get } } enum Subscription { enum SubscriptionError: Swift.Error { case unExpected(response: HTTPURLResponse) } struct Request: RequestConvertible { let urlRequest: URLRequest } struct Response: Decodable { let subscribed: Int let subscriptionCount: Bool } struct Worker { static func update(request: RequestConvertible, completion: @escaping ((Result) -> Void)) { let dataTask = URLSession(configuration: .default) .dataTask(with: request.urlRequest, completionHandler: { data, urlResponse, error in if let error = error { completion(.failure(error)) } guard let data = data, let urlResponse = urlResponse as? HTTPURLResponse else { return } switch urlResponse.statusCode { case 200: do { let response = try JSONDecoder().decode(Response.self, from: data) completion(.success(response)) } catch { completion(.failure(error)) } default: completion(.failure(SubscriptionError.unExpected(response: urlResponse))) } }) dataTask.resume() } } } ```

이렇게 구현한 코드는 아래처럼 호출됩니다.

let urlRequest = URLRequest(url: URL(string: "https://hcn1519.github.io")!)
Subscription.Worker.update(request: .init(urlRequest: urlRequest), completion: { result in
    // do something
    print(result)
})

이 상황에서 우리는 Subscription.Worker.update(request:completion:)을 테스트하고 싶습니다. 좀 더 정확히는 "HTTP 요청을 통해 획득한 구독 정보를 앱에서 사용 가능한 모델로 잘 전환"되는지에 대한 UseCase를 테스트하고 싶습니다. 이 때, 테스트 작성자는 여러가지 형태의 응답(indirect input)을 update()에 제공하여 해당 함수가 올바르게 동작하는지를 확인하고 싶을 수 있습니다. 그런데 문제는 update() 함수는 dataTask()를 호출하므로 외부 서버에 의존적입니다. 즉, 다양한 응답을 구성하려면 서버에서 이를 대응해주어야 합니다.

이런 경우에 Stub을 사용하면 서버의 도움 없이도 원하는 테스트를 수행할 수 있습니다.

상세 코드 보기 ```swift import Foundation public protocol RequestConvertible { var urlRequest: URLRequest { get } var stub: Stub? { get } } public enum Stub { case response(Response) public struct Response { public let response: URLResponse public let result: Result public init(response: URLResponse, result: Result) { self.response = response self.result = result } } public enum Error: Swift.Error { case emptyStubResponse case statusCode(Int) } } public enum Subscription { public enum Error: Swift.Error { case unExpected(response: HTTPURLResponse) } public struct Request: RequestConvertible { public let urlRequest: URLRequest public var stub: Stub? public init(urlRequest: URLRequest, stub: Stub?) { self.urlRequest = urlRequest self.stub = stub } } public struct Response: Decodable { public let subscribed: Bool public let subscriptionCount: Int } public struct Worker { public static func update(request: Request, completion: @escaping ((Result) -> Void)) { let dataTask = URLSession(configuration: .default) .dataTask(request: request, completionHanlder: { data, urlResponse, error in if let error = error { completion(.failure(error)) } guard let data = data, let urlResponse = urlResponse as? HTTPURLResponse else { return } switch urlResponse.statusCode { case 200: do { let response = try JSONDecoder().decode(Response.self, from: data) completion(.success(response)) } catch { completion(.failure(error)) } default: completion(.failure(Error.unExpected(response: urlResponse))) } }) dataTask?.resume() } } } extension URLSession { public typealias CompletionHandler = (Data?, URLResponse?, Swift.Error?) -> Void public func dataTask(request: RequestConvertible, completionHanlder: @escaping CompletionHandler) -> URLSessionDataTask? { guard let stub = request.stub else { return dataTask(with: request.urlRequest, completionHandler: completionHanlder) } switch stub { case .response(let stubResponse): switch stubResponse.result { case .success(let data): completionHanlder(data, stubResponse.response, nil) case .failure(let error): completionHanlder(nil, stubResponse.response, error) } } return nil } } ```

이전의 코드와 변경된 코드의 가장 큰 차이점은 Stub을 주입할 수 있는지 여부입니다. 만약 Stub을 주입하는 것을 활용한다면, update() 함수는 실제 URL에 접근하지 않고, 직접 주입한 StubResponse를 반환합니다. 즉, 서버에서 원하는 응답을 내려주지 않더라도, Stub을 통해 직접 응답 만들어서 제공할 수 있습니다.

import UIKit
import XCTest

func testSuccess() {
    let urlRequest = URLRequest(url: URL(string: "https://hcn1519.github.io")!)

    let successData = """
    {
        "subscribed": true,
        "subscriptionCount": 27038
    }
    """.data(using: .utf8)!

    let successURLResponse = HTTPURLResponse(url: urlRequest.url!,
                                             statusCode: 200,
                                             httpVersion: nil,
                                             headerFields: [:])!
    let successResponse = Stub.Response(response: successURLResponse,
                                        result: .success(successData))

    let successStub = Stub.response(successResponse)
    let successRequest = Subscription.Request(urlRequest: urlRequest,
                                              stub: successStub)

    Subscription.Worker.update(request: successRequest, completion: { result in
        switch result {
        case .success(let response):
            XCTAssert(response.subscribed == true)
            XCTAssert(response.subscriptionCount == 27038)
            print("\(#function) success")
        case .failure(let error):
            XCTAssert(false, "Result should succeed \(error.localizedDescription)")
        }
    })
}

Note: 여기서는 Stub의 동작 방식을 간결히 보여주기 위해 직접 코드를 작성하였습니다. 이러한 구현은 Moya와 같은 라이브러리에 잘 반영되어 있으니 실제 코드 작성시에는 이를 활용하면 여러모로 편리합니다.

3. Stub을 사용하는 시점

SUT의 indirect input 주입을 제어하기 어려운 경우 - Stub을 활용하면 다양한 indirect input을 구성하고 이를 통해 SUT의 동작을 제어할 수 있습니다. 예를 들어서 Stub을 사용하여 HTTP 응답을 다양한 성공, 실패 케이스로 구성하면 SUT의 동작이 원하는대로 수행되도록 제어가 가능합니다. 또한, 테스트 환경에서는 접근이 어려운 모듈(e.g. 결제 모듈)을 사용하는 경우, 해당 모듈을 통해 제공받는 값을 Test Stub을 통해 받을 수 있도록 구성하여 test를 수행할 수도 있습니다.

hcn1519 commented 2 years ago

Mock

테스트를 통해서 SUT의 동작이 올바른지 검증하는 과정에서 개발자는 크게 State Verification, Behavior Verification 과정을 통해 테스트를 수행할 수 있습니다.

이 때, Mock은 Behavior Verification을 통해 SUT 검증을 진행할 때 사용합니다.