Patience with Test-Driven Development (TDD)

publish-google-play

Test-driven development and the mobile development community as a whole is maturing and reinventing traditional paradigms with a mobile twist, i.e., Android’s Data Binding Library.

We’re streamlining our build tools and starting to crave more automated testing as a direct result. I’ve struggled to write tests on new and old projects and have come to the realization that a little practice will go a long way. I recently read Working Effectively With Legacy Code by Michael C. Feathers, which postulates that code written without testing in mind is hard to test. I set out to build a Solitaire engine (also known as “Patience”) with the main focus of gaining some testing experience.

My first few approaches hit road blocks, but it taught me some valuable lessons. I focused on TDD, writing simple rules related to Solitaire and on getting the tests to work. I wasn’t worried about the final design; my goal was testing and coding quickly. I got past those initial road blocks through a willingness to make a few mistakes, some of which to follow:

  • Putting all logic in one class. Just because I am testing one class doesn’t mean I have to put all the logic there. I learned to use the Single Responsibility Principle more consistently.
  • I hid important dependencies initially. In my first design attempt, my approach to getting the first test to pass made it impossible to write any further tests. I learned to use Dependency Injection where needed.
  • I struggled a little with domain logic. My first few attempts didn’t focus too much on all use cases, which led to some decisions being made too late.

Related: Striking a Balance with UI Tests

Review

solimage

If you don’t know how to play Solitaire, ask a friend. I recently learned my wife didn’t know how to play, so before the day was over, I was at my storage unit digging out playing cards. I taught her how to play by using two decks arranged in the same order and playing through the moves simultaneously.

Quick glossary of terms:

  • TableauSlot: A set of playable and stock piles. The main playing area where cards are dealt.
  • Stock Pile: Face down pile of cards to draw from.
  • Waste Pile: Face up pile of cards that drawn cards are placed in. Top waste card is playable.
  • Foundations: Four slots where the playable cards are put in ascending rank order by suit to complete the game.
  • Grab: Action that represents when the user picks up some playable cards. This action creates a Move object with the cards removing them from the original pile.

Latest Approach

I’m going to outline the tests I wrote, but not focus too much on code. The code is available on GitHub where you can follow more closely. The most important parts are the test names, what they do and how previous lessons affected the test writing.

Setup for all tests

@interface SolitaireTests : XCTestCase
@property(nonatomic, strong) STKBoard *board;
@property(nonatomic, strong) STKGameEngine *engine;
@end

- (void)setUp {
    [super setUp];
    [self setBoard:[[STKBoard alloc] init]];
    [self setEngine:[[STKGameEngine alloc] initWithBoard:[self board]
                                               drawCount:[STKGameEngine defaultDrawCount]]];

    [[self engine] dealCards:[STKCard deck]];
}

- (void)tearDown {
    [self setBoard:nil];
    [self setEngine:nil];
    [super tearDown];
}

Notice how this XCTestCase -setUp method:

  • Shares some properties which are then released in -tearDown. This is standard practice, but it’s worth mentioning because it can save you from buggy tests.
  • Injects dependencies for drawCount and board allowing tests to set the board up as needed per test and use the drawCount for assertion logic.
  • Gets some naming out of the way.

GameEngine can deal cards

- (void)testStockHas24CardsAfterSetup {
    //cards are dealt before all tests
    XCTAssertEqual(24, [[[self engine] stock] count]);
}

- (void)testWasteHas0CardsAfterSetup {
    XCTAssertEqual(0, [[[self engine] waste] count]);
}

- (void)testFoundationsHave0CardsAfterSetup {
    for (STKFoundationPile *foundation in [[self board] foundations]) {
        XCTAssertFalse([foundation hasCards]);
    }
}

- (void)testStockTableausHaveAscendingCounts0toNAfterSetup //0-6
{
    NSUInteger expected = 0;
    for (STKStockTableauPile *stockTableau in [[self board] stockTableaus]) {
        XCTAssertEqual(expected++, [[stockTableau cards] count]);
    }
}

- (void)testTableausHave1CardAfterSetup {
    for (STKPlayableTableauPile *tableau in [[self board] playableTableaus]) {
        XCTAssertEqual(1, [[tableau cards] count]);
    }
}

These tests pave the way to write code for accessing cards/piles through STKGameEngine and STKBoard.

GameEngine knows available actions and which cards can be grabbed

- (void)testCanDrawStockToWasteWhenStockIsNotEmpty {
    XCTAssertTrue([[self engine] canDrawStockToWaste]);
}

- (void)testCanNotDrawStockToWasteWhenStockIsEmpty {
    [self moveStockCardsToWaste];

    XCTAssertFalse([[self engine] canDrawStockToWaste]);
}

- (void)testCanRedealWasteToStockWhenStockIsEmptyAndWasteIsNotEmpty {
    [self moveStockCardsToWaste];

    XCTAssertTrue([[self engine] canResetWasteToStock]);
}

- (void)testCanNotRedealWasteToStockWhenStockIsNotEmpty {
    // stock is already none empty, make sure to populate waste to make the next test more valuable
    [STKBoard moveTopCard:[[self board] stock] toPile:[[self board] waste]];

    XCTAssertFalse([[self engine] canResetWasteToStock]);
}

- (void)testCanNotRedealWasteToStockWhenWasteIsEmpty {
    [self clearStock];
    [self clearWaste];

    XCTAssertFalse([[self engine] canResetWasteToStock]);
}

- (void)testCanGrabTopWasteCard {
    [STKBoard moveTopCard:[[self board] stock] toPile:[[self board] waste]];

    STKCard *topWasteCard = [[[self engine] waste] lastObject];
    XCTAssertTrue([[self engine] canGrab:topWasteCard]);
}

- (void)testCanNotGrabCoveredWasteCard {
    [self moveStockCardsToWaste];

    for (STKCard *card in [[self engine] waste]) {
        if (card != [[[self engine] waste] lastObject]) {
            XCTAssertFalse([[self engine] canGrab:card]);
        }
    }
}

- (void)testCanGrabTopFoundationCards {
    for (STKPile *foundation in [[self board] foundations]) {
        [STKBoard moveTopCard:[[self board] stock] toPile:foundation];

        XCTAssertTrue([[self engine] canGrab:[[foundation cards] lastObject]]);
    }
}

- (void)testCanNotGrabCoveredFoundationCards {
    while ([[[self engine] stock] count] > 0) {
        for (STKFoundationPile *foundation in [[self board] foundations]) {
            [STKBoard moveTopCard:[[self board] stock] toPile:foundation];
        }
    }

    for (STKPile *foundationPile in [[self engine] foundations]) {
        for (int i = 0; i < [[foundationPile cards] count] - 1; ++i) {
            XCTAssertFalse([[self engine] canGrab:[foundationPile cards][i]]);
        }
    }
}

- (void)testCanGrabTopTableauCards {
    while ([[[self engine] stock] count] > 0) {
        for (STKPlayableTableauPile *tableau in [[self board] playableTableaus]) {
            [STKBoard moveTopCard:[[self board] stock] toPile:tableau];
        }
    }

    for (NSArray *tableau in [[self engine] playableTableaus]) {
        XCTAssertTrue([[self engine] canGrab:[tableau lastObject]]);
    }
}

- (void)testCanGrabCoveredCardsFromTableau {
    while ([[[self engine] stock] count] > 0) {
        for (STKPlayableTableauPile *tableau in [[self board] playableTableaus]) {
            [STKBoard moveTopCard:[[self board] stock] toPile:tableau];
        }
    }

    for (NSArray *tableau in [[self engine] playableTableaus]) {
        for (STKCard *card in tableau) {
            if (card == [tableau lastObject]) {
                return;
            }
            XCTAssertTrue([[self engine] canGrab:[tableau lastObject]]);
        }
    }
}

There are a lot of tests surrounding which actions can be performed and which cards can be grabbed. They exercise a lot of code that depends on various starting states. It’s a bit of a pain to set up the initial states, but these tests run a decent amount of code that is cumbersome to test.

Grabbing cards removes cards from the original data source

- (void)testGrabbingTopWasteCard {
    [STKBoard moveTopCard:[[self board] stock] toPile:[[self board] waste]];
    STKCard *topWasteCard = [[[self engine] waste] lastObject];
    STKPile *expectedPile = [[self board] pileContainingCard:topWasteCard];

    STKMove *move = [[self engine] grabTopCardsFromCard:topWasteCard];

    XCTAssertEqual([[move cards] firstObject], topWasteCard);
    XCTAssertEqual([[move cards] count], 1);
    XCTAssertFalse([[[self engine] waste] containsObject:topWasteCard]);
    XCTAssertEqual(expectedPile, [move sourcePile]);
}

- (void)testGrabbingTopFoundationCard {
    for (STKFoundationPile *foundation in [[self board] foundations]) {
        [STKBoard moveTopCard:[[self board] stock] toPile:foundation];
    }

    for (NSUInteger i = 0; i < [[[self engine] foundations] count]; ++i) {
        NSArray *foundation = [[self engine] foundationAtIndex:i];
        STKCard *topFoundationCard = [foundation lastObject];
        STKPile *expectedPile = [[self board] pileContainingCard:topFoundationCard];

        STKMove *move = [[self engine] grabTopCardsFromCard:topFoundationCard];

        XCTAssertEqual([[move cards] firstObject], topFoundationCard);
        XCTAssertEqual([[move cards] count], 1);
        XCTAssertFalse([[[self engine] foundationAtIndex:i] containsObject:topFoundationCard]);
        XCTAssertEqual(expectedPile, [move sourcePile]);
    }
}

- (void)testGrabbingTopTableauCard {
    for (STKPlayableTableauPile *tableau in [[self board] playableTableaus]) {
        [STKBoard moveTopCard:[[self board] stock] toPile:tableau];
    }

    for (NSUInteger i = 0; i < [[[self engine] playableTableaus] count]; ++i) {
        NSArray *tableau = [[self engine] tableauAtIndex:i];
        STKCard *topTableauCard = [tableau lastObject];
        STKPile *expectedSourcePile = [[self board] pileContainingCard:topTableauCard];

        STKMove *move = [[self engine] grabTopCardsFromCard:topTableauCard];

        XCTAssertEqual([[move cards] firstObject], topTableauCard);
        XCTAssertEqual([[move cards] count], 1);
        XCTAssertFalse([[[self engine] tableauAtIndex:i] containsObject:topTableauCard]);
        XCTAssertEqual(expectedSourcePile, [move sourcePile]);
    }
}

- (void)testGrabbingTopTableauCards {
    NSUInteger tableauIndex = 0;
    for (NSArray *tableau in [[self engine] playableTableaus]) {
        NSUInteger expectedGrabbedCardsCount = [tableau count] - 1;
        STKPile *expectedPile = [[self board] tableauAtIndex:tableauIndex++];

        for (STKCard *card in tableau) {
            if (card == [tableau lastObject]) {
                break;
            }

            STKMove *move = [[self engine] grabTopCardsFromCard:card];

            XCTAssertEqual([[move cards] firstObject], card);
            XCTAssertEqual([[move cards] count], expectedGrabbedCardsCount--);
            XCTAssertFalse([tableau containsObject:tableau]);
            XCTAssertEqual(expectedPile, [move sourcePile]);
        }
    }
}

Engine can move stock cards to waste and reset waste to stock

- (void)testDrawingStockToWaste {
    NSUInteger expectedLength = [[self engine] drawCount];
    NSArray *expectedWaste = [[[self engine] stock] subarrayWithRange:NSMakeRange([[[self engine] stock] count] - expectedLength, expectedLength)];
    NSEnumerator *expectedWasteEnumerator = [expectedWaste reverseObjectEnumerator];

    [[self engine] drawStockToWaste];

    NSUInteger wasteIndex = [[[self engine] waste] count] - expectedLength;
    for (STKCard *card in expectedWasteEnumerator) {
        XCTAssertEqual(card, [[self engine] waste][wasteIndex++]);
        XCTAssertFalse([[[self engine] stock] containsObject:card]);
    }
}

- (void)testDrawingLessThanDrawCountFromStockToWaste {
    //default draw count = 3
    NSUInteger initialStockCount = [[[self engine] stock] count];
    NSUInteger expectedLength = [[self engine] drawCount] - 1;
    NSUInteger amountToMove = initialStockCount - expectedLength;

    for (NSUInteger i = 0; i < amountToMove; ++i) {
        [STKBoard moveTopCard:[[self board] stock] toPile:[[self board] waste]];
    }

    NSArray *expectedWaste = [[[self engine] stock] subarrayWithRange:NSMakeRange([[[self engine] stock] count] - expectedLength, expectedLength)];
    NSEnumerator *expectedWasteEnumerator = [expectedWaste reverseObjectEnumerator];

    [[self engine] drawStockToWaste];

    NSUInteger wasteIndex = [[[self engine] waste] count] - expectedLength;
    for (STKCard *card in expectedWasteEnumerator) {
        XCTAssertEqual(card, [[self engine] waste][wasteIndex++]);
        XCTAssertFalse([[[self engine] stock] containsObject:card]);
    }
}

- (void)testRedealWasteToStock {
    //redeal should put waste back to stock, in the back in the original stock order
    NSArray *expectedStock = [[self engine] waste];
    while ([[[self engine] stock] count]) {
        [[self engine] drawStockToWaste];
    }

    [[self engine] resetWasteToStock];

    NSUInteger stockIndex = 0;
    for (STKCard *card in expectedStock) {
        XCTAssertEqual(card, [[self engine] stock][stockIndex++]);
        XCTAssertFalse([[[self engine] waste] containsObject:card]);
    }
}

This set of tests gets some basic logic covered on whether you can perform two common moves, resetting a waste pile back to stock and drawing from the stock to the waste.

Engine knows when stock tableau cards can be flipped

- (void)testCanFlipStockTableauWhenTableauIsEmptyAndStockTableauIsNotEmpty {
    // first tableau stock is always empty
    for (NSUInteger i = 1; i < [[[self engine] playableTableaus] count]; ++i) {
        [self clearPlayableTableauAtIndex:i];
        XCTAssertTrue([[self engine] canFlipStockTableauAtIndex:i]);
    }
}

- (void)testCanNotFlipStockTableauWhenStockTableauIsEmpty {
    // first tableau stock is always empty
    XCTAssertFalse([[self engine] canFlipStockTableauAtIndex:0]);
}

- (void)testCanNotFlipStockTableauWhenTableauIsNotEmpty {
    XCTAssertFalse([[self engine] canFlipStockTableauAtIndex:1]);
}

This set of tests covers whether you can perform what I would consider a pretty satisfying move in Solitaire: revealing the top card from a tableau pile.

Engine can validate a solved board

- (void)testSolitaireEngineCanValidateSolvedBoard {
    //set up winning board starting with a clear board
    [self setBoard:[[STKBoard alloc] init]];
    [self setEngine:[[STKGameEngine alloc] initWithBoard:[self board]]];

    for (NSUInteger i = 0; i < [[[self board] foundations] count]; ++i) {
        STKFoundationPile *foundation = [[self board] foundationAtIndex:i];
        STKCardSuit suit = (STKCardSuit) [[STKCard allSuits][i] intValue];
        [[foundation cards] addObjectsFromArray:[STKCard completeAscendingSuit:suit]];
    }

    XCTAssertTrue([[self engine] isBoardSolved]);
}

Notice this is the first appeareance of suit and ranks. A card’s suit and rank had no affect on any of the logic tested up until validating a solved board. I didn’t realize this at first; my first attempt at test writing had unnecessary noise in tests that didn’t need suits or ranks.

Engine knows where cards can be placed

- (void)testCanMoveKingToTableauWhenTableauAndStockTableauAreEmpty {
    STKStockTableauPile *stockTableau = [[[self board] stockTableaus] firstObject];
    STKPlayableTableauPile *tableau = [[[self board] playableTableaus] firstObject];
    [[stockTableau cards] removeAllObjects];
    [[tableau cards] removeAllObjects];

    NSArray *cards = @[[STKCard cardWithRank:STKCardRankKing suit:(STKCardSuit) -1]];

    STKMove *validMove = [[STKMove alloc] initWithCards:cards sourcePile:nil];
    XCTAssertTrue([[self engine] canCompleteMove:validMove withTargetPile:tableau]);
}

- (void)testCanMoveAceToFoundationWhenFoundationIsEmpty {
    STKFoundationPile *foundation = [[[self board] foundations] firstObject];

    NSArray *cards = @[[STKCard cardWithRank:STKCardRankAce suit:(STKCardSuit) -1]];

    STKMove *validMove = [STKMove moveWithCards:cards sourcePile:nil];
    XCTAssertTrue([[self engine] canCompleteMove:validMove withTargetPile:foundation]);
}

- (void)testCanMoveValidCardsToNotEmptyTableau {
    STKPlayableTableauPile *tableau = [[[self board] playableTableaus] firstObject];
    [[tableau cards] removeAllObjects];
    [[tableau cards] addObject:[STKCard cardWithRank:STKCardRankFive suit:STKCardSuitSpades]];

    NSArray *validCards = @[[STKCard cardWithRank:STKCardRankFour suit:STKCardSuitHearts],
            [STKCard cardWithRank:STKCardRankThree suit:STKCardSuitClubs]];

    STKMove *validMove = [[STKMove alloc] initWithCards:validCards sourcePile:nil];
    XCTAssertTrue([[self engine] canCompleteMove:validMove withTargetPile:tableau]);
}

- (void)testCanNotMoveInvalidCardsToNotEmptyTableau {
    STKPlayableTableauPile *tableau = [[[self board] playableTableaus] firstObject];
    [[tableau cards] removeAllObjects];
    [[tableau cards] addObject:[STKCard cardWithRank:STKCardRankFive suit:STKCardSuitSpades]];

    NSArray *invalidCards = @[[STKCard cardWithRank:STKCardRankFive suit:STKCardSuitHearts],
            [STKCard cardWithRank:STKCardRankThree suit:STKCardSuitClubs]];

    STKMove *invalidMove = [[STKMove alloc] initWithCards:invalidCards sourcePile:nil];
    XCTAssertFalse([[self engine] canCompleteMove:invalidMove withTargetPile:tableau]);
}

- (void)testCanMoveValidCardToNotEmptyFoundation {
    STKFoundationPile *foundation = [[[self board] foundations] firstObject];
    [[foundation cards] addObject:[STKCard cardWithRank:STKCardRankAce suit:STKCardSuitHearts]];

    NSArray *cards = @[[STKCard cardWithRank:STKCardRankTwo suit:STKCardSuitHearts]];

    STKMove *validMove = [[STKMove alloc] initWithCards:cards sourcePile:nil];
    XCTAssertTrue([[self engine] canCompleteMove:validMove withTargetPile:foundation]);
}

- (void)testCanNotMoveInvalidCardToNotEmptyFoundation {
    STKFoundationPile *foundation = [[[self board] foundations] firstObject];
    [[foundation cards] addObject:[STKCard cardWithRank:STKCardRankAce suit:STKCardSuitHearts]];

    NSArray *cards = @[[STKCard cardWithRank:STKCardRankKing suit:STKCardSuitHearts]];

    STKMove *invalidMove = [[STKMove alloc] initWithCards:cards sourcePile:nil];
    XCTAssertFalse([[self engine] canCompleteMove:invalidMove withTargetPile:foundation]);
}

This final batch of tests covers whether the user can put grabbed cards down on a specific pile or whether they have to put them back.

I have a handful of tests that I could write right now. Some of them could be high value, but I’d like to wait and see what bugs result from opting out of writing some tests. Using TDD ended up keeping me pretty grounded through the development process. I look forward to seeing the benefits/adverse affects of having these tests in place while refactoring the design. I plan on building an app to see if the tests cover the behavior I think they do and get better feedback on this testing experience. Best of all, once that is complete, I’ll have a prime candidate for using Xcode 7 Beta’s new “Record UI Test Feature!”

0 replies

Leave a Reply

Want to join the discussion?
Feel free to contribute!

Leave a Reply

Your email address will not be published. Required fields are marked *