My book publisher offers me two options for submitting text, Microsoft Word or an XML-based format. I’ve grown quite fond of Markdown lately and so I formed the idea that I could write my book in markdown and use a parser to create this XML from it.
And since I like to craft my own libraries (because most of the time I’m learning something in the process) I started to work on DTMarkdownParser.
I started work on the library with several design goals in mind:
- Test-driven Development: fashion unit test cases for all the individual scenarios you could encounter in a markdown text and then implement the simplest possible way to get the unit test to pass. Rinse and repeat.
- 100% coverage: set up Travis-CI and Coveralls to make sure that unit test coverage never decreases through pull requests and to also create tests for all possible branches in the parser core code
- No external dependencies: Other projects often depend on libraries like the well-known discount library. But as I stated above this project is meant to be mainly an exercise for me and so depending on an external static lib is out of the question
- Event-based API: similar to NSXMLParser I wanted to have events to be sent to a parsing delegate instead of generating HTML output. This would allow developers to hook up their own code to those events, or even use it as input for DTCoreText.
- Collaborative: There are quite a few enhancements for Markdown and I depend on fellow developers to point these out – as needed – and where possible even implement them.
Unit-Testing a Delegate Protocol
Popular unit testing frameworks like OCMockito provide the ability to have an object pretend to be another. This is called “mocking”. The pretend-object is called a “mock”. You would use those objects in place of delegates to be able to test a specific class in isolation.
Typically you would mock a delegate object, set it as the delegate, call some code and then ask the mock if certain delegate methods were called with certain parameters. A variant of this theme is to specify the return value for specific mocked methods.
For DTMarkdownParser I initially started out with OCMockito (which uses OCHamcrest) to mock delegates. But soon I realized that this would be too limited for me as there are several methods being called for most unit test cases. OCMockito – as far as I am aware – does not have a facility to tell you in which order methods have been called.
Consider the following example:
Hello Markdown. *This is emphasized*
This would result in the following methods being invoked on the delegate:
- Begin of document
- Begin of paragraph tag
- Found characters: “Hello Markdown. “
- Begin of EM tag
- Found characters. “This is emphasized”
- End of EM tag
- End of paragraph tag
- End of document
OCMockito would only allow me to inquire if there was a call to the method indicating that characters were found. But I couldn’t test the exact order.
And then there were also issues with getting OCMockito to work with code coverage on Travis-CI… so I removed it and switched back to SenTest.
Enter DTInvocationRecorder
So I built DTInvocationRecorder which can be configured to mock any kind of protocol. Then you set it as delegate. It records all invocations of all methods and you can then determine exactly what methods were called how.
In the unit test class I’m setting up the recorder like so:
- (void)setUp { [super setUp]; _recorder = [[DTInvocationRecorder alloc] init]; [_recorder addProtocol:@protocol(DTMarkdownParserDelegate)]; } |
The -addProtocol: method lets you specify protocols that it should pretend to be implementing. From the protocol it derives all necessary instance methods and will record only these. Any other method would produce an unrecognized selector exception.
Since I want to reset the contents of the recorder for each test case I’m clearing the table of recorded invocation before each test is executed.
- (void)performTest:(SenTestRun *)aRun { // clear recorder before each test [_recorder clearLog]; [super performTest:aRun]; } |
Now to test if the -parserDidStartDocument: event method is sent, I just do this:
- (void)testStartDocument { NSString *string = @"Hello Markdown"; DTMarkdownParser *parser = [self _parserForString:string options:0]; BOOL result = [parser parse]; STAssertTrue(result, @"Parser should return YES"); DTAssertInvocationRecorderContainsCallWithParameter(_recorder, @selector(parserDidStartDocument:), nil); } |
You see I created my own assert macro which abstracts away the complexity of going into the recorders invocations and seeing if there is any invocation for this selector. To build that I looked at how the SenTest macros work and rejiggered one to do my bidding.
If you are interested to learn how this works you can look at the DTMarkdownParser unit test code. Suffice it to say that the result is that you end up with a very simple and easy to grasp assertion to parse. You assert that there is a call to a certain selector. If there is none then the unit test fails like you expect to.
This works nicely for simple invocations, but for the more complex ones it makes little sense having to check for the presence of all the individual delegate method calls. Instead I build a helper method that constructs “quick and dirty” HTML from the invocations just like a consumer of the API would. This HTML I can now compare against some HTML I got from a markdown editor app.
Conclusion
If you have anything to do with markdown please have a look at DTMarkdownParser. The first released version bears the version number 0.1.0, is tagged as such on GitHub as well as available as Cocoapod.
DTInvocationRecorder is also available for your inspection on GitHub. It’s only part of the test targets but is universally usable. It contains some neat tricks using the Objective-C runtime to dynamically add protocol methods and record them. There is also a nice category on NSInvocation which allows you to retrieve arguments from invocations as objects.
I worked hard on this over the last few days to get the basics working. The TDD approach helped to get the unit test code coverage to 100% and I would like for it to remain this way. If you know of any scenarios that it doesn’t handle correctly as of yet, please open an issue and post your test case.
Or – preferably – go about improving and enhancing the code to cover these special cases. I would love to receive your pull request.
Categories: Updates