TDD: Back to Hand Rolled Stubs
I’m unashamedly an Agile practitioner and self-proclaimed enthusiast. It’s not a perfect way to build software, but I haven’t found anything better. And until I do, this is the approach I’m taking.
Building Quality In
One of the core principles of Agile is the focus of building quality in from the start, not asserting quality after the product is built. At first this is counter-intuitive, right? How can you ensure the quality of something which is in the process of being built? This is where TDD saves the day. Write your tests first – set the expectations of what the code is supposed to do – then write the production code.
When I rewrote Booked for the 2.0 release I did it all test-first. In fact, there are thousands of assertions covering the majority of the code base. This is super important for a dynamic language like PHP where it is very easy to shoot yourself in the foot. Lots of tests = high confidence that my change didn’t break something. Of course, unit tests in a statically typed language are also critical.
[ad name=”Leaderboard”]
The Unit Testing Approach
I use PHPUnit in Booked and it works great. Aside from providing your typical unit testing functionality, PHPUnit also includes test doubles. Mocks and stubs are great for substituting behavior and isolating components in your tests. It’s a technique I rely on heavily and it leads to loosely coupled code and more focused tests.
Martin Fowler has a great writeup comparing mocks, stubs and other substitution patterns.
Death By Over-Specification
When I first starting practicing TDD there weren’t a lot of mocking frameworks or libraries available. We wrote our stubs and mocks by hand and used a plain assertion framework (nUnit in the .NET space). It takes some time to write this code – you’re building new objects to stand in for existing objects. The interfaces need to match, the return types need to match, and in some cases the return object needs to be set up in a certain way. It can be a decent amount of work. My laziness is what led me to mocking frameworks.
Mocking libraries gained a lot of traction around 2006/2007. Mocks, and mocking frameworks in particular, work by setting up expectations and then returning appropriate responses when you exercise the code. This awesome because you can quickly substitute behavior of specific methods without building up a whole object.
Great! Look how fast I’m going!
One problem with mocks is that they want to know everything about your code. For example, given you pass parameters x, y, z to this function then return this specific result. This is great when you care about how you execute a function – and there are valid reasons to care about that. This is not great when all you want to do is return a canned response and move on. One of the most frequent arguments I hear against unit testing and TDD is that the tests are fragile and difficult to maintain. Over-specification is generally the real cause of these problems.
An Example
Here’s a test pulled straight from the Booked test suite.
public function testBindsDefaultScheduleByMonthWhenNothingSelected() { $this->page = $this->getMock('ICalendarPage'); $this->repository = $this->getMock('IReservationViewRepository'); $this->scheduleRepository = $this->getMock('IScheduleRepository'); $this->calendarFactory = $this->getMock('ICalendarFactory'); $this->resourceService = $this->getMock('IResourceService'); $this->subscriptionService = $this->getMock('ICalendarSubscriptionService'); $this->privacyFilter = new FakePrivacyFilter(); $this->presenter = new CalendarPresenter( $this->page, $this->calendarFactory, $this->repository, $this->scheduleRepository, $this->resourceService, $this->subscriptionService, $this->privacyFilter); $showInaccessible = true; $this->fakeConfig->SetSectionKey(ConfigSection::SCHEDULE, ConfigKeys::SCHEDULE_SHOW_INACCESSIBLE_RESOURCES, 'true'); $userId = $this->fakeUser->UserId; $defaultScheduleId = 10; $userTimezone = "America/New_York"; $calendarType = CalendarTypes::Month; $requestedDay = 4; $requestedMonth = 3; $requestedYear = 2011; $month = new CalendarMonth($requestedMonth, $requestedYear, $userTimezone); $startDate = Date::Parse('2011-01-01', 'UTC'); $endDate = Date::Parse('2011-01-02', 'UTC'); $summary = 'foo summary'; $resourceId = 3; $fname = 'fname'; $lname = 'lname'; $referenceNumber = 'refnum'; $resourceName = 'resource name'; $res = new ReservationItemView($referenceNumber, $startDate, $endDate, 'resource name', $resourceId, 1, null, null, $summary, null, $fname, $lname, $userId); $r1 = new FakeBookableResource(1, 'dude1'); $r2 = new FakeBookableResource($resourceId, $resourceName); $reservations = array($res); $resources = array($r1, $r2); /** @var Schedule[] $schedules */ $schedules = array(new Schedule(1, null, false, 2, null), new Schedule($defaultScheduleId, null, true, 3, null),); $this->scheduleRepository ->expects($this->atLeastOnce()) ->method('GetAll') ->will($this->returnValue($schedules)); $this->resourceService ->expects($this->atLeastOnce()) ->method('GetAllResources') ->with($this->equalTo($showInaccessible), $this->equalTo($this->fakeUser)) ->will($this->returnValue($resources)); $this->resourceService ->expects($this->atLeastOnce()) ->method('GetResourceGroups') ->with($this->equalTo(null), $this->equalTo($this->fakeUser)) ->will($this->returnValue(new ResourceGroupTree())); $this->page ->expects($this->atLeastOnce()) ->method('GetScheduleId') ->will($this->returnValue(null)); $this->page ->expects($this->atLeastOnce()) ->method('GetResourceId') ->will($this->returnValue(null)); $this->repository ->expects($this->atLeastOnce()) ->method('GetReservationList') ->with($this->equalTo($month->FirstDay()), $this->equalTo($month->LastDay()->AddDays(1)), $this->equalTo(null), $this->equalTo(null), $this->equalTo(null), $this->equalTo(null)) ->will($this->returnValue($reservations)); $this->page ->expects($this->atLeastOnce()) ->method('GetCalendarType') ->will($this->returnValue($calendarType)); $this->page ->expects($this->atLeastOnce()) ->method('GetDay') ->will($this->returnValue($requestedDay)); $this->page ->expects($this->atLeastOnce()) ->method('GetMonth') ->will($this->returnValue($requestedMonth)); $this->page ->expects($this->atLeastOnce()) ->method('GetYear') ->will($this->returnValue($requestedYear)); $this->page ->expects($this->atLeastOnce()) ->method('SetFirstDay') ->with($this->equalTo($schedules[1]->GetWeekdayStart())); $this->calendarFactory ->expects($this->atLeastOnce()) ->method('Create') ->with($this->equalTo($calendarType), $this->equalTo($requestedYear), $this->equalTo($requestedMonth), $this->equalTo($requestedDay), $this->equalTo($userTimezone)) ->will($this->returnValue($month)); $this->page->expects($this->atLeastOnce())->method('BindCalendar')->with($this->equalTo($month)); $details = new CalendarSubscriptionDetails(true); $this->subscriptionService->expects($this->once())->method('ForSchedule')->with($this->equalTo($defaultScheduleId))->will($this->returnValue($details)); $this->page->expects($this->atLeastOnce())->method('BindSubscription')->with($this->equalTo($details)); $calendarFilters = new CalendarFilters($schedules, $resources, null, null, new ResourceGroupTree()); $this->page->expects($this->atLeastOnce())->method('BindFilters')->with($this->equalTo($calendarFilters)); $this->presenter->PageLoad($this->fakeUser, $userTimezone); $actualReservations = $month->Reservations(); $expectedReservations = CalendarReservation::FromScheduleReservationList($reservations, $resources, $this->fakeUser, $this->privacyFilter); $this->assertEquals($expectedReservations, $actualReservations); }
This is 75 lines of mock setup code! We’re expecting specific parameters and have mock objects being returned all over. Let’s take a look at this same test with stubs.
public function testBindsDefaultScheduleByMonthWhenNothingSelected() { $this->page = new StubCalendarPage(); $this->repository = new StubReservationViewRepository(); $this->scheduleRepository = new StubScheduleRepository(); $this->calendarFactory = new StubCalendarFactory(); $this->resourceService = new StubResourceService(); $this->subscriptionService = new StubCalendarSubscriptionService(); $this->privacyFilter = new FakePrivacyFilter(); $this->presenter = new CalendarPresenter( $this->page, $this->calendarFactory, $this->repository, $this->scheduleRepository, $this->resourceService, $this->subscriptionService, $this->privacyFilter); $showInaccessible = true; $this->fakeConfig->SetSectionKey(ConfigSection::SCHEDULE, ConfigKeys::SCHEDULE_SHOW_INACCESSIBLE_RESOURCES, 'true'); $userId = $this->fakeUser->UserId; $defaultScheduleId = 10; $userTimezone = "America/New_York"; $calendarType = CalendarTypes::Month; $requestedDay = 4; $requestedMonth = 3; $requestedYear = 2011; $month = new CalendarMonth($requestedMonth, $requestedYear, $userTimezone); $startDate = Date::Parse('2011-01-01', 'UTC'); $endDate = Date::Parse('2011-01-02', 'UTC'); $summary = 'foo summary'; $resourceId = 3; $fname = 'fname'; $lname = 'lname'; $referenceNumber = 'refnum'; $resourceName = 'resource name'; $res = new ReservationItemView($referenceNumber, $startDate, $endDate, 'resource name', $resourceId, 1, null, null, $summary, null, $fname, $lname, $userId); $r1 = new FakeBookableResource(1, 'dude1'); $r2 = new FakeBookableResource($resourceId, $resourceName); $reservations = array($res); $resources = array($r1, $r2); /** @var Schedule[] $schedules */ $schedules = array(new Schedule(1, null, false, 2, null), new Schedule($defaultScheduleId, null, true, 3, null),); $this->scheduleRepository->SetSchedules($schedules); $this->resourceService->SetResources($resources); $this->page->SetScheduleId(null); $this->page->SetResourceId(null); $this->repository->SetReservaions($reservations); $this->page->SetCalendarType($calendarType); $this->page->SetDate($requestedDay, $requestedMonth, $requestedYear); $this->page->SetFirstDayStart($schedules[1]->GetWeekdayStart()); $this->calendarFactory->SetCalendarMonth($month); $details = new CalendarSubscriptionDetails(true); $this->subscriptionService->SetDetails($details); $this->presenter->PageLoad($this->fakeUser, $userTimezone); $actualReservations = $month->Reservations(); $expectedReservations = CalendarReservation::FromScheduleReservationList($reservations, $resources, $this->fakeUser, $this->privacyFilter); $this->assertEquals($expectedReservations, $actualReservations); $this->assertEquals($month->FirstDay(), $this->repository->_SearchStart); $this->assertEquals($month->LastDay()->AddDays(1), $this->repository->_SearchEnd); $this->assertEquals($calendarType, $this->calendarFactory->_CalendarType); $this->assertEquals($requestedYear, $this->calendarFactory->_CreateYear); $this->assertEquals($requestedMonth, $this->calendarFactory->_CreateMonth); $this->assertEquals($requestedDay, $this->calendarFactory->_CreateDay); $this->assertEquals($userTimezone, $this->calendarFactory->_CreateTimezone); $this->assertEquals($month, $this->page->_BoundMonth); $this->assertEquals($details, $this->page->_BoundSubscription); $calendarFilters = new CalendarFilters($schedules, $resources, null, null, new ResourceGroupTree()); $this->assertEquals($calendarFilters, $this->page->_BoundFlters); $this->assertEquals($this->subscriptionService, $this->subscriptionService->_ScheduleId); }
This is still a huge test (that’s a problem for another post). Here the stub setup code gets cut down to about 15 lines. What I also love is that I don’t have expectations set in the mock specification. Any assertions around parameters have been moved down with the rest of the assertions. This is wonderful for a few reasons.
1 – This follows the Arrange-Act-Assert model that helps us in writing clean tests.
2 – Most of the time I don’t care about what parameters I pass to a method.
3 – Mocks will typically not return anything if you don’t pass the right value to a mocked-out function. When you pass the wrong value null is returned, causing a null reference exception in your code. This is very frustrating and difficult to trace, especially when you changed the parameters intentionally.
I don’t care which method is called (most of the time). Often times I don’t care about the data that we’re feeding to a collaborating object, either. I just want to test this specific object’s functionality and assume that everything else is tested elsewhere and works as it should.
The Stub Library
One of the biggest ancillary benefits I’ve found to hand writing my stubs is that I’ve built up a pretty solid library of reusable objects. I have stub objects ready to go for different pages, for reservations, data access and so on. I can pull in an existing stub and continue on with my test. I don’t need to set up the same stub expectation or response over and over. I don’t even have to think about how this collaborating object works. And if I apply an automated refactor to a method signature I don’t have to worry about breaking a ton of tests – the refactoring would apply to the stub object, as well.
Looking Forward
I’ve come full circle on stubs since my early days of TDD. I used to view them as a productivity killer, but my opinion now is that they actually save me time. Sure, there’s a little upfront investment, but the return on that investment is high. Who knows, maybe I’ll venture back into the land of mocks again one day. Until then, I’m happy with my well-tested, easy to maintain production code base.
[ad name=”Footer”]