Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Mastering TDD for iOS

Mastering TDD for iOS

Slides from Day 1 of Mastering TDD Course.

Paul Stringer

May 24, 2016
Tweet

More Decks by Paul Stringer

Other Decks in Technology

Transcript

  1. ” “ — Andy Hunt, David Thomas, The Pragmatic Programmer

    LIKE OUR HARDWARE COLLEAGUES, WE NEED TO BUILD TESTABILITY INTO SOFTWARE FROM THE VERY BEGINNING
  2. TOTAL COST OF OWNING A MESS PRODUCTIVITY 0 25 50

    75 100 TIME Source: Clean Code, Robert C. Martin
  3. ” “ — First Law of TDD YOU MAY NOT

    WRITE PRODUCTION CODE UNTIL YOU HAVE WRITTEN A FAILING TEST
  4. ” “ — Second Law of TDD YOU MAY NOT

    WRITE MORE OF A UNIT TEST THAN IS SUFFICIENT TO FAIL AND NOT COMPILING IS FAILING
  5. ” “ — Second Law of TDD YOU MAY NOT

    WRITE MORE PRODUCTION CODE THAN IS SUFFICIENT TO PASS THE CURRENTLY FAILING TEST
  6. WORKING EFFECTIVELY WITH LEGACY CODE *Working effectively with legacy code,

    Michael C. Feathers • Write a failing test • Get it to compile • Make it pass • Remove Duplication • Repeat TDD
  7. CLEARING THE THORNS CALCULATOR Go to Test Driven Development Xcode

    What happens with Zero elements, NaN what else could now happen down the line.
  8. ” “ — Robert C. Martin, Thorns around the Gold,

    Clean Coder Blog SHY AWAY FROM ANY TESTS THAT ARE CLOSE TO THE CORE FUNCTIONALITY UNTIL I HAVE COMPLETELY SURROUNDED THE PROBLEM WITH PASSING TESTS THAT DESCRIBE EVERYTHING BUT THE CORE FUNCTIONALITY blog.cleancoder.com/uncle-bob/2014/11/19/GoingForTheGold.html
  9. func testGetDataAsHtml(){ crawler.addPage(root, PathParser.parse("TestPageOne"), "test page") request.setResource("TestPageOne") request.addInput("type", "data") let

    responder = SerializedPageResponder() let response = responder.makeResponse(Context(root), request) let xml = response.getContent() XCTAssertEquals("text/xml", response.getContentType()) assertSubString("test page", xml) assertSubString("<Test", xml) } func testGetPageHieratchyAsXml() { crawler.addPage(root, PathParser.parse("PageOne")) crawler.addPage(root, PathParser.parse("PageOne.ChildOne")) crawler.addPage(root, PathParser.parse("PageTwo")) request.setResource("root") request.addInput("type", "pages") let responder = SerializedPageResponder() let response = responder.makeResponse(Context(root), request) as? SimpleResponse let xml = response.getContent() XCTAssertEqual("text/xml", response.getContentType()); assertSubString("<name>PageOne</name>", xml) assertSubString("<name>PageTwo</name>", xml) assertSubString("<name>ChildOne</name>", xml) } func testGetPageHieratchyAsXmlDoesntContainSymbolicLinks() { let pageOne = crawler.addPage(root, PathParser.parse("PageOne")) crawler.addPage(root, PathParser.parse("PageOne.ChildOne")) crawler.addPage(root, PathParser.parse("PageTwo")) let data = pageOne.getData() let properties = data.getProperties() let symLinks = properties.set(SymbolicPage.PROPERTY_NAME) symLinks.set("SymPage", "PageTwo") pageOne.commit(data) request.setResource("root") request.addInput("type", "pages") let responder = SerializedPageResponder() let response = responder.makeResponse(Context(root), request) as? SimpleResponse let xml = response.getContent() XCTAssertEqual("text/xml", response.getContentType()) assertSubString("<name>PageOne</name>", xml) assertSubString("<name>PageTwo</name>", xml) assertSubString("<name>ChildOne</name>", xml) assertNotSubString("SymPage", xml) }
  10. func testGetDataAsHtml(){ crawler.addPage(root, PathParser.parse("TestPageOne"), "test page") request.setResource("TestPageOne") request.addInput("type", "data") let

    responder = SerializedPageResponder() let response = responder.makeResponse(Context(root), request) let xml = response.getContent() XCTAssertEquals("text/xml", response.getContentType()) assertSubString("test page", xml) assertSubString("<Test", xml) } func testGetPageHieratchyAsXml() { crawler.addPage(root, PathParser.parse("PageOne")) crawler.addPage(root, PathParser.parse("PageOne.ChildOne")) crawler.addPage(root, PathParser.parse("PageTwo")) request.setResource("root") request.addInput("type", "pages") let responder = SerializedPageResponder() let response = responder.makeResponse(Context(root), request) as? SimpleResponse let xml = response.getContent() XCTAssertEqual("text/xml", response.getContentType()); assertSubString("<name>PageOne</name>", xml) assertSubString("<name>PageTwo</name>", xml) assertSubString("<name>ChildOne</name>", xml) } func testGetPageHieratchyAsXmlDoesntContainSymbolicLinks() { let pageOne = crawler.addPage(root, PathParser.parse("PageOne")) crawler.addPage(root, PathParser.parse("PageOne.ChildOne")) crawler.addPage(root, PathParser.parse("PageTwo")) let data = pageOne.getData() let properties = data.getProperties() let symLinks = properties.set(SymbolicPage.PROPERTY_NAME) symLinks.set("SymPage", "PageTwo") pageOne.commit(data) request.setResource("root") request.addInput("type", "pages") let responder = SerializedPageResponder() let response = responder.makeResponse(Context(root), request) as? SimpleResponse let xml = response.getContent() XCTAssertEqual("text/xml", response.getContentType()) assertSubString("<name>PageOne</name>", xml) assertSubString("<name>PageTwo</name>", xml) assertSubString("<name>ChildOne</name>", xml) assertNotSubString("SymPage", xml) } //SETUP //ACTION //ASSERT //SETUP //ACTION //ASSERT
  11. func testGetPageHierarchyAsXml() { makePages("PageOne", "PageOne.ChildOne", "PageTwo") submitRequest("root", "type:pages") assertResponseIsXML() assertResponseContains("<name>PageOne</name>",

    "<name>PageTwo</name>", "<name>ChildOne</name>") } func testSymbolicLinksAreNotInXmlPageHierarchy() { let page = makePage("PageOne") makePages("PageOne.ChildOne", "PageTwo") addLinkTo(page, "PageTwo", "SymPage") submitRequest("root", "type:pages") assertResponseIsXML() assertResponseContains("<name>PageOne</name>", "<name>PageTwo</name>","<name>ChildOne</name>") assertResponseDoesNotContain("SymPage") } func testGetDataAsXml() { makePageWithContent("TestPageOne", "test page") submitRequest("TestPageOne", "type:data") assertResponseIsXML() assertResponseContains("test page", "<Test") }
  12. func testGetPageHierarchyAsXml() { makePages("PageOne", "PageOne.ChildOne", "PageTwo") submitRequest("root", "type:pages") assertXMLResponseContains("<name>PageOne</name>", "<name>PageTwo</name>",

    "<name>ChildOne</name>") } func testSymbolicLinksAreNotInXmlPageHierarchy() { let page = makePage("PageOne") makePages("PageOne.ChildOne", "PageTwo") addLinkTo(page, "PageTwo", "SymPage") submitRequest("root", "type:pages") assertXMLResponseContains("<name>PageOne</name>", "<name>PageTwo</name>","<name>ChildOne</name>") assertXMLResponseDoesNotContain("SymPage") } func testGetDataAsXml() { makePageWithContent("TestPageOne", "test page") submitRequest("TestPageOne", "type:data") assertXMLResponseContains("test page", "<Test") }
  13. - CLEAN CODE “CLEAN TESTS FOLLOW THESE FIVE RULES”. •

    F.AST • I.NDEPENDANT • R.EPEATABLE • S.ELF-VALIDATING • T.IMELY
  14. ROY OSHEROVE, THE ART OF UNIT TESTING ASSERTIONS • Check

    the return value, or an exception. (Classic) • Check the state of the object, or the state of a collaborator. (Classic) • Check the object correctly interacts with a collaborator. (Mockist)
  15. ANY KIND OF PRETEND OBJECT USED IN PLACE OF A

    REAL OBJECT FOR TESTING PURPOSES TEST DOUBLES • Dummy • Fake • Stubs, • Spys • Mocks Behaviour Verification State Verification BDD Classic TDD Outcomes Mechanisms Low Coupling High Coupling Martin Fowler, Mocks aren’t Stubs - http://martinfowler.com/articles/mocksArentStubs.html
  16. KENT BECK, XP EXPLAINED FOUR RULES OF SIMPLE DESIGN •

    Runs all the tests • Contains no duplication • Expresses the intent of the programmer • Minimises the user of classes or methods (The Rules are in the order of importance) REFACTORING
  17. ” “ — Uncle Bob, Clean Code THE FACT THAT

    WE HAVE ALL THE TESTS ELIMINATES THE FEAR CLEANING UP THE CODE WILL BREAK IT
  18. class InMemoryDirectory { private var elements = [Element]() func addElement(element:

    Element) { elements.append(element) } var elementCount: Int { get { return elements.count } } func element(name: String) -> Element? { guard let index = ( elements.indexOf { (element) -> Bool in return element.name == name } ) else { return nil } return elements[index] } func generateIndex() { var index = Element(name: "Index") for element in elements { index.addText(element.name + "\n") } addElement(index) } } ISSUE “Change InMemoryDirectory so that clients can add elements and that the index be maintained.” Guard existing behaviour, before adding new ones.
  19. MICHAEL FEATHERS, WORKING EFFECTIVELY WITH LEGACY CODE THE LEGACY CODE

    CHANGE ALGORITHM • Identify Change Points • Find Test Points • Break Dependancies • Write Tests • Make Changes and Refactor class InMemoryDirectory { private var elements = [Element]() func addElement(element: Element) { elements.append(element) } var elementCount: Int { get { return elements.count } } func element(name: String) -> Element? { guard let index = ( elements.indexOf { (element) -> Bool in return element.name == name } ) else { return nil } return elements[index] } func generateIndex() { var index = Element(name: "Index") for element in elements { index.addText(element.name + "\n") } addElement(index) } }
  20. class InMemoryDirectory { private var elements = [Element]() func addElement(element:

    Element) { elements.append(element) } var elementCount: Int { get { return elements.count } } func element(name: String) -> Element? { guard let index = ( elements.indexOf { (element) -> Bool in return element.name == name } ) else { return nil } return elements[index] } func generateIndex() { var index = Element(name: "Index") for element in elements { index.addText(element.name + "\n") } addElement(index) } } generateIndex elements addElement getElement getElementCount newElement addText newElement.name creates struct Element { var name: String mutating func addText(text: String) { name = name + text } }
  21. CHANGING LEGACY CODE CODE EXCERCISE Our tests aren’t so fast.

    Lets speed them up! I’ll show you a trick.
  22. • gem install slather • slather coverage -s --scheme ChangingLegacyCode

    • slather coverage --html --show --scheme ChangingLegacyCode
  23. WORKING EFFECTIVELY WITH LEGACY CODE WHY DO WE BREAK DEPENDENCIES

    • SENSING
 
 “WE BREAK DEPENDANCIES TO SENSE WHEN WE CAN’T ACCESS VALUES OUR CODE COMPUTES” • SEPARATION
 
 “WE BREAK DEPENDANCIES TO SEPARATE WHEN WE CAN’T EVEN GET A PIECE OF CODE INTO A TEST HARNESS TO RUN”
  24. MUSIC DIFF #import <MediaPlayer/MediaPlayer.h> #import "MusicDiff.h" #import "MusicDatabase.h" #import "MusicTrack.h"

    #import "MusicTrackListen.h" #import <AdSupport/AdSupport.h> #import "NetworkManager.h" #import "DatabaseConnection.h" #import "DatabaseTransaction.h" @interface MusicDiff () @property (nonatomic,strong)__block NSMutableArray *tracksToProcess; @property (nonatomic) __block BOOL processing; @end NSString * const kMusicUpdating = @"MusicDiffProcessingNotification"; NSString * const kMusicPercentComplete = @"MusicDiffProcessingPercentComplete"; @implementation MusicDiff + (void)initialize { if (self == [MusicDiff class]) { } } + (MusicDiff *)sharedManager { static MusicDiff *_sharedManager; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ _sharedManager = [[MusicDiff alloc] init]; }); return _sharedManager; } - (void) process { if(_processing) return; [self setProcessing:YES]; if (_tracksToProcess==nil) { _tracksToProcess = [[NSMutableArray alloc]init]; }else{ [_tracksToProcess removeAllObjects]; } [[MusicDatabase sharedManager] allTracksWithCompletionHandler:^(NSArray *songs) { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0),^{ __block NSInteger lastReadDate = 0; if([[NSUserDefaults standardUserDefaults]integerForKey:@"lastItunesProcessDate"]>0){ lastReadDate = [[NSUserDefaults standardUserDefaults]integerForKey:@"lastItunesProcessDate"]; }else{ lastReadDate = [[NSDate dateWithTimeIntervalSinceNow:-2628000]timeIntervalSince1970]; } MPMediaQuery *query = [[MPMediaQuery alloc] init]; [query addFilterPredicate:[MPMediaPropertyPredicate predicateWithValue:[NSNumber numberWithInteger:MPMediaTypeMusic] forProperty:MPMediaItemPropertyMediaType]]; NSArray *items = [query items]; NSSortDescriptor *sorter = [NSSortDescriptor sortDescriptorWithKey:MPMediaItemPropertyPlayCount ascending:NO]; NSArray *newItems = [items sortedArrayUsingDescriptors:@[sorter]]; NSMutableArray *playedItems = [NSMutableArray array]; for (MPMediaItem *item in newItems) { if([item playCount]>0){ [playedItems addObject:item]; } } newItems=nil; query=nil; __block NSMutableArray *itemsToUpdate = [NSMutableArray array]; if([playedItems count]){ for (int i =0; i<[playedItems count]; i++) { NSString *trackID = [NSString stringWithFormat:@"%llu", [(NSNumber *)[(MPMediaItem*)[playedItems objectAtIndex:i] valueForProperty:MPMediaItemPropertyPersistentID] unsignedLongLongValue]]; BOOL shouldUpdate = NO; BOOL hasBeenFound = NO; for (MusicTrack *track in songs) { if ([track.trackID isEqualToString:trackID]) { hasBeenFound = YES; if([[(MPMediaItem*)[playedItems objectAtIndex:i]valueForProperty:MPMediaItemPropertyPlayCount]intValue]>[[track numberOfPlays]intValue]){ shouldUpdate=YES; } } } if(shouldUpdate||!hasBeenFound){ [itemsToUpdate addObject:(MPMediaItem*)[playedItems objectAtIndex:i]]; } } if([itemsToUpdate count]){ __block int index = 0; __block NSMutableArray *listens = [NSMutableArray array]; for (MPMediaItem *item in itemsToUpdate) { NSString *trackID = [NSString stringWithFormat:@"%llu", [(NSNumber *)[item valueForProperty:MPMediaItemPropertyPersistentID] unsignedLongLongValue]]; [[MusicDatabase sharedManager] insertOrUpdateTrackWithMediaItemID:trackID andMPMediaItem:item withCompletionHandler:^(MusicTrack *track) { if(track){ [MusicTrackListen createListenWithTrack:track andCompletion:^(MusicTrackListen *listen) { [listens addObject:listen]; index++; [[NSNotificationCenter defaultCenter]postNotificationName:kMusicUpdating object:nil userInfo:@{kMusicPercentComplete:[NSNumber numberWithFloat:(float) ((float)index/(float)[itemsToUpdate count])]}]; if(index==[itemsToUpdate count]){ if([listens count]){ NSMutableDictionary *passingDictionary = [[NSMutableDictionary alloc]initWithDictionary: @{@"deviceId":[[[ASIdentifierManager sharedManager] advertisingIdentifier] UUIDString],@"timestamp":@((long long)([[NSDate date] timeIntervalSince1970]*1000))}]; NSMutableArray *listensArray = [NSMutableArray array]; for (MusicTrackListen *listen in listens) { [listensArray addObject:listen.jsonRepresentation]; } if([[NSUserDefaults standardUserDefaults]boolForKey:@"passedInitialListens"]){ [passingDictionary setObject:[NSNumber numberWithBool:NO] forKey:@"historical"]; }else{ [passingDictionary setObject:[NSNumber numberWithBool:YES] forKey:@"historical"]; [[NSUserDefaults standardUserDefaults]setBool:YES forKey:@"passedInitialListens"]; [[NSUserDefaults standardUserDefaults]synchronize]; Hidden Dependancies Singleton Accessors Asynchronous GCD Code Long Methods System Dependancies Zero Test Coverage
  25. ” “ — Michael Feathers, Working Effectively with Legacy Code

    A SEAM IS A PLACE WHERE YOU CAN ALTER BEHAVIOUR IN YOUR PROGRAM WITHOUT EDITING IN THAT PLACE
  26. static SharedGlobalResource *_sharedInstance; + (id)sharedInstance { static dispatch_once_t onceToken; dispatch_once(&onceToken,

    ^{ _sharedTracker = [[SharedGlobalResource _hiddenAlloc] init]; }); } BREAKING DEPENDANCIES 1. INTRODUCE STATIC SETTER static SharedGlobalResource *_sharedInstance; + (id)sharedInstance { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ _sharedTracker = [[SharedGlobalResource alloc] init]; }); } + (void)setTestingSharedInstance:(SharedGlobalResource *)sharedInstance { [SharedGlobalResource sharedTracker]; _sharedInstance = sharedTracker; } - (void)testExample { SharedGlobalResource *tracker = [[TestableSharedGlobalResourceSpy alloc] init]; [SharedGlobalResource setTestingSharedTracker:tracker]; } SEAM
  27. BREAKING DEPENDANCIES 2. EXTRACT AND OVERRIDE CALL + (id)alloc{ [NSException

    raise:@"Creating SharedGlobalResource is not allowed. Use sharedInstance instead" format:@“"]; return nil; } + (id)new { return [SharedGlobalResource alloc]; } + (id)_hiddenAlloc /* Private */ { return [super alloc]; } // Subclass and Override in Test Target @interface SharedGlobalResource () + (id)_hiddenAlloc; @end @implementation TestableSharedGlobalResource: SharedGlobalResource + (id)alloc { return [super _hiddenAlloc]; } @end
  28. - (void) execute:(dispatch_block_t)completion { [self dispatchAsync:^{ // ... completion(); }];

    } - (void)dispatchAsync:(dispatch_block_t)block { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0),block); } BREAKING DEPENDANCIES 2. EXTRACT AND OVERRIDE CALL - (void) execute:(dispatch_block_t)completion { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ // ... completion(); }); } @interface TestableAsyncCommand: AsyncCommand // Subclass and Override - (void)dispatchAsync:(dispatch_block_t)block { block(); } SEAM
  29. BREAKING HIDDEN DEPENDANCIES 3. PARAMETERIZE CONSTRUCTOR, METHOD class MailChecker {

    let checkPeriodSeconds: Int let mailReceiver = MailReceiver() init(checkPeriodSeconds: Int) { self.checkPeriodSeconds = checkPeriodSeconds } } class MailChecker { let checkPeriodSeconds: Int let mailReceiver: MailReceiver init(checkPeriodSeconds: Int, mailReceiver: MailReceiver = MailReceiver()) { self.checkPeriodSeconds = checkPeriodSeconds self.mailReceiver = mailReceiver } } SEAM