[iOS] - Test Code 작성하기



코드를 새로 작성하는 것만큼이나 중요한 테스트 코드를 작성하는 법을 알아보자.


XCTest를 이용해서 로직 상의 문제, UI 문제, 그리고 성능을 테스트 할 수 있다.

앱을 Xcode에서 test하기

개요

XCTest는 다른 추상화 레벨에서 테스트를 작성할 수 있게 해준다. 좋은 테스트 전략은 테스트 각각의 이점을 극대화하기 위해 여러 타입의 테스트를 섞는 것이다.

아래 그림에서 확인할 수 있듯이 테스트의 “피라미드 형” 분포를 목표로 하는 것이 좋다. 앱의 로직을 커버할 수 있는 빠르고, 고립된 유닛 테스트를 많이 포함시키고, 서로 적절히 연결되어 있는 작은 부분들을 검증하는 것을 테스트하는 통합 테스트는 더 적게, 그리고 일반적인 use case에서 올바른 행위를 확인하는 UI 테스트는 더 적게 포함시킨다.

image

UI 테스트는 내가 예상하는 대로 앱이 동작하는 것을 보여주는 궁극적인 지표이지만, 다른 테스트를 실행하는 거보다 더 오래 걸린다. 같은 UI 테스트에서도 실패하게 되는 여러가지 앱의 요소들이 있다. 테스트 피라미드는 사용자가 그들의 작업을 수행할 수 있는 것을 검증하는 high-fidelity 테스트를 앱의 로직이 맞는지와 내가 수정한 사항들의 영향에 대해 빠른 피드백을 줄 수 있는 tightly-focused 테스트와 균형을 맞춘다.

테스트 피라미드에 더불어, 코드의 성능적으로-중요한 부분을 커버하는 성능 테스트 코드를 작성하라. 이에 대한 것은 여기에서 확인할 수 있다.

유닛 테스트 작성하기

각 유닛 테스트는 프로젝트의 메서드나 함수를 통한 단일한 경로의 예상된 행위를 확인해야 한다. 여러 가지 경로를 커버하고 싶다면 각 시나리오마다 하나의 테스트를 작성해야 한다. 예를 들어 함수가 optional 파라미터를 받는다면, 파라미터가 nil일때와 non-nil일 때의 경우를 모두 테스트해야 한다. 코드에서 논리적으로 갈라지는 부분을 정의하고, 각 케이스마다 커버하는 유닛 테스트를 작성해야 한다.

테스트할 클래스나 함수를 선택하고, 그 함수나 클래스에 대한 테스트를 포함하는 XCTestCase의 하위 클래스를 작성해라. 아무런 인자도 받지 않고 Void를 리턴하며, 이름이 “test”로 시작하는 메서드를 XCTestCase의 하위 클래스에 추가한다. Xcode에서는 새로운 파일을 만들고, 자동으로 적절한 클래스를 생성하는 Unit Test Case Class 템플릿ㅇㄹ 선택한다.

테스트 메서드는 꼭 아래의 세 단계를 포함해야 한다.

  1. Arrange. 내가 실행하는 코드의 흐름을 따라가는 객체나 데이터 구조를 생성한다. 테스트가 빠르고 결정적으로 실행될 수 있는 것을 보장할 수 있게 복잡한 의존성을 구성하기 쉬운 “stub”으로 대체한다. 프로토콜 기반 프로그래밍을 채택하는 것은 앱에서 객체 사이의 관계가 stub를 실제 구현하는 것으로 대체하는 것을 가능하게 하게끔 충분히 유연하게 만든다.
  2. Act. Arrange 단계에서 내가 구성한 파라미터와 프로퍼티를 사용해서 내가 테스트하는 메서드나 함수를 호출한다.
  3. Assert. Act 단계에서 내가 실행하는 코드의 동작과 실제 무슨 일이 일어나는지를 비교하기 위해 XCTest 프레임워크의 Test Assertion을 활용한다. Assertion의 조건이 false면 test는 실패하게 된다.

아래의 예제를 보자.

class MyAPITests : XCTestCase {
  func testMyAPIWorks() {
    // Arrange: create the necessary dependencies.
    // Act: call my API, using the dependencies created above.
    XCTAssertTrue(/* … */, "The result wasn't what I expected")
  }
}

통합 테스트 작성하기

통합 테스트는 유닛 테스트와 굉장히 비슷하게 보인다. 같은 API를 사용하고, 같은 Arrange-Act-Asser 패턴을 따른다. 유닛 테스트와 통합 테스트의 차이는 크기다. 유닛 테스트가 앱의 로직의 굉장히 작은 부분을 커버한다면, 통합 테스트는 더 넓은 하위 시스템이나 클래스나 함수의 결합을 테스트한다. Arrange 단계에서는 더 적은 stub 객체를 사용해서 테스트에서 커버하는 실제 프로젝트의 코드의 범위를 넓힌다.

모든 다른 조건을 커버하기 위해 유닛 테스트를 사용하기 보다는, 중요한 상황에서 앱의 목표를 달성하기 위해 같이 컴포넌트들이 같이 동작하는지를 확인하기 위해 통합 테스트를 사용한다. 컨트롤러에서 받아진 값이 model에 잘 넣어졌는지 테스트하는 것과, 네트워크 요청에 의해 발생된 에러가 유저 인터페이스에 잘 전달되어서 나타나지는것과 같은 테스트도 여기에 포함된다.

UI 테스트 작성하기

UI 테스트는 유닛과 통합 테스트와는 다르게 동작하지만, XCTestCase의 하위 클래스의 메서드로 여전히 분류된다. Xcode에서 새 파일을 만들 때 선택할 수 있는 UI Test Case Class 템플릿은 UI 테스트를 작성하는 시작점을 제공한다. 앱의 코드를 직접 실행하기 보다는, 이 테스트는 사용자가 앱을 이용해서 특정 작업을 끝낼 수 있는지를 판단하기 위해 실제 사용자가 하듯이 앱의 UI control을 사용한다.

중요한 사용자 task가 앱에서 완료될 수 있고 버그가 UI control의 행위를 깨지 않는 다는 것을 확인하기 위해 UI test를 생성한다. 실제 사용자의 동작을 똑같이 따라하는 UI 테스트는 앱이 의도된 작업에 사용될 수 있다는 확신을 제공한다. 문서 기반의 앱에 사용되는 UI 테스트는 앱이 새로운 문서를 만들고, 이를 수정하고, 문서를 삭제할 수 있는지를 검증할 것이다.

XCTestCase의 하위 클래스에서 UI 테스트를 생성하기 위해, Xcode의 Record UI Test 기능을 이용해서 내가 앱과 상호작용하는 것을 기록한다. 실행이 안되었을 때 사용자에게 가장 큰 영향을 줄 것 같은 중요한 실행 흐름을 그대로 따라하고, 문제를 회피할 수 있게 버그를 다시 출력할 수 있게 UI 테스트를 디자인한다.

image

내가 테스트하는 기능을 실행하는 워크플로우를 녹화했다면, test assertion 함수들을 이용해서 녹화된 상호작용이 끝나고 UI의 마지막 상태가 내가 예상한 대로인 것을 보장할 수 있게 한다.

UI 테스특여러개의 뚜렷한 단계를 구성하는 복잡한 워크 플로우를 따라하는 곳에서, XCTActivity를 이용해서 공유된 단계에 이름을 붙이고 구성한다. 여러 테스트에서 사용되는 행위들을 구현한 것을 공유하기 위해 helper 메서드를 작성한다.

성능 테스트 작성하기

특정 코드 구간이 실행될때 걸린 시간, 소요된 메모리, 덮어써진 데이터에 대한 정보를 모으는 성능 테스트를 작성해라. XCTest는 요구된 수치를 측정하면서 코드를 여러번 수행할 것이다. 수치에 대한 기대치를 제시할 수도 있고 만약 측정된 수치가 기대치보다 낮으면, XCTest는 실패하게 된다.

소요된 시간을 테스트하기 위해, measure(_:) 메서드를 테스트 함수 안에서 작성하고 코드를 실행시킨다. 사용된 메모리나 디스크에 써진 데이터의 크기를 포함한 수치를 측정하기 위해, measure(metrics:block:)을 호출해라.

class PerformanceTests : XCTestCase {
  func testCodeIsFastEnough() {
    self.measure() {
      // performance-sensitive code here
    }
  }
}

프로젝트에 Unit Test 추가하기

테스트 커버리지와 신뢰성을 증가시키기 위해 요소 간의 결합을 지운다.

개요

XCTest를 이용해서 작성하는 유닛 테스트는 수정 사항과 추가사항이 앱의 기능에 문제를 일으키지 않는다는 자신을 줌으로써 개발 속도를 증가시킬 수 있다. 테스트 성을 고려하지 않고 내린 디자인 결정은 별도의 클래스나 하위 시스템을 연결지어서 단일하게 테스트 할 수 없도록 만들기 때문에 존재하는 프로젝트에 유닛 테스트를 추가하는 것이 어려울 수 있다. 소프트웨어 디자인에서 커플링은 클래스나 함수가 한 코드가 특정한 방법으로 다른 것과 연결지어질 때만 정상적으로 동작하는 것을 확실하게 한다. 가끔, 이 커플링은 테스트가 테스트를 느리게 만들고 결과가 비결정적이게 만드는 네트워크 연결이나 파일 시스템과의 상호작용을 시도할 수 있다. 커플링을 제거하는 것은 유닛 테스트를 더 생성할 수 있게 하지만 테스트로 커버되지 않는 곳에서 코드를 수정해야 하므로 위험할 수 있다.

테스트하고 싶은 요소를 정의하고 확인하고 싶은 행위를 커버하는 테스트 케이스를 작성해서 프로젝트의 테스트 커버리지를 높인다. 사용자가 겪는 버그가 많이 나타나는 기능의 로직이나 성능이 중요한 영향을 끼치는 곳을 커버하는 것의 우선순위에 risk-focused 접근 법을 사용한다.

테스트하는 코드가 프로젝트의 다른 부분이나 프레임워크 클래스와 연관되어 있다면, 행동을 수정하지 않으면서 컴포넌트를 격리시키는 코드를 최대한 작게 변경한다. 커플링을 없애고, 각 변화와 관련된 위험을 줄이기 위해 변화를 적게 만들어 테스트에서 클래스의 사용성을 향상시킨다.

아래에 나올 내용은 고려사항과 다른 요소가 테스트하는 것을 막는 것 사이에 커플링이 존재할 때 커플링을 제거하는 변화를 소개한다. 각 해결방법은 XCTest 가 행위를 확인하기 위해 어떻게 변경된 코드와 동작하는지를 서술한다.

구체적인 타입을 프로토콜로 대체하기

내 코드가 특정 클래스에 의존하고 있어서 테스트를 어렵게 만들때, 내 코드에 의해 사용되는 메서드와 프로퍼티를 나열하는 프로토콜을 생성해라. 이런 문제가 있는 의존성은 외부 상태에 접근하고, 사용자 문서나 데이터 베이스, 혹은 비결정적인 겨ㄹ과를 갖는 것들, 그리고 네트워크 연결이나 임의의 값을 생성하는 것들을 포함한다.

밑의 리스트는 이메일이나 메세지에 첨부되는 파일을 열기 위해 NSWorkspace를 사용하는 코코아 앱의 클래스를 보여준다. openAttachment(file:in) 메서드의 결과는 사용자가 요구되는 타입을 다룰 수 있는 앱을 설치했는지와, 파일을 성공적으로 열었는지 여부에 따라 결정된다. 이 모든 요소가 테스트가 실패하게끔 만들며, 개발 속도도 코드와 상관 없는 문제들로 판명난 “에러를” 찾는 것을 느리게 만든다.

Swift

import Cocoa

enum AttachmentOpeningError : Error {
  case UnableToOpenAttachment
}

class AttachmentOpener {
  func openAttachment(file location: URL, with workspace: NSWorkspace) throws {
    if (!workspace.open(location)) {
      throw AttachmentOpeningError.UnableToOpenAttachment
    }
  }
}

Objective-C

// AttachmentOpener.h
#import <Cocoa/Cocoa.h>

NS_ASSUME_NONNULL_BEGIN
@interface AttachmentOpener : NSObject

- (BOOL)openAttachmentAtLocation:(NSURL *)file
                   withWorkspace:(NSWorkspace *)workspace
                           error:(NSError * __autoreleasing _Nullable *)error;

@end

extern NSErrorDomain const AttachmentOpenerErrorDomain;

typedef NS_ERROR_ENUM(AttachmentOpenerErrorDomain, AttachmentOpenerErrorCode) {
    AttachmentOpenerErrorUnableToOpenAttachment,
};
NS_ASSUME_NONNULL_END

// AttachmentOpener.m
#import "AttachmentOpener.h"

@implementation AttachmentOpener

- (BOOL)openAttachmentAtLocation:(NSURL *)file
                   withWorkspace:(NSWorkspace *)workspace
                           error:(NSError *__autoreleasing  _Nullable *)error {
    BOOL result = [workspace openURL:file];
    if (!result && error) {
        *error = [NSError errorWithDomain:AttachmentOpenerErrorDomain
                                     code:AttachmentOpenerErrorUnableToOpenAttachment
                                 userInfo:nil];
    }
    return result;
}

@end

const NSErrorDomain AttachmentOpenerErrorDomain = @"AttachmentOpenerErrorDomain";

이 커플링된 코드를 테스트하기 위해, 코드가 문제가 되는 의존성과 어떻게 상호작용하는지를 나타내는 프로토콜을 설계한다. 코드에서 프로토콜을 사용해서, 클래스가 프로토콜에 있는 메서드의 존재에는 영향을 받지만 구현에는 영향을 받지 않게 설정한다. 상태가 있는 것을 지양하고 비결정적인 작업을 수행하지 않는 프로토콜을 구현하고, 구현된 것을 이용해서 테스트 코드를 작성한다.

아래에서는 프로토콜은 open(_:) 메서드를 선언했고, NSWorkSpace의 extension을 이용해 이 프로토콜을 따르게 했다.

Swift

import Cocoa

enum AttachmentOpeningError: Error {
  case UnableToOpenAttachment
}

protocol URLOpener {
  func open(_ file: URL) -> Bool
}

extension NSWorkspace : URLOpener {}

class AttachmentOpener {
  func openAttachment(file location: URL, with workspace: URLOpener) throws {
    if (!workspace.open(location)) {
      throw AttachmentOpeningError.UnableToOpenAttachment
    }
  }
}

Objective-C

// URLOpener.h
#import <Cocoa/Cocoa.h>

@protocol URLOpener <NSObject>
- (BOOL)openURL:(NSURL *)url;
@end

@interface NSWorkspace (URLOpener) <URLOpener>
@end

// AttachmentOpener.h
#import <Cocoa/Cocoa.h>
#import "URLOpener.h"

NS_ASSUME_NONNULL_BEGIN

@interface AttachmentOpener : NSObject

- (BOOL)openAttachmentAtLocation:(NSURL *)file
                   withWorkspace:(id <URLOpener>)workspace
                           error:(NSError * __autoreleasing _Nullable *)error;

@end

// The rest of AttachmentOpener.h, and AttachmentOpener.m remains unchanged.

테스트에서는 사용자 컴퓨터에 설치된 앱에 의존하지 않는 URLOpener 프로토콜을 다르게 구현한다.

Swift

class StubWorkspace: URLOpener {
  var isSuccessful = true

  func open(_ file: URL) -> Bool {
    return isSuccessful
  }
}

class AttachmentOpenerTests: XCTestCase {
  var workspace: StubWorkspace! = nil
  var attachmentOpener: AttachmentOpener! = nil
  let location = URL(fileURLWithPath: "/tmp/a_file.txt")

  override func setUp() {
    workspace = StubWorkspace()
    attachmentOpener = AttachmentOpener()
  }

  override func tearDown() {
    workspace = nil
    attachmentOpener = nil
  }

  func testWorkspaceCanOpenAttachment() {
    workspace.isSuccessful = true
    XCTAssertNoThrow(try attachmentOpener.openAttachment(file: location, with: workspace))
  }

  func testThrowIfWorkspaceCannotOpenAttachment() {
    workspace.isSuccessful = false
    XCTAssertThrowsError(try attachmentOpener.openAttachment(file: location, with: workspace))
  }
}

Objective-C

#import <XCTest/XCTest.h>
#import "AttachmentOpener.h"
#import "URLOpener.h"

@interface StubWorkspace: NSObject <URLOpener>
@property (nonatomic, assign, getter=isSuccessful) BOOL successful;
@end

@implementation StubWorkspace

- (BOOL)openURL:(NSURL *)url
{
    return self.isSuccessful;
}

@end

@interface AttachmentOpenerTests : XCTestCase
@end

@implementation AttachmentOpenerTests
{
    StubWorkspace *workspace;
    AttachmentOpener *opener;
    NSURL *location;
}

- (void)setUp {
    workspace = [[StubWorkspace alloc] init];
    opener = [[AttachmentOpener alloc] init];
    location = [NSURL fileURLWithPath:@"/tmp/a_file.txt"];
}

- (void)tearDown {
    workspace = nil;
    opener = nil;
    location = nil;
}

- (void)testWorkspaceCanOpenAttachment {
    workspace.successful = YES;
    NSError *error = nil;
    XCTAssertTrue([opener openAttachmentAtLocation:location
                                     withWorkspace:workspace
                                             error:&error],
                  @"Opening attachment should have succeeded but failed with %@", error);
}

- (void)testErrorIfWorkspaceCannotOpenAttachment {
    workspace.successful = NO;
    NSError *error = nil;
    XCTAssertFalse([opener openAttachmentAtLocation:location
                                      withWorkspace:workspace
                                              error:&error],
                   @"Opening attachment should not have succeeded");
}

@end

Named Type 을 MetaType 값으로 바꾸기

앱에 있는 클래스 하나가 생성되었고 다른 클래스의 인스턴스를 쓰고, 생성된 객체가 테스트하기 어려울 때, 클래스를 생성된 곳에서 테스트하기 어려울 수 있다. 생성된 객체의 타입을 파라미터로 만들고 인스턴스를 생성하기 위해 필요한 이니셜라이저를 사용한다. 사용자의 행위에 응답해서 파일시스템에서 새로운 문저를 만들어야 한다던지, 웹 서비스에서 받은 JSON을 해석하고 받은 데이터를 나타내는 Core Data에 의해 관리되는 새로운 객체를 만드는 메서드와 같은 경우들이 복잡한 테스트 상황의 경우에 포함된다.

각각의 케이스에서 객체는 테스트하고 싶은 코드에 의해 생성되기 때문에 이를 메서드의 파라미터로써 다른 객체로 전달하지 못한다. 객체는 코드에 의해 생성될때까지 존재하지 않고, 이때는 타입이 테스트하지 못하는 상황일 때가 될 것이다.

아래는 사용자가 브라우저에서 문서를 선택할 때 문서 객체를 생성하고 여는 UIDocumentBrowserViewControllerDelegate를 보여준다. 이게 생성하는 문서 객체는 파일 시스템에서 데이터를 읽고 쓰기 때문에, 유닛 테스트에서 이를 관리하기 쉽지 않다.

Swift

class DocumentBrowserDelegate: NSObject, UIDocumentBrowserViewControllerDelegate {
  func documentBrowser(_ controller: UIDocumentBrowserViewController, didPickDocumentsAt documentURLs: [URL]) {
    guard let sourceURL = documentURLs.first else { return }
    let storyBoard = UIStoryboard(name: "Main", bundle: nil)
    let documentViewController = storyBoard.instantiateViewController(withIdentifier: "DocumentViewController") as! DocumentViewController
    documentViewController.document = Document(fileURL: sourceURL)
    documentViewController.modalPresentationStyle = .fullScreen

    controller.present(documentViewController, animated: true, completion: nil)
  }
}

Objective-C

// DocumentBrowserDelegate.h
#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN
@interface DocumentBrowserDelegate : NSObject <UIDocumentBrowserViewControllerDelegate>
@end
NS_ASSUME_NONNULL_END

// DocumentBrowserDelegate.m
#import "DocumentBrowserDelegate.h"
#import "DocumentViewController.h"
#import "Document.h"

@implementation DocumentBrowserDelegate

- (void)documentBrowser:(UIDocumentBrowserViewController *)controller
 didPickDocumentsAtURLs:(NSArray<NSURL *> *)documentURLs {
    NSURL *sourceURL = [documentURLs firstObject];
    UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"Main"
                                                         bundle:nil];
    DocumentViewController *documentViewController = [storyboard instantiateViewControllerWithIdentifier:@"DocumentViewController"];
    documentViewController.document = [[Document alloc] initWithFileURL:sourceURL];
    documentViewController.modalPresentationStyle = UIModalPresentationFullScreen;
    [controller presentViewController:documentViewController
                             animated:YES
                           completion:nil];
}

@end

내가 테스트하려는 코드와 이게 생성하는 객체 사이의 결합을 없애기 위해, 객체의 타입을 나타내는 변수를 클래스 아래에 생성해라. 변수는 meatatype value라고 불린다. 타입의 default 값을 클래스가 이미 사용하는 값으로 설정해라. 인스턴스를 생성하는데 사용되는 이니셜라이저가 required 로 표기외어있는 것을 보장해야 한다. 이 리스팅은 앞서 나온 변수와 함께 문서 브라우저 view controller delegate를 보여준다. delegate는 메타타입 값으로 정의된 타입으로 문서를 생성한다.

Swift

class DocumentBrowserDelegate : NSObject, UIDocumentBrowserViewControllerDelegate {
  var DocumentClass = Document.self

  func documentBrowser(_ controller: UIDocumentBrowserViewController, didPickDocumentsAt documentURLs: [URL]) {
    guard let sourceURL = documentURLs.first else { return }
    let storyBoard = UIStoryboard(name: "Main", bundle: nil)
    let documentViewController = storyBoard.instantiateViewController(withIdentifier: "DocumentViewController") as! DocumentViewController
    documentViewController.document = DocumentClass.init(fileURL: sourceURL)
    documentViewController.modalPresentationStyle = .fullScreen

    controller.present(documentViewController, animated: true, completion: nil)
  }
}

Objective-C

// DocumentBrowserDelegate.h

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN
@interface DocumentBrowserDelegate : NSObject <UIDocumentBrowserViewControllerDelegate>
@property (nonatomic, assign) Class DocumentClass;
@end
NS_ASSUME_NONNULL_END

// DocumentBrowserDelegate.m

#import "DocumentBrowserDelegate.h"
#import "DocumentViewController.h"
#import "Document.h"

@implementation DocumentBrowserDelegate

- (instancetype)init {
    self = [super init];
    if (self) {
        self.DocumentClass = [Document class];
    }
    return self;
}

- (void)documentBrowser:(UIDocumentBrowserViewController *)controller
 didPickDocumentsAtURLs:(NSArray<NSURL *> *)documentURLs {
    NSURL *sourceURL = [documentURLs firstObject];
    UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"Main"
                                                         bundle:nil];
    DocumentViewController *documentViewController = [storyboard instantiateViewControllerWithIdentifier:@"DocumentViewController"];
    documentViewController.document = [[self.DocumentClass alloc] initWithFileURL:sourceURL];
    documentViewController.modalPresentationStyle = UIModalPresentationFullScreen;
    [controller presentViewController:documentViewController
                             animated:YES
                           completion:nil];
}

@end

테스트에서 메타타입을 위한 다른 값을 할당해서, 코드가 같은 테스트할 수 없는 행동을 하지 않는 객체를 코드가 생성하도록 하자. 테스트에서는 문서 클래스의 “테스트 더미” 버전을 생성한다. 이 버전은 같은 인터페이스를 갖는 클래스이지만 테스트하기 어려운 행위를 구현하지 않는다. 이 경우에, 문서 클래스는 파일 시스템과 상호작용하지 말아야 한다.

Swift

class DummyDocument : Document {
  static var opensSuccessfully = true
  static var savesSuccessfully = true
  static var closesSuccessfully = true

  override func save(to url: URL, for saveOperation: UIDocument.SaveOperation, completionHandler: ((Bool) -> Void)? = nil) {
    // don't save anything, just call the completion handler
    guard let handler = completionHandler else { return }
    handler(StubDocument.savesSuccessfully)
  }

  override func close(completionHandler: ((Bool) -> Void)? = nil) {
    guard let handler = completionHandler else { return }
    handler(StubDocument.closesSuccessfully)
  }

  override func open(completionHandler: ((Bool) -> Void)? = nil) {
    guard let handler = completionHandler else { return }
    handler(StubDocument.opensSuccessfully)
  }
}

Objective-C

@interface DummyDocument : Document
@property (nonatomic, assign) BOOL savesSuccessfully;
@property (nonatomic, assign) BOOL opensSuccessfully;
@property (nonatomic, assign) BOOL closesSuccessfully;
@end

@implementation DummyDocument

- (void)saveToURL:(NSURL *)url
 forSaveOperation:(UIDocumentSaveOperation)saveOperation
completionHandler:(void (^)(BOOL))completionHandler {
    // don't save anything, just call the completion handler
    if (completionHandler) {
        completionHandler(self.savesSuccessfully);
    }
}

- (void)openWithCompletionHandler:(void (^)(BOOL))completionHandler {
    if (completionHandler) {
        completionHandler(self.opensSuccessfully);
    }
}

- (void)closeWithCompletionHandler:(void (^)(BOOL))completionHandler {
    if (completionHandler) {
        completionHandler(self.closesSuccessfully);
    }
}

@end

테스트케이스의 setUp() 메서드에서 문서타입을 터미 타입을 바꿔서 테스트에서 결정적으로 동작하는 더미 문서 타입의 인스턴스를 테스트되는 delegate가 생성할 수 있도록 하자.

Swift

class DocumentBrowserDelegateTests: XCTestCase {
  var delegate: DocumentBrowserDelegate! = nil

  override func setUp() {
    delegate = DocumentBrowserDelegate()
    delegate.DocumentClass = StubDocument.self
  }

  override func tearDown() {}

  // Add test methods here.
}

Objective-C

@implementation DocumentBrowserDelegateTests
{
    DocumentBrowserDelegate *delegate;
}

- (void)setUp {
    delegate = [[DocumentBrowserDelegate alloc] init];
    delegate.DocumentClass = [DummyDocument class];
}

- (void)tearDown {
    delegate = nil;
}

// Add test methods here.

@end

자식클래스를 만들고 테스트할 수 없는 메서드를 재정의하기

클래스를 테스트하기 어렵게 만드는 상호작용이나 행위를 가지고 있는 로직을 클래스가 가지고 있을 때, 몇개의 클래스의 메서드를 테스트하기 쉽게 만들게 override한 자식 클래스를 만들어라. 테스트에서 관리하기 어려운 행위를 출력하는 프레임워크, 환경과 상호작용하는 행위, 그리고 앱의 로직을 모두 포함하는 클래스를 디자인하는 것은 흔하다. 일반적인 예는 UIViewController 의 하위 클래스로, action 메서드에서 앱에 특정화된 코드와 다른 뷰 컨트롤러를 출력하고 뷰를 로드하는 애들이다.

커스텀 앱의 로직을 테스트하는 것은 이 로직들이 예상된 대로 작동하고 성능에 결함이 없는지를 보장하기 위해 필요하다. 클래스와 환경 사이의 상호작용을 관리하거나 작업하는 복잡도는 로직을 테스트하는 것을 더 어렵게 만든다.

예를 들어, 아래의 iOS 뷰 컨트롤러는 프로필 객체에서 찾을 수 있는 사용자의 이름으로 라벨을 생성한다. 파일의 경로를 찾기 위해 UserDefaults를 사용하고, 이걸 딕셔너리로 로드하며, UI를 생성하기 위해 딕셔너리에 있는 값들을 사용한다.

Swift

struct UserProfile {
    let name: String
}

class ProfileViewController: UIViewController {
    @IBOutlet var nameLabel: UILabel!

    override func viewDidLoad() {
        self.loadProfile() { [weak self] user in
            self?.nameLabel.text = user?.name ?? "Unknown User"
        }
    }

    func loadProfile(_ completion: (UserProfile?) -> ()) {
        let path = UserDefaults.standard.string(forKey: "ProfilePath")
        guard let thePath = path else {
            completion(nil)
            return
        }
        let profileURL = URL(fileURLWithPath: thePath)
        let profileDict = NSDictionary(contentsOf: profileURL)
        guard let profileDictionary = profileDict else {
            completion(nil)
            return
        }
        guard let userName = profileDictionary["Name"] else {
            completion(nil)
            return
        }
        let profile = UserProfile(name: userName as! String)
        completion(profile)
    }
}

Objective-C

// ProfileViewController.h
#import <UIKit/UIKit.h>

@interface ProfileViewController
@property (nonatomic, weak) IBOutlet UILabel *nameLabel;
- (void)loadProfileWithCompletion:(void(^)(Profile *))completion;
@end

// ProfileViewController.m
#import "ProfileViewController.h"
#import "Profile.h"

@implementation ProfileViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    [self loadProfileWithCompletion:^(Profile *profile) {
        if (profile) {
            self.nameLabel.text = profile.username;
        } else {
            self.nameLabel.text = @"Unknown User";
        }
    }];
}

- (void)loadProfileWithCompletion:(void (^)(Profile *))completion {
    NSString *path = [[NSUserDefaults standardUserDefaults] stringForKey:@"ProfilePath"];
    if (!path) {
        completion(nil);
        return;
    }
    NSURL *profileLocation = [NSURL fileURLWithPath:path];
    NSDictionary *profileData = [NSDictionary dictionaryWithContentsOfURL:profileLocation];
    if (!profileData) {
        completion(nil);
        return;
    }
    NSString *username = profileData[@"username"];
    if (!username) {
        completion(nil);
        return;
    }
    Profile *profile = [[Profile alloc] initWithUsername:username];
    completion(profile);
}

@end

이 복잡도를 극복하기 위해서, 뷰 컨트롤러의 자식 클래스를 만들고 더 간단한 메서드로 override해서 복잡도와 테스트할 수 없는 상호작용을 생성하는 메서드들을 “stub out”시킨다. 테스트에서 서브클래스를 사용해서 내가 override하지 않은 특정 로직의 행위를 검증한다. 만약 테스트에 있는 코드가 타겟 타입의 인스턴스를 생성한다면 메타 타입 값을 생성해야 할 수 있다.

아래의 리스트는 부모 클래스에 있는 UserDefaults와 파일 시스템의 결합도를 가지고 있지 않은 StubProfileViewController를 소개한다. 대신, 호출자에 의해 구성되지 않은 UserProfile 객체를 사용한다. 이 자식 클래스를 이용해서 테스트하면 테스트하는 로직에서 필요한 객체를 쉽게 제공할 수 있다.

Swift

class StubProfileViewController: ProfileViewController {
    var loadedProfile: UserProfile? = nil

    override func loadProfile(_ completion: (UserProfile?) -> ()) {
        completion(loadedProfile)
    }
}

Objective-C

@interface StubProfileViewController: ProfileViewController
@property (nonatomic, strong) Profile *loadedProfile;
@end

@implementation StubProfileViewController

- (void)loadProfileWithCompletion: (void (^)(Profile *))completion {
    completion(self.loadedProfile);
}

@end

두 테스트는 viewDidLoad()의 행위를 완전히 커버한다. 한 테스트는 만약 프로파일이 로드될 수 있을 때 이름이 프로파일에서 올바르게 설정되었는지를 확인한다. 다른 테스트는 만약 프로필이 로드되지 않았을때 이름으로 placeholder 값이 잘 사용되었는지를 혹인한다.

Swift

class ProfileViewControllerTests: XCTestCase {
    var profileVC: StubProfileViewController! = nil

    override func setUp() {
        profileVC = StubProfileViewController()
        // configure the label, in lieu of loading a storyboard
        profileVC.nameLabel = UILabel(frame: CGRect.zero)
    }

    override func tearDown() {}

    func testSuccessfulProfileLoadingSetsNameLabel() {
        profileVC.loadedProfile = UserProfile(name: "User Name")
        profileVC.viewDidLoad()
        XCTAssertEqual(profileVC.nameLabel.text, "User Name")
    }

    func testFailedProfileLoadingUsesPlaceholderLabelValue() {
        profileVC.loadedProfile = nil
        profileVC.viewDidLoad()
        XCTAssertEqual(profileVC.nameLabel.text, "Unknown User")
    }
}

Objective-C

@interface ProfileViewControllerTests: XCTestCase
@end

@implementation ProfileViewControllerTests
{
    StubProfileViewController *profileVC;
}

- (void)setUp {
    profileVC = [[StubProfileViewController alloc] init];
    // configure the label, in lieu of loading a storyboard
    profileVC.nameLabel = [[UILabel alloc] initWithFrame:CGRectZero];
}

- (void)tearDown {
    profileVC = nil;
}

- (void)testSuccessfulProfileLoadingSetsNameLabel {
    profileVC.loadedProfile = [[Profile alloc] initWithUsername:"User Name"];
    [profileVC viewDidLoad];
    XCTAssertEqualObjects(profileVC.nameLabel.text, "User Name");
}

- (void)testFailedProfileLoadingUsesPlaceholderLabelValue {
    profileVC.loadedProfile = nil
    [profileVC viewDidLoad];
    XCTAssertEqualObjects(profileVC.nameLabel.text, "Unknown User");
}

@end

패턴은 여러 책임을 갖고 있는 클래스를 테스트하는 것에 도움을 주지만, 테스트 가능한 코드를 디자인하는 것을 따라가기에는 좋지 않다. 다른 문제를 다루는 코드는 다른 클래스에 분리해라. 예를 들어 앱의 로직을 포함한 컨트롤러 클래스와 UIKit과 앱에 종속된 행위를 연결하는 뷰 컨트롤러를 별도로 생성한다. 유닛 테스트에서 제외한 로직을 커버하는 실제 클래스의 행위를 검증하기 위해 UI 테스트를 추가한다. 하위 클래스를 만들고 테스트할 수 없는 메서드를 오버라이딩 하는 것은 존재하는 코드를 다시 디자인하는 것의 첫번째 단계이다. 이를 통해 프레임워크나 다른 외부의 데이터와 앱의 로직이 분리될 것이다. 이런 식으로 코드를 나누는 것은 프로젝트의 어떤 부분이 앱의 기능을 구현했고, 시스템의 나머지와 통합하는지를 이해하기 쉽게 만들고, 새로운 API나 다른 기술을 채택할 때 코드에 대한 변화로 인한 로직에서 생기는 버그를 줄일 수도 있다.

Singleton 채택하기

만약 코드가 전역에서 접근가능한 상태나 행위를 하기 위해 싱글턴을 사용한다면, 싱글턴을 테스트에서만 사용가능하게 대체할 수 있는 변수로 바꿔라. 싱글톤을 사용하는 것은 codebase를 통해 퍼질 수 있고, 이것은 내가 테스트하려는 컴포넌트에 의해 사용될 때 싱글톤의 상태를 알기 어렵게 만든다. 다른 순서로 테스트를 실행하면 다른 결과가 나올 수도 있다.

NSApplication와 default FileManager를 포함해서 흔히 사용되는 싱글턴은 외부 상태에 의존하는 행위를 갖고 있고. 이 싱글톤을 사용하는 컴포넌트들은 의존적인 테스트를 하는 것에 직접적으로 복잡도를 추가한다.

이 예제에서, Cocoa 뷰 컨트롤러는 뉴스 앱의 문서 인스펙터의 일부를 출력한다. 뷰 컨트롤러에서 출력되는 객체가 변할 때, 앱의 다른 컴포넌트들이 구독하는 default notification center에 알림을 전송한다.

Swift

let InspectedArticleDidChangeNotificationName: String = "InspectedArticleDidChange"

class ArticleInspectorViewController: NSViewController {
  var article: Article! = nil

  override var representedObject: Any? {
    didSet {
      article = representedObject as! Article?
      NotificationCenter.default.post(name: NSNotification.Name(rawValue:
        InspectedArticleDidChangeNotificationName), object: article, userInfo: ["Article": article!])
    }
  }
}

Objective-C

// ArticleInspectorViewController.h
#import <Cocoa/Cocoa.h>
NS_ASSUME_NONNULL_BEGIN
@interface ArticleInspectorViewController : NSViewController
@end
extern const NSNotificationName InspectedArticleDidChangeNotificationName;
NS_ASSUME_NONNULL_END

// ArticleInspectorViewController.m
#import "ArticleInspectorViewController.h"

@implementation ArticleInspectorViewController

- (void)setRepresentedObject:(id)representedObject {
    [super setRepresentedObject:representedObject];
    [[NSNotificationCenter defaultCenter] postNotificationName: InspectedArticleDidChangeNotificationName
                                                        object: self
                                                      userInfo: @{ @"Article" : representedObject }];
}

@end

const NSNotificationName InspectedArticleDidChangeNotificationName = @"InspectedArticleDidChange";

테스트가 이런 알림을 관찰하기 위해 default notification center로 등록할 때, 단일한 싱글톤 알림 센터는 앱의 다른 컴포넌트들이 테스트의 결과에 간섭할 수 있게 만든다. 다른 코드는 알림을 전송하고 관찰자를 제거하거나, 알림에 대한 반응으로 자신만의 코드를 실행할 수 있고, 이와 같이 테스트의 결과에 영향을 받는다.

싱글톤 객체에 대한 직접적인 접근을 테스트에서 컴포넌트 밖에서 제어가 가능한 파라미터나 프로퍼티로 교체한다. 앱에서는, 컴포넌트에 협력하는 것처럼 싱글톤을 계속 사용한다. 테스트에서는, 더 쉽게 제어할 수 있는 대체 객체를 제공한다.

아래의 리스트는 위에서 봤던 문서 인스펙터 뷰 컨트롤러에 이런 변화를 적용한 것을 보여준 것이다. 뷰 컨트롤러는 notificationCenter 프로퍼티 안에 default center로 초기화 된 notification center로 전달한다.

Swift

let InspectedArticleDidChangeNotificationName = "InspectedArticleDidChange"

class ArticleInspectorViewController: NSViewController {
  var article: Article! = nil
  var notificationCenter: NotificationCenter = NotificationCenter.default

  override var representedObject: Any? {
    didSet {
      article = representedObject as! Article?
      notificationCenter.post(name: NSNotification.Name(rawValue: 
        InspectedArticleDidChangeNotificationName), object: self, userInfo: ["Article": article!])
    }
  }
}

Objective-C

// ArticleInspectorViewController.h
@interface ArticleInspectorViewController : NSViewController
@property (nonatomic, strong) NSNotificationCenter *notificationCenter;
@end

// ArticleInspectorViewController.m
@implementation ArticleInspectorViewController

- (instancetype)initWithNibName:(NSNibName)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil {
    self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
    if (self) {
        self.notificationCenter = [NSNotificationCenter defaultCenter];
    }
    return self;
}

- (void)setRepresentedObject:(id)representedObject {
    [super setRepresentedObject:representedObject];
    [self.notificationCenter postNotificationName: InspectedArticleDidChangeNotificationName
                                                        object: self
                                                      userInfo: @{ @"Article" : representedObject }];
}

@end

테스트 케이스에서는 다른 어디에서도 사용되지 않은 또 다른 notification center로 치원할 수 있다. 이렇게 하면 다른 테스트와 모듈의 행위로부터 고립시킬 수 있다.

Swift

class ArticleInspectorViewControllerTests: XCTestCase {

  var viewController: ArticleInspectorViewController! = nil
  var article: Article! = nil
  var notificationCenter: NotificationCenter! = nil
  var articleFromNotification: Article! = nil
  var observer: NSObjectProtocol? = nil

  override func setUp() {
    notificationCenter = NotificationCenter()
    viewController = ArticleInspectorViewController()
    viewController.notificationCenter = notificationCenter
    article = Article()
    observer = notificationCenter.addObserver(forName: NSNotification.Name(InspectedArticleDidChangeNotificationName), object: viewController, queue: nil) { note in
      let observedArticle = note.userInfo?["Article"]
      self.articleFromNotification = observedArticle as? Article
    }
  }

  override func tearDown() {
    notificationCenter.removeObserver(observer!)
  }

  func testNotificationSentOnChangingInspectedArticle() {
    viewController.representedObject = self.article
    XCTAssertEqual(self.articleFromNotification, self.article, "Notification should have been posted")
  }
}

Objective-C

@implementation ArticleInspectorViewControllerTests
{
    ArticleInspectorViewController *viewController;
    NSNotificationCenter *center;
    Article *article;
    Article *articleFromNotification;
    id <NSObject> observer;
}

- (void)setUp {
    center = [[NSNotificationCenter alloc] init];
    viewController = [[ArticleInspectorViewController alloc] initWithNibName:nil
                                                                      bundle:nil];
    viewController.notificationCenter = center;
    article = [[Article alloc] init];
    observer = [center addObserverForName:InspectedArticleDidChangeNotificationName
                                   object:viewController
                                    queue:nil
                               usingBlock:^(NSNotification * _Nonnull note) {
        self->articleFromNotification = [note userInfo][@"Article"];
    }];
}

- (void)tearDown {
    [center removeObserver:observer];
    observer = nil;
    center = nil;
    article = nil;
    articleFromNotification = nil;
    viewController = nil;
}

- (void)testNotificationSentOnChangingInspectedArticle {
    viewController.representedObject = article;
    XCTAssertEqualObjects(articleFromNotification, article, @"Notification should have been posted");
}

@end

출처

  • https://developer.apple.com/documentation/xcode/testing-your-apps-in-xcode
  • https://developer.apple.com/documentation/xcode/adding-unit-tests-to-your-existing-project