iOS: Test Driving Objective-C Retain & Release, Revisited

Michael found a few problems in the code I presented in iOS: Test Driving Objective-C Retain & Release.

Obviously he tried to use it :-)

Based upon his comments I have revisited the code as you can see it below.

First issue is, that the RetainReleaseMock will simply crash when the verify complains. RetainReleaseMock is no test case and so it is missing the method used to report an issue.

Next issue is, that it doesn’t provide very useful messages. You don’t see if it is complaining about the retain or the release count.

To fix the first issue, Michael moved the assertions back to the test method. Although this is a bit longer than the simple verify, it fixes the first issue and second issue in one step.

Ok, I agree, that’s better so I have revisited my code with some renaming (it is no longer a mock but a spy), a special hamcrest matcher and a noise reducing macro which you may use or not.

Here is the test code (without macro and with macro):

@interface CredentialsTest : GTMTestCase {
}
@end

@implementation CredentialsTest
- (void)testShouldRetainAndReleaseUserAndPassword {
  id user = [[[RetainReleaseSpy alloc] init] autorelease];
  id password = [[[RetainReleaseSpy alloc] init] autorelease];

  [[[Credentials alloc] initWithUser:user password:password] release];

  assertThat (user, isRetained (1));
  assertThat (user, isReleased (1));
  assertThat (password, isRetained (1));
  assertThat (password, isReleased (1));
}

@implementation CredentialsTest
- (void)testShouldRetainAndReleaseUserAndPassword {
  id user = RRSpy;
  id password = RRSpy;

  [[[Credentials alloc] initWithUser:user password:password] release];

  assertThat (user, isRetained (1));
  assertThat (user, isReleased (1));
  assertThat (password, isRetained (1));
  assertThat (password, isReleased (1));
}

@end

In this case I intentionally put everything in a single test and violate the “single assertion (concept) per test” rule. It has everything in one spot and I think I can handle “hidden assertion” issue (I wont see the failures of the later assertions when the first fails). ;-)

Assertion failures are reported like this:

-[CredentialsTest testShouldRetainAndReleaseUserAndPassword] : Expected release count 1, but was 0

The isRetained and isReleased calls are creating hamcrest matchers to make it a little bit easier to read.

Here is the code of the new RetainReleaseSpy, the matcher code (header and implementation) and the matcher factory methods:

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

// test
#import <OCHamcrest/HCBaseMatcher.h>

@interface RetainReleaseSpy : NSObject {
  int refCount;
}

@property (nonatomic, readonly) int actualRetainCount;
@property (nonatomic, readonly) int actualReleaseCount;

- (id)init;
- (id)retain;
- (void)release;

@end


id<HCMatcher> isRetained (int expectedRetainCount);
id<HCMatcher> isReleased (int expectedReleaseCount);

#define RRSpy [[[RetainReleaseMock alloc] init] autorelease]
#import "RetainReleaseSpy.h"

// test
#import <OCHamcrest/OCHamcrest.h>
#import <OCHamcrest/HCDescription.h>


@implementation RetainReleaseSpy

@synthesize actualRetainCount;
@synthesize actualReleaseCount;

- (id)init {
  if ((self = [super init])) {
    actualRetainCount = 0;
    actualReleaseCount = 0;
    refCount = 1;
  }
  return self;  
}

- (id)retain {
  ++refCount;
  ++actualRetainCount;
  return self;
}

- (void)release {
  ++actualReleaseCount;
  --refCount;
  
  if (refCount == 0) {
    [self dealloc];
  }
}

@end


@interface RetainReleaseMatcher : HCBaseMatcher {
  int expectedCount;

  SEL property;
  NSString* info;
}

- (id) initWithExpectedCount:(int)expectedCount property:(SEL)property info:(NSString*)info;

@end


@implementation RetainReleaseMatcher

- (id) initWithExpectedCount:(int)anExpectedCount property:(SEL)aProperty info:(NSString*)anInfo {
  if ((self = [super init])) {
    expectedCount = anExpectedCount;
    property = aProperty;
    info = [anInfo retain];
  }
  return self;
}

- (BOOL) matches:(id)item {
  int actualCount = (int)[item performSelector:property];
  if (actualCount != expectedCount) {
    return NO;
  }
  return YES;
}

- (void) describeTo:(id<HCDescription>)description {
  [description appendText:[NSString stringWithFormat:@"%@ count %d", info, expectedCount]];
}

- (void) describeMismatchOf:(id)item to:(id<HCDescription>)mismatchDescription {
  int actualCount = (int)[item performSelector:property];
  [mismatchDescription appendText:[NSString stringWithFormat:@"was %d", actualCount]];
}

- (void)dealloc {
  [info release];
  [super dealloc];
}   

@end


id<HCMatcher> isRetained (int expectedRetainCount)
{
  return [[[RetainReleaseMatcher alloc] initWithExpectedCount:expectedRetainCount
    property:@selector(actualRetainCount) info:@"retain"] autorelease];
}

id<HCMatcher> isReleased (int expectedReleaseCount)
{
  return [[[RetainReleaseMatcher alloc] initWithExpectedCount:expectedReleaseCount
    property:@selector(actualReleaseCount) info:@"release"] autorelease];
}

Better than before? I think so.

Advertisements

iOS: Test Driving Objective-C Retain & Release

Does it make sense to write unit test for retain & release for my classes?

If I strictly follow Uncle Bob’s TDD Rules then yes. I have to write some test code that will make me add the retain & release cycle for an object.

Even if I don’t strictly follow the rules it may make sense… at least it is a reminder to add the release. Which I forget from time to time.

But finally it is your decision if you test it or not. Anyway, I’d like to talk about how would I do it?

First I tried to achieve it with OCMock but that did not work at all. Fiddling with retain and release on objects out of your control is, let’s say, one on the easiest way to make your test code crash ;-)

So I created a hand made mock called RetainReleaseMock that tracks the retain & release calls.

Here is a test that demonstrates how I use it. It tests that my Credentials object properly retains & releases the user and password NSStrings passed to the initWithUser:password: method.

@interface CredentialsTest : GTMTestCase {
}
@end

@implementation CredentialsTest

- (void)testShouldRetainAndReleaseUserAndPassword {
 id user = [[[RetainReleaseMock alloc] init] autorelease];
 id password = [[[RetainReleaseMock alloc] init] autorelease];

 [[[Credentials alloc] initWithUser:user password:password] release];

 [user verify];
 [password verify];
}

@end

First I create a RetainReleaseMock for the user and password string. Then I create the Credentials object (passing the two mocks) to trigger the retain and directly release it again to trigger the release.

The final step is to verify that retain and release was called on each mock.

That’s it.

Note that you can use [autorelease] to get the mock properly deallocated.

Here is the code of the RetainReleaseMock:

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

@interface RetainReleaseMock : NSObject {
  int refCount;

  int expectedRetainCount;
  int expectedReleaseCount;
  int actualRetainCount;
  int actualReleaseCount;
}

- (id)init;
- (id)initWithRetain:(int)retainCount release:(int)releaseCount;

- (id)retain;
- (void)release;
- (void)verify;

@end

#import "RetainReleaseMock.h"

// test
#import <OCHamcrest/OCHamcrest.h>

@implementation RetainReleaseMock

- (id)init {
  return [self initWithRetain:1 release:1];
}

- (id)initWithRetain:(int)retainCount release:(int)releaseCount {
  if ((self = [super init])) {
    expectedRetainCount = retainCount;
    expectedReleaseCount = releaseCount;
    actualRetainCount = 0;
    actualReleaseCount = 0;
    refCount = 1;
  }
  return self;
}

- (id)retain {
  ++refCount;
  ++actualRetainCount;
  return self;
}

- (void)release {
  ++actualReleaseCount;
  --refCount;

  if (refCount == 0) {
    [self dealloc];
  }
}

- (void)verify {
  assertThatInt (actualRetainCount, equalToInt (expectedRetainCount));
  assertThatInt (actualReleaseCount, equalToInt (expectedReleaseCount));
}

@end

In case you wonder what the second init method is for:

I have added it to test retain & release on an AppDelegate where I cannot pass the dependencies directly to the AppDelegate (.. because it is created by “iOS”). The AppDelegate calls a (global) factory object that I have stubbed (with OCMock) to return the RetainReleaseMock. The factory stub retains my mock too so I have to adjust the expected retain count.

The test then looks like this:

- (void)testReleasesCoreData {
  // extra retain from appFactory stub.
  id coredata = [[[RetainReleaseMock alloc] initWithRetain:2 release:1] autorelease];

  id appFactory = Stub (AppFactory);
  [[[appFactory stub] andReturn:controller] mainViewController];
  [AppFactory setFactory:appFactory];

  [delegate application:nil didFinishLaunchingWithOptions:nil];
  [delegate release];

  [coredata verify];
  [ReadmeAppFactory reset];
}

If you have a better suggestion to handle this situation, I would like to hear it. :-)