FBSnapshotTestController.m 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. /*
  2. * Copyright (c) 2015, Facebook, Inc.
  3. * All rights reserved.
  4. *
  5. * This source code is licensed under the BSD-style license found in the
  6. * LICENSE file in the root directory of this source tree. An additional grant
  7. * of patent rights can be found in the PATENTS file in the same directory.
  8. *
  9. */
  10. #import <FBSnapshotTestCase/FBSnapshotTestController.h>
  11. #import <FBSnapshotTestCase/FBSnapshotTestCasePlatform.h>
  12. #import <FBSnapshotTestCase/UIImage+Compare.h>
  13. #import <FBSnapshotTestCase/UIImage+Diff.h>
  14. #import <FBSnapshotTestCase/UIImage+Snapshot.h>
  15. #import <UIKit/UIKit.h>
  16. NSString *const FBSnapshotTestControllerErrorDomain = @"FBSnapshotTestControllerErrorDomain";
  17. NSString *const FBReferenceImageFilePathKey = @"FBReferenceImageFilePathKey";
  18. NSString *const FBReferenceImageKey = @"FBReferenceImageKey";
  19. NSString *const FBCapturedImageKey = @"FBCapturedImageKey";
  20. NSString *const FBDiffedImageKey = @"FBDiffedImageKey";
  21. typedef NS_ENUM(NSUInteger, FBTestSnapshotFileNameType) {
  22. FBTestSnapshotFileNameTypeReference,
  23. FBTestSnapshotFileNameTypeFailedReference,
  24. FBTestSnapshotFileNameTypeFailedTest,
  25. FBTestSnapshotFileNameTypeFailedTestDiff,
  26. };
  27. @implementation FBSnapshotTestController
  28. {
  29. NSString *_testName;
  30. NSFileManager *_fileManager;
  31. }
  32. #pragma mark - Initializers
  33. - (instancetype)initWithTestClass:(Class)testClass;
  34. {
  35. return [self initWithTestName:NSStringFromClass(testClass)];
  36. }
  37. - (instancetype)initWithTestName:(NSString *)testName
  38. {
  39. if (self = [super init]) {
  40. _testName = [testName copy];
  41. _deviceAgnostic = NO;
  42. _fileManager = [[NSFileManager alloc] init];
  43. }
  44. return self;
  45. }
  46. #pragma mark - Overrides
  47. - (NSString *)description
  48. {
  49. return [NSString stringWithFormat:@"%@ %@", [super description], _referenceImagesDirectory];
  50. }
  51. #pragma mark - Public API
  52. - (BOOL)compareSnapshotOfLayer:(CALayer *)layer
  53. selector:(SEL)selector
  54. identifier:(NSString *)identifier
  55. error:(NSError **)errorPtr
  56. {
  57. return [self compareSnapshotOfViewOrLayer:layer
  58. selector:selector
  59. identifier:identifier
  60. tolerance:0
  61. error:errorPtr];
  62. }
  63. - (BOOL)compareSnapshotOfView:(UIView *)view
  64. selector:(SEL)selector
  65. identifier:(NSString *)identifier
  66. error:(NSError **)errorPtr
  67. {
  68. return [self compareSnapshotOfViewOrLayer:view
  69. selector:selector
  70. identifier:identifier
  71. tolerance:0
  72. error:errorPtr];
  73. }
  74. - (BOOL)compareSnapshotOfViewOrLayer:(id)viewOrLayer
  75. selector:(SEL)selector
  76. identifier:(NSString *)identifier
  77. tolerance:(CGFloat)tolerance
  78. error:(NSError **)errorPtr
  79. {
  80. if (self.recordMode) {
  81. return [self _recordSnapshotOfViewOrLayer:viewOrLayer selector:selector identifier:identifier error:errorPtr];
  82. } else {
  83. return [self _performPixelComparisonWithViewOrLayer:viewOrLayer selector:selector identifier:identifier tolerance:tolerance error:errorPtr];
  84. }
  85. }
  86. - (UIImage *)referenceImageForSelector:(SEL)selector
  87. identifier:(NSString *)identifier
  88. error:(NSError **)errorPtr
  89. {
  90. NSString *filePath = [self _referenceFilePathForSelector:selector identifier:identifier];
  91. UIImage *image = [UIImage imageWithContentsOfFile:filePath];
  92. if (nil == image && NULL != errorPtr) {
  93. BOOL exists = [_fileManager fileExistsAtPath:filePath];
  94. if (!exists) {
  95. *errorPtr = [NSError errorWithDomain:FBSnapshotTestControllerErrorDomain
  96. code:FBSnapshotTestControllerErrorCodeNeedsRecord
  97. userInfo:@{
  98. FBReferenceImageFilePathKey: filePath,
  99. NSLocalizedDescriptionKey: @"Unable to load reference image.",
  100. NSLocalizedFailureReasonErrorKey: @"Reference image not found. You need to run the test in record mode",
  101. }];
  102. } else {
  103. *errorPtr = [NSError errorWithDomain:FBSnapshotTestControllerErrorDomain
  104. code:FBSnapshotTestControllerErrorCodeUnknown
  105. userInfo:nil];
  106. }
  107. }
  108. return image;
  109. }
  110. - (BOOL)compareReferenceImage:(UIImage *)referenceImage
  111. toImage:(UIImage *)image
  112. tolerance:(CGFloat)tolerance
  113. error:(NSError **)errorPtr
  114. {
  115. BOOL sameImageDimensions = CGSizeEqualToSize(referenceImage.size, image.size);
  116. if (sameImageDimensions && [referenceImage fb_compareWithImage:image tolerance:tolerance]) {
  117. return YES;
  118. }
  119. if (NULL != errorPtr) {
  120. NSString *errorDescription = sameImageDimensions ? @"Images different" : @"Images different sizes";
  121. NSString *errorReason = sameImageDimensions ? [NSString stringWithFormat:@"image pixels differed by more than %.2f%% from the reference image", tolerance * 100]
  122. : [NSString stringWithFormat:@"referenceImage:%@, image:%@", NSStringFromCGSize(referenceImage.size), NSStringFromCGSize(image.size)];
  123. FBSnapshotTestControllerErrorCode errorCode = sameImageDimensions ? FBSnapshotTestControllerErrorCodeImagesDifferent : FBSnapshotTestControllerErrorCodeImagesDifferentSizes;
  124. *errorPtr = [NSError errorWithDomain:FBSnapshotTestControllerErrorDomain
  125. code:errorCode
  126. userInfo:@{
  127. NSLocalizedDescriptionKey: errorDescription,
  128. NSLocalizedFailureReasonErrorKey: errorReason,
  129. FBReferenceImageKey: referenceImage,
  130. FBCapturedImageKey: image,
  131. FBDiffedImageKey: [referenceImage fb_diffWithImage:image],
  132. }];
  133. }
  134. return NO;
  135. }
  136. - (BOOL)saveFailedReferenceImage:(UIImage *)referenceImage
  137. testImage:(UIImage *)testImage
  138. selector:(SEL)selector
  139. identifier:(NSString *)identifier
  140. error:(NSError **)errorPtr
  141. {
  142. NSData *referencePNGData = UIImagePNGRepresentation(referenceImage);
  143. NSData *testPNGData = UIImagePNGRepresentation(testImage);
  144. NSString *referencePath = [self _failedFilePathForSelector:selector
  145. identifier:identifier
  146. fileNameType:FBTestSnapshotFileNameTypeFailedReference];
  147. NSError *creationError = nil;
  148. BOOL didCreateDir = [_fileManager createDirectoryAtPath:[referencePath stringByDeletingLastPathComponent]
  149. withIntermediateDirectories:YES
  150. attributes:nil
  151. error:&creationError];
  152. if (!didCreateDir) {
  153. if (NULL != errorPtr) {
  154. *errorPtr = creationError;
  155. }
  156. return NO;
  157. }
  158. if (![referencePNGData writeToFile:referencePath options:NSDataWritingAtomic error:errorPtr]) {
  159. return NO;
  160. }
  161. NSString *testPath = [self _failedFilePathForSelector:selector
  162. identifier:identifier
  163. fileNameType:FBTestSnapshotFileNameTypeFailedTest];
  164. if (![testPNGData writeToFile:testPath options:NSDataWritingAtomic error:errorPtr]) {
  165. return NO;
  166. }
  167. NSString *diffPath = [self _failedFilePathForSelector:selector
  168. identifier:identifier
  169. fileNameType:FBTestSnapshotFileNameTypeFailedTestDiff];
  170. UIImage *diffImage = [referenceImage fb_diffWithImage:testImage];
  171. NSData *diffImageData = UIImagePNGRepresentation(diffImage);
  172. if (![diffImageData writeToFile:diffPath options:NSDataWritingAtomic error:errorPtr]) {
  173. return NO;
  174. }
  175. NSLog(@"If you have Kaleidoscope installed you can run this command to see an image diff:\n"
  176. @"ksdiff \"%@\" \"%@\"", referencePath, testPath);
  177. return YES;
  178. }
  179. #pragma mark - Private API
  180. - (NSString *)_fileNameForSelector:(SEL)selector
  181. identifier:(NSString *)identifier
  182. fileNameType:(FBTestSnapshotFileNameType)fileNameType
  183. {
  184. NSString *fileName = nil;
  185. switch (fileNameType) {
  186. case FBTestSnapshotFileNameTypeFailedReference:
  187. fileName = @"reference_";
  188. break;
  189. case FBTestSnapshotFileNameTypeFailedTest:
  190. fileName = @"failed_";
  191. break;
  192. case FBTestSnapshotFileNameTypeFailedTestDiff:
  193. fileName = @"diff_";
  194. break;
  195. default:
  196. fileName = @"";
  197. break;
  198. }
  199. fileName = [fileName stringByAppendingString:NSStringFromSelector(selector)];
  200. if (0 < identifier.length) {
  201. fileName = [fileName stringByAppendingFormat:@"_%@", identifier];
  202. }
  203. if (self.isDeviceAgnostic) {
  204. fileName = FBDeviceAgnosticNormalizedFileName(fileName);
  205. }
  206. if ([[UIScreen mainScreen] scale] > 1) {
  207. fileName = [fileName stringByAppendingFormat:@"@%.fx", [[UIScreen mainScreen] scale]];
  208. }
  209. fileName = [fileName stringByAppendingPathExtension:@"png"];
  210. return fileName;
  211. }
  212. - (NSString *)_referenceFilePathForSelector:(SEL)selector
  213. identifier:(NSString *)identifier
  214. {
  215. NSString *fileName = [self _fileNameForSelector:selector
  216. identifier:identifier
  217. fileNameType:FBTestSnapshotFileNameTypeReference];
  218. NSString *filePath = [_referenceImagesDirectory stringByAppendingPathComponent:_testName];
  219. filePath = [filePath stringByAppendingPathComponent:fileName];
  220. return filePath;
  221. }
  222. - (NSString *)_failedFilePathForSelector:(SEL)selector
  223. identifier:(NSString *)identifier
  224. fileNameType:(FBTestSnapshotFileNameType)fileNameType
  225. {
  226. NSString *fileName = [self _fileNameForSelector:selector
  227. identifier:identifier
  228. fileNameType:fileNameType];
  229. NSString *folderPath = NSTemporaryDirectory();
  230. if (getenv("IMAGE_DIFF_DIR")) {
  231. folderPath = @(getenv("IMAGE_DIFF_DIR"));
  232. }
  233. NSString *filePath = [folderPath stringByAppendingPathComponent:_testName];
  234. filePath = [filePath stringByAppendingPathComponent:fileName];
  235. return filePath;
  236. }
  237. - (BOOL)_performPixelComparisonWithViewOrLayer:(id)viewOrLayer
  238. selector:(SEL)selector
  239. identifier:(NSString *)identifier
  240. tolerance:(CGFloat)tolerance
  241. error:(NSError **)errorPtr
  242. {
  243. UIImage *referenceImage = [self referenceImageForSelector:selector identifier:identifier error:errorPtr];
  244. if (nil != referenceImage) {
  245. UIImage *snapshot = [self _imageForViewOrLayer:viewOrLayer];
  246. BOOL imagesSame = [self compareReferenceImage:referenceImage toImage:snapshot tolerance:tolerance error:errorPtr];
  247. if (!imagesSame) {
  248. NSError *saveError = nil;
  249. if ([self saveFailedReferenceImage:referenceImage testImage:snapshot selector:selector identifier:identifier error:&saveError] == NO) {
  250. NSLog(@"Error saving test images: %@", saveError);
  251. }
  252. }
  253. return imagesSame;
  254. }
  255. return NO;
  256. }
  257. - (BOOL)_recordSnapshotOfViewOrLayer:(id)viewOrLayer
  258. selector:(SEL)selector
  259. identifier:(NSString *)identifier
  260. error:(NSError **)errorPtr
  261. {
  262. UIImage *snapshot = [self _imageForViewOrLayer:viewOrLayer];
  263. return [self _saveReferenceImage:snapshot selector:selector identifier:identifier error:errorPtr];
  264. }
  265. - (BOOL)_saveReferenceImage:(UIImage *)image
  266. selector:(SEL)selector
  267. identifier:(NSString *)identifier
  268. error:(NSError **)errorPtr
  269. {
  270. BOOL didWrite = NO;
  271. if (nil != image) {
  272. NSString *filePath = [self _referenceFilePathForSelector:selector identifier:identifier];
  273. NSData *pngData = UIImagePNGRepresentation(image);
  274. if (nil != pngData) {
  275. NSError *creationError = nil;
  276. BOOL didCreateDir = [_fileManager createDirectoryAtPath:[filePath stringByDeletingLastPathComponent]
  277. withIntermediateDirectories:YES
  278. attributes:nil
  279. error:&creationError];
  280. if (!didCreateDir) {
  281. if (NULL != errorPtr) {
  282. *errorPtr = creationError;
  283. }
  284. return NO;
  285. }
  286. didWrite = [pngData writeToFile:filePath options:NSDataWritingAtomic error:errorPtr];
  287. if (didWrite) {
  288. NSLog(@"Reference image save at: %@", filePath);
  289. }
  290. } else {
  291. if (nil != errorPtr) {
  292. *errorPtr = [NSError errorWithDomain:FBSnapshotTestControllerErrorDomain
  293. code:FBSnapshotTestControllerErrorCodePNGCreationFailed
  294. userInfo:@{
  295. FBReferenceImageFilePathKey: filePath,
  296. }];
  297. }
  298. }
  299. }
  300. return didWrite;
  301. }
  302. - (UIImage *)_imageForViewOrLayer:(id)viewOrLayer
  303. {
  304. if ([viewOrLayer isKindOfClass:[UIView class]]) {
  305. if (_usesDrawViewHierarchyInRect) {
  306. return [UIImage fb_imageForView:viewOrLayer];
  307. } else {
  308. return [UIImage fb_imageForViewLayer:viewOrLayer];
  309. }
  310. } else if ([viewOrLayer isKindOfClass:[CALayer class]]) {
  311. return [UIImage fb_imageForLayer:viewOrLayer];
  312. } else {
  313. [NSException raise:@"Only UIView and CALayer classes can be snapshotted" format:@"%@", viewOrLayer];
  314. }
  315. return nil;
  316. }
  317. @end