是否可以在没有 Xcode 的情况下使用 XCTest 单元测试?

Is it possible to use XCTest unit tests without Xcode?

我想实现 XCTest 单元测试,但真的不想使用 XCode 来实现它。如何做到这一点并不明显,我想知道这是否可能?

根据我目前的发现,人们可能会觉得 XCTest 完全依赖于 XCode。我找到了 xcodebuild test 命令行支持,但这取决于找到 XCode 项目或工作区。

我有什么选择吗,或者我只是撕掉现有的 SenTestingKit 代码并恢复到一些自制的单元测试代码?我手头有一些这样的代码,但这不是正确的做法。


Rationale/history:

这不仅仅是我守旧。我有一个 Objective-C 程序,我最后一次接触它是在两年前,为此我在 SenTestingKit 的基础上开发了一套合理的单元测试。现在我回到这段代码——我可能至少必须重建它,因为中间库发生了变化——我发现 SenTestingKit 已经消失,被 XCTest 取代。嗯嗯....

此代码不是使用 XCode 开发的,因此没有与之关联的 .project 文件,到目前为止,使用 SenTestingKit 的主程序和 Makefile 愉快地管理测试check 目标(这部分是老式的,再一次,部分是对 IDE 缺乏喜爱,部分是对 Objective-C 的实验,所以最初坚持我所知道的)。

如果您正在寻找 Xcode-based 解决方案,请参阅 this 及其链接的解决方案以获取示例。

完整的非Xcode-based解决方案继续阅读。

几年前我曾经问过类似的答案:Is there any non-Xcode-based command line unit testing tool for Objective-C?但从那以后情况发生了变化。

随着时间的推移,XCTest 中出现的一个有趣的功能是能够 运行 您的自定义测试套件。我曾经成功地实现它们以满足我的研究需要,这里是一个示例代码,它是一个命令行 Mac OS application:

@interface FooTest : XCTestCase
@end

@implementation FooTest
- (void)testFoo {
  XCTAssert(YES);
}
- (void)testFoo2 {
  XCTAssert(NO);
}

@end

@interface TestObserver : NSObject <XCTestObservation>
@property (assign, nonatomic) NSUInteger testsFailed;
@end

@implementation TestObserver

- (instancetype)init {
  self = [super init];

  self.testsFailed = 0;

  return self;
}

- (void)testBundleWillStart:(NSBundle *)testBundle {
  NSLog(@"testBundleWillStart: %@", testBundle);
}

- (void)testBundleDidFinish:(NSBundle *)testBundle {
  NSLog(@"testBundleDidFinish: %@", testBundle);
}

- (void)testSuiteWillStart:(XCTestSuite *)testSuite {
  NSLog(@"testSuiteWillStart: %@", testSuite);
}

- (void)testCaseWillStart:(XCTestCase *)testCase {
  NSLog(@"testCaseWillStart: %@", testCase);
}

- (void)testSuiteDidFinish:(XCTestSuite *)testSuite {
  NSLog(@"testSuiteDidFinish: %@", testSuite);
}

- (void)testSuite:(XCTestSuite *)testSuite didFailWithDescription:(NSString *)description inFile:(NSString *)filePath atLine:(NSUInteger)lineNumber {
  NSLog(@"testSuite:didFailWithDescription:inFile:atLine: %@ %@ %@ %tu",
        testSuite, description, filePath, lineNumber);
}

- (void)testCase:(XCTestCase *)testCase didFailWithDescription:(NSString *)description inFile:(NSString *)filePath atLine:(NSUInteger)lineNumber {
  NSLog(@"testCase:didFailWithDescription:inFile:atLine: %@ %@ %@ %tu",
        testCase, description, filePath, lineNumber);
  self.testsFailed++;
}

- (void)testCaseDidFinish:(XCTestCase *)testCase {
  NSLog(@"testCaseWillFinish: %@", testCase);
}

@end

int RunXCTests() {
  XCTestObserver *testObserver = [XCTestObserver new];

  XCTestObservationCenter *center = [XCTestObservationCenter sharedTestObservationCenter];
  [center addTestObserver:testObserver];

  XCTestSuite *suite = [XCTestSuite defaultTestSuite];

  [suite runTest];

  NSLog(@"RunXCTests: tests failed: %tu", testObserver.testsFailed);

  if (testObserver.testsFailed > 0) {
    return 1;
  }

  return 0;
}

要编译此类代码,您需要显示 XCTest 所在文件夹的路径,例如:

# in your Makefile
clang -F/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks XCTestDriver.m

不要指望代码可以编译,但它应该会给您一个想法。如果您有任何问题,请随时提出。另请关注 XCTest 框架的 headers 以了解有关其 类 及其文档的更多信息。

谢谢@stanislaw-pankevich 的精彩回答。在这里,为了完整起见,我包括了 (more-or-less) 我最终得到的完整测试程序,其中包括一些额外的细节和评论。

(从我的角度来看,这是一个完整的程序,因为它测试了功能 util.h 中定义,此处未包括)

文件UtilTest.h:

#import <XCTest/XCTest.h>
@interface UtilTest : XCTestCase
@end

文件UtilTest.m:

#import "UtilTest.h"
#import "../util.h"  // the definition of the functions being tested

@implementation UtilTest

// We could add methods setUp and tearDown here.
// Every no-arg method which starts test... is included as a test-case.

- (void)testPathCanonicalization
{
    XCTAssertEqualObjects(canonicalisePath("/p1/./p2///p3/..//f3"), @"/p1/p2/f3");
}
@end

Driver 程序 runtests.m(这是主程序,makefile 实际调用它来 运行 所有测试):

#import "UtilTest.h"
#import <XCTest/XCTestObservationCenter.h>

// Define my Observation object -- I only have to do this in one place
@interface BrownieTestObservation : NSObject<XCTestObservation>
@property (assign, nonatomic) NSUInteger testsFailed;
@property (assign, nonatomic) NSUInteger testsCalled;
@end

@implementation BrownieTestObservation

- (instancetype)init {
    self = [super init];
    self.testsFailed = 0;
    return self;
}

// We can add various other functions here, to be informed about
// various events: see XCTestObservation at
// https://developer.apple.com/reference/xctest?language=objc
- (void)testSuiteWillStart:(XCTestSuite *)testSuite {
    NSLog(@"suite %@...", [testSuite name]);
    self.testsCalled = 0;
}

- (void)testSuiteDidFinish:(XCTestSuite *)testSuite {
    NSLog(@"...suite %@ (%tu tests)", [testSuite name], self.testsCalled);
}

- (void)testCaseWillStart:(XCTestSuite *)testCase {
    NSLog(@"  test case: %@", [testCase name]);
    self.testsCalled++;
}

- (void)testCase:(XCTestCase *)testCase didFailWithDescription:(NSString *)description inFile:(NSString *)filePath atLine:(NSUInteger)lineNumber {
    NSLog(@"  FAILED: %@, %@ (%@:%tu)", testCase, description, filePath, lineNumber);
    self.testsFailed++;
}

@end

int main(int argc, char** argv) {
    XCTestObservationCenter *center = [XCTestObservationCenter sharedTestObservationCenter];
    BrownieTestObservation *observer = [BrownieTestObservation new];
    [center addTestObserver:observer];

    Class classes[] = { [UtilTest class], };  // add other classes here
    int nclasses = sizeof(classes)/sizeof(classes[0]);

    for (int i=0; i<nclasses; i++) {
        XCTestSuite *suite = [XCTestSuite testSuiteForTestCaseClass:classes[i]];
        [suite runTest];
    }

    int rval = 0;
    if (observer.testsFailed > 0) {
        NSLog(@"runtests: %tu failures", observer.testsFailed);
        rval = 1;
    }

    return rval;
}

生成文件:

FRAMEWORKS=/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks
TESTCASES=UtilTest

%.o: %.m
    clang -F$(FRAMEWORKS) -c $<

check: runtests
    ./runtests 2>runtests.stderr

runtests: runtests.o $(TESTCASES:=.o) ../libmylib.a
    cc -o $@ $< -framework Cocoa -F$(FRAMEWORKS) -rpath $(FRAMEWORKS) \
        -framework XCTest $(TESTCASES:=.o) -L.. -lmylib

备注:

  • XCTestObserver class 现已弃用,取而代之的是 XCTestObservation
  • 测试结果被发送到 shared XCTestObservationCenter,不幸的是,它会分散注意力到 stderr(因此必须重定向到其他地方)——似乎无法避免然后让他们 only 发送到我的观察中心。在我的实际程序中,我将 runtests.m 中的 NSLog 调用替换为一个向 stdout 发送信号的函数,因此我可以将其与进入默认 ObservationCenter 的信号区分开来。
  • 另见 overview documentation (假设您使用的是 XCode),
  • ...XCTest API documentation,
  • ...以及文件 headers 中的注释(例如)/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework/Headers

这个工作正常:

  1. 照常构建您的 .xctest 目标。默认情况下,它会被添加到宿主应用程序的插件中,但位置无关紧要。
  2. 使用以下代码创建运行器命令行工具。 更新:Xcode 运送在 /Applications/Xcode.app/Contents/Developer/usr/bin/xctest 中相当独立的运行器 如果您不想创建自己的简单跑步者,您可以使用这个。

  3. 运行 具有测试套件完整路径的工具。

示例运行程序代码:

#import <Foundation/Foundation.h>
#import <XCTest/XCTest.h>

int main(int argc, const char* argv[]) {
  @autoreleasepool {
    XCTestSuite *suite = [XCTestSuite testSuiteForBundlePath:
      [NSString stringWithUTF8String:argv[1]]];
    [suite runTest];

    // Note that XCTestSuite is very shy in terms of errors,
    // so make sure that it loaded anything indeed:
    if (!suite.testRun.testCaseCount) return 1;

    return suite.testRun.hasSucceeded;
  }
}