Share
Embed
Transcript
46 slides
-
” “ — Andy Hunt, David Thomas, The Pragmatic Programmer
LIKE OUR HARDWARE COLLEAGUES, WE NEED TO BUILD TESTABILITY INTO SOFTWARE FROM THE VERY BEGINNING -
TOTAL COST OF OWNING A MESS PRODUCTIVITY 0 25 50
75 100 TIME Source: Clean Code, Robert C. Martin -
” “ — First Law of TDD YOU MAY NOT
WRITE PRODUCTION CODE UNTIL YOU HAVE WRITTEN A FAILING TEST -
” “ — Second Law of TDD YOU MAY NOT
WRITE MORE OF A UNIT TEST THAN IS SUFFICIENT TO FAIL AND NOT COMPILING IS FAILING -
” “ — Second Law of TDD YOU MAY NOT
WRITE MORE PRODUCTION CODE THAN IS SUFFICIENT TO PASS THE CURRENTLY FAILING TEST -
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 -
CLEARING THE THORNS CALCULATOR Go to Test Driven Development Xcode
What happens with Zero elements, NaN what else could now happen down the line. -
” “ — 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 -
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) } -
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 -
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") } -
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") } -
- CLEAN CODE “CLEAN TESTS FOLLOW THESE FIVE RULES”. •
F.AST • I.NDEPENDANT • R.EPEATABLE • S.ELF-VALIDATING • T.IMELY -
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) -
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 -
func testCalculatorCallsNthMomentForSecondMomentWithPoint() { let calculatorMock = OCPartialMock(calculator) OCMExpect(calculator.nthMomentAboutPoint(1.0, n: 2.0))
calculator.secondMomentAboutPoint(1.0) XCTAssetNoThrow(calculator.verify()) } ❌ -
WORKING WITH LEGACY CODE FAKING COLLABORATORS Sale -(void)scan Hardware Display
Show Price and Name -
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 -
” “ — Uncle Bob, Clean Code THE FACT THAT
WE HAVE ALL THE TESTS ELIMINATES THE FEAR CLEANING UP THE CODE WILL BREAK IT -
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. -
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) } } -
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 } } -
CHANGING LEGACY CODE CODE EXCERCISE Our tests aren’t so fast.
Lets speed them up! I’ll show you a trick. -
• gem install slather • slather coverage -s --scheme ChangingLegacyCode
• slather coverage --html --show --scheme ChangingLegacyCode -
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” -
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 -
” “ — 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 -
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 -
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 -
- (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 -
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